mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-03 16:51:26 +00:00
Compare commits
25 Commits
5e62ee5100
...
N3WTS-2-Re
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fef6d61a4 | |||
| 0501c1dd73 | |||
| 4f7d7d0024 | |||
| 8fd1b62ec0 | |||
| 3779a47417 | |||
| 05c68ebfaa | |||
| 195579e217 | |||
| ddcaba382e | |||
| a82483f3bd | |||
| 26d4b5633f | |||
| d66db1b019 | |||
| bd7dc2b0c2 | |||
| 176edc5c45 | |||
| 92c6a31740 | |||
| 9dff32b388 | |||
| abb4b525b2 | |||
| b4f70e6bad | |||
| 8549699dec | |||
| a034149eae | |||
| 12f5fc7aa9 | |||
| 2dc0dfa268 | |||
| dd00cba385 | |||
| 7486f6c5ce | |||
| 1e5bc6ccba | |||
| 0fb668b212 |
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
|
||||
@ -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)
|
||||
is_active = models.BooleanField(default=False, blank=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@ -236,7 +236,6 @@ def makeToken(user):
|
||||
"establishment__name": role.establishment.name,
|
||||
"establishment__evaluation_frequency": role.establishment.evaluation_frequency,
|
||||
"establishment__total_capacity": role.establishment.total_capacity,
|
||||
"establishment__api_docuseal": role.establishment.api_docuseal,
|
||||
"establishment__logo": logo_url,
|
||||
})
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -1 +0,0 @@
|
||||
# This file is intentionally left blank to make this directory a Python package.
|
||||
@ -1,9 +0,0 @@
|
||||
from django.urls import path, re_path
|
||||
from .views import generate_jwt_token, clone_template, remove_template, download_template
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'generateToken$', generate_jwt_token, name='generate_jwt_token'),
|
||||
re_path(r'cloneTemplate$', clone_template, name='clone_template'),
|
||||
re_path(r'removeTemplate/(?P<id>[0-9]+)$', remove_template, name='remove_template'),
|
||||
re_path(r'downloadTemplate/(?P<slug>[\w-]+)$', download_template, name='download_template')
|
||||
]
|
||||
@ -1,200 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
import jwt
|
||||
import datetime
|
||||
import requests
|
||||
from Establishment.models import Establishment
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(['POST'])
|
||||
def generate_jwt_token(request):
|
||||
# Récupérer l'établissement concerné (par ID ou autre info transmise)
|
||||
establishment_id = request.data.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
establishment = Establishment.objects.get(id=establishment_id)
|
||||
except Establishment.DoesNotExist:
|
||||
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Vérifier la clé API reçue dans le header
|
||||
api_key = request.headers.get('X-Auth-Token')
|
||||
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
|
||||
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# Récupérer les données de la requête
|
||||
user_email = request.data.get('user_email')
|
||||
documents_urls = request.data.get('documents_urls', [])
|
||||
template_id = request.data.get('id')
|
||||
|
||||
if not user_email:
|
||||
return Response({'error': 'User email is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Utiliser la clé API de l'établissement comme secret JWT
|
||||
jwt_secret = establishment.api_docuseal
|
||||
jwt_algorithm = settings.DOCUSEAL_JWT['ALGORITHM']
|
||||
expiration_delta = settings.DOCUSEAL_JWT['EXPIRATION_DELTA']
|
||||
|
||||
payload = {
|
||||
'user_email': user_email,
|
||||
'documents_urls': documents_urls,
|
||||
'template_id': template_id,
|
||||
'exp': datetime.datetime.utcnow() + expiration_delta
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, jwt_secret, algorithm=jwt_algorithm)
|
||||
return Response({'token': token}, status=status.HTTP_200_OK)
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(['POST'])
|
||||
def clone_template(request):
|
||||
# Récupérer l'établissement concerné
|
||||
establishment_id = request.data.get('establishment_id')
|
||||
print(f"establishment_id : {establishment_id}")
|
||||
if not establishment_id:
|
||||
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
establishment = Establishment.objects.get(id=establishment_id)
|
||||
except Establishment.DoesNotExist:
|
||||
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Vérifier la clé API reçue dans le header
|
||||
api_key = request.headers.get('X-Auth-Token')
|
||||
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
|
||||
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# Récupérer les données de la requête
|
||||
document_id = request.data.get('templateId')
|
||||
email = request.data.get('email')
|
||||
is_required = request.data.get('is_required')
|
||||
|
||||
# Vérifier les données requises
|
||||
if not document_id:
|
||||
return Response({'error': 'template ID is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# URL de l'API de DocuSeal pour cloner le template
|
||||
clone_url = f'https://docuseal.com/api/templates/{document_id}/clone'
|
||||
|
||||
# Faire la requête pour cloner le template
|
||||
try:
|
||||
response = requests.post(clone_url, headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Auth-Token': establishment.api_docuseal
|
||||
})
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
return Response({'error': 'Failed to clone template'}, status=response.status_code)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if is_required:
|
||||
# URL de l'API de DocuSeal pour créer une submission
|
||||
submission_url = f'https://docuseal.com/api/submissions'
|
||||
|
||||
try:
|
||||
clone_id = data['id']
|
||||
response = requests.post(submission_url, json={
|
||||
'template_id': clone_id,
|
||||
'send_email': False,
|
||||
'submitters': [{'email': email}]
|
||||
}, headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Auth-Token': establishment.api_docuseal
|
||||
})
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
return Response({'error': 'Failed to create submission'}, status=response.status_code)
|
||||
|
||||
data = response.json()
|
||||
data[0]['id'] = clone_id
|
||||
return Response(data[0], status=status.HTTP_200_OK)
|
||||
|
||||
except requests.RequestException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
else:
|
||||
print(f'NOT REQUIRED -> on ne crée pas de submission')
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
except requests.RequestException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(['DELETE'])
|
||||
def remove_template(request, id):
|
||||
# Récupérer l'établissement concerné
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
establishment = Establishment.objects.get(id=establishment_id)
|
||||
except Establishment.DoesNotExist:
|
||||
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Vérifier la clé API reçue dans le header
|
||||
api_key = request.headers.get('X-Auth-Token')
|
||||
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
|
||||
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# URL de l'API de DocuSeal pour supprimer le template
|
||||
|
||||
clone_url = f'https://docuseal.com/api/templates/{id}'
|
||||
|
||||
try:
|
||||
response = requests.delete(clone_url, headers={
|
||||
'X-Auth-Token': establishment.api_docuseal
|
||||
})
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
return Response({'error': 'Failed to remove template'}, status=response.status_code)
|
||||
|
||||
data = response.json()
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
except requests.RequestException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@csrf_exempt
|
||||
@api_view(['GET'])
|
||||
def download_template(request, slug):
|
||||
# Récupérer l'établissement concerné
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
establishment = Establishment.objects.get(id=establishment_id)
|
||||
except Establishment.DoesNotExist:
|
||||
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Vérifier la clé API reçue dans le header
|
||||
api_key = request.headers.get('X-Auth-Token')
|
||||
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
|
||||
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# Vérifier les données requises
|
||||
if not slug:
|
||||
return Response({'error': 'slug is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# URL de l'API de DocuSeal pour télécharger le template
|
||||
download_url = f'https://docuseal.com/submitters/{slug}/download'
|
||||
|
||||
try:
|
||||
response = requests.get(download_url, headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Auth-Token': establishment.api_docuseal
|
||||
})
|
||||
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
return Response({'error': 'Failed to download template'}, status=response.status_code)
|
||||
|
||||
data = response.json()
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
except requests.RequestException as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
@ -1,5 +1,6 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import Establishment.models
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -24,6 +25,7 @@ class Migration(migrations.Migration):
|
||||
('licence_code', models.CharField(blank=True, max_length=100)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('logo', models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-30 07:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('Establishment', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='establishment',
|
||||
name='api_docuseal',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,19 +0,0 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-31 09:56
|
||||
|
||||
import Establishment.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('Establishment', '0002_establishment_api_docuseal'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='establishment',
|
||||
name='logo',
|
||||
field=models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to),
|
||||
),
|
||||
]
|
||||
@ -27,7 +27,6 @@ class Establishment(models.Model):
|
||||
licence_code = models.CharField(max_length=100, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
api_docuseal = models.CharField(max_length=255, blank=True, null=True)
|
||||
logo = models.FileField(
|
||||
upload_to=registration_logo_upload_to,
|
||||
null=True,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -14,6 +16,39 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Conversation',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('conversation_type', models.CharField(choices=[('private', 'Privée'), ('group', 'Groupe')], default='private', max_length=10)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_activity', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Message',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('content', models.TextField()),
|
||||
('message_type', models.CharField(choices=[('text', 'Texte'), ('file', 'Fichier'), ('image', 'Image'), ('system', 'Système')], default='text', max_length=10)),
|
||||
('file_url', models.URLField(blank=True, null=True)),
|
||||
('file_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('file_size', models.BigIntegerField(blank=True, null=True)),
|
||||
('file_type', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_edited', models.BooleanField(default=False)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='GestionMessagerie.conversation')),
|
||||
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Messagerie',
|
||||
fields=[
|
||||
@ -27,4 +62,40 @@ class Migration(migrations.Migration):
|
||||
('emetteur', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='messages_envoyes', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserPresence',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('online', 'En ligne'), ('away', 'Absent'), ('busy', 'Occupé'), ('offline', 'Hors ligne')], default='offline', max_length=10)),
|
||||
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('is_typing_in', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='typing_users', to='GestionMessagerie.conversation')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='presence', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ConversationParticipant',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_read_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='GestionMessagerie.conversation')),
|
||||
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation_participants', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('conversation', 'participant')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MessageRead',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('read_at', models.DateTimeField(auto_now_add=True)),
|
||||
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_by', to='GestionMessagerie.message')),
|
||||
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('message', 'participant')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-30 07:40
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('GestionMessagerie', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Conversation',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('conversation_type', models.CharField(choices=[('private', 'Privée'), ('group', 'Groupe')], default='private', max_length=10)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_activity', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Message',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('content', models.TextField()),
|
||||
('message_type', models.CharField(choices=[('text', 'Texte'), ('file', 'Fichier'), ('image', 'Image'), ('system', 'Système')], default='text', max_length=10)),
|
||||
('file_url', models.URLField(blank=True, null=True)),
|
||||
('file_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('file_size', models.BigIntegerField(blank=True, null=True)),
|
||||
('file_type', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_edited', models.BooleanField(default=False)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='GestionMessagerie.conversation')),
|
||||
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserPresence',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('online', 'En ligne'), ('away', 'Absent'), ('busy', 'Occupé'), ('offline', 'Hors ligne')], default='offline', max_length=10)),
|
||||
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('is_typing_in', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='typing_users', to='GestionMessagerie.conversation')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='presence', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ConversationParticipant',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_read_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='GestionMessagerie.conversation')),
|
||||
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation_participants', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('conversation', 'participant')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MessageRead',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('read_at', models.DateTimeField(auto_now_add=True)),
|
||||
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_by', to='GestionMessagerie.message')),
|
||||
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('message', 'participant')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"hostSMTP": "",
|
||||
"portSMTP": 25,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"useSSL": false,
|
||||
"useTLS": false
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
from django.core.mail import send_mail, get_connection, EmailMultiAlternatives, EmailMessage
|
||||
from django.core.mail import get_connection, EmailMultiAlternatives, EmailMessage
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
@ -17,9 +17,12 @@ def getConnection(id_establishement):
|
||||
try:
|
||||
# Récupérer l'instance de l'établissement
|
||||
establishment = Establishment.objects.get(id=id_establishement)
|
||||
logger.info(f"Establishment trouvé: {establishment.name} (ID: {id_establishement})")
|
||||
|
||||
try:
|
||||
# Récupérer les paramètres SMTP associés à l'établissement
|
||||
smtp_settings = SMTPSettings.objects.get(establishment=establishment)
|
||||
logger.info(f"Paramètres SMTP trouvés pour {establishment.name}: {smtp_settings.smtp_server}:{smtp_settings.smtp_port}")
|
||||
|
||||
# Créer une connexion SMTP avec les paramètres récupérés
|
||||
connection = get_connection(
|
||||
@ -32,9 +35,11 @@ def getConnection(id_establishement):
|
||||
)
|
||||
return connection
|
||||
except SMTPSettings.DoesNotExist:
|
||||
logger.warning(f"Aucun paramètre SMTP spécifique trouvé pour l'établissement {establishment.name} (ID: {id_establishement})")
|
||||
# Aucun paramètre SMTP spécifique, retournera None
|
||||
return None
|
||||
except Establishment.DoesNotExist:
|
||||
logger.error(f"Aucun établissement trouvé avec l'ID {id_establishement}")
|
||||
raise NotFound(f"Aucun établissement trouvé avec l'ID {id_establishement}")
|
||||
|
||||
def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connection=None):
|
||||
@ -53,11 +58,13 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
|
||||
plain_message = strip_tags(message)
|
||||
if connection is not None:
|
||||
from_email = username
|
||||
logger.info(f"Utilisation de la connexion SMTP spécifique: {username}")
|
||||
else:
|
||||
from_email = settings.EMAIL_HOST_USER
|
||||
|
||||
logger.info(f"Utilisation de la configuration SMTP par défaut: {from_email}")
|
||||
|
||||
logger.info(f"From email: {from_email}")
|
||||
logger.info(f"Configuration par défaut - Host: {settings.EMAIL_HOST}, Port: {settings.EMAIL_PORT}, Use TLS: {settings.EMAIL_USE_TLS}")
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
@ -79,6 +86,8 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
|
||||
return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'envoi de l'email: {str(e)}")
|
||||
logger.error(f"Settings : {connection}")
|
||||
logger.error(f"Settings : {connection}")
|
||||
logger.error(f"Type d'erreur: {type(e)}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
@ -198,4 +207,124 @@ def isValid(message, fiche_inscription):
|
||||
responsable = eleve.getMainGuardian()
|
||||
mailReponsableAVerifier = responsable.mail
|
||||
|
||||
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)
|
||||
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
|
||||
@ -349,13 +349,6 @@ SIMPLE_JWT = {
|
||||
'TOKEN_TYPE_CLAIM': 'token_type',
|
||||
}
|
||||
|
||||
# Configuration for DocuSeal JWT
|
||||
DOCUSEAL_JWT = {
|
||||
'ALGORITHM': 'HS256',
|
||||
'SIGNING_KEY': SECRET_KEY,
|
||||
'EXPIRATION_DELTA': timedelta(hours=1)
|
||||
}
|
||||
|
||||
# Django Channels Configuration
|
||||
ASGI_APPLICATION = 'N3wtSchool.asgi.application'
|
||||
|
||||
|
||||
@ -46,7 +46,6 @@ urlpatterns = [
|
||||
path("GestionEmail/", include(("GestionEmail.urls", 'GestionEmail'), namespace='GestionEmail')),
|
||||
path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')),
|
||||
path("School/", include(("School.urls", 'School'), namespace='School')),
|
||||
path("DocuSeal/", include(("DocuSeal.urls", 'DocuSeal'), namespace='DocuSeal')),
|
||||
path("Planning/", include(("Planning.urls", 'Planning'), namespace='Planning')),
|
||||
path("Establishment/", include(("Establishment.urls", 'Establishment'), namespace='Establishment')),
|
||||
path("Settings/", include(("Settings.urls", 'Settings'), namespace='Settings')),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
|
||||
@ -60,6 +60,7 @@ 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
|
||||
@ -155,6 +156,12 @@ 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,6 +35,7 @@ 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__)
|
||||
|
||||
@ -102,8 +103,17 @@ class TeacherListCreateView(APIView):
|
||||
teacher_serializer = TeacherSerializer(data=teacher_data)
|
||||
|
||||
if teacher_serializer.is_valid():
|
||||
teacher_serializer.save()
|
||||
|
||||
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)
|
||||
return JsonResponse(teacher_serializer.data, safe=False)
|
||||
|
||||
return JsonResponse(teacher_serializer.errors, safe=False)
|
||||
@ -118,17 +128,43 @@ 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):
|
||||
return delete_object(Teacher, id, related_field='profile_role')
|
||||
# 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
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
{
|
||||
"activationMailRelance": "Oui",
|
||||
"delaiRelance": "30",
|
||||
"ambiances": [
|
||||
"2-3 ans",
|
||||
"3-6 ans",
|
||||
"6-12 ans"
|
||||
],
|
||||
"genres": [
|
||||
"Fille",
|
||||
"Garçon"
|
||||
],
|
||||
"modesPaiement": [
|
||||
"Chèque",
|
||||
"Virement",
|
||||
"Prélèvement SEPA"
|
||||
]
|
||||
}
|
||||
0
Back-End/Subscriptions/management/__init__.py
Normal file
0
Back-End/Subscriptions/management/__init__.py
Normal file
43
Back-End/Subscriptions/management/commands/test_email.py
Normal file
43
Back-End/Subscriptions/management/commands/test_email.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""
|
||||
Management command pour tester la configuration email Django
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from N3wtSchool.mailManager import getConnection, sendMail
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Test de la configuration email'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--establishment-id', type=int, help='ID de l\'établissement pour test')
|
||||
parser.add_argument('--email', type=str, default='test@example.com', help='Email de destination')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write("=== Test de configuration email ===")
|
||||
|
||||
# Affichage de la configuration
|
||||
self.stdout.write(f"EMAIL_HOST: {settings.EMAIL_HOST}")
|
||||
self.stdout.write(f"EMAIL_PORT: {settings.EMAIL_PORT}")
|
||||
self.stdout.write(f"EMAIL_HOST_USER: {settings.EMAIL_HOST_USER}")
|
||||
self.stdout.write(f"EMAIL_HOST_PASSWORD: {settings.EMAIL_HOST_PASSWORD}")
|
||||
self.stdout.write(f"EMAIL_USE_TLS: {settings.EMAIL_USE_TLS}")
|
||||
self.stdout.write(f"EMAIL_USE_SSL: {settings.EMAIL_USE_SSL}")
|
||||
self.stdout.write(f"EMAIL_BACKEND: {settings.EMAIL_BACKEND}")
|
||||
|
||||
# Test 1: Configuration par défaut Django
|
||||
self.stdout.write("\n--- Test : Configuration EMAIL par défaut ---")
|
||||
try:
|
||||
result = send_mail(
|
||||
'Test Django Email',
|
||||
'Ceci est un test de la configuration email par défaut.',
|
||||
settings.EMAIL_HOST_USER,
|
||||
[options['email']],
|
||||
fail_silently=False,
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"✅ Email envoyé avec succès (résultat: {result})"))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"❌ Erreur: {e}"))
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
|
||||
import Subscriptions.models
|
||||
import django.db.models.deletion
|
||||
@ -46,10 +46,11 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name='RegistrationSchoolFileTemplate',
|
||||
fields=[
|
||||
('id', models.IntegerField(primary_key=True, serialize=False)),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.CharField(default='', max_length=255)),
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)),
|
||||
('formTemplateData', models.JSONField(blank=True, default=list, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@ -153,7 +154,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('description', models.CharField(blank=True, null=True)),
|
||||
('description', models.CharField(blank=True, max_length=500, null=True)),
|
||||
('is_required', models.BooleanField(default=False)),
|
||||
('groups', models.ManyToManyField(blank=True, related_name='parent_file_masters', to='Subscriptions.registrationfilegroup')),
|
||||
],
|
||||
@ -161,9 +162,10 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name='RegistrationSchoolFileMaster',
|
||||
fields=[
|
||||
('id', models.IntegerField(primary_key=True, serialize=False)),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('is_required', models.BooleanField(default=False)),
|
||||
('formMasterData', models.JSONField(blank=True, default=list, null=True)),
|
||||
('groups', models.ManyToManyField(blank=True, related_name='school_file_masters', to='Subscriptions.registrationfilegroup')),
|
||||
],
|
||||
),
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.1.3 on 2025-05-30 07:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('Subscriptions', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='registrationparentfilemaster',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
]
|
||||
@ -2,11 +2,9 @@ from django.db import models
|
||||
from django.utils.timezone import now
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger("SubscriptionModels")
|
||||
|
||||
@ -216,9 +214,28 @@ class RegistrationFileGroup(models.Model):
|
||||
def __str__(self):
|
||||
return f'{self.group.name} - {self.id}'
|
||||
|
||||
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}'
|
||||
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}")
|
||||
|
||||
class RegistrationForm(models.Model):
|
||||
class RegistrationFormStatus(models.IntegerChoices):
|
||||
@ -240,17 +257,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_file_path,
|
||||
upload_to=registration_form_file_upload_to,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
sepa_file = models.FileField(
|
||||
upload_to=registration_file_path,
|
||||
upload_to=registration_form_file_upload_to,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
fusion_file = models.FileField(
|
||||
upload_to=registration_file_path,
|
||||
upload_to=registration_form_file_upload_to,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
@ -277,32 +294,213 @@ class RegistrationForm(models.Model):
|
||||
return "RF_" + self.student.last_name + "_" + self.student.first_name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Vérifier si un fichier existant doit être remplacé
|
||||
# Préparer le flag de création / changement de fileGroup
|
||||
was_new = self.pk is None
|
||||
old_fileGroup = None
|
||||
if not was_new:
|
||||
try:
|
||||
old_instance = RegistrationForm.objects.get(pk=self.pk)
|
||||
old_fileGroup = old_instance.fileGroup
|
||||
except RegistrationForm.DoesNotExist:
|
||||
old_fileGroup = None
|
||||
|
||||
# Supprimer les anciens fichiers si remplacés (évite les suffixes Django)
|
||||
if self.pk: # Si l'objet existe déjà dans la base de données
|
||||
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:
|
||||
# Supprimer l'ancien fichier
|
||||
old_instance.sepa_file.delete(save=False)
|
||||
_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)
|
||||
|
||||
except RegistrationForm.DoesNotExist:
|
||||
pass # L'objet n'existe pas encore, rien à supprimer
|
||||
|
||||
# Appeler la méthode save originale
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Après save : si nouveau ou changement de fileGroup -> créer les templates
|
||||
fileGroup_changed = (self.fileGroup is not None) and (old_fileGroup is None or (old_fileGroup and old_fileGroup.id != self.fileGroup.id))
|
||||
if was_new or fileGroup_changed:
|
||||
try:
|
||||
import Subscriptions.util as util
|
||||
created = util.create_templates_for_registration_form(self)
|
||||
if created:
|
||||
logger.info("Created %d templates for RegistrationForm %s", len(created), self.pk)
|
||||
except Exception as e:
|
||||
logger.exception("Error creating templates for RegistrationForm %s: %s", self.pk, e)
|
||||
|
||||
#############################################################
|
||||
####################### MASTER FILES ########################
|
||||
#############################################################
|
||||
|
||||
####### DocuSeal masters (documents école, à signer ou pas) #######
|
||||
####### Formulaires masters (documents école, à signer ou pas) #######
|
||||
def registration_school_file_master_upload_to(instance, filename):
|
||||
# Stocke les fichiers masters dans un dossier dédié
|
||||
# Utilise l'ID si le nom n'est pas encore disponible
|
||||
est_name = None
|
||||
if instance.establishment and instance.establishment.name:
|
||||
est_name = instance.establishment.name
|
||||
else:
|
||||
# fallback si pas d'établissement (devrait être rare)
|
||||
est_name = "unknown_establishment"
|
||||
return f"{est_name}/Formulaires/{filename}"
|
||||
|
||||
class RegistrationSchoolFileMaster(models.Model):
|
||||
groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True)
|
||||
id = models.IntegerField(primary_key=True)
|
||||
name = models.CharField(max_length=255, default="")
|
||||
is_required = models.BooleanField(default=False)
|
||||
formMasterData = models.JSONField(default=list, blank=True, null=True)
|
||||
file = models.FileField(
|
||||
upload_to=registration_school_file_master_upload_to,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Fichier du formulaire existant (PDF, DOC, etc.)"
|
||||
)
|
||||
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='school_file_masters', null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.group.name} - {self.id}'
|
||||
return f'{self.name} - {self.id}'
|
||||
|
||||
@property
|
||||
def file_url(self):
|
||||
if self.file and hasattr(self.file, 'url'):
|
||||
return self.file.url
|
||||
return None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
affected_rf_ids = set()
|
||||
is_new = self.pk is None
|
||||
|
||||
# Log création ou modification du master
|
||||
if is_new:
|
||||
logger.info(f"[FormPerso] Création master '{self.name}' pour établissement '{self.establishment}'")
|
||||
else:
|
||||
logger.info(f"[FormPerso] Modification master '{self.name}' (id={self.pk}) pour établissement '{self.establishment}'")
|
||||
|
||||
# --- Suppression de l'ancien fichier master si le nom change (form existant ou dynamique) ---
|
||||
if self.pk:
|
||||
try:
|
||||
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
|
||||
if old.file and old.file.name:
|
||||
old_filename = os.path.basename(old.file.name)
|
||||
# Nouveau nom selon le type (dynamique ou existant)
|
||||
if (
|
||||
self.formMasterData
|
||||
and isinstance(self.formMasterData, dict)
|
||||
and self.formMasterData.get("fields")
|
||||
):
|
||||
new_filename = f"{self.name}.pdf"
|
||||
else:
|
||||
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
|
||||
extension = os.path.splitext(old_filename)[1]
|
||||
new_filename = f"{self.name}{extension}" if extension else self.name
|
||||
if new_filename and old_filename != new_filename:
|
||||
old_file_path = old.file.path
|
||||
if os.path.exists(old_file_path):
|
||||
try:
|
||||
os.remove(old_file_path)
|
||||
logger.info(f"[FormPerso] Suppression de l'ancien fichier master: {old_file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[FormPerso] Erreur suppression ancien fichier master: {e}")
|
||||
# Correction du nom du fichier pour éviter le suffixe random
|
||||
if (
|
||||
not self.formMasterData
|
||||
or not (isinstance(self.formMasterData, dict) and self.formMasterData.get("fields"))
|
||||
):
|
||||
# Si le fichier existe et le nom ne correspond pas, renommer le fichier physique et mettre à jour le FileField
|
||||
if self.file and self.file.name:
|
||||
current_filename = os.path.basename(self.file.name)
|
||||
current_path = self.file.path
|
||||
expected_filename = new_filename
|
||||
expected_path = os.path.join(os.path.dirname(current_path), expected_filename)
|
||||
if current_filename != expected_filename:
|
||||
try:
|
||||
if os.path.exists(current_path):
|
||||
os.rename(current_path, expected_path)
|
||||
self.file.name = os.path.join(os.path.dirname(self.file.name), expected_filename).replace("\\", "/")
|
||||
logger.info(f"[FormPerso] Renommage du fichier master: {current_path} -> {expected_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[FormPerso] Erreur lors du renommage du fichier master: {e}")
|
||||
except RegistrationSchoolFileMaster.DoesNotExist:
|
||||
pass
|
||||
|
||||
# --- Traitement PDF dynamique AVANT le super().save() ---
|
||||
if (
|
||||
self.formMasterData
|
||||
and isinstance(self.formMasterData, dict)
|
||||
and self.formMasterData.get("fields")
|
||||
):
|
||||
from Subscriptions.util import generate_form_json_pdf
|
||||
pdf_filename = f"{self.name}.pdf"
|
||||
pdf_file = generate_form_json_pdf(self, self.formMasterData)
|
||||
self.file.save(pdf_filename, pdf_file, save=False)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Synchronisation des templates pour tous les dossiers d'inscription concernés (création ou modification)
|
||||
try:
|
||||
# Import local pour éviter le circular import
|
||||
from Subscriptions.util import create_templates_for_registration_form
|
||||
from Subscriptions.models import RegistrationForm, RegistrationSchoolFileTemplate
|
||||
# Détermination des RF concernés
|
||||
if is_new:
|
||||
new_groups = set(self.groups.values_list('id', flat=True))
|
||||
affected_rf_ids.update(
|
||||
RegistrationForm.objects.filter(fileGroup__in=list(new_groups)).values_list('pk', flat=True)
|
||||
)
|
||||
else:
|
||||
try:
|
||||
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
|
||||
old_groups = set(old.groups.values_list('id', flat=True))
|
||||
new_groups = set(self.groups.values_list('id', flat=True))
|
||||
affected_rf_ids.update(
|
||||
RegistrationForm.objects.filter(fileGroup__in=list(old_groups | new_groups)).values_list('pk', flat=True)
|
||||
)
|
||||
form_data_changed = (
|
||||
old.formMasterData != self.formMasterData
|
||||
and self.formMasterData
|
||||
and isinstance(self.formMasterData, dict)
|
||||
and self.formMasterData.get("fields")
|
||||
)
|
||||
name_changed = old.name != self.name
|
||||
if form_data_changed or name_changed:
|
||||
logger.info(f"[FormPerso] Modification du contenu du master '{self.name}' (id={self.pk})")
|
||||
except RegistrationSchoolFileMaster.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Pour chaque RF concerné, régénérer les templates
|
||||
for rf_id in affected_rf_ids:
|
||||
try:
|
||||
rf = RegistrationForm.objects.get(pk=rf_id)
|
||||
logger.info(f"[FormPerso] Synchronisation template pour élève '{rf.student.last_name}_{rf.student.first_name}' (RF id={rf.pk}) suite à modification/ajout du master '{self.name}'")
|
||||
create_templates_for_registration_form(rf)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la synchronisation des templates pour RF {rf_id} après modification du master {self.pk}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur globale lors de la synchronisation des templates après modification du master {self.pk}: {e}")
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
logger.info(f"[FormPerso] Suppression master '{self.name}' (id={self.pk}) et tous ses templates")
|
||||
# Import local pour éviter le circular import
|
||||
from Subscriptions.models import RegistrationSchoolFileTemplate
|
||||
templates = RegistrationSchoolFileTemplate.objects.filter(master=self)
|
||||
for tmpl in templates:
|
||||
logger.info(f"[FormPerso] Suppression template '{tmpl.name}' pour élève '{tmpl.registration_form.student.last_name}_{tmpl.registration_form.student.first_name}' (RF id={tmpl.registration_form.pk})")
|
||||
if self.file and hasattr(self.file, 'path') and os.path.exists(self.file.path):
|
||||
try:
|
||||
self.file.delete(save=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la suppression du fichier master: {e}")
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
####### Parent files masters (documents à fournir par les parents) #######
|
||||
class RegistrationParentFileMaster(models.Model):
|
||||
@ -316,32 +514,57 @@ class RegistrationParentFileMaster(models.Model):
|
||||
############################################################
|
||||
|
||||
def registration_school_file_upload_to(instance, filename):
|
||||
return f"registration_files/dossier_rf_{instance.registration_form.pk}/school/{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):
|
||||
return f"registration_files/dossier_rf_{instance.registration_form.pk}/parent/{filename}"
|
||||
"""
|
||||
Génère le chemin pour les fichiers à fournir par les parents.
|
||||
Structure : Etablissement/dossier_NomEleve_PrenomEleve/parent/filename
|
||||
"""
|
||||
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/parent/{filename}"
|
||||
|
||||
####### DocuSeal templates (par dossier d'inscription) #######
|
||||
####### Formulaires templates (par dossier d'inscription) #######
|
||||
class RegistrationSchoolFileTemplate(models.Model):
|
||||
master = models.ForeignKey(RegistrationSchoolFileMaster, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
||||
id = models.IntegerField(primary_key=True)
|
||||
slug = models.CharField(max_length=255, default="")
|
||||
name = models.CharField(max_length=255, default="")
|
||||
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:
|
||||
filenames.append(reg_file.file.path)
|
||||
if reg_file.file and hasattr(reg_file.file, 'path'):
|
||||
if os.path.exists(reg_file.file.path):
|
||||
filenames.append(reg_file.file.path)
|
||||
else:
|
||||
logger.warning(f"Fichier introuvable ignoré: {reg_file.file.path}")
|
||||
return filenames
|
||||
|
||||
class StudentCompetency(models.Model):
|
||||
@ -371,22 +594,24 @@ 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.name
|
||||
return self.master.name if self.master else f"ParentFile_{self.pk}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.pk: # Si l'objet existe déjà dans la base de données
|
||||
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
|
||||
if self.pk:
|
||||
try:
|
||||
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
|
||||
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.")
|
||||
# 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
|
||||
except RegistrationParentFileTemplate.DoesNotExist:
|
||||
print("Ancienne instance introuvable.")
|
||||
pass
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
from rest_framework import serializers
|
||||
from .models import (
|
||||
RegistrationFileGroup,
|
||||
RegistrationForm,
|
||||
Student,
|
||||
Guardian,
|
||||
Sibling,
|
||||
RegistrationFileGroup,
|
||||
RegistrationForm,
|
||||
Student,
|
||||
Guardian,
|
||||
Sibling,
|
||||
Language,
|
||||
RegistrationSchoolFileMaster,
|
||||
RegistrationSchoolFileTemplate,
|
||||
RegistrationParentFileMaster,
|
||||
RegistrationParentFileTemplate,
|
||||
RegistrationSchoolFileMaster,
|
||||
RegistrationSchoolFileTemplate,
|
||||
RegistrationParentFileMaster,
|
||||
RegistrationParentFileTemplate,
|
||||
AbsenceManagement,
|
||||
BilanCompetence
|
||||
)
|
||||
@ -21,6 +21,7 @@ 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()
|
||||
@ -95,7 +96,7 @@ class RegistrationFormSimpleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = RegistrationForm
|
||||
fields = ['student_id', 'last_name', 'first_name', 'guardians']
|
||||
|
||||
|
||||
def get_last_name(self, obj):
|
||||
return obj.student.last_name
|
||||
|
||||
@ -164,12 +165,20 @@ class StudentSerializer(serializers.ModelSerializer):
|
||||
|
||||
if guardian_id:
|
||||
# Si un ID est fourni, récupérer ou mettre à jour le Guardian existant
|
||||
guardian_instance, created = Guardian.objects.update_or_create(
|
||||
id=guardian_id,
|
||||
defaults=guardian_data
|
||||
)
|
||||
guardians_ids.append(guardian_instance.id)
|
||||
continue
|
||||
try:
|
||||
guardian_instance = Guardian.objects.get(id=guardian_id)
|
||||
# Mettre à jour explicitement tous les champs y compris birth_date, profession, address
|
||||
for field, value in guardian_data.items():
|
||||
if field != 'id': # Ne pas mettre à jour l'ID
|
||||
setattr(guardian_instance, field, value)
|
||||
guardian_instance.save()
|
||||
guardians_ids.append(guardian_instance.id)
|
||||
continue
|
||||
except Guardian.DoesNotExist:
|
||||
# Si le guardian n'existe pas, créer un nouveau
|
||||
guardian_instance = Guardian.objects.create(**guardian_data)
|
||||
guardians_ids.append(guardian_instance.id)
|
||||
continue
|
||||
|
||||
if profile_role_data:
|
||||
# Vérifiez si 'profile_data' est fourni pour créer un nouveau profil
|
||||
@ -207,6 +216,14 @@ 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)
|
||||
|
||||
@ -5,6 +5,8 @@ from Subscriptions.automate import Automate_RF_Register, updateStateMachine
|
||||
from .models import RegistrationForm
|
||||
from GestionMessagerie.models import Messagerie
|
||||
from N3wtSchool import settings, bdd
|
||||
from N3wtSchool.mailManager import sendMail, getConnection
|
||||
from django.template.loader import render_to_string
|
||||
import requests
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -26,17 +28,82 @@ def send_notification(dossier):
|
||||
# Changer l'état de l'automate
|
||||
updateStateMachine(dossier, 'EVENT_FOLLOW_UP')
|
||||
|
||||
url = settings.URL_DJANGO + 'GestionMessagerie/message'
|
||||
# Envoyer un email de relance aux responsables
|
||||
try:
|
||||
# Récupérer l'établissement du dossier
|
||||
establishment_id = dossier.establishment.id
|
||||
|
||||
destinataires = dossier.eleve.profiles.all()
|
||||
for destinataire in destinataires:
|
||||
message = {
|
||||
"objet": "[RELANCE]",
|
||||
"destinataire" : destinataire.id,
|
||||
"corpus": "RELANCE pour le dossier d'inscription"
|
||||
# Obtenir la connexion SMTP pour cet établissement
|
||||
connection = getConnection(establishment_id)
|
||||
|
||||
# Préparer le contenu de l'email
|
||||
subject = f"[RELANCE] Dossier d'inscription en attente - {dossier.eleve.first_name} {dossier.eleve.last_name}"
|
||||
|
||||
context = {
|
||||
'student_name': f"{dossier.eleve.first_name} {dossier.eleve.last_name}",
|
||||
'deadline_date': (timezone.now() - timezone.timedelta(days=settings.EXPIRATION_DI_NB_DAYS)).strftime('%d/%m/%Y'),
|
||||
'establishment_name': dossier.establishment.name,
|
||||
'base_url': settings.BASE_URL
|
||||
}
|
||||
|
||||
response = requests.post(url, json=message)
|
||||
# Utiliser un template HTML pour l'email (si disponible)
|
||||
try:
|
||||
html_message = render_to_string('emails/relance_signature.html', context)
|
||||
except:
|
||||
# Si pas de template, message simple
|
||||
html_message = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Relance - Dossier d'inscription en attente</h2>
|
||||
<p>Bonjour,</p>
|
||||
<p>Le dossier d'inscription de <strong>{context['student_name']}</strong> est en attente de signature depuis plus de {settings.EXPIRATION_DI_NB_DAYS} jours.</p>
|
||||
<p>Merci de vous connecter à votre espace pour finaliser l'inscription.</p>
|
||||
<p>Cordialement,<br>L'équipe {context['establishment_name']}</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# Récupérer les emails des responsables
|
||||
destinataires = []
|
||||
profiles = dossier.eleve.profiles.all()
|
||||
for profile in profiles:
|
||||
if profile.email:
|
||||
destinataires.append(profile.email)
|
||||
|
||||
if destinataires:
|
||||
# Envoyer l'email
|
||||
result = sendMail(
|
||||
subject=subject,
|
||||
message=html_message,
|
||||
recipients=destinataires,
|
||||
connection=connection
|
||||
)
|
||||
logger.info(f"Email de relance envoyé pour le dossier {dossier.id} à {destinataires}")
|
||||
else:
|
||||
logger.warning(f"Aucun email trouvé pour les responsables du dossier {dossier.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'envoi de l'email de relance pour le dossier {dossier.id}: {str(e)}")
|
||||
|
||||
# En cas d'erreur email, utiliser la messagerie interne comme fallback
|
||||
try:
|
||||
url = settings.URL_DJANGO + 'GestionMessagerie/send-message/'
|
||||
|
||||
# Créer ou récupérer une conversation avec chaque responsable
|
||||
destinataires = dossier.eleve.profiles.all()
|
||||
for destinataire in destinataires:
|
||||
message_data = {
|
||||
"conversation_id": None, # Sera géré par l'API
|
||||
"sender_id": 1, # ID du système ou admin
|
||||
"content": f"RELANCE pour le dossier d'inscription de {dossier.eleve.first_name} {dossier.eleve.last_name}"
|
||||
}
|
||||
|
||||
response = requests.post(url, json=message_data)
|
||||
if response.status_code != 201:
|
||||
logger.error(f"Erreur lors de l'envoi du message interne: {response.text}")
|
||||
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Erreur lors de l'envoi du message interne de fallback: {str(inner_e)}")
|
||||
|
||||
# subject = f"Dossier d'inscription non signé - {dossier.objet}"
|
||||
# message = f"Le dossier d'inscription avec l'objet '{dossier.objet}' n'a pas été signé depuis {dossier.created_at}."
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
<!-- 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>
|
||||
66
Back-End/Subscriptions/templates/emails/refus_definitif.html
Normal file
66
Back-End/Subscriptions/templates/emails/refus_definitif.html
Normal file
@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dossier d'inscription refusé</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background-color: #f4f4f4;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
.notes {
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
|
||||
<h1>Dossier d'inscription refusé</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Bonjour,</p>
|
||||
<p>Nous avons le regret de vous informer que le dossier d'inscription de <strong>{{ student_name }}</strong> n'a pas été retenu.</p>
|
||||
|
||||
<div class="notes">
|
||||
<strong>Motif(s) :</strong><br>
|
||||
{{ notes }}
|
||||
</div>
|
||||
|
||||
<p>Nous vous remercions de l'intérêt que vous avez porté à notre établissement et restons à votre disposition pour tout renseignement complémentaire.</p>
|
||||
|
||||
<p>Cordialement,</p>
|
||||
<p>L'équipe N3wt School</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
67
Back-End/Subscriptions/templates/emails/refus_dossier.html
Normal file
67
Back-End/Subscriptions/templates/emails/refus_dossier.html
Normal file
@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dossier d'inscription - Corrections requises</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background-color: #f4f4f4;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
.notes {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
|
||||
<h1>Dossier d'inscription - Corrections requises</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Bonjour,</p>
|
||||
<p>Nous avons examiné le dossier d'inscription de <strong>{{ student_name }}</strong> et certaines corrections sont nécessaires avant de pouvoir le valider.</p>
|
||||
|
||||
<div class="notes">
|
||||
<strong>Motif(s) :</strong><br>
|
||||
{{ notes }}
|
||||
</div>
|
||||
|
||||
<p>Veuillez vous connecter à votre espace pour effectuer les corrections demandées :</p>
|
||||
<p><a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
|
||||
|
||||
<p>Cordialement,</p>
|
||||
<p>L'équipe N3wt School</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
121
Back-End/Subscriptions/templates/emails/relance_signature.html
Normal file
121
Back-End/Subscriptions/templates/emails/relance_signature.html
Normal file
@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Relance - Dossier d'inscription</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 3px solid #007bff;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #007bff;
|
||||
margin: 0;
|
||||
}
|
||||
.alert {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.alert-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.content {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.student-info {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #007bff;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 12px 25px;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>{{ establishment_name }}</h1>
|
||||
<p>Relance - Dossier d'inscription</p>
|
||||
</div>
|
||||
|
||||
<div class="alert">
|
||||
<span class="alert-icon">⚠️</span>
|
||||
<strong>Attention :</strong> Votre dossier d'inscription nécessite votre attention
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Bonjour,</p>
|
||||
|
||||
<div class="student-info">
|
||||
<h3>Dossier d'inscription de : <strong>{{ student_name }}</strong></h3>
|
||||
<p>En attente depuis le : <strong>{{ deadline_date }}</strong></p>
|
||||
</div>
|
||||
|
||||
<p>Nous vous informons que le dossier d'inscription mentionné ci-dessus est en attente de finalisation depuis plus de {{ deadline_date }}.</p>
|
||||
|
||||
<p><strong>Action requise :</strong></p>
|
||||
<ul>
|
||||
<li>Connectez-vous à votre espace personnel</li>
|
||||
<li>Vérifiez les documents manquants</li>
|
||||
<li>Complétez et signez les formulaires en attente</li>
|
||||
</ul>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="{{ base_url }}" class="cta-button">Accéder à mon espace</a>
|
||||
</div>
|
||||
|
||||
<p>Si vous rencontrez des difficultés ou avez des questions concernant ce dossier, n'hésitez pas à nous contacter.</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Cordialement,<br>
|
||||
L'équipe {{ establishment_name }}</p>
|
||||
|
||||
<hr style="margin: 20px 0;">
|
||||
|
||||
<p style="font-size: 12px;">
|
||||
Cet email a été envoyé automatiquement. Si vous pensez avoir reçu ce message par erreur,
|
||||
veuillez contacter l'établissement directement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dossier d'inscription validé</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background-color: #f4f4f4;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
.success-box {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #28a745;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.success-box h2 {
|
||||
color: #155724;
|
||||
margin: 0;
|
||||
}
|
||||
.class-info {
|
||||
background-color: #e7f3ff;
|
||||
border: 1px solid #0066cc;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
|
||||
<h1>Dossier d'inscription validé</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Bonjour,</p>
|
||||
|
||||
<div class="success-box">
|
||||
<h2>Félicitations !</h2>
|
||||
<p>Le dossier d'inscription de <strong>{{ student_name }}</strong> a été validé.</p>
|
||||
</div>
|
||||
|
||||
{% if class_name %}
|
||||
<div class="class-info">
|
||||
<strong>Classe attribuée :</strong> {{ class_name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p>Vous pouvez accéder à votre espace pour consulter les détails :</p>
|
||||
<p><a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
|
||||
|
||||
<p>Nous vous remercions de votre confiance et vous souhaitons une excellente année scolaire.</p>
|
||||
|
||||
<p>Cordialement,</p>
|
||||
<p>L'équipe N3wt School</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -24,7 +24,10 @@ from .views import (
|
||||
)
|
||||
|
||||
from .views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group
|
||||
from .views import registration_file_views, get_school_file_templates_by_rf, get_parent_file_templates_by_rf
|
||||
from .views import (
|
||||
get_school_file_templates_by_rf,
|
||||
get_parent_file_templates_by_rf
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^registerForms/(?P<id>[0-9]+)/archive$', archive, name="archive"),
|
||||
|
||||
@ -8,6 +8,9 @@ from N3wtSchool import renderers
|
||||
from N3wtSchool import bdd
|
||||
|
||||
from io import BytesIO
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files import File
|
||||
from pathlib import Path
|
||||
import os
|
||||
@ -16,13 +19,279 @@ from enum import Enum
|
||||
import random
|
||||
import string
|
||||
from rest_framework.parsers import JSONParser
|
||||
from PyPDF2 import PdfMerger
|
||||
from PyPDF2 import PdfMerger, PdfReader
|
||||
from PyPDF2.errors import PdfReadError
|
||||
|
||||
import shutil
|
||||
import logging
|
||||
|
||||
import json
|
||||
from django.http import QueryDict
|
||||
from rest_framework.response import Response
|
||||
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.
|
||||
- supporte multipart/form-data où le front envoie 'data' (JSON string) ou un fichier JSON + fichiers
|
||||
- supporte application/json ou form-data simple
|
||||
Retour: (payload_dict, None) ou (None, Response erreur)
|
||||
"""
|
||||
# Si c'est du JSON pur (Content-Type: application/json)
|
||||
if hasattr(request, 'content_type') and 'application/json' in request.content_type:
|
||||
try:
|
||||
# request.data contient déjà le JSON parsé par Django REST
|
||||
payload = dict(request.data) if hasattr(request.data, 'items') else request.data
|
||||
logger.info(f"JSON payload extracted: {payload}")
|
||||
return payload, None
|
||||
except Exception as e:
|
||||
logger.error(f'Error processing JSON: {e}')
|
||||
return None, Response({'error': "Invalid JSON", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Cas multipart/form-data avec champ 'data'
|
||||
data_field = request.data.get('data') if hasattr(request.data, 'get') else None
|
||||
if data_field:
|
||||
try:
|
||||
# Si 'data' est un fichier (InMemoryUploadedFile ou fichier similaire), lire et décoder
|
||||
if hasattr(data_field, 'read'):
|
||||
raw = data_field.read()
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
text = raw.decode('utf-8')
|
||||
else:
|
||||
text = raw
|
||||
payload = json.loads(text)
|
||||
# Si 'data' est bytes déjà
|
||||
elif isinstance(data_field, (bytes, bytearray)):
|
||||
payload = json.loads(data_field.decode('utf-8'))
|
||||
# Si 'data' est une string JSON
|
||||
elif isinstance(data_field, str):
|
||||
payload = json.loads(data_field)
|
||||
else:
|
||||
# type inattendu
|
||||
raise ValueError(f"Unsupported 'data' type: {type(data_field)}")
|
||||
except (json.JSONDecodeError, ValueError, UnicodeDecodeError) as e:
|
||||
logger.error(f'Invalid JSON in "data": {e}')
|
||||
return None, Response({'error': "Invalid JSON in 'data'", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
payload = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data)
|
||||
if isinstance(payload, QueryDict):
|
||||
payload = payload.dict()
|
||||
|
||||
# Attacher les fichiers présents (ex: photo, files.*, etc.), sauf 'data' (déjà traité)
|
||||
for f_key, f_val in request.FILES.items():
|
||||
if f_key == 'data':
|
||||
# remettre le pointeur au début si besoin (déjà lu) — non indispensable ici mais sûr
|
||||
try:
|
||||
f_val.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
# ne pas mettre le fichier 'data' dans le payload (c'est le JSON)
|
||||
continue
|
||||
payload[f_key] = f_val
|
||||
|
||||
return payload, None
|
||||
|
||||
def create_templates_for_registration_form(register_form):
|
||||
"""
|
||||
Idempotent:
|
||||
- supprime les templates existants qui ne correspondent pas
|
||||
aux masters du fileGroup courant du register_form (et supprime leurs fichiers).
|
||||
- crée les templates manquants pour les masters du fileGroup courant.
|
||||
Retourne la liste des templates créés.
|
||||
"""
|
||||
from Subscriptions.models import (
|
||||
RegistrationSchoolFileMaster,
|
||||
RegistrationSchoolFileTemplate,
|
||||
RegistrationParentFileMaster,
|
||||
RegistrationParentFileTemplate,
|
||||
registration_school_file_upload_to,
|
||||
)
|
||||
|
||||
created = []
|
||||
logger.info("util.create_templates_for_registration_form - create_templates_for_registration_form")
|
||||
|
||||
# Récupérer les masters du fileGroup courant
|
||||
current_group = getattr(register_form, "fileGroup", None)
|
||||
if not current_group:
|
||||
# Si plus de fileGroup, supprimer tous les templates existants pour ce RF
|
||||
school_existing = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form)
|
||||
for t in school_existing:
|
||||
try:
|
||||
if getattr(t, "file", None):
|
||||
logger.info("Deleted school template %s for RF %s", t.pk, register_form.pk)
|
||||
t.file.delete(save=False)
|
||||
except Exception:
|
||||
logger.exception("Erreur suppression fichier school template %s", getattr(t, "pk", None))
|
||||
t.delete()
|
||||
parent_existing = RegistrationParentFileTemplate.objects.filter(registration_form=register_form)
|
||||
for t in parent_existing:
|
||||
try:
|
||||
if getattr(t, "file", None):
|
||||
t.file.delete(save=False)
|
||||
except Exception:
|
||||
logger.exception("Erreur suppression fichier parent template %s", getattr(t, "pk", None))
|
||||
t.delete()
|
||||
return created
|
||||
|
||||
school_masters = RegistrationSchoolFileMaster.objects.filter(groups=current_group).distinct()
|
||||
parent_masters = RegistrationParentFileMaster.objects.filter(groups=current_group).distinct()
|
||||
|
||||
school_master_ids = {m.pk for m in school_masters}
|
||||
parent_master_ids = {m.pk for m in parent_masters}
|
||||
|
||||
# Supprimer les school templates obsolètes
|
||||
for tmpl in RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form):
|
||||
if not tmpl.master_id or tmpl.master_id not in school_master_ids:
|
||||
try:
|
||||
if getattr(tmpl, "file", None):
|
||||
tmpl.file.delete(save=False)
|
||||
except Exception:
|
||||
logger.exception("Erreur suppression fichier school template obsolète %s", getattr(tmpl, "pk", None))
|
||||
tmpl.delete()
|
||||
logger.info("Deleted obsolete school template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
|
||||
|
||||
# Supprimer les parent templates obsolètes
|
||||
for tmpl in RegistrationParentFileTemplate.objects.filter(registration_form=register_form):
|
||||
if not tmpl.master_id or tmpl.master_id not in parent_master_ids:
|
||||
try:
|
||||
if getattr(tmpl, "file", None):
|
||||
tmpl.file.delete(save=False)
|
||||
except Exception:
|
||||
logger.exception("Erreur suppression fichier parent template obsolète %s", getattr(tmpl, "pk", None))
|
||||
tmpl.delete()
|
||||
logger.info("Deleted obsolete parent template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
|
||||
|
||||
# Créer les school templates manquants ou mettre à jour les existants si le master a changé
|
||||
for m in school_masters:
|
||||
tmpl_qs = RegistrationSchoolFileTemplate.objects.filter(master=m, registration_form=register_form)
|
||||
tmpl = tmpl_qs.first() if tmpl_qs.exists() else None
|
||||
|
||||
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
|
||||
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
|
||||
|
||||
file_name = None
|
||||
if m.file and hasattr(m.file, 'name') and m.file.name:
|
||||
file_name = os.path.basename(m.file.name)
|
||||
elif m.file:
|
||||
file_name = str(m.file)
|
||||
else:
|
||||
try:
|
||||
pdf_file = generate_form_json_pdf(register_form, m.formMasterData)
|
||||
file_name = os.path.basename(pdf_file.name)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la génération du PDF pour le template: {e}")
|
||||
file_name = None
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
upload_rel_path = registration_school_file_upload_to(
|
||||
type("Tmp", (), {
|
||||
"registration_form": register_form,
|
||||
"establishment": getattr(register_form, "establishment", None),
|
||||
"student": getattr(register_form, "student", None)
|
||||
})(),
|
||||
file_name
|
||||
)
|
||||
abs_path = os.path.join(settings.MEDIA_ROOT, upload_rel_path)
|
||||
master_file_path = m.file.path if m.file and hasattr(m.file, 'path') else None
|
||||
|
||||
if tmpl:
|
||||
template_file_name = os.path.basename(tmpl.file.name) if tmpl.file and tmpl.file.name else None
|
||||
master_file_changed = template_file_name != file_name
|
||||
# --- GESTION FORM EXISTANT : suppression ancien template si nom ou contenu master changé ---
|
||||
if master_file_changed or (
|
||||
master_file_path and os.path.exists(master_file_path) and
|
||||
(not tmpl.file or not os.path.exists(abs_path) or os.path.getmtime(master_file_path) > os.path.getmtime(abs_path))
|
||||
):
|
||||
# Supprimer l'ancien fichier du template (même si le nom change)
|
||||
if tmpl.file and tmpl.file.name:
|
||||
old_template_path = os.path.join(settings.MEDIA_ROOT, tmpl.file.name)
|
||||
if os.path.exists(old_template_path):
|
||||
try:
|
||||
os.remove(old_template_path)
|
||||
logger.info(f"util.create_templates_for_registration_form - Suppression ancien fichier template: {old_template_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur suppression ancien fichier template: {e}")
|
||||
# Copier le nouveau fichier du master (form existant)
|
||||
if master_file_path and os.path.exists(master_file_path):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
import shutil
|
||||
shutil.copy2(master_file_path, abs_path)
|
||||
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
|
||||
tmpl.file.name = upload_rel_path
|
||||
tmpl.name = m.name or ""
|
||||
tmpl.slug = slug
|
||||
tmpl.formTemplateData = m.formMasterData or []
|
||||
tmpl.save()
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la copie du fichier master pour mise à jour du template: {e}")
|
||||
created.append(tmpl)
|
||||
logger.info("util.create_templates_for_registration_form - Mise à jour school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||
continue
|
||||
|
||||
# Sinon, création du template comme avant
|
||||
tmpl = RegistrationSchoolFileTemplate(
|
||||
master=m,
|
||||
registration_form=register_form,
|
||||
name=m.name or "",
|
||||
formTemplateData=m.formMasterData or [],
|
||||
slug=slug,
|
||||
)
|
||||
if file_name:
|
||||
# Copier le fichier du master si besoin (form existant)
|
||||
if master_file_path and not os.path.exists(abs_path):
|
||||
try:
|
||||
import shutil
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
shutil.copy2(master_file_path, abs_path)
|
||||
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la copie du fichier master pour le template: {e}")
|
||||
tmpl.file.name = upload_rel_path
|
||||
tmpl.save()
|
||||
created.append(tmpl)
|
||||
logger.info("util.create_templates_for_registration_form - Created school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||
|
||||
# Créer les parent templates manquants
|
||||
for m in parent_masters:
|
||||
exists = RegistrationParentFileTemplate.objects.filter(master=m, registration_form=register_form).exists()
|
||||
if exists:
|
||||
continue
|
||||
tmpl = RegistrationParentFileTemplate.objects.create(
|
||||
master=m,
|
||||
registration_form=register_form,
|
||||
file=None,
|
||||
)
|
||||
created.append(tmpl)
|
||||
logger.info("Created parent template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||
|
||||
return created
|
||||
|
||||
def recupereListeFichesInscription():
|
||||
"""
|
||||
Retourne la liste complète des fiches d’inscription.
|
||||
@ -99,12 +368,70 @@ 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:
|
||||
merger.append(file_path)
|
||||
# Vérifier que le fichier existe
|
||||
if not os.path.exists(file_path):
|
||||
logger.warning(f"[merge_files_pdf] Fichier introuvable, ignoré: {file_path}")
|
||||
continue
|
||||
|
||||
file_ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
# Si c'est une image, la convertir en PDF
|
||||
if file_ext in image_extensions:
|
||||
try:
|
||||
from PIL import Image
|
||||
from reportlab.lib.utils import ImageReader
|
||||
|
||||
img = Image.open(file_path)
|
||||
img_width, img_height = img.size
|
||||
|
||||
# Créer un PDF en mémoire avec l'image
|
||||
img_pdf = BytesIO()
|
||||
c = canvas.Canvas(img_pdf, pagesize=(img_width, img_height))
|
||||
c.drawImage(file_path, 0, 0, width=img_width, height=img_height)
|
||||
c.save()
|
||||
img_pdf.seek(0)
|
||||
|
||||
merger.append(img_pdf)
|
||||
files_added += 1
|
||||
logger.debug(f"[merge_files_pdf] Image convertie et ajoutée: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[merge_files_pdf] Erreur lors de la conversion de l'image {file_path}: {e}")
|
||||
continue
|
||||
|
||||
# Sinon, essayer de l'ajouter comme PDF
|
||||
try:
|
||||
# Valider que c'est un PDF lisible
|
||||
with open(file_path, 'rb') as f:
|
||||
PdfReader(f, strict=False)
|
||||
|
||||
# Si la validation passe, ajouter au merger
|
||||
merger.append(file_path)
|
||||
files_added += 1
|
||||
logger.debug(f"[merge_files_pdf] PDF ajouté: {file_path}")
|
||||
except PdfReadError as e:
|
||||
logger.error(f"[merge_files_pdf] Fichier PDF invalide, ignoré: {file_path} - {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[merge_files_pdf] Erreur lors de la lecture du fichier {file_path}: {e}")
|
||||
|
||||
if files_added == 0:
|
||||
logger.warning("[merge_files_pdf] Aucun fichier valide à fusionner")
|
||||
# Retourner un PDF vide
|
||||
empty_pdf = BytesIO()
|
||||
c = canvas.Canvas(empty_pdf, pagesize=A4)
|
||||
c.drawString(100, 750, "Aucun document à afficher")
|
||||
c.save()
|
||||
empty_pdf.seek(0)
|
||||
return empty_pdf
|
||||
|
||||
# Sauvegarder le fichier fusionné en mémoire
|
||||
merged_pdf = BytesIO()
|
||||
@ -133,25 +460,11 @@ def rfToPDF(registerForm, filename):
|
||||
if not pdf:
|
||||
raise ValueError("Erreur lors de la génération du PDF.")
|
||||
|
||||
# Vérifier si un fichier avec le même nom existe déjà et le supprimer
|
||||
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
|
||||
# Enregistrer directement le fichier dans le champ registration_file (écrase l'existant)
|
||||
try:
|
||||
registerForm.registration_file.save(
|
||||
os.path.basename(filename), # Utiliser uniquement le nom de fichier
|
||||
save_file_replacing_existing(
|
||||
registerForm.registration_file,
|
||||
os.path.basename(filename),
|
||||
File(BytesIO(pdf.content)),
|
||||
save=True
|
||||
)
|
||||
@ -212,4 +525,57 @@ def getHistoricalYears(count=5):
|
||||
historical_start_year = start_year - i
|
||||
historical_years.append(f"{historical_start_year}-{historical_start_year + 1}")
|
||||
|
||||
return historical_years
|
||||
return historical_years
|
||||
|
||||
def generate_form_json_pdf(register_form, form_json):
|
||||
"""
|
||||
Génère un PDF du rendu du formulaire dynamique à partir du JSON (formConfig)
|
||||
et l'associe au RegistrationSchoolFileTemplate.
|
||||
Le PDF contient le titre, les labels et types de champs.
|
||||
Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier.
|
||||
"""
|
||||
|
||||
# Récupérer le nom du formulaire
|
||||
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
|
||||
filename = f"{form_name}.pdf"
|
||||
|
||||
# Générer le PDF
|
||||
buffer = BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=A4)
|
||||
y = 800
|
||||
|
||||
# Titre
|
||||
c.setFont("Helvetica-Bold", 20)
|
||||
c.drawString(100, y, form_json.get("title", "Formulaire"))
|
||||
y -= 40
|
||||
|
||||
# Champs
|
||||
c.setFont("Helvetica", 12)
|
||||
fields = form_json.get("fields", [])
|
||||
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}]")
|
||||
y -= 25
|
||||
if y < 100:
|
||||
c.showPage()
|
||||
y = 800
|
||||
|
||||
c.save()
|
||||
buffer.seek(0)
|
||||
pdf_content = buffer.read()
|
||||
|
||||
# Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage)
|
||||
if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name:
|
||||
existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/'))
|
||||
if os.path.exists(existing_file_path):
|
||||
os.remove(existing_file_path)
|
||||
register_form.registration_file.delete(save=False)
|
||||
|
||||
# Retourner le ContentFile avec uniquement le nom du fichier
|
||||
return ContentFile(pdf_content, name=os.path.basename(filename))
|
||||
|
||||
@ -1,11 +1,25 @@
|
||||
from .register_form_views import RegisterFormView, RegisterFormWithIdView, send, resend, archive, get_school_file_templates_by_rf, get_parent_file_templates_by_rf
|
||||
from .registration_file_views import (
|
||||
from .register_form_views import (
|
||||
RegisterFormView,
|
||||
RegisterFormWithIdView,
|
||||
send,
|
||||
resend,
|
||||
archive,
|
||||
get_school_file_templates_by_rf,
|
||||
get_parent_file_templates_by_rf
|
||||
)
|
||||
from .registration_school_file_masters_views import (
|
||||
RegistrationSchoolFileMasterView,
|
||||
RegistrationSchoolFileMasterSimpleView,
|
||||
RegistrationSchoolFileMasterSimpleView,
|
||||
)
|
||||
from .registration_school_file_templates_views import (
|
||||
RegistrationSchoolFileTemplateView,
|
||||
RegistrationSchoolFileTemplateSimpleView,
|
||||
RegistrationSchoolFileTemplateSimpleView
|
||||
)
|
||||
from .registration_parent_file_masters_views import (
|
||||
RegistrationParentFileMasterView,
|
||||
RegistrationParentFileMasterSimpleView,
|
||||
RegistrationParentFileMasterSimpleView
|
||||
)
|
||||
from .registration_parent_file_templates_views import (
|
||||
RegistrationParentFileTemplateSimpleView,
|
||||
RegistrationParentFileTemplateView
|
||||
)
|
||||
@ -33,7 +47,7 @@ __all__ = [
|
||||
'RegistrationFileGroupSimpleView',
|
||||
'get_registration_files_by_group',
|
||||
'get_school_file_templates_by_rf',
|
||||
'get_parent_file_templates_by_rf'
|
||||
'get_parent_file_templates_by_rf',
|
||||
'StudentView',
|
||||
'StudentListView',
|
||||
'ChildrenListView',
|
||||
|
||||
@ -9,6 +9,7 @@ from drf_yasg import openapi
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from django.core.files import File
|
||||
|
||||
import N3wtSchool.mailManager as mailer
|
||||
@ -17,10 +18,10 @@ import Subscriptions.util as util
|
||||
from Subscriptions.serializers import RegistrationFormSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileTemplateSerializer
|
||||
from Subscriptions.pagination import CustomSubscriptionPagination
|
||||
from Subscriptions.models import (
|
||||
Guardian,
|
||||
RegistrationForm,
|
||||
RegistrationSchoolFileTemplate,
|
||||
RegistrationFileGroup,
|
||||
Guardian,
|
||||
RegistrationForm,
|
||||
RegistrationSchoolFileTemplate,
|
||||
RegistrationFileGroup,
|
||||
RegistrationParentFileTemplate,
|
||||
StudentCompetency
|
||||
)
|
||||
@ -323,6 +324,27 @@ 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:
|
||||
@ -332,9 +354,32 @@ 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:
|
||||
@ -377,14 +422,23 @@ class RegisterFormWithIdView(APIView):
|
||||
fileNames.extend(parent_file_templates)
|
||||
|
||||
# Création du fichier PDF fusionné
|
||||
merged_pdf_content = util.merge_files_pdf(fileNames)
|
||||
merged_pdf_content = None
|
||||
try:
|
||||
merged_pdf_content = util.merge_files_pdf(fileNames)
|
||||
|
||||
# Mise à jour du champ registration_file avec le fichier fusionné
|
||||
registerForm.fusion_file.save(
|
||||
f"dossier_complet.pdf",
|
||||
File(merged_pdf_content),
|
||||
save=True
|
||||
)
|
||||
# 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()
|
||||
# Valorisation des StudentCompetency pour l'élève
|
||||
try:
|
||||
student = registerForm.student
|
||||
@ -426,11 +480,324 @@ 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)
|
||||
|
||||
@swagger_auto_schema(
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'student_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données étudiant'),
|
||||
'guardians_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données responsables'),
|
||||
'siblings_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données fratrie'),
|
||||
'payment_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données de paiement'),
|
||||
'current_page': openapi.Schema(type=openapi.TYPE_INTEGER, description='Page actuelle du formulaire'),
|
||||
'auto_save': openapi.Schema(type=openapi.TYPE_BOOLEAN, description='Indicateur auto-save'),
|
||||
}
|
||||
),
|
||||
responses={200: RegistrationFormSerializer()},
|
||||
operation_description="Auto-sauvegarde partielle d'un dossier d'inscription.",
|
||||
operation_summary="Auto-sauvegarder un dossier d'inscription"
|
||||
)
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
def patch(self, request, id):
|
||||
"""
|
||||
Auto-sauvegarde partielle d'un dossier d'inscription.
|
||||
Cette méthode est optimisée pour les sauvegardes automatiques périodiques.
|
||||
"""
|
||||
try:
|
||||
# Récupérer le dossier d'inscription
|
||||
registerForm = bdd.getObject(_objectName=RegistrationForm, _columnName='student__id', _value=id)
|
||||
if not registerForm:
|
||||
return JsonResponse({"error": "Dossier d'inscription introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Préparer les données à mettre à jour
|
||||
update_data = {}
|
||||
|
||||
# Traiter les données étudiant si présentes
|
||||
if 'student_data' in request.data:
|
||||
try:
|
||||
student_data = json.loads(request.data['student_data'])
|
||||
|
||||
# Extraire les données de paiement des données étudiant
|
||||
payment_fields = ['registration_payment', 'tuition_payment', 'registration_payment_plan', 'tuition_payment_plan']
|
||||
payment_data = {}
|
||||
|
||||
for field in payment_fields:
|
||||
if field in student_data:
|
||||
payment_data[field] = student_data.pop(field)
|
||||
|
||||
# Si nous avons des données de paiement, les traiter
|
||||
if payment_data:
|
||||
logger.debug(f"Auto-save: extracted payment_data from student_data = {payment_data}")
|
||||
|
||||
# Traiter les données de paiement
|
||||
payment_updates = {}
|
||||
|
||||
# Gestion du mode de paiement d'inscription
|
||||
if 'registration_payment' in payment_data and payment_data['registration_payment']:
|
||||
try:
|
||||
from School.models import PaymentMode
|
||||
payment_mode = PaymentMode.objects.get(id=payment_data['registration_payment'])
|
||||
registerForm.registration_payment = payment_mode
|
||||
payment_updates['registration_payment'] = payment_mode.id
|
||||
except PaymentMode.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentMode with id {payment_data['registration_payment']} not found")
|
||||
|
||||
# Gestion du mode de paiement de scolarité
|
||||
if 'tuition_payment' in payment_data and payment_data['tuition_payment']:
|
||||
try:
|
||||
from School.models import PaymentMode
|
||||
payment_mode = PaymentMode.objects.get(id=payment_data['tuition_payment'])
|
||||
registerForm.tuition_payment = payment_mode
|
||||
payment_updates['tuition_payment'] = payment_mode.id
|
||||
except PaymentMode.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentMode with id {payment_data['tuition_payment']} not found")
|
||||
|
||||
# Gestion du plan de paiement d'inscription
|
||||
if 'registration_payment_plan' in payment_data and payment_data['registration_payment_plan']:
|
||||
try:
|
||||
from School.models import PaymentPlan
|
||||
payment_plan = PaymentPlan.objects.get(id=payment_data['registration_payment_plan'])
|
||||
registerForm.registration_payment_plan = payment_plan
|
||||
payment_updates['registration_payment_plan'] = payment_plan.id
|
||||
except PaymentPlan.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['registration_payment_plan']} not found")
|
||||
|
||||
# Gestion du plan de paiement de scolarité
|
||||
if 'tuition_payment_plan' in payment_data and payment_data['tuition_payment_plan']:
|
||||
try:
|
||||
from School.models import PaymentPlan
|
||||
payment_plan = PaymentPlan.objects.get(id=payment_data['tuition_payment_plan'])
|
||||
registerForm.tuition_payment_plan = payment_plan
|
||||
payment_updates['tuition_payment_plan'] = payment_plan.id
|
||||
except PaymentPlan.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['tuition_payment_plan']} not found")
|
||||
|
||||
# Sauvegarder les modifications de paiement
|
||||
if payment_updates:
|
||||
registerForm.save()
|
||||
logger.debug(f"Auto-save: Payment data updated - {payment_updates}")
|
||||
|
||||
update_data['student'] = student_data
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Auto-save: Invalid JSON in student_data")
|
||||
|
||||
# Traiter les données des responsables si présentes
|
||||
if 'guardians_data' in request.data:
|
||||
try:
|
||||
guardians_data = json.loads(request.data['guardians_data'])
|
||||
logger.debug(f"Auto-save: guardians_data = {guardians_data}")
|
||||
|
||||
# Enregistrer directement chaque guardian avec le modèle
|
||||
for i, guardian_data in enumerate(guardians_data):
|
||||
guardian_id = guardian_data.get('id')
|
||||
if guardian_id:
|
||||
try:
|
||||
# Récupérer le guardian existant et mettre à jour ses champs
|
||||
guardian = Guardian.objects.get(id=guardian_id)
|
||||
|
||||
# Mettre à jour les champs si ils sont présents
|
||||
if 'birth_date' in guardian_data and guardian_data['birth_date']:
|
||||
guardian.birth_date = guardian_data['birth_date']
|
||||
if 'profession' in guardian_data:
|
||||
guardian.profession = guardian_data['profession']
|
||||
if 'address' in guardian_data:
|
||||
guardian.address = guardian_data['address']
|
||||
if 'phone' in guardian_data:
|
||||
guardian.phone = guardian_data['phone']
|
||||
if 'first_name' in guardian_data:
|
||||
guardian.first_name = guardian_data['first_name']
|
||||
if 'last_name' in guardian_data:
|
||||
guardian.last_name = guardian_data['last_name']
|
||||
|
||||
guardian.save()
|
||||
logger.debug(f"Guardian {i}: Updated birth_date={guardian.birth_date}, profession={guardian.profession}, address={guardian.address}")
|
||||
|
||||
except Guardian.DoesNotExist:
|
||||
logger.warning(f"Auto-save: Guardian with id {guardian_id} not found")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Auto-save: Invalid JSON in guardians_data")
|
||||
|
||||
# Traiter les données de la fratrie si présentes
|
||||
if 'siblings_data' in request.data:
|
||||
try:
|
||||
siblings_data = json.loads(request.data['siblings_data'])
|
||||
logger.debug(f"Auto-save: siblings_data = {siblings_data}")
|
||||
|
||||
# Enregistrer directement chaque sibling avec le modèle
|
||||
for i, sibling_data in enumerate(siblings_data):
|
||||
sibling_id = sibling_data.get('id')
|
||||
if sibling_id:
|
||||
try:
|
||||
# Récupérer le sibling existant et mettre à jour ses champs
|
||||
from Subscriptions.models import Sibling
|
||||
sibling = Sibling.objects.get(id=sibling_id)
|
||||
|
||||
# Mettre à jour les champs si ils sont présents
|
||||
if 'first_name' in sibling_data:
|
||||
sibling.first_name = sibling_data['first_name']
|
||||
if 'last_name' in sibling_data:
|
||||
sibling.last_name = sibling_data['last_name']
|
||||
if 'birth_date' in sibling_data and sibling_data['birth_date']:
|
||||
sibling.birth_date = sibling_data['birth_date']
|
||||
|
||||
sibling.save()
|
||||
logger.debug(f"Sibling {i}: Updated first_name={sibling.first_name}, last_name={sibling.last_name}, birth_date={sibling.birth_date}")
|
||||
|
||||
except Sibling.DoesNotExist:
|
||||
logger.warning(f"Auto-save: Sibling with id {sibling_id} not found")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Auto-save: Invalid JSON in siblings_data")
|
||||
|
||||
# Traiter les données de paiement si présentes
|
||||
if 'payment_data' in request.data:
|
||||
try:
|
||||
payment_data = json.loads(request.data['payment_data'])
|
||||
logger.debug(f"Auto-save: payment_data = {payment_data}")
|
||||
|
||||
# Mettre à jour directement les champs de paiement du formulaire
|
||||
payment_updates = {}
|
||||
|
||||
# Gestion du mode de paiement d'inscription
|
||||
if 'registration_payment' in payment_data and payment_data['registration_payment']:
|
||||
try:
|
||||
from School.models import PaymentMode
|
||||
payment_mode = PaymentMode.objects.get(id=payment_data['registration_payment'])
|
||||
registerForm.registration_payment = payment_mode
|
||||
payment_updates['registration_payment'] = payment_mode.id
|
||||
except PaymentMode.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentMode with id {payment_data['registration_payment']} not found")
|
||||
|
||||
# Gestion du mode de paiement de scolarité
|
||||
if 'tuition_payment' in payment_data and payment_data['tuition_payment']:
|
||||
try:
|
||||
from School.models import PaymentMode
|
||||
payment_mode = PaymentMode.objects.get(id=payment_data['tuition_payment'])
|
||||
registerForm.tuition_payment = payment_mode
|
||||
payment_updates['tuition_payment'] = payment_mode.id
|
||||
except PaymentMode.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentMode with id {payment_data['tuition_payment']} not found")
|
||||
|
||||
# Gestion du plan de paiement d'inscription
|
||||
if 'registration_payment_plan' in payment_data and payment_data['registration_payment_plan']:
|
||||
try:
|
||||
from School.models import PaymentPlan
|
||||
payment_plan = PaymentPlan.objects.get(id=payment_data['registration_payment_plan'])
|
||||
registerForm.registration_payment_plan = payment_plan
|
||||
payment_updates['registration_payment_plan'] = payment_plan.id
|
||||
except PaymentPlan.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['registration_payment_plan']} not found")
|
||||
|
||||
# Gestion du plan de paiement de scolarité
|
||||
if 'tuition_payment_plan' in payment_data and payment_data['tuition_payment_plan']:
|
||||
try:
|
||||
from School.models import PaymentPlan
|
||||
payment_plan = PaymentPlan.objects.get(id=payment_data['tuition_payment_plan'])
|
||||
registerForm.tuition_payment_plan = payment_plan
|
||||
payment_updates['tuition_payment_plan'] = payment_plan.id
|
||||
except PaymentPlan.DoesNotExist:
|
||||
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['tuition_payment_plan']} not found")
|
||||
|
||||
# Sauvegarder les modifications de paiement
|
||||
if payment_updates:
|
||||
registerForm.save()
|
||||
logger.debug(f"Auto-save: Payment data updated - {payment_updates}")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Auto-save: Invalid JSON in payment_data")
|
||||
|
||||
# Mettre à jour la page actuelle si présente
|
||||
if 'current_page' in request.data:
|
||||
try:
|
||||
current_page = int(request.data['current_page'])
|
||||
# Vous pouvez sauvegarder cette info dans un champ du modèle si nécessaire
|
||||
logger.debug(f"Auto-save: current_page = {current_page}")
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("Auto-save: Invalid current_page value")
|
||||
|
||||
# Effectuer la mise à jour partielle seulement si nous avons des données
|
||||
if update_data:
|
||||
serializer = RegistrationFormSerializer(registerForm, data=update_data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
logger.debug(f"Auto-save successful for student {id}")
|
||||
return JsonResponse({"status": "auto_save_success", "timestamp": util._now().isoformat()}, safe=False)
|
||||
else:
|
||||
logger.warning(f"Auto-save validation errors: {serializer.errors}")
|
||||
# Pour l'auto-save, on retourne un succès même en cas d'erreur de validation
|
||||
return JsonResponse({"status": "auto_save_partial", "errors": serializer.errors}, safe=False)
|
||||
else:
|
||||
# Pas de données à sauvegarder, mais on retourne un succès
|
||||
return JsonResponse({"status": "auto_save_no_data"}, safe=False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-save error for student {id}: {str(e)}")
|
||||
# Pour l'auto-save, on ne retourne pas d'erreur HTTP pour éviter d'interrompre l'UX
|
||||
return JsonResponse({"status": "auto_save_failed", "error": str(e)}, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
responses={204: 'No Content'},
|
||||
operation_description="Supprime un dossier d'inscription donné.",
|
||||
|
||||
@ -1,372 +0,0 @@
|
||||
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
|
||||
|
||||
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
|
||||
from Subscriptions.models import RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate
|
||||
from N3wtSchool import bdd
|
||||
|
||||
class RegistrationSchoolFileMasterView(APIView):
|
||||
@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):
|
||||
serializer = RegistrationSchoolFileMasterSerializer(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 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)
|
||||
serializer = RegistrationSchoolFileMasterSerializer(master, data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
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_204_NO_CONTENT)
|
||||
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é",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationSchoolFileTemplateSerializer(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 liés à l'établissement via master.groups.establishment
|
||||
templates = RegistrationSchoolFileTemplate.objects.filter(
|
||||
master__groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(templates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Crée un nouveau template d'inscription",
|
||||
request_body=RegistrationSchoolFileTemplateSerializer,
|
||||
responses={
|
||||
201: RegistrationSchoolFileTemplateSerializer,
|
||||
400: "Données invalides"
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(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 RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un template d'inscription spécifique",
|
||||
responses={
|
||||
200: RegistrationSchoolFileTemplateSerializer,
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def get(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _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 = RegistrationSchoolFileTemplateSerializer(template)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour un template d'inscription existant",
|
||||
request_body=RegistrationSchoolFileTemplateSerializer,
|
||||
responses={
|
||||
200: RegistrationSchoolFileTemplateSerializer,
|
||||
400: "Données invalides",
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
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)
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(template, data=request.data)
|
||||
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=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
||||
if template is not None:
|
||||
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)
|
||||
|
||||
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:
|
||||
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)
|
||||
@ -0,0 +1,177 @@
|
||||
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, JSONParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
|
||||
from Subscriptions.serializers import RegistrationParentFileMasterSerializer
|
||||
from Subscriptions.models import (
|
||||
RegistrationForm,
|
||||
RegistrationParentFileMaster
|
||||
)
|
||||
from N3wtSchool import bdd
|
||||
import logging
|
||||
import Subscriptions.util as util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RegistrationParentFileMasterView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
@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):
|
||||
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 = RegistrationParentFileMasterSerializer(data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
obj = serializer.save()
|
||||
|
||||
# Propager la création des templates côté serveur pour les RegistrationForm
|
||||
try:
|
||||
groups_qs = obj.groups.all()
|
||||
if groups_qs.exists():
|
||||
rfs = RegistrationForm.objects.filter(fileGroup__in=groups_qs).distinct()
|
||||
for rf in rfs:
|
||||
try:
|
||||
util.create_templates_for_registration_form(rf)
|
||||
except Exception as e:
|
||||
logger.exception("Error creating templates for RF %s from parent master %s: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||
except Exception:
|
||||
logger.exception("Error while propagating templates after parent master creation %s", getattr(obj, 'pk', None))
|
||||
|
||||
return Response(RegistrationParentFileMasterSerializer(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 RegistrationParentFileMasterSimpleView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
@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):
|
||||
master = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||
if master is None:
|
||||
return JsonResponse({'erreur': "Le master de fichier parent n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# snapshot des groups avant update
|
||||
old_group_ids = set(master.groups.values_list('id', flat=True))
|
||||
|
||||
# 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 = RegistrationParentFileMasterSerializer(master, data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
obj = serializer.save()
|
||||
|
||||
# groups après update
|
||||
new_group_ids = set(obj.groups.values_list('id', flat=True))
|
||||
|
||||
removed_group_ids = old_group_ids - new_group_ids
|
||||
added_group_ids = new_group_ids - old_group_ids
|
||||
|
||||
# Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent)
|
||||
if removed_group_ids:
|
||||
try:
|
||||
rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct()
|
||||
for rf in rfs_removed:
|
||||
try:
|
||||
util.create_templates_for_registration_form(rf)
|
||||
except Exception as e:
|
||||
logger.exception("Error cleaning templates for RF %s after parent master %s group removal: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||
except Exception:
|
||||
logger.exception("Error while processing RFs for removed groups after parent master update %s", getattr(obj, 'pk', None))
|
||||
|
||||
# Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants
|
||||
if added_group_ids:
|
||||
try:
|
||||
rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct()
|
||||
for rf in rfs_added:
|
||||
try:
|
||||
util.create_templates_for_registration_form(rf)
|
||||
except Exception as e:
|
||||
logger.exception("Error creating templates for RF %s after parent master %s group addition: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||
except Exception:
|
||||
logger.exception("Error while processing RFs for added groups after parent master update %s", getattr(obj, 'pk', None))
|
||||
|
||||
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 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)
|
||||
@ -0,0 +1,111 @@
|
||||
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, JSONParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
|
||||
from Subscriptions.serializers import RegistrationParentFileTemplateSerializer
|
||||
from Subscriptions.models import (
|
||||
RegistrationParentFileTemplate
|
||||
)
|
||||
from N3wtSchool import bdd
|
||||
import logging
|
||||
import Subscriptions.util as util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
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)
|
||||
@ -0,0 +1,190 @@
|
||||
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, JSONParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
|
||||
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer
|
||||
from Subscriptions.models import (
|
||||
RegistrationForm,
|
||||
RegistrationSchoolFileMaster,
|
||||
RegistrationSchoolFileTemplate
|
||||
)
|
||||
from N3wtSchool import bdd
|
||||
import logging
|
||||
import Subscriptions.util as util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RegistrationSchoolFileMasterView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
@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()
|
||||
|
||||
# Propager la création des templates côté serveur pour les RegistrationForm
|
||||
try:
|
||||
groups_qs = obj.groups.all()
|
||||
if groups_qs.exists():
|
||||
# Tous les RegistrationForm dont fileGroup est dans les groups du master
|
||||
rfs = RegistrationForm.objects.filter(fileGroup__in=groups_qs).distinct()
|
||||
for rf in rfs:
|
||||
try:
|
||||
util.create_templates_for_registration_form(rf)
|
||||
except Exception as e:
|
||||
logger.exception("Error creating templates for RF %s from master %s: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||
except Exception:
|
||||
logger.exception("Error while propagating templates after master creation %s", getattr(obj, 'pk', None))
|
||||
|
||||
|
||||
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):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
@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)
|
||||
|
||||
# snapshot des groups avant update
|
||||
old_group_ids = set(master.groups.values_list('id', flat=True))
|
||||
|
||||
# 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():
|
||||
obj = serializer.save()
|
||||
|
||||
# groups après update
|
||||
new_group_ids = set(obj.groups.values_list('id', flat=True))
|
||||
|
||||
removed_group_ids = old_group_ids - new_group_ids
|
||||
added_group_ids = new_group_ids - old_group_ids
|
||||
|
||||
# Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent)
|
||||
if removed_group_ids:
|
||||
logger.info("REMOVE IDs")
|
||||
try:
|
||||
rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct()
|
||||
for rf in rfs_removed:
|
||||
try:
|
||||
util.create_templates_for_registration_form(rf) # supprimera les templates obsolètes
|
||||
except Exception as e:
|
||||
logger.exception("Error cleaning templates for RF %s after master %s group removal: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||
except Exception:
|
||||
logger.exception("Error while processing RFs for removed groups after master update %s", getattr(obj, 'pk', None))
|
||||
|
||||
# Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants
|
||||
if added_group_ids:
|
||||
logger.info("ADD IDs")
|
||||
try:
|
||||
rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct()
|
||||
for rf in rfs_added:
|
||||
try:
|
||||
util.create_templates_for_registration_form(rf) # créera les templates manquants
|
||||
except Exception as e:
|
||||
logger.exception("Error creating templates for RF %s after master %s group addition: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||
except Exception:
|
||||
logger.exception("Error while processing RFs for added groups after master update %s", getattr(obj, 'pk', None))
|
||||
|
||||
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:
|
||||
# Supprimer tous les templates liés et leurs fichiers PDF
|
||||
templates = RegistrationSchoolFileTemplate.objects.filter(master=master)
|
||||
for template in templates:
|
||||
if template.file and template.file.name:
|
||||
template.file.delete(save=False)
|
||||
template.delete()
|
||||
master.delete()
|
||||
return JsonResponse({'message': 'La suppression du master de template et des fichiers associés 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)
|
||||
@ -0,0 +1,191 @@
|
||||
from django.http.response import JsonResponse
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
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 N3wtSchool import bdd
|
||||
import logging
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
import Subscriptions.util as util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RegistrationSchoolFileTemplateView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les 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: RegistrationSchoolFileTemplateSerializer(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 liés à l'établissement via master.groups.establishment
|
||||
templates = RegistrationSchoolFileTemplate.objects.filter(
|
||||
master__groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(templates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Crée un nouveau template d'inscription",
|
||||
request_body=RegistrationSchoolFileTemplateSerializer,
|
||||
responses={
|
||||
201: RegistrationSchoolFileTemplateSerializer,
|
||||
400: "Données invalides"
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(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 RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un template d'inscription spécifique",
|
||||
responses={
|
||||
200: RegistrationSchoolFileTemplateSerializer,
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def get(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _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 = RegistrationSchoolFileTemplateSerializer(template)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour un template d'inscription existant",
|
||||
request_body=RegistrationSchoolFileTemplateSerializer,
|
||||
responses={
|
||||
200: RegistrationSchoolFileTemplateSerializer,
|
||||
400: "Données invalides",
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
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)
|
||||
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)
|
||||
|
||||
@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=RegistrationSchoolFileTemplate, _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)
|
||||
Binary file not shown.
@ -12,21 +12,32 @@ def run_command(command):
|
||||
return process.returncode
|
||||
|
||||
test_mode = os.getenv('test_mode', 'false').lower() == 'true'
|
||||
flush_data = os.getenv('flush_data', 'false').lower() == 'true'
|
||||
migrate_data = os.getenv('migrate_data', 'false').lower() == 'true'
|
||||
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
||||
|
||||
collect_static_cmd = [
|
||||
["python", "manage.py", "collectstatic", "--noinput"]
|
||||
]
|
||||
|
||||
flush_data_cmd = [
|
||||
["python", "manage.py", "flush", "--noinput"]
|
||||
]
|
||||
|
||||
migrate_commands = [
|
||||
["python", "manage.py", "makemigrations", "Common", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Settings", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Planning", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Auth", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "School", "--noinput"]
|
||||
]
|
||||
|
||||
commands = [
|
||||
["python", "manage.py", "collectstatic", "--noinput"],
|
||||
#["python", "manage.py", "flush", "--noinput"],
|
||||
# ["python", "manage.py", "makemigrations", "Common", "--noinput"],
|
||||
# ["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
|
||||
# ["python", "manage.py", "makemigrations", "Settings", "--noinput"],
|
||||
# ["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
|
||||
# ["python", "manage.py", "makemigrations", "Planning", "--noinput"],
|
||||
# ["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
|
||||
# ["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
|
||||
# ["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
|
||||
# ["python", "manage.py", "makemigrations", "Auth", "--noinput"],
|
||||
# ["python", "manage.py", "makemigrations", "School", "--noinput"],
|
||||
["python", "manage.py", "migrate", "--noinput"]
|
||||
]
|
||||
|
||||
@ -45,14 +56,29 @@ def run_daphne():
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
for command in collect_static_cmd:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if flush_data:
|
||||
for command in flush_data_cmd:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if migrate_data:
|
||||
for command in migrate_commands:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
for command in commands:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
#if test_mode:
|
||||
# for test_command in test_commands:
|
||||
# if run_command(test_command) != 0:
|
||||
# exit(1)
|
||||
if test_mode:
|
||||
for test_command in test_commands:
|
||||
if run_command(test_command) != 0:
|
||||
exit(1)
|
||||
|
||||
if watch_mode:
|
||||
celery_worker = subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"])
|
||||
|
||||
@ -42,14 +42,9 @@ const nextConfig = {
|
||||
NEXT_PUBLIC_USE_FAKE_DATA: process.env.NEXT_PUBLIC_USE_FAKE_DATA || 'false',
|
||||
AUTH_SECRET: process.env.AUTH_SECRET || 'false',
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3000',
|
||||
DOCUSEAL_API_KEY: process.env.DOCUSEAL_API_KEY,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/documents/:path*',
|
||||
destination: 'https://api.docuseal.com/v1/documents/:path*',
|
||||
},
|
||||
{
|
||||
source: '/api/auth/:path*',
|
||||
destination: '/api/auth/:path*', // Exclure les routes NextAuth des réécritures de proxy
|
||||
|
||||
11
Front-End/package-lock.json
generated
11
Front-End/package-lock.json
generated
@ -8,7 +8,6 @@
|
||||
"name": "n3wt-school-front-end",
|
||||
"version": "0.0.3",
|
||||
"dependencies": {
|
||||
"@docuseal/react": "^1.0.56",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"date-fns": "^4.1.0",
|
||||
@ -537,11 +536,6 @@
|
||||
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@docuseal/react": {
|
||||
"version": "1.0.66",
|
||||
"resolved": "https://registry.npmjs.org/@docuseal/react/-/react-1.0.66.tgz",
|
||||
"integrity": "sha512-rYG58gv8Uw1cTtjbHdgWgWBWpLMbIwDVsS3kN27w4sz/eDJilZieePUDS4eLKJ8keBN05BSjxD/iWQpaTBKZLg=="
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
||||
@ -11269,11 +11263,6 @@
|
||||
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
||||
"dev": true
|
||||
},
|
||||
"@docuseal/react": {
|
||||
"version": "1.0.66",
|
||||
"resolved": "https://registry.npmjs.org/@docuseal/react/-/react-1.0.66.tgz",
|
||||
"integrity": "sha512-rYG58gv8Uw1cTtjbHdgWgWBWpLMbIwDVsS3kN27w4sz/eDJilZieePUDS4eLKJ8keBN05BSjxD/iWQpaTBKZLg=="
|
||||
},
|
||||
"@eslint-community/eslint-utils": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
||||
|
||||
@ -14,7 +14,6 @@
|
||||
"test:coverage": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docuseal/react": "^1.0.56",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"date-fns": "^4.1.0",
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
Award,
|
||||
Calendar,
|
||||
Settings,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
@ -29,16 +28,14 @@ import {
|
||||
|
||||
import { disconnect } from '@/app/actions/authAction';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import { getGravatarUrl } from '@/utils/gravatar';
|
||||
import Footer from '@/components/Footer';
|
||||
import { getRightStr, RIGHTS } from '@/utils/rights';
|
||||
import { 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, user, clearContext } =
|
||||
const { profileRole, establishments, clearContext } =
|
||||
useEstablishment();
|
||||
|
||||
const sidebarItems = {
|
||||
@ -97,45 +94,15 @@ 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);
|
||||
};
|
||||
@ -145,6 +112,15 @@ 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 */}
|
||||
@ -156,7 +132,7 @@ export default function Layout({ children }) {
|
||||
<Sidebar
|
||||
establishments={establishments}
|
||||
currentPage={currentPage}
|
||||
items={Object.values(sidebarItems)}
|
||||
items={sidebarItemsToDisplay}
|
||||
onCloseMobile={toggleSidebar}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -36,7 +36,6 @@ export default function DashboardPage() {
|
||||
const {
|
||||
selectedEstablishmentId,
|
||||
selectedEstablishmentTotalCapacity,
|
||||
apiDocuseal,
|
||||
} = useEstablishment();
|
||||
|
||||
const [statusDistribution, setStatusDistribution] = useState([
|
||||
@ -165,25 +164,6 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div key={selectedEstablishmentId} className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<span
|
||||
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
|
||||
apiDocuseal
|
||||
? 'bg-green-100 text-green-700 border border-green-300'
|
||||
: 'bg-red-100 text-red-700 border border-red-300'
|
||||
}`}
|
||||
>
|
||||
{apiDocuseal ? (
|
||||
<CheckCircle2 className="w-4 h-4 mr-2 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 mr-2 text-red-500" />
|
||||
)}
|
||||
{apiDocuseal
|
||||
? 'Clé API Docuseal renseignée'
|
||||
: 'Clé API Docuseal manquante'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Statistiques principales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard
|
||||
|
||||
@ -52,7 +52,7 @@ export default function Page() {
|
||||
);
|
||||
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
@ -316,35 +316,39 @@ export default function Page() {
|
||||
</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>
|
||||
),
|
||||
},
|
||||
...(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: 'Files',
|
||||
label: 'Documents',
|
||||
@ -353,7 +357,7 @@ export default function Page() {
|
||||
<FilesGroupsManagement
|
||||
csrfToken={csrfToken}
|
||||
selectedEstablishmentId={selectedEstablishmentId}
|
||||
apiDocuseal={apiDocuseal}
|
||||
profileRole={profileRole}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@ -34,10 +34,7 @@ import {
|
||||
import {
|
||||
fetchRegistrationFileGroups,
|
||||
fetchRegistrationSchoolFileMasters,
|
||||
fetchRegistrationParentFileMasters,
|
||||
cloneTemplate,
|
||||
createRegistrationSchoolFileTemplate,
|
||||
createRegistrationParentFileTemplate,
|
||||
fetchRegistrationParentFileMasters
|
||||
} from '@/app/actions/registerFileGroupAction';
|
||||
import { fetchProfiles } from '@/app/actions/authAction';
|
||||
import { useClasses } from '@/context/ClassesContext';
|
||||
@ -96,7 +93,7 @@ export default function CreateSubscriptionPage() {
|
||||
const { getNiveauLabel } = useClasses();
|
||||
|
||||
const formDataRef = useRef(formData);
|
||||
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
|
||||
const csrfToken = useCsrfToken();
|
||||
const router = useRouter();
|
||||
@ -522,128 +519,23 @@ export default function CreateSubscriptionPage() {
|
||||
} else {
|
||||
// Création du dossier d'inscription
|
||||
createRegisterForm(data, csrfToken)
|
||||
.then((data) => {
|
||||
// Clonage des schoolFileTemplates
|
||||
const masters = schoolFileMasters.filter((file) =>
|
||||
file.groups.includes(selectedFileGroup)
|
||||
.then((response) => {
|
||||
showNotification(
|
||||
"Dossier d'inscription créé avec succès",
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
const parentMasters = parentFileMasters.filter((file) =>
|
||||
file.groups.includes(selectedFileGroup)
|
||||
);
|
||||
|
||||
const clonePromises = masters.map((templateMaster) =>
|
||||
cloneTemplate(
|
||||
templateMaster.id,
|
||||
formDataRef.current.guardianEmail,
|
||||
templateMaster.is_required,
|
||||
selectedEstablishmentId,
|
||||
apiDocuseal
|
||||
)
|
||||
.then((clonedDocument) => {
|
||||
const cloneData = {
|
||||
name: `${templateMaster.name}_${formDataRef.current.studentFirstName}_${formDataRef.current.studentLastName}`,
|
||||
slug: clonedDocument.slug,
|
||||
id: clonedDocument.id,
|
||||
master: templateMaster.id,
|
||||
registration_form: data.student.id,
|
||||
};
|
||||
|
||||
return createRegistrationSchoolFileTemplate(
|
||||
cloneData,
|
||||
csrfToken
|
||||
)
|
||||
.then((response) =>
|
||||
logger.debug('Template enregistré avec succès:', response)
|
||||
)
|
||||
.catch((error) => {
|
||||
setIsLoading(false);
|
||||
logger.error(
|
||||
"Erreur lors de l'enregistrement du template:",
|
||||
error
|
||||
);
|
||||
showNotification(
|
||||
"Erreur lors de la création du dossier d'inscription",
|
||||
'error',
|
||||
'Erreur',
|
||||
'ERR_ADM_SUB_03'
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setIsLoading(false);
|
||||
logger.error('Error during cloning or sending:', error);
|
||||
showNotification(
|
||||
"Erreur lors de la création du dossier d'inscription",
|
||||
'error',
|
||||
'Erreur',
|
||||
'ERR_ADM_SUB_05'
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Clonage des parentFileTemplates
|
||||
const parentClonePromises = parentMasters.map((parentMaster) => {
|
||||
const parentTemplateData = {
|
||||
master: parentMaster.id,
|
||||
registration_form: data.student.id,
|
||||
};
|
||||
|
||||
return createRegistrationParentFileTemplate(
|
||||
parentTemplateData,
|
||||
csrfToken
|
||||
)
|
||||
.then((response) =>
|
||||
logger.debug(
|
||||
'Parent template enregistré avec succès:',
|
||||
response
|
||||
)
|
||||
)
|
||||
.catch((error) => {
|
||||
setIsLoading(false);
|
||||
logger.error(
|
||||
"Erreur lors de l'enregistrement du parent template:",
|
||||
error
|
||||
);
|
||||
showNotification(
|
||||
"Erreur lors de la création du dossier d'inscription",
|
||||
'error',
|
||||
'Erreur',
|
||||
'ERR_ADM_SUB_02'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Attendre que tous les clones soient créés
|
||||
Promise.all([...clonePromises, ...parentClonePromises])
|
||||
.then(() => {
|
||||
// Redirection après succès
|
||||
showNotification(
|
||||
"Dossier d'inscription créé avec succès",
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
||||
})
|
||||
.catch((error) => {
|
||||
setIsLoading(false);
|
||||
showNotification(
|
||||
"Erreur lors de la création du dossier d'inscription",
|
||||
'error',
|
||||
'Erreur',
|
||||
'ERR_ADM_SUB_04'
|
||||
);
|
||||
logger.error('Error during cloning or sending:', error);
|
||||
});
|
||||
})
|
||||
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
||||
})
|
||||
.catch((error) => {
|
||||
setIsLoading(false);
|
||||
logger.error('Erreur lors de la mise à jour du dossier:', error);
|
||||
showNotification(
|
||||
"Erreur lors de la création du dossier d'inscription",
|
||||
'error',
|
||||
'Erreur',
|
||||
'ERR_ADM_SUB_01'
|
||||
);
|
||||
logger.error('Error during register form creation:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -19,7 +19,7 @@ export default function Page() {
|
||||
|
||||
const [formErrors, setFormErrors] = useState({});
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = (data) => {
|
||||
@ -59,7 +59,6 @@ export default function Page() {
|
||||
studentId={studentId}
|
||||
csrfToken={csrfToken}
|
||||
selectedEstablishmentId={selectedEstablishmentId}
|
||||
apiDocuseal = {apiDocuseal}
|
||||
onSubmit={handleSubmit}
|
||||
cancelUrl={FE_ADMIN_SUBSCRIPTIONS_URL}
|
||||
errors={formErrors}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
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';
|
||||
@ -17,6 +18,7 @@ import {
|
||||
Plus,
|
||||
Upload,
|
||||
Eye,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import Modal from '@/components/Modal';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
@ -83,12 +85,9 @@ 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([]);
|
||||
|
||||
@ -99,9 +98,40 @@ 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 } = useEstablishment();
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const openSepaUploadModal = (row) => {
|
||||
@ -490,10 +520,18 @@ 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}&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}&email=${row.student.guardians[0].associated_profile_email}&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]: [
|
||||
@ -801,15 +839,17 @@ export default function Page({ params: { locale } }) {
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
@ -853,6 +893,25 @@ 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,8 +10,10 @@ 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);
|
||||
@ -20,6 +22,7 @@ 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'
|
||||
@ -84,6 +87,45 @@ 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 />;
|
||||
}
|
||||
@ -93,10 +135,15 @@ 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,7 +15,6 @@ export default function Home() {
|
||||
<h1 className="text-4xl font-bold mb-4">{t('welcomeParents')}</h1>
|
||||
<p className="text-lg mb-8">{t('pleaseLogin')}</p>
|
||||
<Button text={t('loginButton')} primary href="/users/login" />
|
||||
<FormTemplateBuilder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ export default function Page() {
|
||||
const enable = searchParams.get('enabled') === 'true';
|
||||
const router = useRouter();
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = (data) => {
|
||||
@ -53,7 +53,6 @@ export default function Page() {
|
||||
studentId={studentId}
|
||||
csrfToken={csrfToken}
|
||||
selectedEstablishmentId={selectedEstablishmentId}
|
||||
apiDocuseal = {apiDocuseal}
|
||||
onSubmit={handleSubmit}
|
||||
cancelUrl={FE_PARENTS_HOME_URL}
|
||||
enable={enable}
|
||||
|
||||
@ -3,11 +3,7 @@ import {
|
||||
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL,
|
||||
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL,
|
||||
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL,
|
||||
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL,
|
||||
FE_API_DOCUSEAL_CLONE_URL,
|
||||
FE_API_DOCUSEAL_DOWNLOAD_URL,
|
||||
FE_API_DOCUSEAL_GENERATE_TOKEN,
|
||||
FE_API_DOCUSEAL_DELETE_URL
|
||||
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL
|
||||
} from '@/utils/Url';
|
||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||
|
||||
@ -104,12 +100,12 @@ export async function createRegistrationFileGroup(groupData, csrfToken) {
|
||||
}
|
||||
|
||||
export const createRegistrationSchoolFileMaster = (data, csrfToken) => {
|
||||
// Toujours FormData, jamais JSON
|
||||
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
body: data,
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
@ -190,10 +186,9 @@ export const editRegistrationSchoolFileMaster = (fileId, data, csrfToken) => {
|
||||
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
body: data,
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
}
|
||||
@ -327,62 +322,3 @@ export const deleteRegistrationParentFileTemplate = (id, csrfToken) => {
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// API requests
|
||||
export const removeTemplate = (templateId, selectedEstablishmentId, apiDocuseal) => {
|
||||
return fetch(`${FE_API_DOCUSEAL_DELETE_URL}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
templateId,
|
||||
establishment_id :selectedEstablishmentId,
|
||||
apiDocuseal
|
||||
}),
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
export const cloneTemplate = (templateId, email, is_required, selectedEstablishmentId, apiDocuseal) => {
|
||||
return fetch(`${FE_API_DOCUSEAL_CLONE_URL}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
templateId,
|
||||
email,
|
||||
is_required,
|
||||
establishment_id :selectedEstablishmentId,
|
||||
apiDocuseal
|
||||
}),
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
export const downloadTemplate = (slug, selectedEstablishmentId, apiDocuseal) => {
|
||||
const url = `${FE_API_DOCUSEAL_DOWNLOAD_URL}/${slug}?establishment_id=${selectedEstablishmentId}&apiDocuseal=${apiDocuseal}`;
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
export const generateToken = (email, id = null, selectedEstablishmentId, apiDocuseal) => {
|
||||
return fetch(`${FE_API_DOCUSEAL_GENERATE_TOKEN}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_email: email, id, establishment_id :selectedEstablishmentId, apiDocuseal }),
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
@ -6,10 +6,13 @@ import {
|
||||
BE_SUBSCRIPTION_ABSENCES_URL,
|
||||
BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL,
|
||||
BE_SUBSCRIPTION_SEARCH_STUDENTS_URL,
|
||||
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL,
|
||||
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL,
|
||||
} from '@/utils/Url';
|
||||
|
||||
import { CURRENT_YEAR_FILTER } from '@/utils/constants';
|
||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export const editStudentCompetencies = (data, csrfToken) => {
|
||||
const request = new Request(`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}`, {
|
||||
@ -83,6 +86,45 @@ export const editRegisterForm = (id, data, csrfToken) => {
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
export const autoSaveRegisterForm = async (id, data, csrfToken) => {
|
||||
try {
|
||||
// Version allégée pour auto-save - ne pas envoyer tous les fichiers
|
||||
const autoSaveData = new FormData();
|
||||
|
||||
// Ajouter seulement les données textuelles pour l'auto-save
|
||||
if (data.student) {
|
||||
autoSaveData.append('student_data', JSON.stringify(data.student));
|
||||
}
|
||||
if (data.guardians) {
|
||||
autoSaveData.append('guardians_data', JSON.stringify(data.guardians));
|
||||
}
|
||||
if (data.siblings) {
|
||||
autoSaveData.append('siblings_data', JSON.stringify(data.siblings));
|
||||
}
|
||||
if (data.currentPage) {
|
||||
autoSaveData.append('current_page', data.currentPage);
|
||||
}
|
||||
autoSaveData.append('auto_save', 'true');
|
||||
|
||||
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
|
||||
method: 'PATCH', // Utiliser PATCH pour les mises à jour partielles
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: autoSaveData,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(() => {
|
||||
// Silent fail pour l'auto-save
|
||||
logger.debug('Auto-save failed silently');
|
||||
});
|
||||
} catch (error) {
|
||||
// Silent fail pour l'auto-save
|
||||
logger.debug('Auto-save error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createRegisterForm = (data, csrfToken) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}`;
|
||||
return fetch(url, {
|
||||
@ -302,3 +344,68 @@ export const deleteAbsences = (id, csrfToken) => {
|
||||
credentials: 'include',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Récupère les formulaires maîtres d'inscription pour un établissement
|
||||
* @param {number} establishmentId - ID de l'établissement
|
||||
* @returns {Promise<Array>} Liste des formulaires
|
||||
*/
|
||||
export const fetchRegistrationSchoolFileMasters = (establishmentId) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishmentId}`;
|
||||
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sauvegarde les réponses d'un formulaire dans RegistrationSchoolFileTemplate
|
||||
* @param {number} templateId - ID du RegistrationSchoolFileTemplate
|
||||
* @param {Object} formTemplateData - Données du formulaire à sauvegarder
|
||||
* @param {string} csrfToken - Token CSRF
|
||||
* @returns {Promise} Résultat de la sauvegarde
|
||||
*/
|
||||
export const saveFormResponses = (templateId, formTemplateData, csrfToken) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`;
|
||||
|
||||
const payload = {
|
||||
formTemplateData: formTemplateData,
|
||||
};
|
||||
|
||||
return fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Récupère les données sauvegardées d'un RegistrationSchoolFileTemplate
|
||||
* @param {number} templateId - ID du RegistrationSchoolFileTemplate
|
||||
* @returns {Promise<Object>} Template avec formTemplateData
|
||||
*/
|
||||
export const fetchFormResponses = (templateId) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`;
|
||||
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
63
Front-End/src/components/AutoSaveIndicator.js
Normal file
63
Front-End/src/components/AutoSaveIndicator.js
Normal file
@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Composant indicateur de sauvegarde automatique
|
||||
* @param {Boolean} isSaving - Si la sauvegarde est en cours
|
||||
* @param {Date} lastSaved - Date de la dernière sauvegarde
|
||||
* @param {Boolean} autoSaveEnabled - Si l'auto-save est activée
|
||||
* @param {Function} onToggleAutoSave - Callback pour activer/désactiver l'auto-save
|
||||
*/
|
||||
export default function AutoSaveIndicator({
|
||||
isSaving = false,
|
||||
lastSaved = null,
|
||||
autoSaveEnabled = true,
|
||||
onToggleAutoSave = null,
|
||||
}) {
|
||||
if (!autoSaveEnabled && !lastSaved && !isSaving) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center py-2 px-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
<span className="text-sm text-blue-600 font-medium">
|
||||
Sauvegarde en cours...
|
||||
</span>
|
||||
</>
|
||||
) : lastSaved ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm text-green-600">
|
||||
Sauvegardé à {lastSaved.toLocaleTimeString()}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">Auto-sauvegarde activée</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onToggleAutoSave && (
|
||||
<button
|
||||
onClick={onToggleAutoSave}
|
||||
className={`text-xs px-3 py-1 rounded-full font-medium transition-colors ${
|
||||
autoSaveEnabled
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
title={
|
||||
autoSaveEnabled
|
||||
? 'Désactiver la sauvegarde automatique'
|
||||
: 'Activer la sauvegarde automatique'
|
||||
}
|
||||
>
|
||||
{autoSaveEnabled ? '✓ Auto-save' : '○ Auto-save'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import Button from './Button';
|
||||
import IconSelector from './IconSelector';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { FIELD_TYPES } from './FormTypes';
|
||||
import FIELD_TYPES_WITH_ICONS from './FieldTypesWithIcons';
|
||||
|
||||
export default function AddFieldModal({
|
||||
isOpen,
|
||||
@ -55,6 +56,10 @@ export default function AddFieldModal({
|
||||
acceptTypes: '',
|
||||
maxSize: 5,
|
||||
checked: false,
|
||||
signatureData: '',
|
||||
backgroundColor: '#ffffff',
|
||||
penColor: '#000000',
|
||||
penWidth: 2,
|
||||
validation: {
|
||||
pattern: '',
|
||||
minLength: '',
|
||||
@ -62,6 +67,16 @@ export default function AddFieldModal({
|
||||
},
|
||||
};
|
||||
|
||||
// Si un type a été présélectionné depuis le sélecteur de type
|
||||
if (editingField && !isEditing) {
|
||||
// S'assurer que le type est correctement défini
|
||||
if (typeof editingField.type === 'string') {
|
||||
defaultValues.type = editingField.type;
|
||||
} else if (editingField.value) {
|
||||
defaultValues.type = editingField.value;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentField(defaultValues);
|
||||
|
||||
// Réinitialiser le formulaire avec les valeurs de l'élément à éditer
|
||||
@ -75,17 +90,32 @@ export default function AddFieldModal({
|
||||
acceptTypes: defaultValues.acceptTypes,
|
||||
maxSize: defaultValues.maxSize,
|
||||
checked: defaultValues.checked,
|
||||
signatureData: defaultValues.signatureData,
|
||||
backgroundColor: defaultValues.backgroundColor,
|
||||
penColor: defaultValues.penColor,
|
||||
penWidth: defaultValues.penWidth,
|
||||
validation: defaultValues.validation,
|
||||
});
|
||||
}
|
||||
}, [isOpen, editingField, reset]);
|
||||
}, [isOpen, editingField, reset, isEditing]);
|
||||
|
||||
// Ajouter une option au select
|
||||
const addOption = () => {
|
||||
const addOption = (e) => {
|
||||
// Arrêter la propagation de l'événement pour éviter que le clic n'atteigne l'arrière-plan
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (newOption.trim()) {
|
||||
// Vérifie si options existe et est un tableau, sinon initialise comme tableau vide
|
||||
const currentOptions = Array.isArray(currentField.options)
|
||||
? currentField.options
|
||||
: [];
|
||||
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
options: [...currentField.options, newOption.trim()],
|
||||
options: [...currentOptions, newOption.trim()],
|
||||
});
|
||||
setNewOption('');
|
||||
}
|
||||
@ -93,7 +123,12 @@ export default function AddFieldModal({
|
||||
|
||||
// Supprimer une option du select
|
||||
const removeOption = (index) => {
|
||||
const newOptions = currentField.options.filter((_, i) => i !== index);
|
||||
// Vérifie si options existe et est un tableau, sinon initialise comme tableau vide
|
||||
const currentOptions = Array.isArray(currentField.options)
|
||||
? currentField.options
|
||||
: [];
|
||||
const newOptions = currentOptions.filter((_, i) => i !== index);
|
||||
|
||||
setCurrentField({ ...currentField, options: newOptions });
|
||||
};
|
||||
|
||||
@ -141,15 +176,28 @@ export default function AddFieldModal({
|
||||
name="type"
|
||||
selected={value}
|
||||
callback={(e) => {
|
||||
onChange(e.target.value);
|
||||
const newType = e.target.value;
|
||||
onChange(newType);
|
||||
|
||||
// Assurons-nous que les options restent un tableau si on sélectionne select ou radio
|
||||
let updatedOptions = currentField.options;
|
||||
|
||||
// Si options n'existe pas ou n'est pas un tableau, initialiser comme tableau vide
|
||||
if (!updatedOptions || !Array.isArray(updatedOptions)) {
|
||||
updatedOptions = [];
|
||||
}
|
||||
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
type: e.target.value,
|
||||
type: newType,
|
||||
options: updatedOptions,
|
||||
});
|
||||
}}
|
||||
choices={FIELD_TYPES}
|
||||
choices={FIELD_TYPES_WITH_ICONS}
|
||||
placeHolder="Sélectionner un type"
|
||||
required
|
||||
showIcons={true}
|
||||
customSelect={true}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -353,21 +401,22 @@ export default function AddFieldModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{currentField.options.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-gray-50 p-2 rounded"
|
||||
>
|
||||
<span>{option}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
{Array.isArray(currentField.options) &&
|
||||
currentField.options.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-gray-50 p-2 rounded"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<span>{option}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -396,21 +445,22 @@ export default function AddFieldModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{currentField.options.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-gray-50 p-2 rounded"
|
||||
>
|
||||
<span>{option}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
{Array.isArray(currentField.options) &&
|
||||
currentField.options.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-gray-50 p-2 rounded"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<span>{option}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -484,6 +534,81 @@ export default function AddFieldModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentField.type === 'signature' && (
|
||||
<>
|
||||
<Controller
|
||||
name="backgroundColor"
|
||||
control={control}
|
||||
defaultValue={currentField.backgroundColor || '#ffffff'}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Couleur de fond
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
backgroundColor: e.target.value,
|
||||
});
|
||||
}}
|
||||
className="w-full h-10 border border-gray-300 rounded-md cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="penColor"
|
||||
control={control}
|
||||
defaultValue={currentField.penColor || '#000000'}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Couleur du stylo
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
penColor: e.target.value,
|
||||
});
|
||||
}}
|
||||
className="w-full h-10 border border-gray-300 rounded-md cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="penWidth"
|
||||
control={control}
|
||||
defaultValue={currentField.penWidth || 2}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InputTextIcon
|
||||
label="Épaisseur du stylo (px)"
|
||||
name="penWidth"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(parseInt(e.target.value));
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
penWidth: parseInt(e.target.value) || 2,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentField.type === 'checkbox' && (
|
||||
<>
|
||||
<div className="flex items-center mt-2">
|
||||
|
||||
121
Front-End/src/components/Form/FieldTypeSelector.js
Normal file
121
Front-End/src/components/Form/FieldTypeSelector.js
Normal file
@ -0,0 +1,121 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FIELD_TYPES } from './FormTypes';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import Button from './Button';
|
||||
|
||||
// Utiliser les mêmes icônes que dans FormTemplateBuilder
|
||||
const FIELD_TYPES_ICON = {
|
||||
text: { icon: LucideIcons.TextCursorInput },
|
||||
email: { icon: LucideIcons.AtSign },
|
||||
phone: { icon: LucideIcons.Phone },
|
||||
date: { icon: LucideIcons.Calendar },
|
||||
select: { icon: LucideIcons.ChevronDown },
|
||||
radio: { icon: LucideIcons.Radio },
|
||||
checkbox: { icon: LucideIcons.CheckSquare },
|
||||
toggle: { icon: LucideIcons.ToggleLeft },
|
||||
file: { icon: LucideIcons.FileUp },
|
||||
signature: { icon: LucideIcons.PenTool },
|
||||
textarea: { icon: LucideIcons.Type },
|
||||
paragraph: { icon: LucideIcons.AlignLeft },
|
||||
heading1: { icon: LucideIcons.Heading1 },
|
||||
heading2: { icon: LucideIcons.Heading2 },
|
||||
heading3: { icon: LucideIcons.Heading3 },
|
||||
heading4: { icon: LucideIcons.Heading4 },
|
||||
heading5: { icon: LucideIcons.Heading5 },
|
||||
heading6: { icon: LucideIcons.Heading6 },
|
||||
};
|
||||
|
||||
export default function FieldTypeSelector({ isOpen, onClose, onSelect }) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Filtrer les types de champs selon le terme de recherche
|
||||
const filteredFieldTypes = FIELD_TYPES.filter((fieldType) =>
|
||||
fieldType.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const selectFieldType = (fieldType) => {
|
||||
onSelect(fieldType);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[85vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Choisir un type de champ ({filteredFieldTypes.length} /{' '}
|
||||
{FIELD_TYPES.length} types)
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 text-xl"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Barre de recherche */}
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un type de champ..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-3 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<LucideIcons.Search
|
||||
className="absolute left-3 top-3.5 text-gray-400"
|
||||
size={18}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<LucideIcons.X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{filteredFieldTypes.map((fieldType) => {
|
||||
const IconComponent = FIELD_TYPES_ICON[fieldType.value]?.icon;
|
||||
return (
|
||||
<button
|
||||
key={fieldType.value}
|
||||
onClick={() => selectFieldType(fieldType)}
|
||||
className="p-5 rounded-lg border-2 border-gray-200 bg-gray-50
|
||||
hover:bg-blue-50 hover:border-blue-300 hover:shadow-md hover:scale-105
|
||||
flex flex-col items-center justify-center gap-4 min-h-[140px] w-full
|
||||
transition-all duration-200"
|
||||
title={fieldType.label}
|
||||
>
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
size={32}
|
||||
className="text-gray-700 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm text-gray-600 text-center font-medium">
|
||||
{fieldType.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
text="Annuler"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
Front-End/src/components/Form/FieldTypesWithIcons.js
Normal file
73
Front-End/src/components/Form/FieldTypesWithIcons.js
Normal file
@ -0,0 +1,73 @@
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { FIELD_TYPES } from './FormTypes';
|
||||
|
||||
// Associer les icônes à chaque type de champ
|
||||
export const FIELD_TYPES_WITH_ICONS = FIELD_TYPES.map((fieldType) => {
|
||||
let icon = null;
|
||||
|
||||
switch (fieldType.value) {
|
||||
case 'text':
|
||||
icon = LucideIcons.TextCursorInput;
|
||||
break;
|
||||
case 'email':
|
||||
icon = LucideIcons.AtSign;
|
||||
break;
|
||||
case 'phone':
|
||||
icon = LucideIcons.Phone;
|
||||
break;
|
||||
case 'date':
|
||||
icon = LucideIcons.Calendar;
|
||||
break;
|
||||
case 'select':
|
||||
icon = LucideIcons.ChevronDown;
|
||||
break;
|
||||
case 'radio':
|
||||
icon = LucideIcons.Radio;
|
||||
break;
|
||||
case 'checkbox':
|
||||
icon = LucideIcons.CheckSquare;
|
||||
break;
|
||||
case 'toggle':
|
||||
icon = LucideIcons.ToggleLeft;
|
||||
break;
|
||||
case 'file':
|
||||
icon = LucideIcons.FileUp;
|
||||
break;
|
||||
case 'signature':
|
||||
icon = LucideIcons.PenTool;
|
||||
break;
|
||||
case 'textarea':
|
||||
icon = LucideIcons.Type;
|
||||
break;
|
||||
case 'paragraph':
|
||||
icon = LucideIcons.AlignLeft;
|
||||
break;
|
||||
case 'heading1':
|
||||
icon = LucideIcons.Heading1;
|
||||
break;
|
||||
case 'heading2':
|
||||
icon = LucideIcons.Heading2;
|
||||
break;
|
||||
case 'heading3':
|
||||
icon = LucideIcons.Heading3;
|
||||
break;
|
||||
case 'heading4':
|
||||
icon = LucideIcons.Heading4;
|
||||
break;
|
||||
case 'heading5':
|
||||
icon = LucideIcons.Heading5;
|
||||
break;
|
||||
case 'heading6':
|
||||
icon = LucideIcons.Heading6;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
...fieldType,
|
||||
icon,
|
||||
};
|
||||
});
|
||||
|
||||
export default FIELD_TYPES_WITH_ICONS;
|
||||
@ -10,10 +10,16 @@ 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,5 +1,6 @@
|
||||
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';
|
||||
@ -11,6 +12,7 @@ import CheckBox from './CheckBox';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
import InputPhone from './InputPhone';
|
||||
import FileUpload from './FileUpload';
|
||||
import SignatureField from './SignatureField';
|
||||
|
||||
/*
|
||||
* Récupère une icône Lucide par son nom.
|
||||
@ -24,47 +26,10 @@ export function getIcon(name) {
|
||||
}
|
||||
}
|
||||
|
||||
const formConfigTest = {
|
||||
id: 0,
|
||||
title: 'Mon formulaire dynamique',
|
||||
submitLabel: 'Envoyer',
|
||||
fields: [
|
||||
{ id: 'name', label: 'Nom', type: 'text', required: true },
|
||||
{ id: 'email', label: 'Email', type: 'email' },
|
||||
{
|
||||
id: 'email2',
|
||||
label: 'Email',
|
||||
type: 'text',
|
||||
icon: 'Mail',
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
label: 'Rôle',
|
||||
type: 'select',
|
||||
options: ['Admin', 'Utilisateur', 'Invité'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
text: "Bonjour, Bienvenue dans ce formulaire d'inscription haha",
|
||||
},
|
||||
{
|
||||
id: 'birthdate',
|
||||
label: 'Date de naissance',
|
||||
type: 'date',
|
||||
icon: 'Calendar',
|
||||
},
|
||||
{
|
||||
id: 'textarea',
|
||||
label: 'toto',
|
||||
type: 'textarea',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function FormRenderer({
|
||||
formConfig = formConfigTest,
|
||||
formConfig,
|
||||
csrfToken,
|
||||
initialValues = {},
|
||||
onFormSubmit = (data) => {
|
||||
alert(JSON.stringify(data, null, 2));
|
||||
}, // Callback de soumission personnalisé (optionnel)
|
||||
@ -74,7 +39,14 @@ export default function FormRenderer({
|
||||
control,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm();
|
||||
} = useForm({ defaultValues: initialValues });
|
||||
|
||||
// Réinitialiser le formulaire quand les valeurs initiales changent
|
||||
useEffect(() => {
|
||||
if (initialValues && Object.keys(initialValues).length > 0) {
|
||||
reset(initialValues);
|
||||
}
|
||||
}, [initialValues, reset]);
|
||||
|
||||
// Fonction utilitaire pour envoyer les données au backend
|
||||
const sendFormDataToBackend = async (formData) => {
|
||||
@ -109,7 +81,8 @@ export default function FormRenderer({
|
||||
const hasFiles = Object.keys(data).some((key) => {
|
||||
return (
|
||||
data[key] instanceof FileList ||
|
||||
(data[key] && data[key][0] instanceof File)
|
||||
(data[key] && data[key][0] instanceof File) ||
|
||||
(typeof data[key] === 'string' && data[key].startsWith('data:image'))
|
||||
);
|
||||
});
|
||||
|
||||
@ -118,7 +91,7 @@ export default function FormRenderer({
|
||||
const formData = new FormData();
|
||||
|
||||
// Ajouter l'ID du formulaire
|
||||
formData.append('formId', formConfig.id.toString());
|
||||
formData.append('formId', (formConfig?.id || 'unknown').toString());
|
||||
|
||||
// Traiter chaque champ et ses valeurs
|
||||
Object.keys(data).forEach((key) => {
|
||||
@ -134,6 +107,29 @@ export default function FormRenderer({
|
||||
formData.append(`files.${key}`, value[i]);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
typeof value === 'string' &&
|
||||
value.startsWith('data:image')
|
||||
) {
|
||||
// Gérer les signatures (SVG ou images base64)
|
||||
if (value.includes('svg+xml')) {
|
||||
// Gérer les signatures SVG
|
||||
const svgData = value.split(',')[1];
|
||||
const svgBlob = new Blob([atob(svgData)], {
|
||||
type: 'image/svg+xml',
|
||||
});
|
||||
formData.append(`files.${key}`, svgBlob, `signature_${key}.svg`);
|
||||
} else {
|
||||
// Gérer les images base64 classiques
|
||||
const byteString = atob(value.split(',')[1]);
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
formData.append(`files.${key}`, blob, `signature_${key}.png`);
|
||||
}
|
||||
} else {
|
||||
// Gérer les autres types de champs
|
||||
formData.append(
|
||||
@ -154,7 +150,7 @@ export default function FormRenderer({
|
||||
} else {
|
||||
// Pas de fichier, on peut utiliser JSON
|
||||
const formattedData = {
|
||||
formId: formConfig.id,
|
||||
formId: formConfig?.id || 'unknown',
|
||||
responses: { ...data },
|
||||
};
|
||||
|
||||
@ -189,10 +185,10 @@ export default function FormRenderer({
|
||||
>
|
||||
{csrfToken ? <DjangoCSRFToken csrfToken={csrfToken} /> : null}
|
||||
<h2 className="text-2xl font-bold text-center mb-4">
|
||||
{formConfig.title}
|
||||
{formConfig?.title || 'Formulaire'}
|
||||
</h2>
|
||||
|
||||
{formConfig.fields.map((field) => (
|
||||
{(formConfig?.fields || []).map((field) => (
|
||||
<div
|
||||
key={field.id || `field-${Math.random().toString(36).substr(2, 9)}`}
|
||||
className="flex flex-col mt-4"
|
||||
@ -428,13 +424,40 @@ export default function FormRenderer({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{field.type === 'signature' && (
|
||||
<Controller
|
||||
name={field.id}
|
||||
control={control}
|
||||
rules={{ required: field.required }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<SignatureField
|
||||
label={field.label}
|
||||
required={field.required}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
backgroundColor={field.backgroundColor || '#ffffff'}
|
||||
penColor={field.penColor || '#000000'}
|
||||
penWidth={field.penWidth || 2}
|
||||
/>
|
||||
{errors[field.id] && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{field.required
|
||||
? `${field.label} est requis`
|
||||
: 'Champ invalide'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="form-group-submit mt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
primary
|
||||
text={formConfig.submitLabel ? formConfig.submitLabel : 'Envoyer'}
|
||||
text={formConfig?.submitLabel || 'Envoyer'}
|
||||
className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@ import { useForm } from 'react-hook-form';
|
||||
import InputTextIcon from './InputTextIcon';
|
||||
import FormRenderer from './FormRenderer';
|
||||
import AddFieldModal from './AddFieldModal';
|
||||
import FieldTypeSelector from './FieldTypeSelector';
|
||||
import { DndProvider, useDrag, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import {
|
||||
@ -34,7 +35,9 @@ import {
|
||||
ToggleLeft,
|
||||
CheckSquare,
|
||||
FileUp,
|
||||
PenTool,
|
||||
} from 'lucide-react';
|
||||
import CheckBox from '@/components/Form/CheckBox';
|
||||
|
||||
const FIELD_TYPES_ICON = {
|
||||
text: { icon: TextCursorInput },
|
||||
@ -46,6 +49,7 @@ const FIELD_TYPES_ICON = {
|
||||
checkbox: { icon: CheckSquare },
|
||||
toggle: { icon: ToggleLeft },
|
||||
file: { icon: FileUp },
|
||||
signature: { icon: PenTool },
|
||||
textarea: { icon: Type },
|
||||
paragraph: { icon: AlignLeft },
|
||||
heading1: { icon: Heading1 },
|
||||
@ -168,15 +172,26 @@ const DraggableFieldItem = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default function FormTemplateBuilder() {
|
||||
export default function FormTemplateBuilder({
|
||||
onSave,
|
||||
initialData,
|
||||
groups,
|
||||
isEditing,
|
||||
}) {
|
||||
const [formConfig, setFormConfig] = useState({
|
||||
id: 0,
|
||||
title: 'Nouveau formulaire',
|
||||
id: initialData?.id || 0,
|
||||
title: initialData?.name || 'Nouveau formulaire',
|
||||
submitLabel: 'Envoyer',
|
||||
fields: [],
|
||||
fields: initialData?.formMasterData?.fields || [],
|
||||
});
|
||||
|
||||
const [selectedGroups, setSelectedGroups] = useState(
|
||||
initialData?.groups?.map((g) => g.id) || []
|
||||
);
|
||||
|
||||
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
|
||||
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
|
||||
const [selectedFieldType, setSelectedFieldType] = useState(null);
|
||||
const [editingIndex, setEditingIndex] = useState(-1);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState({ type: '', text: '' });
|
||||
@ -185,6 +200,19 @@ export default function FormTemplateBuilder() {
|
||||
|
||||
const { reset: resetField } = useForm();
|
||||
|
||||
// Initialiser les données du formulaire quand initialData change
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setFormConfig({
|
||||
id: initialData.id || 0,
|
||||
title: initialData.name || 'Nouveau formulaire',
|
||||
submitLabel: 'Envoyer',
|
||||
fields: initialData.formMasterData?.fields || [],
|
||||
});
|
||||
setSelectedGroups(initialData.groups?.map((g) => g.id) || []);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// Gérer l'affichage du bouton de défilement
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@ -235,7 +263,9 @@ export default function FormTemplateBuilder() {
|
||||
? undefined
|
||||
: generateFieldId(data.label || 'field'),
|
||||
options: ['select', 'radio'].includes(data.type)
|
||||
? currentField.options
|
||||
? Array.isArray(currentField.options)
|
||||
? currentField.options
|
||||
: []
|
||||
: undefined,
|
||||
icon: data.icon || currentField.icon || undefined,
|
||||
placeholder: data.placeholder || undefined,
|
||||
@ -345,35 +375,36 @@ export default function FormTemplateBuilder() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedGroups.length === 0) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
text: "Sélectionnez au moins un groupe d'inscription",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveMessage({ type: '', text: '' });
|
||||
|
||||
try {
|
||||
// Simulation d'envoi au backend (à remplacer par l'appel API réel)
|
||||
// const response = await fetch('/api/form-templates', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// body: JSON.stringify(formConfig),
|
||||
// });
|
||||
const dataToSave = {
|
||||
name: formConfig.title,
|
||||
group_ids: selectedGroups,
|
||||
formMasterData: formConfig,
|
||||
};
|
||||
|
||||
// if (!response.ok) {
|
||||
// throw new Error('Erreur lors de l\'enregistrement du formulaire');
|
||||
// }
|
||||
if (isEditing && initialData) {
|
||||
dataToSave.id = initialData.id;
|
||||
}
|
||||
|
||||
// const data = await response.json();
|
||||
|
||||
// Simulation d'une réponse du backend
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (onSave) {
|
||||
onSave(dataToSave);
|
||||
}
|
||||
|
||||
setSaveMessage({
|
||||
type: 'success',
|
||||
text: 'Formulaire enregistré avec succès',
|
||||
});
|
||||
|
||||
// Si le backend renvoie un ID, on peut mettre à jour l'ID du formulaire
|
||||
// setFormConfig({ ...formConfig, id: data.id });
|
||||
} catch (error) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
@ -385,6 +416,13 @@ export default function FormTemplateBuilder() {
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour gérer la sélection d'un type de champ
|
||||
const handleFieldTypeSelect = (fieldType) => {
|
||||
setSelectedFieldType(fieldType);
|
||||
setShowFieldTypeSelector(false);
|
||||
setShowAddFieldModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
@ -476,6 +514,42 @@ export default function FormTemplateBuilder() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Sélecteur de groupes */}
|
||||
<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="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
|
||||
{groups && groups.length > 0 ? (
|
||||
groups.map((group) => (
|
||||
<CheckBox
|
||||
key={group.id}
|
||||
item={{ id: group.id }}
|
||||
formData={{
|
||||
groups: selectedGroups
|
||||
}}
|
||||
handleChange={() => {
|
||||
let group_ids = selectedGroups;
|
||||
if (group_ids.includes(group.id)) {
|
||||
group_ids = group_ids.filter((id) => id !== group.id);
|
||||
} else {
|
||||
group_ids = [...group_ids, group.id];
|
||||
}
|
||||
setSelectedGroups(group_ids);
|
||||
}}
|
||||
fieldName="groups"
|
||||
itemLabelFunc={() => group.name}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">
|
||||
Aucun groupe disponible
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste des champs */}
|
||||
@ -487,7 +561,8 @@ export default function FormTemplateBuilder() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingIndex(-1);
|
||||
setShowAddFieldModal(true);
|
||||
setSelectedFieldType(null);
|
||||
setShowFieldTypeSelector(true);
|
||||
}}
|
||||
className="p-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
|
||||
title="Ajouter un champ"
|
||||
@ -504,7 +579,8 @@ export default function FormTemplateBuilder() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingIndex(-1);
|
||||
setShowAddFieldModal(true);
|
||||
setSelectedFieldType(null);
|
||||
setShowFieldTypeSelector(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
@ -593,11 +669,22 @@ export default function FormTemplateBuilder() {
|
||||
onClose={() => setShowAddFieldModal(false)}
|
||||
onSubmit={handleFieldSubmit}
|
||||
editingField={
|
||||
editingIndex >= 0 ? formConfig.fields[editingIndex] : null
|
||||
editingIndex >= 0
|
||||
? formConfig.fields[editingIndex]
|
||||
: selectedFieldType
|
||||
? { type: selectedFieldType.value || selectedFieldType }
|
||||
: null
|
||||
}
|
||||
editingIndex={editingIndex}
|
||||
/>
|
||||
|
||||
{/* Sélecteur de type de champ */}
|
||||
<FieldTypeSelector
|
||||
isOpen={showFieldTypeSelector}
|
||||
onClose={() => setShowFieldTypeSelector(false)}
|
||||
onSelect={handleFieldTypeSelect}
|
||||
/>
|
||||
|
||||
{/* Bouton flottant pour remonter en haut */}
|
||||
{showScrollButton && (
|
||||
<div className="fixed bottom-6 right-6 z-10">
|
||||
|
||||
@ -8,6 +8,7 @@ export const FIELD_TYPES = [
|
||||
{ value: 'checkbox', label: 'Case à cocher' },
|
||||
{ value: 'toggle', label: 'Interrupteur' },
|
||||
{ value: 'file', label: 'Upload de fichier' },
|
||||
{ value: 'signature', label: 'Signature' },
|
||||
{ value: 'textarea', label: 'Zone de texte riche' },
|
||||
{ value: 'paragraph', label: 'Paragraphe' },
|
||||
{ value: 'heading1', label: 'Titre 1' },
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
export default function SelectChoice({
|
||||
type,
|
||||
name,
|
||||
@ -11,8 +13,46 @@ export default function SelectChoice({
|
||||
errorLocalMsg,
|
||||
IconItem,
|
||||
disabled = false,
|
||||
// Nouveaux paramètres
|
||||
showIcons = false, // Activer l'affichage des icônes dans les options
|
||||
customSelect = false, // Utiliser une liste personnalisée au lieu du select natif
|
||||
}) {
|
||||
const isPlaceholderSelected = selected === ''; // Vérifie si le placeholder est sélectionné
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedLabel, setSelectedLabel] = useState('');
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// Ferme le dropdown si on clique en dehors
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (customSelect) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [customSelect]);
|
||||
|
||||
// Met à jour le label affiché quand selected change
|
||||
useEffect(() => {
|
||||
if (selected && choices) {
|
||||
const selectedOption = choices.find(
|
||||
(option) => option.value === selected
|
||||
);
|
||||
if (selectedOption) {
|
||||
setSelectedLabel(selectedOption.label);
|
||||
}
|
||||
} else {
|
||||
setSelectedLabel('');
|
||||
}
|
||||
}, [selected, choices]);
|
||||
|
||||
// Affiche soit le select natif, soit notre version personnalisée avec icônes
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
@ -23,45 +63,172 @@ export default function SelectChoice({
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<div
|
||||
className={`mt-1 flex items-center border rounded-md ${
|
||||
errorMsg || errorLocalMsg
|
||||
? 'border-red-500 hover:border-red-700'
|
||||
: 'border-gray-200 hover:border-gray-400'
|
||||
} ${disabled ? '' : 'focus-within:border-gray-500'}`}
|
||||
>
|
||||
{IconItem && (
|
||||
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
|
||||
{<IconItem />}
|
||||
</span>
|
||||
)}
|
||||
<select
|
||||
className={`flex-1 px-3 py-2 block w-full sm:text-sm border-none focus:ring-0 outline-none rounded-md ${
|
||||
disabled ? 'bg-gray-100' : ''
|
||||
} ${isPlaceholderSelected ? 'italic text-gray-500' : 'not-italic text-gray-800'}`} // Applique le style classique si une option autre que le placeholder est sélectionnée
|
||||
type={type}
|
||||
id={name}
|
||||
name={name}
|
||||
value={selected}
|
||||
onChange={callback}
|
||||
disabled={disabled}
|
||||
|
||||
{customSelect ? (
|
||||
// Version personnalisée avec icônes dans un dropdown
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<div
|
||||
className={`mt-1 flex items-center border rounded-md cursor-pointer ${
|
||||
errorMsg || errorLocalMsg
|
||||
? 'border-red-500 hover:border-red-700'
|
||||
: 'border-gray-200 hover:border-gray-400'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
{IconItem && (
|
||||
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
|
||||
{<IconItem />}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 px-3 py-2 block w-full sm:text-sm">
|
||||
{isPlaceholderSelected ? (
|
||||
<span className="italic text-gray-500">
|
||||
{placeHolder?.toLowerCase()}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
{showIcons && selected && choices && (
|
||||
<span className="mr-2">
|
||||
{(() => {
|
||||
const selectedOption = choices.find(
|
||||
(option) => option.value === selected
|
||||
);
|
||||
if (selectedOption && selectedOption.icon) {
|
||||
const IconComponent = selectedOption.icon;
|
||||
return (
|
||||
<IconComponent
|
||||
size={18}
|
||||
className="text-gray-600"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-800">{selectedLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pr-2">
|
||||
<svg
|
||||
className={`h-5 w-5 text-gray-400 transition-transform ${isOpen ? 'transform rotate-180' : ''}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 mt-1 w-full bg-white shadow-lg rounded-md py-1 max-h-60 overflow-auto">
|
||||
<div
|
||||
className="px-3 py-2 text-gray-500 italic cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
const event = { target: { value: '' } };
|
||||
callback(event);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{placeHolder?.toLowerCase()}
|
||||
</div>
|
||||
{choices.map(({ value, label, icon: Icon }) => (
|
||||
<div
|
||||
key={value}
|
||||
className={`px-3 py-2 cursor-pointer hover:bg-gray-100 ${
|
||||
value === selected
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-800'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
const event = { target: { value } };
|
||||
callback(event);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{showIcons && Icon && (
|
||||
<span className="mr-2">
|
||||
<Icon
|
||||
size={18}
|
||||
className={
|
||||
value === selected
|
||||
? 'text-blue-600'
|
||||
: 'text-gray-600'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input caché pour la compatibilité avec les formulaires */}
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
id={name}
|
||||
value={selected || ''}
|
||||
onChange={() => {}} // Évite l'avertissement React pour input non contrôlé
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Version standard avec select natif (pour la rétrocompatibilité)
|
||||
<div
|
||||
className={`mt-1 flex items-center border rounded-md ${
|
||||
errorMsg || errorLocalMsg
|
||||
? 'border-red-500 hover:border-red-700'
|
||||
: 'border-gray-200 hover:border-gray-400'
|
||||
} ${disabled ? '' : 'focus-within:border-gray-500'}`}
|
||||
>
|
||||
{/* Placeholder en italique */}
|
||||
<option value="" className="italic text-gray-500">
|
||||
{placeHolder?.toLowerCase()}
|
||||
</option>
|
||||
{/* Autres options sans italique */}
|
||||
{choices.map(({ value, label }) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
className="not-italic text-gray-800"
|
||||
>
|
||||
{label}
|
||||
{IconItem && (
|
||||
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
|
||||
{<IconItem />}
|
||||
</span>
|
||||
)}
|
||||
<select
|
||||
className={`flex-1 px-3 py-2 block w-full sm:text-sm border-none focus:ring-0 outline-none rounded-md ${
|
||||
disabled ? 'bg-gray-100' : ''
|
||||
} ${isPlaceholderSelected ? 'italic text-gray-500' : 'not-italic text-gray-800'}`}
|
||||
type={type}
|
||||
id={name}
|
||||
name={name}
|
||||
value={selected}
|
||||
onChange={callback}
|
||||
disabled={disabled}
|
||||
>
|
||||
{/* Placeholder en italique */}
|
||||
<option value="" className="italic text-gray-500">
|
||||
{placeHolder?.toLowerCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Autres options sans italique */}
|
||||
{choices.map(({ value, label }) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
className="not-italic text-gray-800"
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMsg && <p className="mt-2 text-sm text-red-600">{errorMsg}</p>}
|
||||
</div>
|
||||
</>
|
||||
|
||||
346
Front-End/src/components/Form/SignatureField.js
Normal file
346
Front-End/src/components/Form/SignatureField.js
Normal file
@ -0,0 +1,346 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
const SignatureField = ({
|
||||
label = 'Signature',
|
||||
required = false,
|
||||
onChange,
|
||||
value,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
backgroundColor = '#ffffff',
|
||||
penColor = '#000000',
|
||||
penWidth = 2,
|
||||
}) => {
|
||||
const canvasRef = useRef(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [lastPosition, setLastPosition] = useState({ x: 0, y: 0 });
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [svgPaths, setSvgPaths] = useState([]);
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [smoothingPoints, setSmoothingPoints] = useState([]);
|
||||
|
||||
// Initialiser le canvas
|
||||
const initializeCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
// Support High DPI / Retina displays
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
const displayWidth = 400;
|
||||
const displayHeight = 200;
|
||||
|
||||
// Ajuster la taille physique du canvas pour la haute résolution
|
||||
canvas.width = displayWidth * devicePixelRatio;
|
||||
canvas.height = displayHeight * devicePixelRatio;
|
||||
|
||||
// Maintenir la taille d'affichage
|
||||
canvas.style.width = displayWidth + 'px';
|
||||
canvas.style.height = displayHeight + 'px';
|
||||
|
||||
// Adapter le contexte à la densité de pixels
|
||||
context.scale(devicePixelRatio, devicePixelRatio);
|
||||
|
||||
// Améliorer l'anti-aliasing et le rendu
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.textRenderingOptimization = 'optimizeQuality';
|
||||
|
||||
// Configuration du style de dessin
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, displayWidth, displayHeight);
|
||||
context.strokeStyle = penColor;
|
||||
context.lineWidth = penWidth;
|
||||
context.lineCap = 'round';
|
||||
context.lineJoin = 'round';
|
||||
context.globalCompositeOperation = 'source-over';
|
||||
}, [backgroundColor, penColor, penWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
initializeCanvas();
|
||||
|
||||
// Si une valeur est fournie (signature existante), la charger
|
||||
if (value && value !== '') {
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (value.includes('svg+xml')) {
|
||||
// Charger une signature SVG
|
||||
const svgData = atob(value.split(',')[1]);
|
||||
const img = new Image();
|
||||
const svg = new Blob([svgData], {
|
||||
type: 'image/svg+xml;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(svg);
|
||||
|
||||
img.onload = () => {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(img, 0, 0);
|
||||
setIsEmpty(false);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
} else {
|
||||
// Charger une image classique
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(img, 0, 0);
|
||||
setIsEmpty(false);
|
||||
};
|
||||
img.src = value;
|
||||
}
|
||||
}
|
||||
}, [value, initializeCanvas, backgroundColor]);
|
||||
|
||||
// Obtenir les coordonnées relatives au canvas
|
||||
const getCanvasPosition = (e) => {
|
||||
const canvas = canvasRef.current;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.style.width
|
||||
? parseFloat(canvas.style.width) / rect.width
|
||||
: 1;
|
||||
const scaleY = canvas.style.height
|
||||
? parseFloat(canvas.style.height) / rect.height
|
||||
: 1;
|
||||
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY,
|
||||
};
|
||||
};
|
||||
|
||||
// Obtenir les coordonnées pour les événements tactiles
|
||||
const getTouchPosition = (e) => {
|
||||
const canvas = canvasRef.current;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.style.width
|
||||
? parseFloat(canvas.style.width) / rect.width
|
||||
: 1;
|
||||
const scaleY = canvas.style.height
|
||||
? parseFloat(canvas.style.height) / rect.height
|
||||
: 1;
|
||||
|
||||
return {
|
||||
x: (e.touches[0].clientX - rect.left) * scaleX,
|
||||
y: (e.touches[0].clientY - rect.top) * scaleY,
|
||||
};
|
||||
};
|
||||
|
||||
// Commencer le dessin
|
||||
const startDrawing = useCallback(
|
||||
(e) => {
|
||||
if (disabled || readOnly) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDrawing(true);
|
||||
|
||||
const pos = e.type.includes('touch')
|
||||
? getTouchPosition(e)
|
||||
: getCanvasPosition(e);
|
||||
setLastPosition(pos);
|
||||
|
||||
// Commencer un nouveau path SVG
|
||||
setCurrentPath(`M ${pos.x},${pos.y}`);
|
||||
setSmoothingPoints([pos]);
|
||||
},
|
||||
[disabled, readOnly]
|
||||
);
|
||||
|
||||
// Dessiner
|
||||
const draw = useCallback(
|
||||
(e) => {
|
||||
if (!isDrawing || disabled || readOnly) return;
|
||||
|
||||
e.preventDefault();
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
const currentPos = e.type.includes('touch')
|
||||
? getTouchPosition(e)
|
||||
: getCanvasPosition(e);
|
||||
|
||||
// Calculer la distance pour déterminer si on doit interpoler
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(currentPos.x - lastPosition.x, 2) +
|
||||
Math.pow(currentPos.y - lastPosition.y, 2)
|
||||
);
|
||||
|
||||
// Si la distance est grande, interpoler pour un tracé plus lisse
|
||||
if (distance > 2) {
|
||||
const midPoint = {
|
||||
x: (lastPosition.x + currentPos.x) / 2,
|
||||
y: (lastPosition.y + currentPos.y) / 2,
|
||||
};
|
||||
|
||||
// Utiliser une courbe quadratique pour un tracé plus lisse
|
||||
context.beginPath();
|
||||
context.moveTo(lastPosition.x, lastPosition.y);
|
||||
context.quadraticCurveTo(
|
||||
lastPosition.x,
|
||||
lastPosition.y,
|
||||
midPoint.x,
|
||||
midPoint.y
|
||||
);
|
||||
context.stroke();
|
||||
|
||||
setLastPosition(midPoint);
|
||||
setCurrentPath(
|
||||
(prev) =>
|
||||
prev +
|
||||
` Q ${lastPosition.x},${lastPosition.y} ${midPoint.x},${midPoint.y}`
|
||||
);
|
||||
} else {
|
||||
// Tracé direct pour les mouvements lents
|
||||
context.beginPath();
|
||||
context.moveTo(lastPosition.x, lastPosition.y);
|
||||
context.lineTo(currentPos.x, currentPos.y);
|
||||
context.stroke();
|
||||
|
||||
setLastPosition(currentPos);
|
||||
setCurrentPath((prev) => prev + ` L ${currentPos.x},${currentPos.y}`);
|
||||
}
|
||||
|
||||
setIsEmpty(false);
|
||||
},
|
||||
[isDrawing, lastPosition, disabled]
|
||||
);
|
||||
|
||||
// Arrêter le dessin
|
||||
const stopDrawing = useCallback(
|
||||
(e) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDrawing(false);
|
||||
|
||||
// Ajouter le path terminé aux paths SVG
|
||||
if (currentPath) {
|
||||
setSvgPaths((prev) => [...prev, currentPath]);
|
||||
setCurrentPath('');
|
||||
}
|
||||
|
||||
// Notifier le parent du changement avec SVG
|
||||
if (onChange) {
|
||||
const newPaths = [...svgPaths, currentPath].filter((p) => p.length > 0);
|
||||
const svgData = generateSVG(newPaths);
|
||||
onChange(svgData);
|
||||
}
|
||||
},
|
||||
[isDrawing, onChange, svgPaths, currentPath]
|
||||
);
|
||||
|
||||
// Générer le SVG à partir des paths
|
||||
const generateSVG = (paths) => {
|
||||
const svgContent = `<svg width="400" height="200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="${backgroundColor}"/>
|
||||
${paths
|
||||
.map(
|
||||
(path) =>
|
||||
`<path d="${path}" stroke="${penColor}" stroke-width="${penWidth}" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`
|
||||
)
|
||||
.join('\n ')}
|
||||
</svg>`;
|
||||
|
||||
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
|
||||
};
|
||||
|
||||
// Effacer la signature
|
||||
const clearSignature = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
// Effacer en tenant compte des dimensions d'affichage
|
||||
const displayWidth = 400;
|
||||
const displayHeight = 200;
|
||||
|
||||
context.clearRect(0, 0, displayWidth, displayHeight);
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, displayWidth, displayHeight);
|
||||
setIsEmpty(true);
|
||||
setSvgPaths([]);
|
||||
setCurrentPath('');
|
||||
setSmoothingPoints([]);
|
||||
|
||||
if (onChange) {
|
||||
onChange('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="signature-field">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-300 rounded-lg p-4 bg-gray-50">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`border border-gray-200 bg-white rounded touch-none ${
|
||||
readOnly ? 'cursor-default' : 'cursor-crosshair'
|
||||
}`}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
opacity: disabled || readOnly ? 0.7 : 1,
|
||||
cursor: disabled
|
||||
? 'not-allowed'
|
||||
: readOnly
|
||||
? 'default'
|
||||
: 'crosshair',
|
||||
}}
|
||||
onMouseDown={readOnly ? undefined : startDrawing}
|
||||
onMouseMove={readOnly ? undefined : draw}
|
||||
onMouseUp={readOnly ? undefined : stopDrawing}
|
||||
onMouseLeave={readOnly ? undefined : stopDrawing}
|
||||
onTouchStart={readOnly ? undefined : startDrawing}
|
||||
onTouchMove={readOnly ? undefined : draw}
|
||||
onTouchEnd={readOnly ? undefined : stopDrawing}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mt-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
{readOnly
|
||||
? isEmpty
|
||||
? 'Aucune signature'
|
||||
: 'Signature'
|
||||
: isEmpty
|
||||
? 'Signez dans la zone ci-dessus'
|
||||
: 'Signature capturée'}
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSignature}
|
||||
disabled={disabled || isEmpty}
|
||||
className="flex items-center gap-1 px-3 py-1 text-xs bg-red-100 text-red-600 rounded hover:bg-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{required && isEmpty && (
|
||||
<div className="text-xs text-red-500 mt-1">
|
||||
La signature est obligatoire
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignatureField;
|
||||
466
Front-End/src/components/Inscription/DynamicFormsList.js
Normal file
466
Front-End/src/components/Inscription/DynamicFormsList.js
Normal file
@ -0,0 +1,466 @@
|
||||
'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 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 {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,
|
||||
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 (!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;
|
||||
});
|
||||
}
|
||||
}, [existingResponses, schoolFileTemplates]);
|
||||
|
||||
// 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)
|
||||
);
|
||||
|
||||
onValidationChange(allFormsValid);
|
||||
}, [formsData, formsValidation, existingResponses, schoolFileTemplates, onValidationChange]);
|
||||
|
||||
/**
|
||||
* Gère la soumission d'un formulaire individuel
|
||||
*/
|
||||
const handleFormSubmit = async (formData, templateId) => {
|
||||
try {
|
||||
logger.debug('Soumission du formulaire:', { templateId, formData });
|
||||
|
||||
// Sauvegarder les données du formulaire
|
||||
setFormsData((prev) => ({
|
||||
...prev,
|
||||
[templateId]: formData,
|
||||
}));
|
||||
|
||||
// Marquer le formulaire comme complété
|
||||
setFormsValidation((prev) => ({
|
||||
...prev,
|
||||
[templateId]: true,
|
||||
}));
|
||||
|
||||
// Appeler le callback parent
|
||||
if (onFormSubmit) {
|
||||
await onFormSubmit(formData, templateId);
|
||||
}
|
||||
|
||||
// Passer au formulaire suivant si disponible
|
||||
if (currentTemplateIndex < schoolFileTemplates.length - 1) {
|
||||
setCurrentTemplateIndex(currentTemplateIndex + 1);
|
||||
}
|
||||
|
||||
logger.debug('Formulaire soumis avec succès');
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la soumission du formulaire:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Vérifie si un formulaire est complété
|
||||
*/
|
||||
const isFormCompleted = (templateId) => {
|
||||
return (
|
||||
formsValidation[templateId] === true ||
|
||||
(formsData[templateId] &&
|
||||
Object.keys(formsData[templateId]).length > 0) ||
|
||||
(existingResponses[templateId] &&
|
||||
Object.keys(existingResponses[templateId]).length > 0)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtient l'icône de statut d'un formulaire
|
||||
*/
|
||||
const getFormStatusIcon = (templateId, isActive) => {
|
||||
if (isFormCompleted(templateId)) {
|
||||
return <CheckCircle className="w-5 h-5 text-green-600" />;
|
||||
}
|
||||
if (isActive) {
|
||||
return <FileText className="w-5 h-5 text-blue-600" />;
|
||||
}
|
||||
return <Hourglass className="w-5 h-5 text-gray-400" />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtient le formulaire actuel à afficher
|
||||
*/
|
||||
const getCurrentTemplate = () => {
|
||||
return schoolFileTemplates[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) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-4">Aucun formulaire à compléter</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 mb-4 w-full mx-auto flex gap-8">
|
||||
{/* Liste des formulaires */}
|
||||
<div className="w-1/4 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
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
|
||||
</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)
|
||||
);
|
||||
|
||||
// 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>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p className="text-sm text-gray-600">
|
||||
{currentTemplate.formTemplateData?.description ||
|
||||
currentTemplate.description || ''}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Affichage dynamique ou existant */}
|
||||
{isDynamicForm(currentTemplate) ? (
|
||||
<FormRenderer
|
||||
key={currentTemplate.id}
|
||||
formConfig={{
|
||||
id: currentTemplate.id,
|
||||
title:
|
||||
currentTemplate.formTemplateData?.title ||
|
||||
currentTemplate.title ||
|
||||
currentTemplate.name ||
|
||||
'Formulaire',
|
||||
fields:
|
||||
currentTemplate.formTemplateData?.fields ||
|
||||
currentTemplate.fields ||
|
||||
[],
|
||||
submitLabel:
|
||||
currentTemplate.formTemplateData?.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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message de fin */}
|
||||
{currentTemplateIndex >= schoolFileTemplates.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">
|
||||
Tous les formulaires ont été complétés !
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Vous pouvez maintenant passer à l'étape suivante.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,11 +5,12 @@ import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
||||
import {
|
||||
fetchSchoolFileTemplatesFromRegistrationFiles,
|
||||
fetchParentFileTemplatesFromRegistrationFiles,
|
||||
saveFormResponses,
|
||||
fetchFormResponses,
|
||||
autoSaveRegisterForm,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import {
|
||||
downloadTemplate,
|
||||
editRegistrationSchoolFileTemplates,
|
||||
editRegistrationParentFileTemplates,
|
||||
} from '@/app/actions/registerFileGroupAction';
|
||||
import {
|
||||
fetchRegistrationPaymentModes,
|
||||
@ -18,16 +19,16 @@ import {
|
||||
fetchTuitionPaymentPlans,
|
||||
} from '@/app/actions/schoolAction';
|
||||
import { fetchProfiles } from '@/app/actions/authAction';
|
||||
import { BASE_URL, FE_PARENTS_HOME_URL } from '@/utils/Url';
|
||||
import { FE_PARENTS_HOME_URL } from '@/utils/Url';
|
||||
import logger from '@/utils/logger';
|
||||
import FilesToUpload from '@/components/Inscription/FilesToUpload';
|
||||
import { DocusealForm } from '@docuseal/react';
|
||||
import DynamicFormsList from '@/components/Inscription/DynamicFormsList';
|
||||
import AutoSaveIndicator from '@/components/AutoSaveIndicator';
|
||||
import StudentInfoForm from '@/components/Inscription/StudentInfoForm';
|
||||
import ResponsableInputFields from '@/components/Inscription/ResponsableInputFields';
|
||||
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';
|
||||
|
||||
/**
|
||||
@ -42,7 +43,6 @@ export default function InscriptionFormShared({
|
||||
studentId,
|
||||
csrfToken,
|
||||
selectedEstablishmentId,
|
||||
apiDocuseal,
|
||||
onSubmit,
|
||||
errors = {}, // Nouvelle prop pour les erreurs
|
||||
enable = true,
|
||||
@ -75,6 +75,8 @@ export default function InscriptionFormShared({
|
||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
|
||||
const [parentFileTemplates, setParentFileTemplates] = useState([]);
|
||||
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
|
||||
const [formResponses, setFormResponses] = useState({});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const [isPage1Valid, setIsPage1Valid] = useState(false);
|
||||
@ -90,6 +92,9 @@ export default function InscriptionFormShared({
|
||||
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
|
||||
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState(null);
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@ -121,18 +126,18 @@ export default function InscriptionFormShared({
|
||||
}, [schoolFileTemplates]);
|
||||
|
||||
useEffect(() => {
|
||||
// Vérifier si tous les templates ont leur champ "file" différent de null
|
||||
const allSigned = schoolFileTemplates.every(
|
||||
(template) => template.file !== null
|
||||
);
|
||||
// Vérifier si tous les formulaires maîtres sont complétés
|
||||
const allCompleted =
|
||||
schoolFileMasters.length === 0 ||
|
||||
schoolFileMasters.every((master) => master.completed === true);
|
||||
|
||||
// Mettre à jour isPage4Valid en fonction de cette condition
|
||||
setIsPage5Valid(allSigned);
|
||||
// Mettre à jour isPage5Valid en fonction de cette condition
|
||||
setIsPage5Valid(allCompleted);
|
||||
|
||||
if (allSigned) {
|
||||
if (allCompleted) {
|
||||
setCurrentTemplateIndex(0);
|
||||
}
|
||||
}, [schoolFileTemplates]);
|
||||
}, [schoolFileMasters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Vérifier si tous les documents avec is_required = true ont leur champ "file" différent de null
|
||||
@ -145,62 +150,288 @@ export default function InscriptionFormShared({
|
||||
logger.debug(allRequiredUploaded);
|
||||
}, [parentFileTemplates]);
|
||||
|
||||
const handleTemplateSigned = (index) => {
|
||||
const template = schoolFileTemplates[index];
|
||||
// Auto-sauvegarde périodique (toutes les 30 secondes)
|
||||
useEffect(() => {
|
||||
if (!enable || !autoSaveEnabled) return;
|
||||
|
||||
if (!template) {
|
||||
logger.error("Template introuvable pour l'index donné.");
|
||||
const interval = setInterval(() => {
|
||||
autoSave();
|
||||
}, 30000); // 30 secondes
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [enable, autoSaveEnabled, formData, guardians, siblings]);
|
||||
|
||||
// Auto-sauvegarde quand les données changent (avec debounce)
|
||||
useEffect(() => {
|
||||
if (!enable || !autoSaveEnabled) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
autoSave();
|
||||
}, 2000); // Attendre 2 secondes après le dernier changement
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [formData, guardians, siblings]);
|
||||
|
||||
/**
|
||||
* Fonction d'auto-sauvegarde qui sauvegarde les données en cours
|
||||
*/
|
||||
const autoSave = async () => {
|
||||
if (!autoSaveEnabled || !studentId || isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Télécharger le template
|
||||
downloadTemplate(template.slug, selectedEstablishmentId, apiDocuseal)
|
||||
.then((downloadUrl) => fetch(downloadUrl))
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du téléchargement du fichier.');
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const file = new File([blob], `${template.name}.pdf`, {
|
||||
type: blob.type,
|
||||
});
|
||||
|
||||
// Préparer les données pour la mise à jour
|
||||
const updateData = new FormData();
|
||||
updateData.append('file', file);
|
||||
|
||||
// Mettre à jour le template via l'API
|
||||
return editRegistrationSchoolFileTemplates(
|
||||
template.id,
|
||||
updateData,
|
||||
csrfToken
|
||||
);
|
||||
})
|
||||
.then((updatedTemplate) => {
|
||||
logger.debug('Template mis à jour avec succès :', updatedTemplate);
|
||||
|
||||
// Mettre à jour l'état local de schoolFileTemplates
|
||||
setSchoolFileTemplates((prevTemplates) => {
|
||||
const updatedTemplates = prevTemplates.map((t, i) =>
|
||||
i === index ? { ...t, file: updatedTemplate.data.file } : t
|
||||
);
|
||||
logger.debug(
|
||||
'État schoolFileTemplates mis à jour :',
|
||||
updatedTemplates
|
||||
);
|
||||
return updatedTemplates;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Erreur lors de la mise à jour du template :', error);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
logger.debug('Auto-sauvegarde en cours...', {
|
||||
studentId,
|
||||
formDataKeys: Object.keys(formData),
|
||||
paymentFields: {
|
||||
registration_payment: formData.registration_payment,
|
||||
registration_payment_plan: formData.registration_payment_plan,
|
||||
tuition_payment: formData.tuition_payment,
|
||||
tuition_payment_plan: formData.tuition_payment_plan,
|
||||
},
|
||||
guardians: guardians.length,
|
||||
siblings: siblings.length,
|
||||
currentPage,
|
||||
});
|
||||
|
||||
// Fonction helper pour nettoyer les données avant sauvegarde
|
||||
const cleanDataForAutoSave = (data) => {
|
||||
const cleaned = {};
|
||||
Object.keys(data).forEach((key) => {
|
||||
const value = data[key];
|
||||
// Garder seulement les valeurs non-vides et valides
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
// Pour les dates, vérifier le format
|
||||
if (key === 'birth_date' && value) {
|
||||
// Vérifier que la date est dans un format valide
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (dateRegex.test(value)) {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
// Pour les codes postaux, vérifier que c'est un nombre
|
||||
else if (key === 'birth_postal_code' && value) {
|
||||
if (!isNaN(value) && value.toString().trim() !== '') {
|
||||
cleaned[key] = parseInt(value);
|
||||
}
|
||||
}
|
||||
// Pour les champs de paiement, toujours les inclure s'ils ont une valeur
|
||||
else if (key.includes('payment') && value) {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
// Pour les autres champs, garder la valeur si elle n'est pas vide
|
||||
else if (value.toString().trim() !== '') {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
// Préparer les données à sauvegarder avec nettoyage
|
||||
const cleanedFormData = cleanDataForAutoSave(formData);
|
||||
|
||||
const dataToSave = {
|
||||
student: cleanedFormData,
|
||||
guardians: guardians.filter(
|
||||
(guardian) =>
|
||||
guardian &&
|
||||
(guardian.first_name || guardian.last_name || guardian.email)
|
||||
),
|
||||
siblings: siblings.filter(
|
||||
(sibling) => sibling && (sibling.first_name || sibling.last_name)
|
||||
),
|
||||
currentPage: currentPage,
|
||||
};
|
||||
|
||||
// Utiliser la fonction d'auto-save dédiée
|
||||
await autoSaveRegisterForm(studentId, dataToSave, csrfToken);
|
||||
|
||||
setLastSaved(new Date());
|
||||
logger.debug('Auto-sauvegarde réussie');
|
||||
} catch (error) {
|
||||
logger.error("Erreur lors de l'auto-sauvegarde:", error);
|
||||
// Ne pas afficher d'erreur à l'utilisateur pour l'auto-save
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gère la sauvegarde à chaque changement d'étape
|
||||
*/
|
||||
const saveStepData = async () => {
|
||||
await autoSave();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gère la soumission d'un formulaire dynamique
|
||||
*/
|
||||
const handleDynamicFormSubmit = async (formData, templateId) => {
|
||||
try {
|
||||
logger.debug('Soumission du formulaire dynamique:', {
|
||||
templateId,
|
||||
formData,
|
||||
csrfToken: !!csrfToken,
|
||||
});
|
||||
|
||||
// Trouver le template correspondant pour récupérer sa configuration
|
||||
const currentTemplate = schoolFileTemplates.find(
|
||||
(template) => template.id === templateId
|
||||
);
|
||||
if (!currentTemplate) {
|
||||
throw new Error(`Template avec l'ID ${templateId} non trouvé`);
|
||||
}
|
||||
|
||||
// Construire la structure complète avec la configuration et les réponses
|
||||
const formTemplateData = {
|
||||
id: currentTemplate.id,
|
||||
title:
|
||||
currentTemplate.formTemplateData?.title ||
|
||||
currentTemplate.title ||
|
||||
currentTemplate.name ||
|
||||
'Formulaire',
|
||||
fields: (
|
||||
currentTemplate.formTemplateData?.fields ||
|
||||
currentTemplate.fields ||
|
||||
[]
|
||||
).map((field) => ({
|
||||
...field,
|
||||
...(field.type === 'checkbox'
|
||||
? { checked: formData[field.id] || false }
|
||||
: {}),
|
||||
...(field.type === 'radio' ? { selected: formData[field.id] } : {}),
|
||||
...(field.type === 'text' ||
|
||||
field.type === 'textarea' ||
|
||||
field.type === 'email'
|
||||
? { value: formData[field.id] || '' }
|
||||
: {}),
|
||||
})),
|
||||
submitLabel: currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
||||
responses: formData,
|
||||
};
|
||||
|
||||
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
|
||||
logger.debug('Appel API saveFormResponses avec:', {
|
||||
templateId,
|
||||
formTemplateData,
|
||||
});
|
||||
const result = await saveFormResponses(
|
||||
templateId,
|
||||
formTemplateData,
|
||||
csrfToken
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
setFormResponses((prev) => ({
|
||||
...prev,
|
||||
[templateId]: newResponses,
|
||||
}));
|
||||
|
||||
setSchoolFileTemplates((prevTemplates) => {
|
||||
return prevTemplates.map((template) =>
|
||||
template.id === templateId
|
||||
? { ...template, completed: true, responses: newResponses }
|
||||
: template
|
||||
);
|
||||
});
|
||||
|
||||
logger.debug('Formulaire dynamique sauvegardé avec succès');
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la soumission du formulaire dynamique:', {
|
||||
templateId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
alert(`Erreur lors de la sauvegarde du formulaire: ${error.message}`);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gère les changements de validation des formulaires dynamiques
|
||||
*/
|
||||
const handleDynamicFormsValidationChange = (isValid) => {
|
||||
setIsPage5Valid(isValid);
|
||||
};
|
||||
|
||||
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) => {
|
||||
@ -235,7 +466,7 @@ export default function InscriptionFormShared({
|
||||
// Fetch data for tuition payment plans
|
||||
handleTuitionnPaymentPlans();
|
||||
}
|
||||
}, [selectedEstablishmentId]);
|
||||
}, [studentId, selectedEstablishmentId]);
|
||||
|
||||
const handleRegistrationPaymentModes = () => {
|
||||
fetchRegistrationPaymentModes(selectedEstablishmentId)
|
||||
@ -285,10 +516,22 @@ export default function InscriptionFormShared({
|
||||
);
|
||||
}
|
||||
|
||||
const updateData = new FormData();
|
||||
updateData.append('file', file);
|
||||
// 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}`;
|
||||
|
||||
return editRegistrationParentFileTemplates(
|
||||
const updateData = new FormData();
|
||||
updateData.append('file', file, finalFileName);
|
||||
|
||||
return editRegistrationSchoolFileTemplates(
|
||||
selectedFile.id,
|
||||
updateData,
|
||||
csrfToken
|
||||
@ -299,11 +542,10 @@ export default function InscriptionFormShared({
|
||||
setUploadedFiles((prev) => {
|
||||
const updatedFiles = prev.map((uploadedFile) =>
|
||||
uploadedFile.id === selectedFile.id
|
||||
? { ...uploadedFile, fileName: response.data.file } // Met à jour le fichier téléversé
|
||||
? { ...uploadedFile, fileName: response.data.file }
|
||||
: uploadedFile
|
||||
);
|
||||
|
||||
// Si le fichier n'existe pas encore, l'ajouter
|
||||
if (!updatedFiles.find((file) => file.id === selectedFile.id)) {
|
||||
updatedFiles.push({
|
||||
id: selectedFile.id,
|
||||
@ -323,11 +565,11 @@ export default function InscriptionFormShared({
|
||||
)
|
||||
);
|
||||
|
||||
return response; // Retourner la réponse pour signaler le succès
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Erreur lors de la mise à jour du fichier :', error);
|
||||
throw error; // Relancer l'erreur pour que l'appelant puisse la capturer
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
@ -358,7 +600,7 @@ export default function InscriptionFormShared({
|
||||
setUploadedFiles((prev) =>
|
||||
prev.map((uploadedFile) =>
|
||||
uploadedFile.id === templateId
|
||||
? { ...uploadedFile, fileName: null, fileUrl: null } // Réinitialiser les champs liés au fichier
|
||||
? { ...uploadedFile, fileName: null, fileUrl: null }
|
||||
: uploadedFile
|
||||
)
|
||||
);
|
||||
@ -423,11 +665,16 @@ export default function InscriptionFormShared({
|
||||
onSubmit(formDataToSend);
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
const handleNextPage = async () => {
|
||||
// Sauvegarder avant de passer à l'étape suivante
|
||||
await saveStepData();
|
||||
setHasInteracted(false);
|
||||
setCurrentPage(currentPage + 1);
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
const handlePreviousPage = async () => {
|
||||
// Sauvegarder avant de revenir à l'étape précédente
|
||||
await saveStepData();
|
||||
setCurrentPage(currentPage - 1);
|
||||
};
|
||||
|
||||
@ -479,7 +726,18 @@ export default function InscriptionFormShared({
|
||||
setStep={setCurrentPage}
|
||||
isStepValid={isStepValid}
|
||||
/>
|
||||
<div className="flex-1 h-full mt-12 ">
|
||||
|
||||
{/* Indicateur de sauvegarde automatique */}
|
||||
{enable && (
|
||||
<AutoSaveIndicator
|
||||
isSaving={isSaving}
|
||||
lastSaved={lastSaved}
|
||||
autoSaveEnabled={autoSaveEnabled}
|
||||
onToggleAutoSave={() => setAutoSaveEnabled(!autoSaveEnabled)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 h-full mt-6">
|
||||
{/* Page 1 : Informations sur l'élève */}
|
||||
{currentPage === 1 && (
|
||||
<StudentInfoForm
|
||||
@ -538,86 +796,16 @@ export default function InscriptionFormShared({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Page 5 : Section Fichiers d'inscription */}
|
||||
{/* Page 5 : Formulaires dynamiques d'inscription */}
|
||||
{currentPage === 5 && (
|
||||
<div className="mt-8 mb-4 w-full mx-auto flex gap-8">
|
||||
{/* Liste des états de signature */}
|
||||
<div className="w-1/4 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Documents
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{schoolFileTemplates.map((template, index) => (
|
||||
<li
|
||||
key={template.id}
|
||||
className={`flex items-center cursor-pointer ${
|
||||
index === currentTemplateIndex
|
||||
? 'text-blue-600 font-bold'
|
||||
: template.file !== null
|
||||
? 'text-green-600'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
onClick={() => setCurrentTemplateIndex(index)} // Mettre à jour l'index du template actuel
|
||||
>
|
||||
<span className="mr-2">
|
||||
{template.file !== null ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<Hourglass className="w-5 h-5 text-gray-600" />
|
||||
)}
|
||||
</span>
|
||||
{template.name || 'Document sans nom'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Affichage du fichier actuel */}
|
||||
<div className="w-3/4">
|
||||
{currentTemplateIndex < schoolFileTemplates.length && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
{schoolFileTemplates[currentTemplateIndex].name ||
|
||||
'Document sans nom'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{schoolFileTemplates[currentTemplateIndex].description ||
|
||||
'Aucune description disponible pour ce document.'}
|
||||
</p>
|
||||
|
||||
{schoolFileTemplates[currentTemplateIndex].file === null ? (
|
||||
<DocusealForm
|
||||
key={schoolFileTemplates[currentTemplateIndex].slug}
|
||||
id="docusealForm"
|
||||
src={`https://docuseal.com/s/${schoolFileTemplates[currentTemplateIndex].slug}`}
|
||||
withDownloadButton={false}
|
||||
withTitle={false}
|
||||
onComplete={() =>
|
||||
handleTemplateSigned(currentTemplateIndex)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
src={`${BASE_URL}${schoolFileTemplates[currentTemplateIndex].file}`}
|
||||
title="Document Viewer"
|
||||
className="w-full"
|
||||
style={{
|
||||
height: '75vh',
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message de fin */}
|
||||
{currentTemplateIndex >= schoolFileTemplates.length && (
|
||||
<div className="text-center text-green-600 font-semibold">
|
||||
Tous les formulaires ont été signés avec succès !
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DynamicFormsList
|
||||
schoolFileTemplates={schoolFileTemplates}
|
||||
existingResponses={formResponses}
|
||||
onFormSubmit={handleDynamicFormSubmit}
|
||||
onValidationChange={handleDynamicFormsValidationChange}
|
||||
enable={enable}
|
||||
onFileUpload={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dernière page : Section Fichiers parents */}
|
||||
|
||||
@ -100,7 +100,7 @@ export default function ResponsableInputFields({
|
||||
profile_role_data: {
|
||||
establishment: selectedEstablishmentId,
|
||||
role_type: 2,
|
||||
is_active: true,
|
||||
is_active: false,
|
||||
profile_data: {
|
||||
email: '',
|
||||
password: 'Provisoire01!',
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'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';
|
||||
@ -8,24 +9,55 @@ import {
|
||||
fetchParentFileTemplatesFromRegistrationFiles,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import logger from '@/utils/logger';
|
||||
import { School, CheckCircle, Hourglass, FileText } from 'lucide-react';
|
||||
import { School, 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,
|
||||
@ -88,14 +120,27 @@ 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,
|
||||
@ -125,6 +170,17 @@ 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 (
|
||||
@ -162,8 +218,8 @@ export default function ValidateSubscription({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Colonne droite : Liste des documents, Option de fusion et Affectation */}
|
||||
<div className="w-1/4 flex flex-col gap-4">
|
||||
{/* Colonne droite : Liste des documents, Option de fusion, Affectation, Refus */}
|
||||
<div className="w-1/4 flex flex-col flex-1 gap-4 h-full">
|
||||
{/* 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">
|
||||
@ -187,60 +243,176 @@ export default function ValidateSubscription({
|
||||
<FileText className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
</span>
|
||||
{template.name}
|
||||
<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>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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}
|
||||
/>
|
||||
{/* 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>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -24,8 +24,7 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
||||
setSelectedEstablishmentEvaluationFrequency,
|
||||
setSelectedEstablishmentTotalCapacity,
|
||||
selectedEstablishmentLogo,
|
||||
setSelectedEstablishmentLogo,
|
||||
setApiDocuseal
|
||||
setSelectedEstablishmentLogo
|
||||
} = useEstablishment();
|
||||
const { isConnected, connectionStatus } = useChatConnection();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
@ -41,8 +40,6 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
||||
user.roles[roleId].establishment__total_capacity;
|
||||
const establishmentLogo =
|
||||
user.roles[roleId].establishment__logo;
|
||||
const establishmentApiDocuseal =
|
||||
user.roles[roleId].establishment__api_docuseal;
|
||||
setProfileRole(role);
|
||||
setSelectedEstablishmentId(establishmentId);
|
||||
setSelectedEstablishmentEvaluationFrequency(
|
||||
@ -50,7 +47,6 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
||||
);
|
||||
setSelectedEstablishmentTotalCapacity(establishmentTotalCapacity);
|
||||
setSelectedEstablishmentLogo(establishmentLogo);
|
||||
setApiDocuseal(establishmentApiDocuseal);
|
||||
setSelectedRoleId(roleId);
|
||||
if (onRoleChange) {
|
||||
onRoleChange(roleId);
|
||||
|
||||
85
Front-End/src/components/SectionHeaderDocument.js
Normal file
85
Front-End/src/components/SectionHeaderDocument.js
Normal file
@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
// Thèmes couleur selon le type
|
||||
const THEME = {
|
||||
groupe: {
|
||||
bg: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
iconBg: 'bg-blue-100',
|
||||
icon: 'text-blue-600',
|
||||
title: 'text-blue-800',
|
||||
desc: 'text-blue-600',
|
||||
button: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||
buttonText: 'text-blue-700',
|
||||
buttonHover: 'hover:bg-blue-100',
|
||||
},
|
||||
formulaire: {
|
||||
bg: 'bg-emerald-50',
|
||||
border: 'border-emerald-200',
|
||||
iconBg: 'bg-emerald-100',
|
||||
icon: 'text-emerald-600',
|
||||
title: 'text-emerald-800',
|
||||
desc: 'text-emerald-600',
|
||||
button: 'bg-emerald-500 text-white hover:bg-emerald-600',
|
||||
buttonText: 'text-emerald-700',
|
||||
buttonHover: 'hover:bg-emerald-100',
|
||||
},
|
||||
parent: {
|
||||
bg: 'bg-orange-50',
|
||||
border: 'border-orange-200',
|
||||
iconBg: 'bg-orange-100',
|
||||
icon: 'text-orange-500',
|
||||
title: 'text-orange-700',
|
||||
desc: 'text-orange-600',
|
||||
button: 'bg-orange-500 text-white hover:bg-orange-600',
|
||||
buttonText: 'text-orange-700',
|
||||
buttonHover: 'hover:bg-orange-100',
|
||||
},
|
||||
};
|
||||
|
||||
const SectionHeaderDocument = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
button = false,
|
||||
buttonOpeningModal = false,
|
||||
onClick = null,
|
||||
className = '',
|
||||
type = 'groupe', // 'groupe', 'formulaire', 'parent'
|
||||
}) => {
|
||||
const theme = THEME[type] || THEME.groupe;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-between border-b ${theme.border} ${theme.bg} px-2 py-3 mb-4 ${className}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && (
|
||||
<span className={`${theme.iconBg} p-2 rounded-md flex items-center justify-center`}>
|
||||
<Icon className={`w-6 h-6 ${theme.icon}`} />
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
<h2 className={`text-lg font-semibold ${theme.title}`}>{title}</h2>
|
||||
{description && (
|
||||
<p className={`text-xs ${theme.desc}`}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{button && onClick && (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={
|
||||
buttonOpeningModal
|
||||
? `flex items-center ${theme.button} px-3 py-1 rounded-md shadow transition`
|
||||
: `flex items-center ${theme.buttonText} ${theme.buttonHover} px-2 py-1 rounded-md`
|
||||
}
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-1" />
|
||||
<span className="text-sm font-medium">Ajouter</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionHeaderDocument;
|
||||
@ -7,7 +7,6 @@ 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';
|
||||
@ -44,7 +43,7 @@ export default function CompetenciesList({
|
||||
3: false,
|
||||
4: false,
|
||||
});
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const csrfToken = useCsrfToken();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
@ -280,17 +279,19 @@ export default function CompetenciesList({
|
||||
</div>
|
||||
{/* Bouton submit centré en bas */}
|
||||
<div className="flex justify-center mb-2 mt-6">
|
||||
<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}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Légende en dessous du bouton, alignée à gauche */}
|
||||
<div className="flex flex-row items-center gap-4 mb-4">
|
||||
|
||||
@ -5,6 +5,7 @@ import React, {
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { CheckCircle, Circle } from 'lucide-react';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
|
||||
const TreeView = forwardRef(function TreeView(
|
||||
{ data, expandAll, onSelectionChange },
|
||||
@ -72,6 +73,8 @@ const TreeView = forwardRef(function TreeView(
|
||||
clearSelection: () => setSelectedCompetencies({}),
|
||||
}));
|
||||
|
||||
const { profileRole } = useEstablishment();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.map((domaine) => (
|
||||
@ -112,12 +115,18 @@ const TreeView = forwardRef(function TreeView(
|
||||
? 'text-emerald-600 font-semibold cursor-pointer'
|
||||
: 'text-gray-500 cursor-pointer hover:text-emerald-600'
|
||||
}`}
|
||||
onClick={() => handleCompetenceClick(competence)}
|
||||
onClick={
|
||||
profileRole !== 0
|
||||
? () => handleCompetenceClick(competence)
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
cursor:
|
||||
competence.state === 'required'
|
||||
? 'default'
|
||||
: 'pointer',
|
||||
: profileRole !== 0
|
||||
? 'pointer'
|
||||
: 'default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
|
||||
@ -130,9 +130,7 @@ const ClassesSection = ({
|
||||
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||
const [detailsModalVisible, setDetailsModalVisible] = useState(false);
|
||||
const [selectedClass, setSelectedClass] = useState(null);
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
|
||||
const { getNiveauxLabels, allNiveaux } = useClasses();
|
||||
const router = useRouter();
|
||||
@ -449,6 +447,25 @@ 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
|
||||
@ -534,7 +551,7 @@ const ClassesSection = ({
|
||||
icon={Users}
|
||||
title="Liste des classes"
|
||||
description="Gérez les classes de votre école"
|
||||
button={true}
|
||||
button={profileRole !== 0}
|
||||
onClick={handleAddClass}
|
||||
/>
|
||||
<Table
|
||||
|
||||
@ -29,7 +29,7 @@ const SpecialitiesSection = ({
|
||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const { selectedEstablishmentId, profileRole } = 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' },
|
||||
{ name: 'ACTIONS', label: 'Actions' },
|
||||
...(profileRole !== 0 ? [{ 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={true}
|
||||
button={profileRole !== 0}
|
||||
onClick={handleAddSpeciality}
|
||||
/>
|
||||
<Table
|
||||
|
||||
@ -3,8 +3,7 @@ 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 { useCsrfToken } from '@/context/CsrfContext';
|
||||
import { DndProvider, useDrag, useDrop } from 'react-dnd';
|
||||
import { DndProvider, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
|
||||
@ -128,7 +127,6 @@ const TeachersSection = ({
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
}) => {
|
||||
const csrfToken = useCsrfToken();
|
||||
const [editingTeacher, setEditingTeacher] = useState(null);
|
||||
const [newTeacher, setNewTeacher] = useState(null);
|
||||
const [formData, setFormData] = useState({});
|
||||
@ -140,40 +138,46 @@ const TeachersSection = ({
|
||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||
|
||||
const [confirmPopupVisible, setConfirmPopupVisible] = useState(false);
|
||||
const [confirmPopupMessage, setConfirmPopupMessage] = useState('');
|
||||
const [confirmPopupOnConfirm, setConfirmPopupOnConfirm] = useState(() => {});
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
// --- 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 handleEmailChange = (e) => {
|
||||
const email = e.target.value;
|
||||
const existingProfile = getUsedProfileForEmail(email);
|
||||
|
||||
// Vérifier si l'email correspond à un profil existant
|
||||
const existingProfile = profiles.find((profile) => profile.email === email);
|
||||
if (existingProfile) {
|
||||
logger.info(`Adresse email déjà utilisée pour le profil ${existingProfile.id}`);
|
||||
}
|
||||
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
updateFormData({
|
||||
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 = () => {
|
||||
@ -195,15 +199,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((error) => {
|
||||
logger.error(error);
|
||||
});
|
||||
.catch(logger.error);
|
||||
};
|
||||
|
||||
const handleSaveNewTeacher = () => {
|
||||
@ -234,16 +238,29 @@ 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) {
|
||||
logger.error('Form errors:', error.details);
|
||||
setLocalErrors(error.details);
|
||||
}
|
||||
if (error.details) setLocalErrors(error.details);
|
||||
});
|
||||
} else {
|
||||
setPopupMessage('Tous les champs doivent être remplis et valides');
|
||||
@ -252,51 +269,24 @@ 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 data = {
|
||||
last_name: updatedData.last_name,
|
||||
first_name: updatedData.first_name,
|
||||
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 || [],
|
||||
const profileRoleData = {
|
||||
id: updatedData.profile_role,
|
||||
establishment: selectedEstablishmentId,
|
||||
role_type: updatedData.role_type || 0,
|
||||
profile: updatedData.existingProfileId,
|
||||
};
|
||||
|
||||
handleEdit(id, data)
|
||||
handleEdit(id, {
|
||||
last_name: updatedData.last_name,
|
||||
first_name: updatedData.first_name,
|
||||
profile_role_data: profileRoleData,
|
||||
specialities: updatedData.specialities || [],
|
||||
})
|
||||
.then((updatedTeacher) => {
|
||||
setTeachers((prevTeachers) =>
|
||||
prevTeachers.map((teacher) =>
|
||||
@ -308,10 +298,7 @@ const TeachersSection = ({
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error:', error.message);
|
||||
if (error.details) {
|
||||
logger.error('Form errors:', error.details);
|
||||
setLocalErrors(error.details);
|
||||
}
|
||||
if (error.details) setLocalErrors(error.details);
|
||||
});
|
||||
} else {
|
||||
setPopupMessage('Tous les champs doivent être remplis et valides');
|
||||
@ -321,45 +308,12 @@ const TeachersSection = ({
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
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,
|
||||
}));
|
||||
}
|
||||
let parsedValue = type === 'checkbox' ? (checked ? 1 : 0) : value;
|
||||
updateFormData({ [name]: parsedValue });
|
||||
};
|
||||
|
||||
const handleSpecialitiesChange = (selectedSpecialities) => {
|
||||
if (editingTeacher) {
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
specialities: selectedSpecialities,
|
||||
}));
|
||||
} else if (newTeacher) {
|
||||
setNewTeacher((prevData) => ({
|
||||
...prevData,
|
||||
specialities: selectedSpecialities,
|
||||
}));
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
specialities: selectedSpecialities,
|
||||
}));
|
||||
}
|
||||
updateFormData({ specialities: selectedSpecialities });
|
||||
};
|
||||
|
||||
const handleEditTeacher = (teacher) => {
|
||||
@ -406,6 +360,7 @@ const TeachersSection = ({
|
||||
onChange={handleEmailChange}
|
||||
placeholder="Adresse email de l'enseignant"
|
||||
errorMsg={getError('email')}
|
||||
enable={!isEditing}
|
||||
/>
|
||||
);
|
||||
case 'SPECIALITES':
|
||||
@ -563,7 +518,7 @@ const TeachersSection = ({
|
||||
{ name: 'SPECIALITES', label: 'Spécialités' },
|
||||
{ name: 'ADMINISTRATEUR', label: 'Profil' },
|
||||
{ name: 'MISE A JOUR', label: 'Mise à jour' },
|
||||
{ name: 'ACTIONS', label: 'Actions' },
|
||||
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
@ -573,7 +528,7 @@ const TeachersSection = ({
|
||||
icon={GraduationCap}
|
||||
title="Liste des enseignants.es"
|
||||
description="Gérez les enseignants.es de votre école"
|
||||
button={true}
|
||||
button={profileRole !== 0}
|
||||
onClick={handleAddTeacher}
|
||||
/>
|
||||
<Table
|
||||
|
||||
@ -2,10 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
fetchRegistrationFileGroups,
|
||||
createRegistrationSchoolFileTemplate,
|
||||
cloneTemplate,
|
||||
generateToken,
|
||||
} from '@/app/actions/registerFileGroupAction';
|
||||
import { DocusealBuilder } from '@docuseal/react';
|
||||
import logger from '@/utils/logger';
|
||||
import MultiSelect from '@/components/Form/MultiSelect'; // Import du composant MultiSelect
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
@ -13,24 +10,19 @@ import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import Popup from '@/components/Popup';
|
||||
|
||||
export default function FileUploadDocuSeal({
|
||||
handleCreateTemplateMaster,
|
||||
handleEditTemplateMaster,
|
||||
handleCreateSchoolFileMaster,
|
||||
handleEditSchoolFileMaster,
|
||||
fileToEdit = null,
|
||||
onSuccess,
|
||||
}) {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [token, setToken] = useState(null);
|
||||
const [templateMaster, setTemplateMaster] = useState(null);
|
||||
const [uploadedFileName, setUploadedFileName] = useState('');
|
||||
const [selectedGroups, setSelectedGroups] = useState([]);
|
||||
const [guardianDetails, setGuardianDetails] = useState([]);
|
||||
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
const [popupMessage, setPopupMessage] = useState('');
|
||||
|
||||
const csrfToken = useCsrfToken();
|
||||
|
||||
const { selectedEstablishmentId, user, apiDocuseal } = useEstablishment();
|
||||
const { selectedEstablishmentId, user } = useEstablishment();
|
||||
|
||||
useEffect(() => {
|
||||
fetchRegistrationFileGroups(selectedEstablishmentId).then((data) =>
|
||||
@ -47,31 +39,10 @@ export default function FileUploadDocuSeal({
|
||||
if (!user && !user?.email) {
|
||||
return;
|
||||
}
|
||||
const id = fileToEdit ? fileToEdit.id : null;
|
||||
|
||||
generateToken(user?.email, id, selectedEstablishmentId, apiDocuseal)
|
||||
.then((data) => {
|
||||
setToken(data.token);
|
||||
})
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors de la génération du token:', error)
|
||||
);
|
||||
}, [fileToEdit]);
|
||||
|
||||
const handleGroupChange = (selectedGroups) => {
|
||||
setSelectedGroups(selectedGroups);
|
||||
|
||||
const details = selectedGroups.flatMap((group) =>
|
||||
group.registration_forms.flatMap((form) =>
|
||||
form.guardians.map((guardian) => ({
|
||||
email: guardian.associated_profile_email,
|
||||
last_name: form.last_name,
|
||||
first_name: form.first_name,
|
||||
registration_form: form.student_id,
|
||||
}))
|
||||
)
|
||||
);
|
||||
setGuardianDetails(details); // Mettre à jour la variable d'état avec les détails des guardians
|
||||
};
|
||||
|
||||
const handleLoad = (detail) => {
|
||||
@ -104,7 +75,7 @@ export default function FileUploadDocuSeal({
|
||||
const is_required = data.fields.length > 0;
|
||||
if (fileToEdit) {
|
||||
logger.debug('Modification du template master:', templateMaster?.id);
|
||||
handleEditTemplateMaster({
|
||||
handleEditSchoolFileMaster({
|
||||
name: uploadedFileName,
|
||||
group_ids: selectedGroups.map((group) => group.id),
|
||||
id: templateMaster?.id,
|
||||
@ -112,51 +83,12 @@ export default function FileUploadDocuSeal({
|
||||
});
|
||||
} else {
|
||||
logger.debug('Création du template master:', templateMaster?.id);
|
||||
handleCreateTemplateMaster({
|
||||
handleCreateSchoolFileMaster({
|
||||
name: uploadedFileName,
|
||||
group_ids: selectedGroups.map((group) => group.id),
|
||||
id: templateMaster?.id,
|
||||
is_required: is_required,
|
||||
});
|
||||
|
||||
guardianDetails.forEach((guardian, index) => {
|
||||
logger.debug('creation du clone avec required : ', is_required);
|
||||
cloneTemplate(
|
||||
templateMaster?.id,
|
||||
guardian.email,
|
||||
is_required,
|
||||
selectedEstablishmentId,
|
||||
apiDocuseal
|
||||
)
|
||||
.then((clonedDocument) => {
|
||||
// Sauvegarde des schoolFileTemplates clonés dans la base de données
|
||||
const data = {
|
||||
name: `${uploadedFileName}_${guardian.first_name}_${guardian.last_name}`,
|
||||
slug: clonedDocument.slug,
|
||||
id: clonedDocument.id,
|
||||
master: templateMaster?.id,
|
||||
registration_form: guardian.registration_form,
|
||||
};
|
||||
logger.debug('creation : ', data);
|
||||
createRegistrationSchoolFileTemplate(data, csrfToken)
|
||||
.then((response) => {
|
||||
logger.debug('Template enregistré avec succès:', response);
|
||||
onSuccess();
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
"Erreur lors de l'enregistrement du template:",
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
// Logique pour envoyer chaque template au submitter
|
||||
logger.debug('Sending template to:', guardian.email);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error during cloning or sending:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -212,32 +144,7 @@ export default function FileUploadDocuSeal({
|
||||
|
||||
{/* Zone de configuration des documents */}
|
||||
<div className="col-span-8 bg-white p-6 rounded-lg shadow-md border border-gray-200">
|
||||
{token && (
|
||||
<div className="h-full overflow-auto">
|
||||
{/* Description de l'étape */}
|
||||
<p className="text-gray-700 text-base font-medium mb-4">
|
||||
Étape 2 - Sélectionnez un document
|
||||
</p>
|
||||
|
||||
<DocusealBuilder
|
||||
token={token}
|
||||
headers={{
|
||||
Authorization: `Bearer ${token}`,
|
||||
}}
|
||||
withSendButton={false}
|
||||
withSignYourselfButton={false}
|
||||
autosave={false}
|
||||
withDocumentsList={false}
|
||||
language={'fr'}
|
||||
onLoad={handleLoad}
|
||||
onUpload={handleUpload}
|
||||
onChange={handleChange}
|
||||
onSave={handleSubmit}
|
||||
className="h-full overflow-auto"
|
||||
style={{ maxHeight: '65vh' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
267
Front-End/src/components/Structure/Files/ParentFiles.js
Normal file
267
Front-End/src/components/Structure/Files/ParentFiles.js
Normal file
@ -0,0 +1,267 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Modal from '@/components/Modal';
|
||||
import logger from '@/utils/logger';
|
||||
import { Edit3, Trash2, Plus } from 'lucide-react';
|
||||
|
||||
function ParentFileForm({ initialData, groups, onSubmit, onCancel }) {
|
||||
const [name, setName] = useState(initialData?.name || '');
|
||||
const [description, setDescription] = useState(initialData?.description || '');
|
||||
// Correction : s'assurer que selectedGroups ne contient que des IDs uniques
|
||||
const [selectedGroups, setSelectedGroups] = useState(
|
||||
Array.isArray(initialData?.groups)
|
||||
? Array.from(
|
||||
new Set(
|
||||
initialData.groups.map(g => (typeof g === 'object' && g !== null && 'id' in g ? g.id : g))
|
||||
)
|
||||
)
|
||||
: []
|
||||
);
|
||||
const [isRequired, setIsRequired] = useState(initialData?.is_required || false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setName(initialData.name || '');
|
||||
setDescription(initialData.description || '');
|
||||
setSelectedGroups(
|
||||
Array.isArray(initialData.groups)
|
||||
? Array.from(
|
||||
new Set(
|
||||
initialData.groups.map(g => (typeof g === 'object' && g !== null && 'id' in g ? g.id : g))
|
||||
)
|
||||
)
|
||||
: []
|
||||
);
|
||||
setIsRequired(initialData.is_required || false);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!name || selectedGroups.length === 0) return;
|
||||
const data = {
|
||||
name,
|
||||
description,
|
||||
groups: selectedGroups,
|
||||
is_required: isRequired,
|
||||
id: initialData?.id,
|
||||
};
|
||||
logger.debug('[ParentFileForm] handleSubmit data:', data);
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nom de la pièce <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
required
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Dossiers d'inscription <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
multiple
|
||||
value={selectedGroups}
|
||||
onChange={e =>
|
||||
setSelectedGroups(
|
||||
Array.from(new Set(Array.from(e.target.selectedOptions, opt => Number(opt.value))))
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500"
|
||||
required
|
||||
>
|
||||
{groups.map(group => (
|
||||
<option key={`group-option-${group.id}`} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_required"
|
||||
checked={isRequired}
|
||||
onChange={e => setIsRequired(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="is_required" className="text-sm text-gray-700">
|
||||
Obligatoire
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-md bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-orange-600 text-white px-4 py-2 rounded-md hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"
|
||||
disabled={!name || selectedGroups.length === 0}
|
||||
>
|
||||
{initialData?.id ? 'Modifier' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ParentFiles({
|
||||
parentFiles,
|
||||
groups,
|
||||
handleCreate,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
singleForm = false,
|
||||
initialData = null,
|
||||
onCancel,
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(singleForm);
|
||||
const [editingFile, setEditingFile] = useState(initialData);
|
||||
|
||||
useEffect(() => {
|
||||
if (singleForm) {
|
||||
setIsModalOpen(true);
|
||||
setEditingFile(initialData);
|
||||
}
|
||||
}, [singleForm, initialData]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingFile(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (file) => {
|
||||
setEditingFile(file);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setEditingFile(null);
|
||||
setIsModalOpen(false);
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
|
||||
const handleFormSubmit = (data) => {
|
||||
logger.debug('[ParentFiles] handleFormSubmit data:', data);
|
||||
if (editingFile && editingFile.id) {
|
||||
logger.debug('[ParentFiles] handleEdit called with:', data.id, data);
|
||||
handleEdit(data.id, data).then(closeModal);
|
||||
} else {
|
||||
logger.debug('[ParentFiles] handleCreate called with:', data);
|
||||
handleCreate(data).then(closeModal);
|
||||
}
|
||||
};
|
||||
|
||||
if (singleForm) {
|
||||
return (
|
||||
<ParentFileForm
|
||||
initialData={editingFile}
|
||||
groups={groups}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={closeModal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-orange-700">Pièces à fournir</h2>
|
||||
<button
|
||||
className="flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded shadow"
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span>Ajouter une pièce</span>
|
||||
</button>
|
||||
</div>
|
||||
<table className="min-w-full border border-gray-200 rounded bg-white">
|
||||
<thead>
|
||||
<tr className="bg-orange-50">
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-orange-700 border-b">Nom</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-orange-700 border-b">Description</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-orange-700 border-b">Dossiers</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-medium text-orange-700 border-b">Obligatoire</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-medium text-orange-700 border-b">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parentFiles.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-6 text-gray-400">Aucune pièce à fournir</td>
|
||||
</tr>
|
||||
) : (
|
||||
parentFiles.map((file) => (
|
||||
<tr key={file.id} className="hover:bg-orange-50">
|
||||
<td className="px-3 py-2 border-b">{file.name}</td>
|
||||
<td className="px-3 py-2 border-b">{file.description}</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
{(file.groups || []).map(
|
||||
gid => groups.find(g => g.id === gid)?.name || gid
|
||||
).join(', ')}
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b text-center">
|
||||
{file.is_required ? (
|
||||
<span className="bg-green-100 text-green-700 px-2 py-1 rounded text-xs font-semibold">Oui</span>
|
||||
) : (
|
||||
<span className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs">Non</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b text-center">
|
||||
<button
|
||||
className="text-blue-500 hover:text-blue-700 mr-2"
|
||||
onClick={() => openEditModal(file)}
|
||||
>
|
||||
<Edit3 className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
className="text-red-500 hover:text-red-700"
|
||||
onClick={() => handleDelete(file.id)}
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
setIsOpen={closeModal}
|
||||
title={editingFile ? 'Modifier la pièce à fournir' : 'Créer une pièce à fournir'}
|
||||
modalClassName="w-full max-w-md"
|
||||
>
|
||||
<ParentFileForm
|
||||
initialData={editingFile}
|
||||
groups={groups}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={closeModal}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,16 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Edit3, Trash2, Check, X, FileText } from 'lucide-react';
|
||||
import Table from '@/components/Table';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
import MultiSelect from '@/components/Form/MultiSelect';
|
||||
import Popup from '@/components/Popup';
|
||||
import logger from '@/utils/logger';
|
||||
import { createRegistrationParentFileTemplate } from '@/app/actions/registerFileGroupAction';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import AlertMessage from '@/components/AlertMessage';
|
||||
import Popup from '@/components/Popup';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
import MultiSelect from '@/components/Form/MultiSelect';
|
||||
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
||||
|
||||
export default function ParentFilesSection({
|
||||
parentFiles,
|
||||
@ -18,6 +15,11 @@ export default function ParentFilesSection({
|
||||
handleCreate,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
hideCreateButton = false,
|
||||
tableContainerClass = '',
|
||||
headerClassName = '',
|
||||
TableComponent,
|
||||
SectionHeaderComponent,
|
||||
}) {
|
||||
const [editingDocumentId, setEditingDocumentId] = useState(null);
|
||||
const [formData, setFormData] = useState(null);
|
||||
@ -325,27 +327,30 @@ export default function ParentFilesSection({
|
||||
},
|
||||
];
|
||||
|
||||
// Ajout : écouteur d'event global pour déclencher la création depuis la popup centrale
|
||||
React.useEffect(() => {
|
||||
if (!hideCreateButton) return;
|
||||
const handler = () => handleAddEmptyRequiredDocument();
|
||||
window.addEventListener('parentFilesSection:create', handler);
|
||||
return () => window.removeEventListener('parentFilesSection:create', handler);
|
||||
}, [hideCreateButton]);
|
||||
|
||||
const Table = TableComponent || ((props) => <div />); // fallback
|
||||
const SectionHeader = SectionHeaderComponent || ((props) => <div />);
|
||||
|
||||
return (
|
||||
<div className="mt-12 w-4/5">
|
||||
<div className={`w-full h-full flex flex-col ${tableContainerClass}`}>
|
||||
<SectionHeader
|
||||
icon={FileText}
|
||||
title="Pièces à fournir"
|
||||
description="Configurez la liste des documents que les parents doivent fournir."
|
||||
button={true}
|
||||
onClick={handleAddEmptyRequiredDocument}
|
||||
className={headerClassName}
|
||||
/>
|
||||
<Table
|
||||
data={
|
||||
editingDocumentId === 'new' ? [formData, ...parentFiles] : parentFiles
|
||||
}
|
||||
columns={columnsRequiredDocuments}
|
||||
emptyMessage={
|
||||
<AlertMessage
|
||||
type="warning"
|
||||
title="Aucune pièce à fournir enregistrée"
|
||||
message="Veuillez procéder à la création de nouvelles pièces à fournir par les parents"
|
||||
/>
|
||||
}
|
||||
emptyMessage="Aucune pièce à fournir enregistrée"
|
||||
/>
|
||||
<Popup
|
||||
isOpen={removePopupVisible}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
import Button from '@/components/Form/Button';
|
||||
|
||||
export default function RegistrationFileGroupForm({ onSubmit, initialData }) {
|
||||
const [name, setName] = useState('');
|
||||
@ -18,38 +20,28 @@ export default function RegistrationFileGroupForm({ onSubmit, initialData }) {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nom du groupe
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
{/* Utilisation de InputText pour le nom du groupe */}
|
||||
<InputText
|
||||
label="Nom du groupe"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<InputText
|
||||
label="Description"
|
||||
name="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
<Button
|
||||
primary
|
||||
type="submit"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
{initialData ? 'Modifier le groupe' : 'Créer le groupe'}
|
||||
</button>
|
||||
text="Enregistrer"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
23
Front-End/src/components/Textarea.js
Normal file
23
Front-End/src/components/Textarea.js
Normal file
@ -0,0 +1,23 @@
|
||||
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;
|
||||
@ -46,10 +46,6 @@ export const EstablishmentProvider = ({ children }) => {
|
||||
const storedUser = sessionStorage.getItem('user');
|
||||
return storedUser ? JSON.parse(storedUser) : null;
|
||||
});
|
||||
const [apiDocuseal, setApiDocusealState] = useState(() => {
|
||||
const storedApiDocuseal = sessionStorage.getItem('apiDocuseal');
|
||||
return storedApiDocuseal ? JSON.parse(storedApiDocuseal) : null;
|
||||
});
|
||||
const [selectedEstablishmentLogo, setSelectedEstablishmentLogoState] = useState(() => {
|
||||
const storedLogo = sessionStorage.getItem('selectedEstablishmentLogo');
|
||||
return storedLogo ? JSON.parse(storedLogo) : null;
|
||||
@ -94,11 +90,6 @@ export const EstablishmentProvider = ({ children }) => {
|
||||
sessionStorage.setItem('user', JSON.stringify(user));
|
||||
};
|
||||
|
||||
const setApiDocuseal = (api) => {
|
||||
setApiDocusealState(api);
|
||||
sessionStorage.setItem('apiDocuseal', JSON.stringify(api));
|
||||
};
|
||||
|
||||
const setSelectedEstablishmentLogo = (logo) => {
|
||||
setSelectedEstablishmentLogoState(logo);
|
||||
sessionStorage.setItem('selectedEstablishmentLogo', JSON.stringify(logo));
|
||||
@ -122,7 +113,6 @@ export const EstablishmentProvider = ({ children }) => {
|
||||
name: role.establishment__name,
|
||||
evaluation_frequency: role.establishment__evaluation_frequency,
|
||||
total_capacity: role.establishment__total_capacity,
|
||||
api_docuseal: role.establishment__api_docuseal,
|
||||
logo: role.establishment__logo,
|
||||
role_id: i,
|
||||
role_type: role.role_type,
|
||||
@ -143,9 +133,6 @@ export const EstablishmentProvider = ({ children }) => {
|
||||
setSelectedEstablishmentTotalCapacity(
|
||||
userEstablishments[roleIndexDefault].total_capacity
|
||||
);
|
||||
setApiDocuseal(
|
||||
userEstablishments[roleIndexDefault].api_docuseal
|
||||
);
|
||||
setSelectedEstablishmentLogo(
|
||||
userEstablishments[roleIndexDefault].logo
|
||||
);
|
||||
@ -168,7 +155,6 @@ export const EstablishmentProvider = ({ children }) => {
|
||||
setUserState(null);
|
||||
setSelectedEstablishmentEvaluationFrequencyState(null);
|
||||
setSelectedEstablishmentTotalCapacityState(null);
|
||||
setApiDocusealState(null);
|
||||
setSelectedEstablishmentLogoState(null);
|
||||
sessionStorage.clear();
|
||||
};
|
||||
@ -184,8 +170,6 @@ export const EstablishmentProvider = ({ children }) => {
|
||||
setSelectedEstablishmentEvaluationFrequency,
|
||||
selectedEstablishmentTotalCapacity,
|
||||
setSelectedEstablishmentTotalCapacity,
|
||||
apiDocuseal,
|
||||
setApiDocuseal,
|
||||
selectedEstablishmentLogo,
|
||||
setSelectedEstablishmentLogo,
|
||||
selectedRoleId,
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
import logger from '@/utils/logger';
|
||||
import { BE_DOCUSEAL_CLONE_TEMPLATE } from '@/utils/Url';
|
||||
|
||||
export default function handler(req, res) {
|
||||
if (req.method === 'POST') {
|
||||
const { templateId, email, is_required, establishment_id, apiDocuseal } = req.body;
|
||||
|
||||
fetch(BE_DOCUSEAL_CLONE_TEMPLATE, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Auth-Token': apiDocuseal,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
templateId,
|
||||
email,
|
||||
is_required,
|
||||
establishment_id,
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return response.json().then((err) => {
|
||||
throw new Error(err.message);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
logger.debug('Template cloned successfully:', data);
|
||||
res.status(200).json(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error cloning template:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
});
|
||||
} else {
|
||||
res.setHeader('Allow', ['POST']);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import logger from '@/utils/logger';
|
||||
import { BE_DOCUSEAL_DOWNLOAD_TEMPLATE } from '@/utils/Url';
|
||||
|
||||
export default function handler(req, res) {
|
||||
if (req.method === 'GET') {
|
||||
const { slug, establishment_id, apiDocuseal } = req.query;
|
||||
logger.debug('slug : ', slug);
|
||||
|
||||
fetch(`${BE_DOCUSEAL_DOWNLOAD_TEMPLATE}/${slug}?establishment_id=${establishment_id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Auth-Token': apiDocuseal,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return response.json().then((err) => {
|
||||
throw new Error(err.message);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
logger.debug('Template downloaded successfully:', data);
|
||||
res.status(200).json(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error downloading template:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
});
|
||||
} else {
|
||||
res.setHeader('Allow', ['GET']);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import logger from '@/utils/logger';
|
||||
import { BE_DOCUSEAL_GET_JWT } from '@/utils/Url';
|
||||
|
||||
export default function handler(req, res) {
|
||||
if (req.method === 'POST') {
|
||||
const { apiDocuseal, ...rest } = req.body;
|
||||
|
||||
fetch(BE_DOCUSEAL_GET_JWT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Auth-Token': apiDocuseal,
|
||||
},
|
||||
body: JSON.stringify(rest),
|
||||
})
|
||||
.then((response) => {
|
||||
logger.debug('Response status:', response.status);
|
||||
return response
|
||||
.json()
|
||||
.then((data) => ({ status: response.status, data }));
|
||||
})
|
||||
.then(({ status, data }) => {
|
||||
logger.debug('Response data:', data);
|
||||
res.status(status).json(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
});
|
||||
} else {
|
||||
res.setHeader('Allow', ['POST']);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import logger from '@/utils/logger';
|
||||
import { BE_DOCUSEAL_REMOVE_TEMPLATE } from '@/utils/Url';
|
||||
|
||||
export default function handler(req, res) {
|
||||
if (req.method === 'DELETE') {
|
||||
const { templateId, establishment_id, apiDocuseal } = req.body;
|
||||
|
||||
fetch(`${BE_DOCUSEAL_REMOVE_TEMPLATE}/${templateId}?establishment_id=${establishment_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Auth-Token': apiDocuseal,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return response.json().then((err) => {
|
||||
throw new Error(err.message);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
logger.debug('Template removed successfully:', data);
|
||||
res.status(200).json(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error removing template:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
});
|
||||
} else {
|
||||
res.setHeader('Allow', ['DELETE']);
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@ -4,12 +4,6 @@ export const WS_BASE_URL = process.env.NEXT_PUBLIC_WSAPI_URL;
|
||||
|
||||
//URL-Back-End
|
||||
|
||||
// GESTION DocuSeal
|
||||
export const BE_DOCUSEAL_GET_JWT = `${BASE_URL}/DocuSeal/generateToken`;
|
||||
export const BE_DOCUSEAL_CLONE_TEMPLATE = `${BASE_URL}/DocuSeal/cloneTemplate`;
|
||||
export const BE_DOCUSEAL_REMOVE_TEMPLATE = `${BASE_URL}/DocuSeal/removeTemplate`;
|
||||
export const BE_DOCUSEAL_DOWNLOAD_TEMPLATE = `${BASE_URL}/DocuSeal/downloadTemplate`;
|
||||
|
||||
// GESTION LOGIN
|
||||
export const BE_AUTH_NEW_PASSWORD_URL = `${BASE_URL}/Auth/newPassword`;
|
||||
export const BE_AUTH_REGISTER_URL = `${BASE_URL}/Auth/subscribe`;
|
||||
@ -131,12 +125,6 @@ export const FE_PARENTS_HOME_URL = '/parents';
|
||||
export const FE_PARENTS_MESSAGERIE_URL = '/parents/messagerie';
|
||||
export const FE_PARENTS_EDIT_SUBSCRIPTION_URL = '/parents/editSubscription';
|
||||
|
||||
// API DOCUSEAL
|
||||
export const FE_API_DOCUSEAL_GENERATE_TOKEN = '/api/docuseal/generateToken';
|
||||
export const FE_API_DOCUSEAL_CLONE_URL = '/api/docuseal/cloneTemplate';
|
||||
export const FE_API_DOCUSEAL_DOWNLOAD_URL = '/api/docuseal/downloadTemplate';
|
||||
export const FE_API_DOCUSEAL_DELETE_URL = '/api/docuseal/removeTemplate';
|
||||
|
||||
/**
|
||||
* Fonction pour obtenir l'URL de redirection en fonction du rôle
|
||||
* @param {RIGHTS} role
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
|
||||
TZ="Europe/Paris"
|
||||
TEST_MODE=true
|
||||
FLUSH_DATA=false
|
||||
MIGRATE_DATA=false
|
||||
CSRF_COOKIE_SECURE=true
|
||||
CSRF_COOKIE_DOMAIN=".localhost"
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8080,http://127.0.0.1:8080
|
||||
|
||||
@ -10,10 +10,10 @@ services:
|
||||
|
||||
database:
|
||||
image: "postgres:latest"
|
||||
expose:
|
||||
- 5432
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- postgres-data:/var/lib/postgresql
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "n3wt-school",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "n3wt-school",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
|
||||
84
premier-pas.md
Normal file
84
premier-pas.md
Normal file
@ -0,0 +1,84 @@
|
||||
# 🧭 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
Normal file
BIN
premier-pas.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user