mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-05 12:41:27 +00:00
feat: Finalisation formulaire dynamique
This commit is contained in:
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.contrib.auth.models
|
import django.contrib.auth.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -137,6 +137,12 @@ class ServeFileView(APIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Nettoyer les prefixes media usuels si presents
|
||||||
|
if file_path.startswith('/media/'):
|
||||||
|
file_path = file_path[len('/media/'):]
|
||||||
|
elif file_path.startswith('media/'):
|
||||||
|
file_path = file_path[len('media/'):]
|
||||||
|
|
||||||
# Nettoyer le préfixe /data/ si présent
|
# Nettoyer le préfixe /data/ si présent
|
||||||
if file_path.startswith('/data/'):
|
if file_path.startswith('/data/'):
|
||||||
file_path = file_path[len('/data/'):]
|
file_path = file_path[len('/data/'):]
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import Establishment.models
|
import Establishment.models
|
||||||
import django.contrib.postgres.fields
|
import django.contrib.postgres.fields
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.contrib.postgres.fields
|
import django.contrib.postgres.fields
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
@ -99,6 +99,7 @@ class Migration(migrations.Migration):
|
|||||||
('number_of_students', models.PositiveIntegerField(blank=True, null=True)),
|
('number_of_students', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
('teaching_language', models.CharField(blank=True, max_length=255)),
|
('teaching_language', models.CharField(blank=True, max_length=255)),
|
||||||
('school_year', models.CharField(blank=True, max_length=9)),
|
('school_year', models.CharField(blank=True, max_length=9)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
('updated_date', models.DateTimeField(auto_now=True)),
|
('updated_date', models.DateTimeField(auto_now=True)),
|
||||||
('type', models.IntegerField(choices=[(1, 'Annuel'), (2, 'Semestriel'), (3, 'Trimestriel')], default=1)),
|
('type', models.IntegerField(choices=[(1, 'Annuel'), (2, 'Semestriel'), (3, 'Trimestriel')], default=1)),
|
||||||
('time_range', models.JSONField(default=list)),
|
('time_range', models.JSONField(default=list)),
|
||||||
@ -126,6 +127,26 @@ class Migration(migrations.Migration):
|
|||||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='specialities', to='Establishment.establishment')),
|
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='specialities', to='Establishment.establishment')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Evaluation',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('period', models.CharField(help_text='Période ex: T1_2025-2026, S1_2025-2026, A_2025-2026', max_length=20)),
|
||||||
|
('date', models.DateField(blank=True, null=True)),
|
||||||
|
('max_score', models.DecimalField(decimal_places=2, default=20, max_digits=5)),
|
||||||
|
('coefficient', models.DecimalField(decimal_places=2, default=1, max_digits=3)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='Establishment.establishment')),
|
||||||
|
('school_class', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='School.schoolclass')),
|
||||||
|
('speciality', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='School.speciality')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-date', '-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Teacher',
|
name='Teacher',
|
||||||
fields=[
|
fields=[
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import Subscriptions.models
|
import Subscriptions.models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
@ -40,6 +40,8 @@ class Migration(migrations.Migration):
|
|||||||
('birth_place', models.CharField(blank=True, default='', max_length=200)),
|
('birth_place', models.CharField(blank=True, default='', max_length=200)),
|
||||||
('birth_postal_code', models.IntegerField(blank=True, default=0)),
|
('birth_postal_code', models.IntegerField(blank=True, default=0)),
|
||||||
('attending_physician', models.CharField(blank=True, default='', max_length=200)),
|
('attending_physician', models.CharField(blank=True, default='', max_length=200)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, null=True)),
|
||||||
('associated_class', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='School.schoolclass')),
|
('associated_class', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='School.schoolclass')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -90,6 +92,7 @@ class Migration(migrations.Migration):
|
|||||||
fields=[
|
fields=[
|
||||||
('student', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='Subscriptions.student')),
|
('student', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='Subscriptions.student')),
|
||||||
('status', models.IntegerField(choices=[(0, "Pas de dossier d'inscription"), (1, "Dossier d'inscription initialisé"), (2, "Dossier d'inscription envoyé"), (3, "Dossier d'inscription en cours de validation"), (4, "Dossier d'inscription à relancer"), (5, "Dossier d'inscription validé"), (6, "Dossier d'inscription archivé"), (7, 'Mandat SEPA envoyé'), (8, 'Mandat SEPA à envoyer')], default=0)),
|
('status', models.IntegerField(choices=[(0, "Pas de dossier d'inscription"), (1, "Dossier d'inscription initialisé"), (2, "Dossier d'inscription envoyé"), (3, "Dossier d'inscription en cours de validation"), (4, "Dossier d'inscription à relancer"), (5, "Dossier d'inscription validé"), (6, "Dossier d'inscription archivé"), (7, 'Mandat SEPA envoyé'), (8, 'Mandat SEPA à envoyer')], default=0)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
('last_update', models.DateTimeField(auto_now=True)),
|
('last_update', models.DateTimeField(auto_now=True)),
|
||||||
('school_year', models.CharField(blank=True, default='', max_length=9)),
|
('school_year', models.CharField(blank=True, default='', max_length=9)),
|
||||||
('notes', models.CharField(blank=True, max_length=200)),
|
('notes', models.CharField(blank=True, max_length=200)),
|
||||||
@ -209,6 +212,8 @@ class Migration(migrations.Migration):
|
|||||||
('score', models.IntegerField(blank=True, null=True)),
|
('score', models.IntegerField(blank=True, null=True)),
|
||||||
('comment', models.TextField(blank=True, null=True)),
|
('comment', models.TextField(blank=True, null=True)),
|
||||||
('period', models.CharField(blank=True, default='', help_text="Période d'évaluation ex: T1-2024_2025, S1-2024_2025, A-2024_2025", max_length=20)),
|
('period', models.CharField(blank=True, default='', help_text="Période d'évaluation ex: T1-2024_2025, S1-2024_2025, A-2024_2025", max_length=20)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, null=True)),
|
||||||
('establishment_competency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_scores', to='School.establishmentcompetency')),
|
('establishment_competency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_scores', to='School.establishmentcompetency')),
|
||||||
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competency_scores', to='Subscriptions.student')),
|
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competency_scores', to='Subscriptions.student')),
|
||||||
],
|
],
|
||||||
@ -216,4 +221,20 @@ class Migration(migrations.Migration):
|
|||||||
'unique_together': {('student', 'establishment_competency', 'period')},
|
'unique_together': {('student', 'establishment_competency', 'period')},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StudentEvaluation',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
|
||||||
|
('comment', models.TextField(blank=True)),
|
||||||
|
('is_absent', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('evaluation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_scores', to='School.evaluation')),
|
||||||
|
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluation_scores', to='Subscriptions.student')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('student', 'evaluation')},
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -403,7 +403,9 @@ class RegistrationSchoolFileMaster(models.Model):
|
|||||||
and isinstance(self.formMasterData, dict)
|
and isinstance(self.formMasterData, dict)
|
||||||
and self.formMasterData.get("fields")
|
and self.formMasterData.get("fields")
|
||||||
):
|
):
|
||||||
new_filename = f"{self.name}.pdf"
|
# Si un fichier source est déjà présent, conserver son extension.
|
||||||
|
extension = os.path.splitext(old_filename)[1] or '.pdf'
|
||||||
|
new_filename = f"{self.name}{extension}"
|
||||||
else:
|
else:
|
||||||
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
|
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
|
||||||
extension = os.path.splitext(old_filename)[1]
|
extension = os.path.splitext(old_filename)[1]
|
||||||
@ -438,16 +440,9 @@ class RegistrationSchoolFileMaster(models.Model):
|
|||||||
except RegistrationSchoolFileMaster.DoesNotExist:
|
except RegistrationSchoolFileMaster.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# --- Traitement PDF dynamique AVANT le super().save() ---
|
# IMPORTANT: pour les formulaires dynamiques, le fichier du master doit
|
||||||
if (
|
# rester le document source uploadé (PDF/image). La génération du PDF final
|
||||||
self.formMasterData
|
# est faite au niveau des templates (par élève), pas sur le master.
|
||||||
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@ -540,7 +535,8 @@ class RegistrationSchoolFileTemplate(models.Model):
|
|||||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
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)
|
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
|
||||||
formTemplateData = models.JSONField(default=list, blank=True, null=True)
|
formTemplateData = models.JSONField(default=list, blank=True, null=True)
|
||||||
isValidated = models.BooleanField(default=False)
|
# Tri-etat: None=en attente, True=valide, False=refuse
|
||||||
|
isValidated = models.BooleanField(null=True, blank=True, default=None)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@ -622,7 +618,8 @@ class RegistrationParentFileTemplate(models.Model):
|
|||||||
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
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)
|
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)
|
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
|
||||||
isValidated = models.BooleanField(default=False)
|
# Tri-etat: None=en attente, True=valide, False=refuse
|
||||||
|
isValidated = models.BooleanField(null=True, blank=True, default=None)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.master.name if self.master else f"ParentFile_{self.pk}"
|
return self.master.name if self.master else f"ParentFile_{self.pk}"
|
||||||
|
|||||||
@ -39,10 +39,15 @@ class AbsenceManagementSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class RegistrationSchoolFileMasterSerializer(serializers.ModelSerializer):
|
class RegistrationSchoolFileMasterSerializer(serializers.ModelSerializer):
|
||||||
id = serializers.IntegerField(required=False)
|
id = serializers.IntegerField(required=False)
|
||||||
|
file_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RegistrationSchoolFileMaster
|
model = RegistrationSchoolFileMaster
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
|
def get_file_url(self, obj):
|
||||||
|
return obj.file.url if obj.file else None
|
||||||
|
|
||||||
class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
|
class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
|
||||||
id = serializers.IntegerField(required=False)
|
id = serializers.IntegerField(required=False)
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -52,6 +57,7 @@ class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
|
|||||||
class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
||||||
id = serializers.IntegerField(required=False)
|
id = serializers.IntegerField(required=False)
|
||||||
file_url = serializers.SerializerMethodField()
|
file_url = serializers.SerializerMethodField()
|
||||||
|
master_file_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RegistrationSchoolFileTemplate
|
model = RegistrationSchoolFileTemplate
|
||||||
@ -61,6 +67,12 @@ class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
|||||||
# Retourne l'URL complète du fichier si disponible
|
# Retourne l'URL complète du fichier si disponible
|
||||||
return obj.file.url if obj.file else None
|
return obj.file.url if obj.file else None
|
||||||
|
|
||||||
|
def get_master_file_url(self, obj):
|
||||||
|
# URL du fichier source du master (pour l'aperçu FileUpload côté parent)
|
||||||
|
if obj.master and obj.master.file:
|
||||||
|
return obj.master.file.url
|
||||||
|
return None
|
||||||
|
|
||||||
class RegistrationParentFileTemplateSerializer(serializers.ModelSerializer):
|
class RegistrationParentFileTemplateSerializer(serializers.ModelSerializer):
|
||||||
id = serializers.IntegerField(required=False)
|
id = serializers.IntegerField(required=False)
|
||||||
file_url = serializers.SerializerMethodField()
|
file_url = serializers.SerializerMethodField()
|
||||||
|
|||||||
@ -8,18 +8,22 @@ from N3wtSchool import renderers
|
|||||||
from N3wtSchool import bdd
|
from N3wtSchool import bdd
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
import base64
|
||||||
from reportlab.pdfgen import canvas
|
from reportlab.pdfgen import canvas
|
||||||
from reportlab.lib.pagesizes import A4
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.utils import ImageReader
|
||||||
|
from reportlab.graphics import renderPDF
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from urllib.parse import unquote_to_bytes
|
||||||
|
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
from rest_framework.parsers import JSONParser
|
from rest_framework.parsers import JSONParser
|
||||||
from PyPDF2 import PdfMerger, PdfReader
|
from PyPDF2 import PdfMerger, PdfReader, PdfWriter
|
||||||
from PyPDF2.errors import PdfReadError
|
from PyPDF2.errors import PdfReadError
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
@ -29,9 +33,79 @@ import json
|
|||||||
from django.http import QueryDict
|
from django.http import QueryDict
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from svglib.svglib import svg2rlg
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_signature_data_url(cnv, data_url, x, y, width, height):
|
||||||
|
"""
|
||||||
|
Dessine une signature issue d'un data URL dans un canvas ReportLab.
|
||||||
|
Supporte les images raster (PNG/JPEG/...) et SVG.
|
||||||
|
Retourne True si la signature a pu etre dessinee.
|
||||||
|
"""
|
||||||
|
if not isinstance(data_url, str) or not data_url.startswith("data:image"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
header, payload = data_url.split(',', 1)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
is_base64 = ';base64' in header
|
||||||
|
mime_type = header.split(':', 1)[1].split(';', 1)[0] if ':' in header else ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_bytes = base64.b64decode(payload) if is_base64 else unquote_to_bytes(payload)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[_draw_signature_data_url] Decodage impossible: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Support SVG via svglib (deja present dans requirements)
|
||||||
|
if mime_type == 'image/svg+xml':
|
||||||
|
try:
|
||||||
|
drawing = svg2rlg(BytesIO(raw_bytes))
|
||||||
|
if drawing is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
src_w = float(getattr(drawing, 'width', 0) or 0)
|
||||||
|
src_h = float(getattr(drawing, 'height', 0) or 0)
|
||||||
|
if src_w <= 0 or src_h <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
scale = min(width / src_w, height / src_h)
|
||||||
|
draw_w = src_w * scale
|
||||||
|
draw_h = src_h * scale
|
||||||
|
offset_x = x + (width - draw_w) / 2
|
||||||
|
offset_y = y + (height - draw_h) / 2
|
||||||
|
|
||||||
|
cnv.saveState()
|
||||||
|
cnv.translate(offset_x, offset_y)
|
||||||
|
cnv.scale(scale, scale)
|
||||||
|
renderPDF.draw(drawing, cnv, 0, 0)
|
||||||
|
cnv.restoreState()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[_draw_signature_data_url] Rendu SVG impossible: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Support images raster classiques
|
||||||
|
try:
|
||||||
|
img_reader = ImageReader(BytesIO(raw_bytes))
|
||||||
|
cnv.drawImage(
|
||||||
|
img_reader,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
preserveAspectRatio=True,
|
||||||
|
mask='auto',
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[_draw_signature_data_url] Rendu raster impossible: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def save_file_replacing_existing(file_field, filename, content, save=True):
|
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.
|
Sauvegarde un fichier en écrasant l'existant s'il porte le même nom.
|
||||||
@ -55,6 +129,42 @@ def save_file_replacing_existing(file_field, filename, content, save=True):
|
|||||||
# Sauvegarder le nouveau fichier
|
# Sauvegarder le nouveau fichier
|
||||||
file_field.save(filename, content, save=save)
|
file_field.save(filename, content, save=save)
|
||||||
|
|
||||||
|
def save_file_field_without_suffix(instance, field_name, filename, content, save=False):
|
||||||
|
"""
|
||||||
|
Sauvegarde un fichier dans un FileField Django en ecrasant le precedent,
|
||||||
|
sans laisser Django generer de suffixe (_abc123).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance: instance Django portant le FileField
|
||||||
|
field_name: nom du FileField (ex: 'file')
|
||||||
|
filename: nom de fichier cible (basename)
|
||||||
|
content: contenu fichier (ContentFile, File, etc.)
|
||||||
|
save: si True, persiste immediatement l'instance
|
||||||
|
"""
|
||||||
|
file_field = getattr(instance, field_name)
|
||||||
|
field = instance._meta.get_field(field_name)
|
||||||
|
storage = file_field.storage
|
||||||
|
|
||||||
|
target_name = field.generate_filename(instance, filename)
|
||||||
|
|
||||||
|
# Supprimer le fichier actuellement reference si different
|
||||||
|
if file_field and file_field.name and file_field.name != target_name:
|
||||||
|
try:
|
||||||
|
if storage.exists(file_field.name):
|
||||||
|
storage.delete(file_field.name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[save_file_field_without_suffix] Erreur suppression ancien fichier ({file_field.name}): {e}")
|
||||||
|
|
||||||
|
# Supprimer explicitement la cible si elle existe deja
|
||||||
|
try:
|
||||||
|
if storage.exists(target_name):
|
||||||
|
storage.delete(target_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[save_file_field_without_suffix] Erreur suppression cible ({target_name}): {e}")
|
||||||
|
|
||||||
|
# Sauvegarde: la cible n'existe plus, donc pas de suffixe
|
||||||
|
file_field.save(filename, content, save=save)
|
||||||
|
|
||||||
def build_payload_from_request(request):
|
def build_payload_from_request(request):
|
||||||
"""
|
"""
|
||||||
Normalise la request en payload prêt à être donné au serializer.
|
Normalise la request en payload prêt à être donné au serializer.
|
||||||
@ -194,6 +304,91 @@ def create_templates_for_registration_form(register_form):
|
|||||||
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
|
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
|
||||||
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
|
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
|
||||||
|
|
||||||
|
is_dynamic_master = (
|
||||||
|
isinstance(m.formMasterData, dict)
|
||||||
|
and bool(m.formMasterData.get("fields"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Formulaire dynamique: toujours générer le PDF final depuis le JSON
|
||||||
|
# (aperçu admin) au lieu de copier le fichier source brut (PNG/PDF).
|
||||||
|
if is_dynamic_master:
|
||||||
|
base_pdf_content = None
|
||||||
|
base_file_ext = None
|
||||||
|
if m.file and hasattr(m.file, 'name') and m.file.name:
|
||||||
|
base_file_ext = os.path.splitext(m.file.name)[1].lower()
|
||||||
|
try:
|
||||||
|
m.file.open('rb')
|
||||||
|
base_pdf_content = m.file.read()
|
||||||
|
m.file.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lecture fichier source master dynamique: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
generated_pdf = generate_form_json_pdf(
|
||||||
|
register_form,
|
||||||
|
m.formMasterData,
|
||||||
|
base_pdf_content=base_pdf_content,
|
||||||
|
base_file_ext=base_file_ext,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur génération PDF dynamique pour template: {e}")
|
||||||
|
generated_pdf = None
|
||||||
|
|
||||||
|
if tmpl:
|
||||||
|
try:
|
||||||
|
if tmpl.file and tmpl.file.name:
|
||||||
|
tmpl.file.delete(save=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erreur suppression ancien fichier template dynamique %s", getattr(tmpl, "pk", None))
|
||||||
|
|
||||||
|
tmpl.name = m.name or ""
|
||||||
|
tmpl.slug = slug
|
||||||
|
tmpl.formTemplateData = m.formMasterData or []
|
||||||
|
if generated_pdf is not None:
|
||||||
|
output_filename = os.path.basename(generated_pdf.name)
|
||||||
|
save_file_field_without_suffix(
|
||||||
|
tmpl,
|
||||||
|
'file',
|
||||||
|
output_filename,
|
||||||
|
generated_pdf,
|
||||||
|
save=False,
|
||||||
|
)
|
||||||
|
tmpl.save()
|
||||||
|
created.append(tmpl)
|
||||||
|
logger.info(
|
||||||
|
"util.create_templates_for_registration_form - Regenerated dynamic school template %s from master %s for RF %s",
|
||||||
|
tmpl.pk,
|
||||||
|
m.pk,
|
||||||
|
register_form.pk,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
tmpl = RegistrationSchoolFileTemplate(
|
||||||
|
master=m,
|
||||||
|
registration_form=register_form,
|
||||||
|
name=m.name or "",
|
||||||
|
formTemplateData=m.formMasterData or [],
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
if generated_pdf is not None:
|
||||||
|
output_filename = os.path.basename(generated_pdf.name)
|
||||||
|
save_file_field_without_suffix(
|
||||||
|
tmpl,
|
||||||
|
'file',
|
||||||
|
output_filename,
|
||||||
|
generated_pdf,
|
||||||
|
save=False,
|
||||||
|
)
|
||||||
|
tmpl.save()
|
||||||
|
created.append(tmpl)
|
||||||
|
logger.info(
|
||||||
|
"util.create_templates_for_registration_form - Created dynamic school template %s from master %s for RF %s",
|
||||||
|
tmpl.pk,
|
||||||
|
m.pk,
|
||||||
|
register_form.pk,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
file_name = None
|
file_name = None
|
||||||
if m.file and hasattr(m.file, 'name') and m.file.name:
|
if m.file and hasattr(m.file, 'name') and m.file.name:
|
||||||
file_name = os.path.basename(m.file.name)
|
file_name = os.path.basename(m.file.name)
|
||||||
@ -551,55 +746,196 @@ def getHistoricalYears(count=5):
|
|||||||
|
|
||||||
return historical_years
|
return historical_years
|
||||||
|
|
||||||
def generate_form_json_pdf(register_form, form_json):
|
def generate_form_json_pdf(register_form, form_json, base_pdf_content=None, base_file_ext=None, base_pdf_path=None):
|
||||||
"""
|
"""
|
||||||
Génère un PDF du rendu du formulaire dynamique à partir du JSON (formConfig)
|
Génère un PDF composite du formulaire dynamique:
|
||||||
et l'associe au RegistrationSchoolFileTemplate.
|
- le document source uploadé (PDF/image) si présent,
|
||||||
Le PDF contient le titre, les labels et types de champs.
|
- puis un rendu du formulaire (similaire à l'aperçu),
|
||||||
Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier.
|
- avec overlay de signature(s) sur la dernière page du document source.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Récupérer le nom du formulaire
|
|
||||||
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
|
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
|
||||||
filename = f"{form_name}.pdf"
|
filename = f"{form_name}.pdf"
|
||||||
|
fields = form_json.get("fields", []) if isinstance(form_json, dict) else []
|
||||||
|
|
||||||
# Générer le PDF
|
# Compatibilité ascendante : charger depuis un chemin si nécessaire
|
||||||
buffer = BytesIO()
|
if base_pdf_content is None and base_pdf_path and os.path.exists(base_pdf_path):
|
||||||
c = canvas.Canvas(buffer, pagesize=A4)
|
base_file_ext = os.path.splitext(base_pdf_path)[1].lower()
|
||||||
|
try:
|
||||||
|
with open(base_pdf_path, 'rb') as f:
|
||||||
|
base_pdf_content = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[generate_form_json_pdf] Lecture fichier source: {e}")
|
||||||
|
|
||||||
|
writer = PdfWriter()
|
||||||
|
has_source_document = False
|
||||||
|
source_is_image = False
|
||||||
|
source_image_reader = None
|
||||||
|
source_image_size = None
|
||||||
|
|
||||||
|
# 1) Charger le document source (PDF/image) si présent
|
||||||
|
if base_pdf_content:
|
||||||
|
try:
|
||||||
|
image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'}
|
||||||
|
ext = (base_file_ext or '').lower()
|
||||||
|
|
||||||
|
if ext in image_exts:
|
||||||
|
# Pour les images, on les rend dans la section [fichier uploade]
|
||||||
|
# au lieu de les ajouter comme page source separee.
|
||||||
|
img_buffer = BytesIO(base_pdf_content)
|
||||||
|
source_image_reader = ImageReader(img_buffer)
|
||||||
|
source_image_size = source_image_reader.getSize()
|
||||||
|
source_is_image = True
|
||||||
|
else:
|
||||||
|
source_reader = PdfReader(BytesIO(base_pdf_content))
|
||||||
|
for page in source_reader.pages:
|
||||||
|
writer.add_page(page)
|
||||||
|
has_source_document = len(source_reader.pages) > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[generate_form_json_pdf] Erreur chargement source: {e}")
|
||||||
|
|
||||||
|
# 2) Overlay des signatures sur la dernière page du document source
|
||||||
|
# Desactive ici pour eviter les doublons: la signature est rendue
|
||||||
|
# dans la section JSON du formulaire (et non plus en overlay source).
|
||||||
|
signatures = []
|
||||||
|
for field in fields:
|
||||||
|
if field.get("type") == "signature":
|
||||||
|
value = field.get("value")
|
||||||
|
if isinstance(value, str) and value.startswith("data:image"):
|
||||||
|
signatures.append(value)
|
||||||
|
|
||||||
|
enable_source_signature_overlay = False
|
||||||
|
if signatures and len(writer.pages) > 0 and enable_source_signature_overlay:
|
||||||
|
try:
|
||||||
|
target_page = writer.pages[len(writer.pages) - 1]
|
||||||
|
page_width = float(target_page.mediabox.width)
|
||||||
|
page_height = float(target_page.mediabox.height)
|
||||||
|
|
||||||
|
packet = BytesIO()
|
||||||
|
c_overlay = canvas.Canvas(packet, pagesize=(page_width, page_height))
|
||||||
|
|
||||||
|
sig_width = 170
|
||||||
|
sig_height = 70
|
||||||
|
margin = 36
|
||||||
|
spacing = 10
|
||||||
|
|
||||||
|
for i, data_url in enumerate(signatures[:3]):
|
||||||
|
try:
|
||||||
|
x = page_width - sig_width - margin
|
||||||
|
y = margin + i * (sig_height + spacing)
|
||||||
|
_draw_signature_data_url(c_overlay, data_url, x, y, sig_width, sig_height)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[generate_form_json_pdf] Signature ignorée: {e}")
|
||||||
|
|
||||||
|
c_overlay.save()
|
||||||
|
packet.seek(0)
|
||||||
|
overlay_pdf = PdfReader(packet)
|
||||||
|
if overlay_pdf.pages:
|
||||||
|
target_page.merge_page(overlay_pdf.pages[0])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[generate_form_json_pdf] Erreur overlay signature: {e}")
|
||||||
|
|
||||||
|
# 3) Rendu JSON explicite du formulaire final (toujours genere).
|
||||||
|
# Cela garantit la presence des sections H1 / FileUpload / Signature
|
||||||
|
# dans le PDF final, meme si un document source est fourni.
|
||||||
|
fields_to_render = fields
|
||||||
|
|
||||||
|
if fields_to_render:
|
||||||
|
layout_buffer = BytesIO()
|
||||||
|
c = canvas.Canvas(layout_buffer, pagesize=A4)
|
||||||
y = 800
|
y = 800
|
||||||
|
|
||||||
# Titre
|
c.setFont("Helvetica-Bold", 18)
|
||||||
c.setFont("Helvetica-Bold", 20)
|
c.drawString(60, y, form_json.get("title", "Formulaire"))
|
||||||
c.drawString(100, y, form_json.get("title", "Formulaire"))
|
y -= 35
|
||||||
y -= 40
|
|
||||||
|
|
||||||
# Champs
|
c.setFont("Helvetica", 11)
|
||||||
c.setFont("Helvetica", 12)
|
for field in fields_to_render:
|
||||||
fields = form_json.get("fields", [])
|
|
||||||
for field in fields:
|
|
||||||
label = field.get("label", field.get("id", ""))
|
|
||||||
ftype = field.get("type", "")
|
ftype = field.get("type", "")
|
||||||
|
if ftype in {"heading1", "heading2", "heading3", "heading4", "heading5", "heading6", "paragraph"}:
|
||||||
|
text = field.get("text", "")
|
||||||
|
if text:
|
||||||
|
c.setFont("Helvetica-Bold" if ftype.startswith("heading") else "Helvetica", 11)
|
||||||
|
c.drawString(60, y, text[:120])
|
||||||
|
y -= 18
|
||||||
|
c.setFont("Helvetica", 11)
|
||||||
|
continue
|
||||||
|
|
||||||
|
label = field.get("label", field.get("id", "Champ"))
|
||||||
value = field.get("value", "")
|
value = field.get("value", "")
|
||||||
# Afficher la valeur si elle existe
|
|
||||||
if value not in (None, ""):
|
if ftype == "file":
|
||||||
c.drawString(100, y, f"{label} [{ftype}] : {value}")
|
c.drawString(60, y, f"{label}")
|
||||||
else:
|
y -= 18
|
||||||
c.drawString(100, y, f"{label} [{ftype}]")
|
|
||||||
y -= 25
|
if source_is_image and source_image_reader and source_image_size:
|
||||||
if y < 100:
|
img_w, img_h = source_image_size
|
||||||
|
max_w = 420
|
||||||
|
max_h = 260
|
||||||
|
ratio = min(max_w / img_w, max_h / img_h)
|
||||||
|
draw_w = img_w * ratio
|
||||||
|
draw_h = img_h * ratio
|
||||||
|
|
||||||
|
if y - draw_h < 80:
|
||||||
c.showPage()
|
c.showPage()
|
||||||
y = 800
|
y = 800
|
||||||
|
c.setFont("Helvetica", 11)
|
||||||
|
|
||||||
|
c.drawImage(
|
||||||
|
source_image_reader,
|
||||||
|
60,
|
||||||
|
y - draw_h,
|
||||||
|
width=draw_w,
|
||||||
|
height=draw_h,
|
||||||
|
preserveAspectRatio=True,
|
||||||
|
mask='auto',
|
||||||
|
)
|
||||||
|
y -= draw_h + 14
|
||||||
|
elif ftype == "signature":
|
||||||
|
c.drawString(60, y, f"{label}")
|
||||||
|
sig_drawn = False
|
||||||
|
if isinstance(value, str) and value.startswith("data:image"):
|
||||||
|
try:
|
||||||
|
sig_drawn = _draw_signature_data_url(c, value, 260, y - 55, 170, 55)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[generate_form_json_pdf] Signature render echoue: {e}")
|
||||||
|
if not sig_drawn:
|
||||||
|
c.rect(260, y - 55, 170, 55)
|
||||||
|
y -= 70
|
||||||
|
else:
|
||||||
|
if value not in (None, ""):
|
||||||
|
c.drawString(60, y, f"{label} [{ftype}] : {str(value)[:120]}")
|
||||||
|
else:
|
||||||
|
c.drawString(60, y, f"{label} [{ftype}]")
|
||||||
|
y -= 18
|
||||||
|
|
||||||
|
if y < 80:
|
||||||
|
c.showPage()
|
||||||
|
y = 800
|
||||||
|
c.setFont("Helvetica", 11)
|
||||||
|
|
||||||
c.save()
|
c.save()
|
||||||
buffer.seek(0)
|
layout_buffer.seek(0)
|
||||||
pdf_content = buffer.read()
|
try:
|
||||||
|
layout_reader = PdfReader(layout_buffer)
|
||||||
|
for page in layout_reader.pages:
|
||||||
|
writer.add_page(page)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[generate_form_json_pdf] Erreur ajout rendu formulaire: {e}")
|
||||||
|
|
||||||
# Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage)
|
# 4) Fallback minimal si aucune page n'a été créée
|
||||||
if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name:
|
if len(writer.pages) == 0:
|
||||||
existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/'))
|
fallback = BytesIO()
|
||||||
if os.path.exists(existing_file_path):
|
c_fb = canvas.Canvas(fallback, pagesize=A4)
|
||||||
os.remove(existing_file_path)
|
c_fb.setFont("Helvetica-Bold", 16)
|
||||||
register_form.registration_file.delete(save=False)
|
c_fb.drawString(60, 800, form_json.get("title", "Formulaire"))
|
||||||
|
c_fb.save()
|
||||||
|
fallback.seek(0)
|
||||||
|
fallback_reader = PdfReader(fallback)
|
||||||
|
for page in fallback_reader.pages:
|
||||||
|
writer.add_page(page)
|
||||||
|
|
||||||
# Retourner le ContentFile avec uniquement le nom du fichier
|
out = BytesIO()
|
||||||
return ContentFile(pdf_content, name=os.path.basename(filename))
|
writer.write(out)
|
||||||
|
out.seek(0)
|
||||||
|
return ContentFile(out.read(), name=os.path.basename(filename))
|
||||||
|
|||||||
@ -58,6 +58,8 @@ class RegistrationParentFileTemplateView(APIView):
|
|||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
class RegistrationParentFileTemplateSimpleView(APIView):
|
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
operation_description="Récupère un template d'inscription spécifique",
|
operation_description="Récupère un template d'inscription spécifique",
|
||||||
responses={
|
responses={
|
||||||
@ -82,11 +84,15 @@ class RegistrationParentFileTemplateSimpleView(APIView):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
def put(self, request, id):
|
def put(self, request, id):
|
||||||
|
payload, resp = util.build_payload_from_request(request)
|
||||||
|
if resp is not None:
|
||||||
|
return resp
|
||||||
|
|
||||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||||
if template is None:
|
if template is None:
|
||||||
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
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)
|
serializer = RegistrationParentFileTemplateSerializer(template, data=payload, partial=True)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||||
|
|||||||
@ -125,6 +125,25 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
|
|||||||
if resp:
|
if resp:
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
# Garde-fou: eviter d'ecraser un master dynamique existant avec un
|
||||||
|
# formMasterData vide/malforme (cas observe en multipart).
|
||||||
|
if 'formMasterData' in payload:
|
||||||
|
incoming_form_data = payload.get('formMasterData')
|
||||||
|
current_is_dynamic = (
|
||||||
|
isinstance(master.formMasterData, dict)
|
||||||
|
and bool(master.formMasterData.get('fields'))
|
||||||
|
)
|
||||||
|
incoming_is_dynamic = (
|
||||||
|
isinstance(incoming_form_data, dict)
|
||||||
|
and bool(incoming_form_data.get('fields'))
|
||||||
|
)
|
||||||
|
if current_is_dynamic and not incoming_is_dynamic:
|
||||||
|
logger.warning(
|
||||||
|
"formMasterData invalide recu pour master %s: conservation de la config dynamique existante",
|
||||||
|
master.pk,
|
||||||
|
)
|
||||||
|
payload['formMasterData'] = master.formMasterData
|
||||||
|
|
||||||
|
|
||||||
logger.info(f"payload for update serializer: {payload}")
|
logger.info(f"payload for update serializer: {payload}")
|
||||||
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
||||||
|
|||||||
@ -16,6 +16,20 @@ import Subscriptions.util as util
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_nested_responses(data, max_depth=8):
|
||||||
|
"""Extrait le dictionnaire de reponses depuis des structures imbriquees."""
|
||||||
|
current = data
|
||||||
|
for _ in range(max_depth):
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return None
|
||||||
|
nested = current.get("responses")
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
current = nested
|
||||||
|
continue
|
||||||
|
return current
|
||||||
|
return current if isinstance(current, dict) else None
|
||||||
|
|
||||||
class RegistrationSchoolFileTemplateView(APIView):
|
class RegistrationSchoolFileTemplateView(APIView):
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
|
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
|
||||||
@ -95,15 +109,26 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
|||||||
responses = None
|
responses = None
|
||||||
if "responses" in formTemplateData:
|
if "responses" in formTemplateData:
|
||||||
resp = formTemplateData["responses"]
|
resp = formTemplateData["responses"]
|
||||||
if isinstance(resp, dict) and "responses" in resp:
|
responses = _extract_nested_responses(resp)
|
||||||
responses = resp["responses"]
|
|
||||||
elif isinstance(resp, dict):
|
# Nettoyer les meta-cles qui ne sont pas des reponses de champs
|
||||||
responses = resp
|
if isinstance(responses, dict):
|
||||||
|
cleaned = {
|
||||||
|
key: value
|
||||||
|
for key, value in responses.items()
|
||||||
|
if key not in {"responses", "formId", "id", "templateId"}
|
||||||
|
}
|
||||||
|
responses = cleaned
|
||||||
|
|
||||||
if responses and "fields" in formTemplateData:
|
if responses and "fields" in formTemplateData:
|
||||||
for field in formTemplateData["fields"]:
|
for field in formTemplateData["fields"]:
|
||||||
field_id = field.get("id")
|
field_id = field.get("id")
|
||||||
if field_id and field_id in responses:
|
if field_id and field_id in responses:
|
||||||
field["value"] = responses[field_id]
|
field["value"] = responses[field_id]
|
||||||
|
|
||||||
|
# Stocker les reponses aplaties pour eviter l'empilement responses.responses
|
||||||
|
if isinstance(responses, dict):
|
||||||
|
formTemplateData["responses"] = responses
|
||||||
payload['formTemplateData'] = formTemplateData
|
payload['formTemplateData'] = formTemplateData
|
||||||
|
|
||||||
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
||||||
@ -137,7 +162,7 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
|||||||
return Response({'message': 'Template mis à jour avec succès', 'data': RegistrationSchoolFileTemplateSerializer(template).data}, status=status.HTTP_200_OK)
|
return Response({'message': 'Template mis à jour avec succès', 'data': RegistrationSchoolFileTemplateSerializer(template).data}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
# Cas 2 : Formulaire dynamique (JSON)
|
# Cas 2 : Formulaire dynamique (JSON)
|
||||||
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload)
|
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload, partial=True)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
# Régénérer le PDF si besoin
|
# Régénérer le PDF si besoin
|
||||||
@ -148,19 +173,50 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
|||||||
and formTemplateData.get("fields")
|
and formTemplateData.get("fields")
|
||||||
and hasattr(template, "file")
|
and hasattr(template, "file")
|
||||||
):
|
):
|
||||||
old_pdf_name = None
|
# Lire le contenu du fichier source en mémoire AVANT suppression.
|
||||||
if template.file and template.file.name:
|
# Priorité au fichier master (document source admin) pour éviter
|
||||||
old_pdf_name = os.path.basename(template.file.name)
|
# de re-générer à partir d'un PDF template déjà enrichi.
|
||||||
|
base_pdf_content = None
|
||||||
|
base_file_ext = None
|
||||||
|
if template.master and template.master.file and template.master.file.name:
|
||||||
|
base_file_ext = os.path.splitext(template.master.file.name)[1].lower()
|
||||||
try:
|
try:
|
||||||
|
template.master.file.open('rb')
|
||||||
|
base_pdf_content = template.master.file.read()
|
||||||
|
template.master.file.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lecture fichier source master: {e}")
|
||||||
|
elif template.file and template.file.name:
|
||||||
|
base_file_ext = os.path.splitext(template.file.name)[1].lower()
|
||||||
|
try:
|
||||||
|
template.file.open('rb')
|
||||||
|
base_pdf_content = template.file.read()
|
||||||
|
template.file.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lecture fichier source template: {e}")
|
||||||
|
try:
|
||||||
|
old_path = template.file.path
|
||||||
template.file.delete(save=False)
|
template.file.delete(save=False)
|
||||||
if os.path.exists(template.file.path):
|
if os.path.exists(old_path):
|
||||||
os.remove(template.file.path)
|
os.remove(old_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Erreur lors de la suppression du fichier existant: {e}")
|
logger.error(f"Erreur lors de la suppression du fichier existant: {e}")
|
||||||
from Subscriptions.util import generate_form_json_pdf
|
from Subscriptions.util import generate_form_json_pdf
|
||||||
pdf_file = generate_form_json_pdf(template.registration_form, formTemplateData)
|
pdf_file = generate_form_json_pdf(
|
||||||
pdf_filename = old_pdf_name or f"{template.name}_{template.id}.pdf"
|
template.registration_form,
|
||||||
template.file.save(pdf_filename, pdf_file, save=True)
|
formTemplateData,
|
||||||
|
base_pdf_content=base_pdf_content,
|
||||||
|
base_file_ext=base_file_ext,
|
||||||
|
)
|
||||||
|
form_name = (formTemplateData.get("title") or template.name or f"formulaire_{template.id}").strip().replace(" ", "_")
|
||||||
|
pdf_filename = f"{form_name}.pdf"
|
||||||
|
util.save_file_field_without_suffix(
|
||||||
|
template,
|
||||||
|
'file',
|
||||||
|
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({'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)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|||||||
@ -61,12 +61,6 @@ if __name__ == "__main__":
|
|||||||
if run_command(command) != 0:
|
if run_command(command) != 0:
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
if flush_data:
|
|
||||||
for command in flush_data_cmd:
|
|
||||||
if run_command(command) != 0:
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
for command in migrate_commands:
|
for command in migrate_commands:
|
||||||
if run_command(command) != 0:
|
if run_command(command) != 0:
|
||||||
exit(1)
|
exit(1)
|
||||||
@ -75,6 +69,11 @@ if __name__ == "__main__":
|
|||||||
if run_command(command) != 0:
|
if run_command(command) != 0:
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
|
if flush_data:
|
||||||
|
for command in flush_data_cmd:
|
||||||
|
if run_command(command) != 0:
|
||||||
|
exit(1)
|
||||||
|
|
||||||
if test_mode:
|
if test_mode:
|
||||||
for test_command in test_commands:
|
for test_command in test_commands:
|
||||||
if run_command(test_command) != 0:
|
if run_command(test_command) != 0:
|
||||||
|
|||||||
225
Front-End/src/app/[locale]/admin/structure/FormBuilder/page.js
Normal file
225
Front-End/src/app/[locale]/admin/structure/FormBuilder/page.js
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
||||||
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
|
import {
|
||||||
|
fetchRegistrationFileGroups,
|
||||||
|
fetchRegistrationSchoolFileMasterById,
|
||||||
|
createRegistrationSchoolFileMaster,
|
||||||
|
editRegistrationSchoolFileMaster,
|
||||||
|
} from '@/app/actions/registerFileGroupAction';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
|
import logger from '@/utils/logger';
|
||||||
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
|
import { FE_ADMIN_STRUCTURE_URL } from '@/utils/Url';
|
||||||
|
|
||||||
|
export default function FormBuilderPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { selectedEstablishmentId } = useEstablishment();
|
||||||
|
const csrfToken = useCsrfToken();
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
|
const formId = searchParams.get('id');
|
||||||
|
const preGroupId = searchParams.get('groupId');
|
||||||
|
const isEditing = !!formId;
|
||||||
|
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [initialData, setInitialData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploadedFile, setUploadedFile] = useState(null);
|
||||||
|
const [existingFileUrl, setExistingFileUrl] = useState(null);
|
||||||
|
|
||||||
|
const normalizeBackendFile = (rawFile, rawFileUrl) => {
|
||||||
|
if (typeof rawFileUrl === 'string' && rawFileUrl.trim()) {
|
||||||
|
return rawFileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawFile === 'string' && rawFile.trim()) {
|
||||||
|
return rawFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawFile && typeof rawFile === 'object') {
|
||||||
|
if (typeof rawFile.url === 'string' && rawFile.url.trim()) {
|
||||||
|
return rawFile.url;
|
||||||
|
}
|
||||||
|
if (typeof rawFile.path === 'string' && rawFile.path.trim()) {
|
||||||
|
return rawFile.path;
|
||||||
|
}
|
||||||
|
if (typeof rawFile.name === 'string' && rawFile.name.trim()) {
|
||||||
|
return rawFile.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewFileUrl = useMemo(() => {
|
||||||
|
if (uploadedFile instanceof File) {
|
||||||
|
return URL.createObjectURL(uploadedFile);
|
||||||
|
}
|
||||||
|
return existingFileUrl || null;
|
||||||
|
}, [uploadedFile, existingFileUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewFileUrl && previewFileUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewFileUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previewFileUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedEstablishmentId) return;
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
fetchRegistrationFileGroups(selectedEstablishmentId),
|
||||||
|
formId ? fetchRegistrationSchoolFileMasterById(formId) : Promise.resolve(null),
|
||||||
|
])
|
||||||
|
.then(([groupsData, formData]) => {
|
||||||
|
setGroups(groupsData || []);
|
||||||
|
if (formData) {
|
||||||
|
setInitialData(formData);
|
||||||
|
const resolvedFile = normalizeBackendFile(
|
||||||
|
formData.file,
|
||||||
|
formData.file_url
|
||||||
|
);
|
||||||
|
if (resolvedFile) {
|
||||||
|
setExistingFileUrl(resolvedFile);
|
||||||
|
}
|
||||||
|
} else if (preGroupId) {
|
||||||
|
setInitialData({ groups: [{ id: Number(preGroupId) }] });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error('Error loading FormBuilder data:', err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [selectedEstablishmentId, formId, preGroupId]);
|
||||||
|
|
||||||
|
const buildFormData = async (name, group_ids, formMasterData) => {
|
||||||
|
const dataToSend = new FormData();
|
||||||
|
dataToSend.append(
|
||||||
|
'data',
|
||||||
|
JSON.stringify({
|
||||||
|
name,
|
||||||
|
groups: group_ids,
|
||||||
|
formMasterData,
|
||||||
|
establishment: selectedEstablishmentId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uploadedFile instanceof File) {
|
||||||
|
const ext =
|
||||||
|
uploadedFile.name.lastIndexOf('.') !== -1
|
||||||
|
? uploadedFile.name.substring(uploadedFile.name.lastIndexOf('.'))
|
||||||
|
: '';
|
||||||
|
const cleanName = (name || 'document')
|
||||||
|
.replace(/[^a-zA-Z0-9_\-]/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
dataToSend.append('file', uploadedFile, `${cleanName}${ext}`);
|
||||||
|
} else if (existingFileUrl && isEditing) {
|
||||||
|
const lastDot = existingFileUrl.lastIndexOf('.');
|
||||||
|
const ext = lastDot !== -1 ? existingFileUrl.substring(lastDot) : '';
|
||||||
|
const cleanName = (name || 'document')
|
||||||
|
.replace(/[^a-zA-Z0-9_\-]/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
try {
|
||||||
|
const resp = await fetch(getSecureFileUrl(existingFileUrl));
|
||||||
|
if (resp.ok) {
|
||||||
|
const blob = await resp.blob();
|
||||||
|
dataToSend.append('file', blob, `${cleanName}${ext}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Could not re-fetch existing file:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataToSend;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async ({ name, group_ids, formMasterData, id }) => {
|
||||||
|
const hasFileField = (formMasterData?.fields || []).some(
|
||||||
|
(field) => field.type === 'file'
|
||||||
|
);
|
||||||
|
const hasUploadedDocument =
|
||||||
|
uploadedFile instanceof File || Boolean(existingFileUrl);
|
||||||
|
|
||||||
|
if (hasFileField && !hasUploadedDocument) {
|
||||||
|
showNotification(
|
||||||
|
'Un document PDF doit être uploadé si le formulaire contient un champ fichier.',
|
||||||
|
'error',
|
||||||
|
'Erreur'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataToSend = await buildFormData(name, group_ids, formMasterData);
|
||||||
|
if (isEditing) {
|
||||||
|
await editRegistrationSchoolFileMaster(id || formId, dataToSend, csrfToken);
|
||||||
|
showNotification(
|
||||||
|
`Le formulaire "${name}" a été modifié avec succès.`,
|
||||||
|
'success',
|
||||||
|
'Succès'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await createRegistrationSchoolFileMaster(dataToSend, csrfToken);
|
||||||
|
showNotification(
|
||||||
|
`Le formulaire "${name}" a été créé avec succès.`,
|
||||||
|
'success',
|
||||||
|
'Succès'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
router.push(FE_ADMIN_STRUCTURE_URL);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error saving form:', err);
|
||||||
|
showNotification('Erreur lors de la sauvegarde du formulaire', 'error', 'Erreur');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<p className="text-gray-500">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full min-h-screen bg-neutral">
|
||||||
|
{/* Header sticky */}
|
||||||
|
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-4 py-3 flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(FE_ADMIN_STRUCTURE_URL)}
|
||||||
|
className="flex items-center gap-2 text-primary hover:text-secondary font-label font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
<h1 className="text-lg font-headline font-semibold text-gray-800">
|
||||||
|
{isEditing ? 'Modifier le formulaire' : 'Créer un formulaire personnalisé'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-6 space-y-4">
|
||||||
|
{/* FormTemplateBuilder */}
|
||||||
|
<FormTemplateBuilder
|
||||||
|
onSave={handleSave}
|
||||||
|
initialData={initialData}
|
||||||
|
groups={groups}
|
||||||
|
isEditing={isEditing}
|
||||||
|
masterFile={previewFileUrl}
|
||||||
|
onMasterFileUpload={(file) => setUploadedFile(file)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -26,6 +26,10 @@ export const fetchRegistrationSchoolFileMasters = (establishment) => {
|
|||||||
return fetchWithAuth(url);
|
return fetchWithAuth(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchRegistrationSchoolFileMasterById = (id) => {
|
||||||
|
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchRegistrationParentFileMasters = (establishment) => {
|
export const fetchRegistrationParentFileMasters = (establishment) => {
|
||||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
||||||
return fetchWithAuth(url);
|
return fetchWithAuth(url);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useForm, Controller } from 'react-hook-form';
|
|||||||
import InputTextIcon from './InputTextIcon';
|
import InputTextIcon from './InputTextIcon';
|
||||||
import SelectChoice from './SelectChoice';
|
import SelectChoice from './SelectChoice';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
|
import FileUpload from './FileUpload';
|
||||||
import IconSelector from './IconSelector';
|
import IconSelector from './IconSelector';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import { FIELD_TYPES } from './FormTypes';
|
import { FIELD_TYPES } from './FormTypes';
|
||||||
@ -14,6 +15,8 @@ export default function AddFieldModal({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
editingField = null,
|
editingField = null,
|
||||||
editingIndex = -1,
|
editingIndex = -1,
|
||||||
|
hasMasterFile = false,
|
||||||
|
onMasterFileUpload,
|
||||||
}) {
|
}) {
|
||||||
const isEditing = editingIndex >= 0;
|
const isEditing = editingIndex >= 0;
|
||||||
|
|
||||||
@ -29,6 +32,7 @@ export default function AddFieldModal({
|
|||||||
acceptTypes: '',
|
acceptTypes: '',
|
||||||
maxSize: 5, // 5MB par défaut
|
maxSize: 5, // 5MB par défaut
|
||||||
checked: false,
|
checked: false,
|
||||||
|
masterFileToUpload: null,
|
||||||
validation: {
|
validation: {
|
||||||
pattern: '',
|
pattern: '',
|
||||||
minLength: '',
|
minLength: '',
|
||||||
@ -56,6 +60,7 @@ export default function AddFieldModal({
|
|||||||
acceptTypes: '',
|
acceptTypes: '',
|
||||||
maxSize: 5,
|
maxSize: 5,
|
||||||
checked: false,
|
checked: false,
|
||||||
|
masterFileToUpload: null,
|
||||||
signatureData: '',
|
signatureData: '',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
penColor: '#000000',
|
penColor: '#000000',
|
||||||
@ -492,6 +497,31 @@ export default function AddFieldModal({
|
|||||||
|
|
||||||
{currentField.type === 'file' && (
|
{currentField.type === 'file' && (
|
||||||
<>
|
<>
|
||||||
|
<div className="rounded border border-gray-200 bg-gray-50 p-3">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Document PDF du formulaire{' '}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<FileUpload
|
||||||
|
selectionMessage="Uploader le PDF à afficher dans l'aperçu"
|
||||||
|
onFileSelect={(file) => {
|
||||||
|
setCurrentField((prev) => ({
|
||||||
|
...prev,
|
||||||
|
masterFileToUpload: file,
|
||||||
|
}));
|
||||||
|
if (onMasterFileUpload) {
|
||||||
|
onMasterFileUpload(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
enable
|
||||||
|
/>
|
||||||
|
{!hasMasterFile && !currentField.masterFileToUpload && (
|
||||||
|
<p className="text-xs text-red-500 mt-2">
|
||||||
|
Uploadez un document avant d'ajouter ce type de champ.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="acceptTypes"
|
name="acceptTypes"
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import logger from '@/utils/logger';
|
|||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import SelectChoice from './SelectChoice';
|
import SelectChoice from './SelectChoice';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import InputTextIcon from './InputTextIcon';
|
import InputTextIcon from './InputTextIcon';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
@ -33,7 +34,22 @@ export default function FormRenderer({
|
|||||||
onFormSubmit = (data) => {
|
onFormSubmit = (data) => {
|
||||||
alert(JSON.stringify(data, null, 2));
|
alert(JSON.stringify(data, null, 2));
|
||||||
}, // Callback de soumission personnalisé (optionnel)
|
}, // Callback de soumission personnalisé (optionnel)
|
||||||
|
masterFile = null,
|
||||||
}) {
|
}) {
|
||||||
|
const resolveMasterFileUrl = (fileValue) => {
|
||||||
|
if (!fileValue) return null;
|
||||||
|
if (typeof fileValue !== 'string') return null;
|
||||||
|
if (fileValue.startsWith('blob:')) return fileValue;
|
||||||
|
if (fileValue.startsWith('data:')) return fileValue;
|
||||||
|
if (fileValue.startsWith('http://') || fileValue.startsWith('https://')) {
|
||||||
|
return fileValue;
|
||||||
|
}
|
||||||
|
if (fileValue.startsWith('/api/download?')) return fileValue;
|
||||||
|
return getSecureFileUrl(fileValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const masterFileUrl = resolveMasterFileUrl(masterFile);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
@ -57,8 +73,7 @@ export default function FormRenderer({
|
|||||||
const hasFiles = Object.keys(data).some((key) => {
|
const hasFiles = Object.keys(data).some((key) => {
|
||||||
return (
|
return (
|
||||||
data[key] instanceof FileList ||
|
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'))
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -83,29 +98,6 @@ export default function FormRenderer({
|
|||||||
formData.append(`files.${key}`, value[i]);
|
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 {
|
} else {
|
||||||
// Gérer les autres types de champs
|
// Gérer les autres types de champs
|
||||||
formData.append(
|
formData.append(
|
||||||
@ -356,12 +348,26 @@ export default function FormRenderer({
|
|||||||
control={control}
|
control={control}
|
||||||
rules={{ required: field.required }}
|
rules={{ required: field.required }}
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
|
masterFileUrl ? (
|
||||||
|
<div className="w-full bg-neutral border border-gray-200 rounded p-3">
|
||||||
|
{field.label && (
|
||||||
|
<p className="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{field.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<iframe
|
||||||
|
src={masterFileUrl}
|
||||||
|
title={field.label || 'Document'}
|
||||||
|
className="w-full rounded border border-gray-200 bg-white"
|
||||||
|
style={{ height: '520px', border: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<FileUpload
|
<FileUpload
|
||||||
selectionMessage={field.label}
|
selectionMessage={field.label}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
uploadedFileName={value ? value[0]?.name : null}
|
uploadedFileName={value ? value[0]?.name : null}
|
||||||
onFileSelect={(file) => {
|
onFileSelect={(file) => {
|
||||||
// Créer un objet de type FileList similaire pour la compatibilité
|
|
||||||
const dataTransfer = new DataTransfer();
|
const dataTransfer = new DataTransfer();
|
||||||
dataTransfer.items.add(file);
|
dataTransfer.items.add(file);
|
||||||
onChange(dataTransfer.files);
|
onChange(dataTransfer.files);
|
||||||
@ -374,6 +380,7 @@ export default function FormRenderer({
|
|||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -406,7 +413,14 @@ export default function FormRenderer({
|
|||||||
control={control}
|
control={control}
|
||||||
rules={{ required: field.required }}
|
rules={{ required: field.required }}
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<div>
|
<div
|
||||||
|
className={
|
||||||
|
masterFile
|
||||||
|
? 'mt-3 flex justify-end'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={masterFile ? 'w-full max-w-xs' : 'w-full'}>
|
||||||
<SignatureField
|
<SignatureField
|
||||||
label={field.label}
|
label={field.label}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
@ -415,6 +429,8 @@ export default function FormRenderer({
|
|||||||
backgroundColor={field.backgroundColor || '#ffffff'}
|
backgroundColor={field.backgroundColor || '#ffffff'}
|
||||||
penColor={field.penColor || '#000000'}
|
penColor={field.penColor || '#000000'}
|
||||||
penWidth={field.penWidth || 2}
|
penWidth={field.penWidth || 2}
|
||||||
|
displayWidth={masterFile ? 260 : 400}
|
||||||
|
displayHeight={masterFile ? 120 : 200}
|
||||||
/>
|
/>
|
||||||
{errors[field.id] && (
|
{errors[field.id] && (
|
||||||
<p className="text-red-500 text-sm mt-1">
|
<p className="text-red-500 text-sm mt-1">
|
||||||
@ -424,6 +440,7 @@ export default function FormRenderer({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -177,6 +177,8 @@ export default function FormTemplateBuilder({
|
|||||||
initialData,
|
initialData,
|
||||||
groups,
|
groups,
|
||||||
isEditing,
|
isEditing,
|
||||||
|
masterFile = null,
|
||||||
|
onMasterFileUpload,
|
||||||
}) {
|
}) {
|
||||||
const [formConfig, setFormConfig] = useState({
|
const [formConfig, setFormConfig] = useState({
|
||||||
id: initialData?.id || 0,
|
id: initialData?.id || 0,
|
||||||
@ -186,7 +188,9 @@ export default function FormTemplateBuilder({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [selectedGroups, setSelectedGroups] = useState(
|
const [selectedGroups, setSelectedGroups] = useState(
|
||||||
initialData?.groups?.map((g) => g.id) || []
|
initialData?.groups?.map((g) =>
|
||||||
|
typeof g === 'object' && g !== null ? g.id : g
|
||||||
|
) || []
|
||||||
);
|
);
|
||||||
|
|
||||||
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
|
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
|
||||||
@ -209,7 +213,11 @@ export default function FormTemplateBuilder({
|
|||||||
submitLabel: 'Envoyer',
|
submitLabel: 'Envoyer',
|
||||||
fields: initialData.formMasterData?.fields || [],
|
fields: initialData.formMasterData?.fields || [],
|
||||||
});
|
});
|
||||||
setSelectedGroups(initialData.groups?.map((g) => g.id) || []);
|
setSelectedGroups(
|
||||||
|
initialData.groups?.map((g) =>
|
||||||
|
typeof g === 'object' && g !== null ? g.id : g
|
||||||
|
) || []
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [initialData]);
|
}, [initialData]);
|
||||||
|
|
||||||
@ -256,6 +264,21 @@ export default function FormTemplateBuilder({
|
|||||||
const handleFieldSubmit = (data, currentField, editIndex) => {
|
const handleFieldSubmit = (data, currentField, editIndex) => {
|
||||||
const isHeadingType = data.type.startsWith('heading');
|
const isHeadingType = data.type.startsWith('heading');
|
||||||
const isContentTypeOnly = data.type === 'paragraph' || isHeadingType;
|
const isContentTypeOnly = data.type === 'paragraph' || isHeadingType;
|
||||||
|
const effectiveMasterFile = masterFile || currentField?.masterFileToUpload;
|
||||||
|
|
||||||
|
if (currentField?.masterFileToUpload && onMasterFileUpload) {
|
||||||
|
onMasterFileUpload(currentField.masterFileToUpload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un champ fichier nécessite un document source déjà uploadé.
|
||||||
|
if (data.type === 'file' && !effectiveMasterFile) {
|
||||||
|
setSaveMessage({
|
||||||
|
type: 'error',
|
||||||
|
text:
|
||||||
|
'Veuillez d\'abord uploader le document du formulaire avant d\'ajouter un champ fichier.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fieldData = {
|
const fieldData = {
|
||||||
...data,
|
...data,
|
||||||
@ -653,7 +676,7 @@ export default function FormTemplateBuilder({
|
|||||||
<h3 className="text-lg font-semibold mb-4">Aperçu du formulaire</h3>
|
<h3 className="text-lg font-semibold mb-4">Aperçu du formulaire</h3>
|
||||||
<div className="border-2 border-dashed border-gray-300 p-6 rounded">
|
<div className="border-2 border-dashed border-gray-300 p-6 rounded">
|
||||||
{formConfig.fields.length > 0 ? (
|
{formConfig.fields.length > 0 ? (
|
||||||
<FormRenderer formConfig={formConfig} />
|
<FormRenderer formConfig={formConfig} masterFile={masterFile} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500 italic text-center">
|
<p className="text-gray-500 italic text-center">
|
||||||
Ajoutez des champs pour voir l'aperçu
|
Ajoutez des champs pour voir l'aperçu
|
||||||
@ -668,6 +691,8 @@ export default function FormTemplateBuilder({
|
|||||||
isOpen={showAddFieldModal}
|
isOpen={showAddFieldModal}
|
||||||
onClose={() => setShowAddFieldModal(false)}
|
onClose={() => setShowAddFieldModal(false)}
|
||||||
onSubmit={handleFieldSubmit}
|
onSubmit={handleFieldSubmit}
|
||||||
|
hasMasterFile={Boolean(masterFile)}
|
||||||
|
onMasterFileUpload={onMasterFileUpload}
|
||||||
editingField={
|
editingField={
|
||||||
editingIndex >= 0
|
editingIndex >= 0
|
||||||
? formConfig.fields[editingIndex]
|
? formConfig.fields[editingIndex]
|
||||||
|
|||||||
@ -11,6 +11,8 @@ const SignatureField = ({
|
|||||||
backgroundColor = '#ffffff',
|
backgroundColor = '#ffffff',
|
||||||
penColor = '#000000',
|
penColor = '#000000',
|
||||||
penWidth = 2,
|
penWidth = 2,
|
||||||
|
displayWidth = 400,
|
||||||
|
displayHeight = 200,
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const [isDrawing, setIsDrawing] = useState(false);
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
@ -29,9 +31,6 @@ const SignatureField = ({
|
|||||||
|
|
||||||
// Support High DPI / Retina displays
|
// Support High DPI / Retina displays
|
||||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||||
const displayWidth = 400;
|
|
||||||
const displayHeight = 200;
|
|
||||||
|
|
||||||
// Ajuster la taille physique du canvas pour la haute résolution
|
// Ajuster la taille physique du canvas pour la haute résolution
|
||||||
canvas.width = displayWidth * devicePixelRatio;
|
canvas.width = displayWidth * devicePixelRatio;
|
||||||
canvas.height = displayHeight * devicePixelRatio;
|
canvas.height = displayHeight * devicePixelRatio;
|
||||||
@ -56,7 +55,7 @@ const SignatureField = ({
|
|||||||
context.lineCap = 'round';
|
context.lineCap = 'round';
|
||||||
context.lineJoin = 'round';
|
context.lineJoin = 'round';
|
||||||
context.globalCompositeOperation = 'source-over';
|
context.globalCompositeOperation = 'source-over';
|
||||||
}, [backgroundColor, penColor, penWidth]);
|
}, [backgroundColor, penColor, penWidth, displayWidth, displayHeight]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeCanvas();
|
initializeCanvas();
|
||||||
@ -226,11 +225,12 @@ const SignatureField = ({
|
|||||||
setCurrentPath('');
|
setCurrentPath('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifier le parent du changement avec SVG
|
// Notifier le parent du changement avec PNG pour garantir
|
||||||
|
// la compatibilite de rendu cote backend/PDF.
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
const newPaths = [...svgPaths, currentPath].filter((p) => p.length > 0);
|
const canvas = canvasRef.current;
|
||||||
const svgData = generateSVG(newPaths);
|
const pngData = canvas ? canvas.toDataURL('image/png') : '';
|
||||||
onChange(svgData);
|
onChange(pngData);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isDrawing, onChange, svgPaths, currentPath]
|
[isDrawing, onChange, svgPaths, currentPath]
|
||||||
@ -238,7 +238,7 @@ const SignatureField = ({
|
|||||||
|
|
||||||
// Générer le SVG à partir des paths
|
// Générer le SVG à partir des paths
|
||||||
const generateSVG = (paths) => {
|
const generateSVG = (paths) => {
|
||||||
const svgContent = `<svg width="400" height="200" xmlns="http://www.w3.org/2000/svg">
|
const svgContent = `<svg width="${displayWidth}" height="${displayHeight}" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect width="100%" height="100%" fill="${backgroundColor}"/>
|
<rect width="100%" height="100%" fill="${backgroundColor}"/>
|
||||||
${paths
|
${paths
|
||||||
.map(
|
.map(
|
||||||
@ -257,9 +257,6 @@ const SignatureField = ({
|
|||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
// Effacer en tenant compte des dimensions d'affichage
|
// Effacer en tenant compte des dimensions d'affichage
|
||||||
const displayWidth = 400;
|
|
||||||
const displayHeight = 200;
|
|
||||||
|
|
||||||
context.clearRect(0, 0, displayWidth, displayHeight);
|
context.clearRect(0, 0, displayWidth, displayHeight);
|
||||||
context.fillStyle = backgroundColor;
|
context.fillStyle = backgroundColor;
|
||||||
context.fillRect(0, 0, displayWidth, displayHeight);
|
context.fillRect(0, 0, displayWidth, displayHeight);
|
||||||
@ -273,6 +270,14 @@ const SignatureField = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hintText = readOnly
|
||||||
|
? isEmpty
|
||||||
|
? 'Aucune signature'
|
||||||
|
: 'Signature'
|
||||||
|
: isEmpty
|
||||||
|
? 'Signez dans la zone ci-dessus'
|
||||||
|
: 'Signature capturée';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signature-field">
|
<div className="signature-field">
|
||||||
{label && (
|
{label && (
|
||||||
@ -282,7 +287,7 @@ const SignatureField = ({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border border-gray-300 rounded-lg p-4 bg-gray-50">
|
<div className="border border-gray-300 rounded-lg p-3 bg-gray-50">
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={`border border-gray-200 bg-white rounded touch-none ${
|
className={`border border-gray-200 bg-white rounded touch-none ${
|
||||||
@ -307,16 +312,8 @@ const SignatureField = ({
|
|||||||
onTouchEnd={readOnly ? undefined : stopDrawing}
|
onTouchEnd={readOnly ? undefined : stopDrawing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-3">
|
<div className="flex justify-between items-center mt-2">
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">{hintText}</div>
|
||||||
{readOnly
|
|
||||||
? isEmpty
|
|
||||||
? 'Aucune signature'
|
|
||||||
: 'Signature'
|
|
||||||
: isEmpty
|
|
||||||
? 'Signez dans la zone ci-dessus'
|
|
||||||
: 'Signature capturée'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@ -34,6 +34,44 @@ export default function DynamicFormsList({
|
|||||||
const [formsValidation, setFormsValidation] = useState({});
|
const [formsValidation, setFormsValidation] = useState({});
|
||||||
const fileInputRefs = React.useRef({});
|
const fileInputRefs = React.useRef({});
|
||||||
|
|
||||||
|
const extractResponses = (data, maxDepth = 8) => {
|
||||||
|
let current = data;
|
||||||
|
for (let i = 0; i < maxDepth; i += 1) {
|
||||||
|
if (!current || typeof current !== 'object') return {};
|
||||||
|
if (current.responses && typeof current.responses === 'object') {
|
||||||
|
current = current.responses;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current || typeof current !== 'object') return {};
|
||||||
|
|
||||||
|
const cleaned = { ...current };
|
||||||
|
delete cleaned.formId;
|
||||||
|
delete cleaned.id;
|
||||||
|
delete cleaned.templateId;
|
||||||
|
delete cleaned.responses;
|
||||||
|
return cleaned;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasLocalCompletion = (templateId) => {
|
||||||
|
if (formsValidation[templateId] === true) return true;
|
||||||
|
|
||||||
|
const localData = formsData[templateId];
|
||||||
|
if (localData instanceof FormData) return true;
|
||||||
|
if (localData && typeof localData === 'object') {
|
||||||
|
return Object.keys(localData).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedResponses = existingResponses[templateId];
|
||||||
|
return !!(
|
||||||
|
savedResponses &&
|
||||||
|
typeof savedResponses === 'object' &&
|
||||||
|
Object.keys(savedResponses).length > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Initialiser les données avec les réponses existantes
|
// Initialiser les données avec les réponses existantes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initialisation complète de formsValidation et formsData pour chaque template
|
// Initialisation complète de formsValidation et formsData pour chaque template
|
||||||
@ -90,11 +128,7 @@ export default function DynamicFormsList({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
|
// 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(
|
const allFormsValid = schoolFileTemplates.every(
|
||||||
(tpl) =>
|
(tpl) => tpl.isValidated === true || hasLocalCompletion(tpl.id)
|
||||||
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);
|
onValidationChange(allFormsValid);
|
||||||
@ -113,10 +147,12 @@ export default function DynamicFormsList({
|
|||||||
try {
|
try {
|
||||||
logger.debug('Soumission du formulaire:', { templateId, formData });
|
logger.debug('Soumission du formulaire:', { templateId, formData });
|
||||||
|
|
||||||
|
const normalizedResponses = extractResponses(formData);
|
||||||
|
|
||||||
// Sauvegarder les données du formulaire
|
// Sauvegarder les données du formulaire
|
||||||
setFormsData((prev) => ({
|
setFormsData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[templateId]: formData,
|
[templateId]: normalizedResponses,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Marquer le formulaire comme complété
|
// Marquer le formulaire comme complété
|
||||||
@ -145,13 +181,7 @@ export default function DynamicFormsList({
|
|||||||
* Vérifie si un formulaire est complété
|
* Vérifie si un formulaire est complété
|
||||||
*/
|
*/
|
||||||
const isFormCompleted = (templateId) => {
|
const isFormCompleted = (templateId) => {
|
||||||
return (
|
return hasLocalCompletion(templateId);
|
||||||
formsValidation[templateId] === true ||
|
|
||||||
(formsData[templateId] &&
|
|
||||||
Object.keys(formsData[templateId]).length > 0) ||
|
|
||||||
(existingResponses[templateId] &&
|
|
||||||
Object.keys(existingResponses[templateId]).length > 0)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -229,13 +259,7 @@ export default function DynamicFormsList({
|
|||||||
{
|
{
|
||||||
schoolFileTemplates.filter((tpl) => {
|
schoolFileTemplates.filter((tpl) => {
|
||||||
// Validé ou complété localement
|
// Validé ou complété localement
|
||||||
return (
|
return tpl.isValidated === true || hasLocalCompletion(tpl.id);
|
||||||
tpl.isValidated === true ||
|
|
||||||
(formsData[tpl.id] &&
|
|
||||||
Object.keys(formsData[tpl.id]).length > 0) ||
|
|
||||||
(existingResponses[tpl.id] &&
|
|
||||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
|
||||||
);
|
|
||||||
}).length
|
}).length
|
||||||
}
|
}
|
||||||
{' / '}
|
{' / '}
|
||||||
@ -247,14 +271,10 @@ export default function DynamicFormsList({
|
|||||||
// Helper pour état
|
// Helper pour état
|
||||||
const getState = (tpl) => {
|
const getState = (tpl) => {
|
||||||
if (tpl.isValidated === true) return 0; // validé
|
if (tpl.isValidated === true) return 0; // validé
|
||||||
const isCompletedLocally = !!(
|
const isCompletedLocally = hasLocalCompletion(tpl.id);
|
||||||
(formsData[tpl.id] &&
|
if (isCompletedLocally) return 1; // complété (en attente de traitement)
|
||||||
Object.keys(formsData[tpl.id]).length > 0) ||
|
if (tpl.isValidated === false) return 2; // refusé
|
||||||
(existingResponses[tpl.id] &&
|
return 3; // à compléter
|
||||||
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) => {
|
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => {
|
||||||
return getState(a) - getState(b);
|
return getState(a) - getState(b);
|
||||||
@ -268,12 +288,7 @@ export default function DynamicFormsList({
|
|||||||
typeof tpl.isValidated === 'boolean'
|
typeof tpl.isValidated === 'boolean'
|
||||||
? tpl.isValidated
|
? tpl.isValidated
|
||||||
: undefined;
|
: undefined;
|
||||||
const isCompletedLocally = !!(
|
const isCompletedLocally = hasLocalCompletion(tpl.id);
|
||||||
(formsData[tpl.id] &&
|
|
||||||
Object.keys(formsData[tpl.id]).length > 0) ||
|
|
||||||
(existingResponses[tpl.id] &&
|
|
||||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Statut d'affichage
|
// Statut d'affichage
|
||||||
let statusLabel = '';
|
let statusLabel = '';
|
||||||
@ -300,22 +315,9 @@ export default function DynamicFormsList({
|
|||||||
: textClass;
|
: textClass;
|
||||||
canEdit = false;
|
canEdit = false;
|
||||||
} else if (isValidated === 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é';
|
statusLabel = 'Refusé';
|
||||||
statusColor = 'red';
|
statusColor = 'red';
|
||||||
icon = <XCircle className="w-5 h-5 text-red-500" />;
|
icon = <Hourglass className="w-5 h-5 text-red-500" />;
|
||||||
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
|
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
|
||||||
borderClass = isActive
|
borderClass = isActive
|
||||||
? 'border border-red-300'
|
? 'border border-red-300'
|
||||||
@ -324,7 +326,6 @@ export default function DynamicFormsList({
|
|||||||
? 'text-red-900 font-semibold'
|
? 'text-red-900 font-semibold'
|
||||||
: 'text-red-700';
|
: 'text-red-700';
|
||||||
canEdit = true;
|
canEdit = true;
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (isCompletedLocally) {
|
if (isCompletedLocally) {
|
||||||
statusLabel = 'Complété';
|
statusLabel = 'Complété';
|
||||||
@ -405,17 +406,17 @@ export default function DynamicFormsList({
|
|||||||
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">
|
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">
|
||||||
Validé
|
Validé
|
||||||
</span>
|
</span>
|
||||||
) : (formsData[currentTemplate.id] &&
|
) : currentTemplate.isValidated === false ? (
|
||||||
Object.keys(formsData[currentTemplate.id]).length > 0) ||
|
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">
|
||||||
(existingResponses[currentTemplate.id] &&
|
Refusé
|
||||||
Object.keys(existingResponses[currentTemplate.id]).length >
|
</span>
|
||||||
0) ? (
|
) : hasLocalCompletion(currentTemplate.id) ? (
|
||||||
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">
|
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">
|
||||||
Complété
|
Complété
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">
|
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-700 text-sm font-semibold">
|
||||||
Refusé
|
En attente
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -430,14 +431,10 @@ export default function DynamicFormsList({
|
|||||||
// Trouver l'index du template courant dans la liste triée
|
// Trouver l'index du template courant dans la liste triée
|
||||||
const getState = (tpl) => {
|
const getState = (tpl) => {
|
||||||
if (tpl.isValidated === true) return 0;
|
if (tpl.isValidated === true) return 0;
|
||||||
const isCompletedLocally = !!(
|
const isCompletedLocally = hasLocalCompletion(tpl.id);
|
||||||
(formsData[tpl.id] &&
|
|
||||||
Object.keys(formsData[tpl.id]).length > 0) ||
|
|
||||||
(existingResponses[tpl.id] &&
|
|
||||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
|
||||||
);
|
|
||||||
if (isCompletedLocally) return 1;
|
if (isCompletedLocally) return 1;
|
||||||
return 2;
|
if (tpl.isValidated === false) return 2;
|
||||||
|
return 3;
|
||||||
};
|
};
|
||||||
const sortedTemplates = [...schoolFileTemplates].sort(
|
const sortedTemplates = [...schoolFileTemplates].sort(
|
||||||
(a, b) => getState(a) - getState(b)
|
(a, b) => getState(a) - getState(b)
|
||||||
@ -469,8 +466,11 @@ export default function DynamicFormsList({
|
|||||||
submitLabel:
|
submitLabel:
|
||||||
currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
||||||
}}
|
}}
|
||||||
|
masterFile={
|
||||||
|
currentTemplate.master_file_url || currentTemplate.file || null
|
||||||
|
}
|
||||||
initialValues={
|
initialValues={
|
||||||
formsData[currentTemplate.id] ||
|
extractResponses(formsData[currentTemplate.id]) ||
|
||||||
existingResponses[currentTemplate.id] ||
|
existingResponses[currentTemplate.id] ||
|
||||||
{}
|
{}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -285,6 +285,29 @@ export default function InscriptionFormShared({
|
|||||||
throw new Error(`Template avec l'ID ${templateId} non trouvé`);
|
throw new Error(`Template avec l'ID ${templateId} non trouvé`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aplatit les structures de type { responses: { responses: {...} } }
|
||||||
|
// pour ne conserver que les reponses de champs (ex: sign).
|
||||||
|
const extractResponses = (data, maxDepth = 8) => {
|
||||||
|
let current = data;
|
||||||
|
for (let i = 0; i < maxDepth; i += 1) {
|
||||||
|
if (!current || typeof current !== 'object') return {};
|
||||||
|
if (current.responses && typeof current.responses === 'object') {
|
||||||
|
current = current.responses;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!current || typeof current !== 'object') return {};
|
||||||
|
const cleaned = { ...current };
|
||||||
|
delete cleaned.formId;
|
||||||
|
delete cleaned.id;
|
||||||
|
delete cleaned.templateId;
|
||||||
|
delete cleaned.responses;
|
||||||
|
return cleaned;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedResponses = extractResponses(formData);
|
||||||
|
|
||||||
// Construire la structure complète avec la configuration et les réponses
|
// Construire la structure complète avec la configuration et les réponses
|
||||||
const formTemplateData = {
|
const formTemplateData = {
|
||||||
id: currentTemplate.id,
|
id: currentTemplate.id,
|
||||||
@ -300,17 +323,17 @@ export default function InscriptionFormShared({
|
|||||||
).map((field) => ({
|
).map((field) => ({
|
||||||
...field,
|
...field,
|
||||||
...(field.type === 'checkbox'
|
...(field.type === 'checkbox'
|
||||||
? { checked: formData[field.id] || false }
|
? { checked: normalizedResponses[field.id] || false }
|
||||||
: {}),
|
: {}),
|
||||||
...(field.type === 'radio' ? { selected: formData[field.id] } : {}),
|
...(field.type === 'radio'
|
||||||
...(field.type === 'text' ||
|
? { selected: normalizedResponses[field.id] }
|
||||||
field.type === 'textarea' ||
|
: {}),
|
||||||
field.type === 'email'
|
...(field.id
|
||||||
? { value: formData[field.id] || '' }
|
? { value: normalizedResponses[field.id] ?? field.value ?? '' }
|
||||||
: {}),
|
: {}),
|
||||||
})),
|
})),
|
||||||
submitLabel: currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
submitLabel: currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
||||||
responses: formData,
|
responses: normalizedResponses,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
|
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
|
||||||
@ -326,7 +349,7 @@ export default function InscriptionFormShared({
|
|||||||
logger.debug("Réponse de l'API:", result);
|
logger.debug("Réponse de l'API:", result);
|
||||||
|
|
||||||
// Prendre en compte la réponse du back pour mettre à jour les réponses locales
|
// Prendre en compte la réponse du back pour mettre à jour les réponses locales
|
||||||
let newResponses = formData;
|
let newResponses = normalizedResponses;
|
||||||
if (
|
if (
|
||||||
result &&
|
result &&
|
||||||
result.data &&
|
result.data &&
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { Edit, Trash2, FileText, Star, ChevronDown, Plus } from 'lucide-react';
|
import { Edit, Trash2, FileText, Star, ChevronDown, Plus } from 'lucide-react';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
|
||||||
import {
|
import {
|
||||||
// GET
|
// GET
|
||||||
fetchRegistrationFileGroups,
|
fetchRegistrationFileGroups,
|
||||||
@ -32,6 +32,7 @@ import CheckBox from '@/components/Form/CheckBox';
|
|||||||
import Button from '@/components/Form/Button';
|
import Button from '@/components/Form/Button';
|
||||||
import InputText from '@/components/Form/InputText';
|
import InputText from '@/components/Form/InputText';
|
||||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
|
import { FE_ADMIN_STRUCTURE_FORM_BUILDER_URL } from '@/utils/Url';
|
||||||
|
|
||||||
function getItemBgColor(type, selected, forceTheme = false) {
|
function getItemBgColor(type, selected, forceTheme = false) {
|
||||||
// Colonne gauche : blanc si rien n'est sélectionné, emerald si sélectionné
|
// Colonne gauche : blanc si rien n'est sélectionné, emerald si sélectionné
|
||||||
@ -200,7 +201,7 @@ export default function FilesGroupsManagement({
|
|||||||
const [parentFiles, setParentFileMasters] = useState([]);
|
const [parentFiles, setParentFileMasters] = useState([]);
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const router = useRouter();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [fileToEdit, setFileToEdit] = useState(null);
|
const [fileToEdit, setFileToEdit] = useState(null);
|
||||||
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||||
@ -226,10 +227,8 @@ export default function FilesGroupsManagement({
|
|||||||
const handleDocDropdownSelect = (type) => {
|
const handleDocDropdownSelect = (type) => {
|
||||||
setIsDocDropdownOpen(false);
|
setIsDocDropdownOpen(false);
|
||||||
if (type === 'formulaire') {
|
if (type === 'formulaire') {
|
||||||
// Ouvre la modale unique en mode création
|
const groupParam = selectedGroupId ? `?groupId=${selectedGroupId}` : '';
|
||||||
setIsEditing(false);
|
router.push(`${FE_ADMIN_STRUCTURE_FORM_BUILDER_URL}${groupParam}`);
|
||||||
setFileToEdit(null);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
} else if (type === 'formulaire_existant') {
|
} else if (type === 'formulaire_existant') {
|
||||||
setIsFileUploadPopupOpen(true);
|
setIsFileUploadPopupOpen(true);
|
||||||
setFileToEdit({});
|
setFileToEdit({});
|
||||||
@ -329,28 +328,29 @@ export default function FilesGroupsManagement({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const editTemplateMaster = (file) => {
|
const editTemplateMaster = (file) => {
|
||||||
// Si le formulaire n'est pas personnalisé, ouvrir la popup de téléchargement
|
const isDynamic =
|
||||||
if (
|
file.formMasterData &&
|
||||||
!file.formMasterData ||
|
Array.isArray(file.formMasterData.fields) &&
|
||||||
!Array.isArray(file.formMasterData.fields) ||
|
file.formMasterData.fields.length > 0;
|
||||||
file.formMasterData.fields.length === 0
|
|
||||||
) {
|
if (isDynamic) {
|
||||||
setFileToEdit(file);
|
router.push(`${FE_ADMIN_STRUCTURE_FORM_BUILDER_URL}?id=${file.id}`);
|
||||||
setIsFileUploadPopupOpen(true);
|
|
||||||
setIsEditing(true);
|
|
||||||
} else {
|
} else {
|
||||||
setIsEditing(true);
|
|
||||||
setFileToEdit(file);
|
setFileToEdit(file);
|
||||||
setIsModalOpen(true);
|
setIsEditing(true);
|
||||||
|
setIsFileUploadPopupOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSchoolFileMaster = ({
|
const handleCreateSchoolFileMaster = (
|
||||||
|
{
|
||||||
name,
|
name,
|
||||||
group_ids,
|
group_ids,
|
||||||
formMasterData,
|
formMasterData,
|
||||||
file,
|
file,
|
||||||
}) => {
|
},
|
||||||
|
onCreated
|
||||||
|
) => {
|
||||||
// Toujours envoyer en FormData, même sans fichier
|
// Toujours envoyer en FormData, même sans fichier
|
||||||
const dataToSend = new FormData();
|
const dataToSend = new FormData();
|
||||||
const jsonData = {
|
const jsonData = {
|
||||||
@ -379,12 +379,12 @@ export default function FilesGroupsManagement({
|
|||||||
createRegistrationSchoolFileMaster(dataToSend, csrfToken)
|
createRegistrationSchoolFileMaster(dataToSend, csrfToken)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setSchoolFileMasters((prevFiles) => [...prevFiles, data]);
|
setSchoolFileMasters((prevFiles) => [...prevFiles, data]);
|
||||||
setIsModalOpen(false);
|
|
||||||
showNotification(
|
showNotification(
|
||||||
`Le formulaire "${name}" a été créé avec succès.`,
|
`Le formulaire "${name}" a été créé avec succès.`,
|
||||||
'success',
|
'success',
|
||||||
'Succès'
|
'Succès'
|
||||||
);
|
);
|
||||||
|
if (onCreated) onCreated(data);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('Error creating form:', error);
|
logger.error('Error creating form:', error);
|
||||||
@ -460,7 +460,6 @@ export default function FilesGroupsManagement({
|
|||||||
setSchoolFileMasters((prevFichiers) =>
|
setSchoolFileMasters((prevFichiers) =>
|
||||||
prevFichiers.map((f) => (f.id === id ? data : f))
|
prevFichiers.map((f) => (f.id === id ? data : f))
|
||||||
);
|
);
|
||||||
setIsModalOpen(false);
|
|
||||||
showNotification(
|
showNotification(
|
||||||
`Le formulaire "${name}" a été modifié avec succès.`,
|
`Le formulaire "${name}" a été modifié avec succès.`,
|
||||||
'success',
|
'success',
|
||||||
@ -495,7 +494,6 @@ export default function FilesGroupsManagement({
|
|||||||
setSchoolFileMasters((prevFichiers) =>
|
setSchoolFileMasters((prevFichiers) =>
|
||||||
prevFichiers.map((f) => (f.id === id ? data : f))
|
prevFichiers.map((f) => (f.id === id ? data : f))
|
||||||
);
|
);
|
||||||
setIsModalOpen(false);
|
|
||||||
showNotification(
|
showNotification(
|
||||||
`Le formulaire "${name}" a été modifié avec succès.`,
|
`Le formulaire "${name}" a été modifié avec succès.`,
|
||||||
'success',
|
'success',
|
||||||
@ -888,13 +886,6 @@ export default function FilesGroupsManagement({
|
|||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Utilitaire pour ouvrir la modale FormTemplateBuilder (création ou édition)
|
|
||||||
const openFormBuilderModal = (editing = false, initialData = null) => {
|
|
||||||
setIsEditing(editing);
|
|
||||||
setFileToEdit(initialData);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{/* Aide optionnelle */}
|
{/* Aide optionnelle */}
|
||||||
@ -1094,37 +1085,6 @@ export default function FilesGroupsManagement({
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Modals pour création/édition d'un formulaire dynamique */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
setIsOpen={(isOpen) => {
|
|
||||||
setIsModalOpen(isOpen);
|
|
||||||
if (!isOpen) {
|
|
||||||
setFileToEdit(null);
|
|
||||||
setIsEditing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
isEditing
|
|
||||||
? 'Modification du formulaire'
|
|
||||||
: 'Créer un formulaire personnalisé'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="w-11/12 h-5/6 max-w-5xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<FormTemplateBuilder
|
|
||||||
onSave={(data) => {
|
|
||||||
(isEditing
|
|
||||||
? handleEditSchoolFileMaster
|
|
||||||
: handleCreateSchoolFileMaster)(data);
|
|
||||||
setIsModalOpen(false);
|
|
||||||
}}
|
|
||||||
initialData={isEditing ? fileToEdit : undefined}
|
|
||||||
groups={groups}
|
|
||||||
isEditing={isEditing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Popup pour création/édition d'un formulaire d'école déjà existant */}
|
{/* Popup pour création/édition d'un formulaire d'école déjà existant */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isFileUploadPopupOpen}
|
isOpen={isFileUploadPopupOpen}
|
||||||
@ -1262,11 +1222,13 @@ export default function FilesGroupsManagement({
|
|||||||
!fileToEdit?.file
|
!fileToEdit?.file
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
handleCreateSchoolFileMaster({
|
handleCreateSchoolFileMaster(
|
||||||
|
{
|
||||||
name: fileToEdit.name,
|
name: fileToEdit.name,
|
||||||
group_ids: fileToEdit.groups,
|
group_ids: fileToEdit.groups,
|
||||||
file: fileToEdit.file,
|
file: fileToEdit.file,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
setIsFileUploadPopupOpen(false);
|
setIsFileUploadPopupOpen(false);
|
||||||
setFileToEdit(null);
|
setFileToEdit(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logger } from '@/utils/logger';
|
||||||
import { getToken } from 'next-auth/jwt';
|
import { getToken } from 'next-auth/jwt';
|
||||||
|
|
||||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;
|
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
@ -27,7 +28,6 @@ export default async function handler(req, res) {
|
|||||||
const backendRes = await fetch(backendUrl, {
|
const backendRes = await fetch(backendUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.token}`,
|
Authorization: `Bearer ${token.token}`,
|
||||||
Connection: 'close',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -48,7 +48,8 @@ export default async function handler(req, res) {
|
|||||||
|
|
||||||
const buffer = Buffer.from(await backendRes.arrayBuffer());
|
const buffer = Buffer.from(await backendRes.arrayBuffer());
|
||||||
return res.send(buffer);
|
return res.send(buffer);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error('Download proxy error:', error);
|
||||||
return res.status(500).json({ error: 'Erreur lors du téléchargement' });
|
return res.status(500).json({ error: 'Erreur lors du téléchargement' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,6 +103,8 @@ export const FE_ADMIN_CLASSES_URL = '/admin/classes';
|
|||||||
export const FE_ADMIN_STRUCTURE_URL = '/admin/structure';
|
export const FE_ADMIN_STRUCTURE_URL = '/admin/structure';
|
||||||
export const FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL =
|
export const FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL =
|
||||||
'/admin/structure/SchoolClassManagement';
|
'/admin/structure/SchoolClassManagement';
|
||||||
|
export const FE_ADMIN_STRUCTURE_FORM_BUILDER_URL =
|
||||||
|
'/admin/structure/FormBuilder';
|
||||||
|
|
||||||
//ADMIN/DIRECTORY URL
|
//ADMIN/DIRECTORY URL
|
||||||
export const FE_ADMIN_DIRECTORY_URL = '/admin/directory';
|
export const FE_ADMIN_DIRECTORY_URL = '/admin/directory';
|
||||||
|
|||||||
@ -10,6 +10,12 @@
|
|||||||
*/
|
*/
|
||||||
export const getSecureFileUrl = (filePath) => {
|
export const getSecureFileUrl = (filePath) => {
|
||||||
if (!filePath) return null;
|
if (!filePath) return null;
|
||||||
|
if (typeof filePath !== 'string') return null;
|
||||||
|
|
||||||
|
// URL deja proxifiee: la reutiliser telle quelle.
|
||||||
|
if (filePath.startsWith('/api/download?')) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
// Si c'est une URL absolue, extraire le chemin /data/...
|
// Si c'est une URL absolue, extraire le chemin /data/...
|
||||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user