From 90b0d144184a1c402aedfe3f70647c4bb43f8c37 Mon Sep 17 00:00:00 2001 From: N3WT DE COMPET Date: Sat, 4 Apr 2026 20:08:25 +0200 Subject: [PATCH] feat: Finalisation formulaire dynamique --- Back-End/Auth/migrations/0001_initial.py | 2 +- Back-End/Common/migrations/0001_initial.py | 2 +- Back-End/Common/views.py | 6 + .../Establishment/migrations/0001_initial.py | 2 +- .../migrations/0001_initial.py | 2 +- .../migrations/0001_initial.py | 2 +- Back-End/Planning/migrations/0001_initial.py | 2 +- Back-End/School/migrations/0001_initial.py | 23 +- Back-End/Settings/migrations/0001_initial.py | 2 +- .../Subscriptions/migrations/0001_initial.py | 23 +- Back-End/Subscriptions/models.py | 23 +- Back-End/Subscriptions/serializers.py | 12 + Back-End/Subscriptions/util.py | 418 ++++++++++++++++-- ...egistration_parent_file_templates_views.py | 8 +- .../registration_school_file_masters_views.py | 19 + ...egistration_school_file_templates_views.py | 82 +++- Back-End/start.py | 11 +- .../admin/structure/FormBuilder/page.js | 225 ++++++++++ .../app/actions/registerFileGroupAction.js | 4 + .../src/components/Form/AddFieldModal.js | 30 ++ Front-End/src/components/Form/FormRenderer.js | 105 +++-- .../components/Form/FormTemplateBuilder.js | 31 +- .../src/components/Form/SignatureField.js | 43 +- .../Inscription/DynamicFormsList.js | 148 +++---- .../Inscription/InscriptionFormShared.js | 39 +- .../Structure/Files/FilesGroupsManagement.js | 100 ++--- Front-End/src/pages/api/download.js | 5 +- Front-End/src/utils/Url.js | 2 + Front-End/src/utils/fileUrl.js | 6 + 29 files changed, 1071 insertions(+), 306 deletions(-) create mode 100644 Front-End/src/app/[locale]/admin/structure/FormBuilder/page.js diff --git a/Back-End/Auth/migrations/0001_initial.py b/Back-End/Auth/migrations/0001_initial.py index c0792e2..c7d826b 100644 --- a/Back-End/Auth/migrations/0001_initial.py +++ b/Back-End/Auth/migrations/0001_initial.py @@ -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.validators diff --git a/Back-End/Common/migrations/0001_initial.py b/Back-End/Common/migrations/0001_initial.py index 551197b..db9180b 100644 --- a/Back-End/Common/migrations/0001_initial.py +++ b/Back-End/Common/migrations/0001_initial.py @@ -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 from django.db import migrations, models diff --git a/Back-End/Common/views.py b/Back-End/Common/views.py index 24608b5..aba37fe 100644 --- a/Back-End/Common/views.py +++ b/Back-End/Common/views.py @@ -137,6 +137,12 @@ class ServeFileView(APIView): 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 if file_path.startswith('/data/'): file_path = file_path[len('/data/'):] diff --git a/Back-End/Establishment/migrations/0001_initial.py b/Back-End/Establishment/migrations/0001_initial.py index aa3a761..d658067 100644 --- a/Back-End/Establishment/migrations/0001_initial.py +++ b/Back-End/Establishment/migrations/0001_initial.py @@ -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 django.contrib.postgres.fields diff --git a/Back-End/GestionMessagerie/migrations/0001_initial.py b/Back-End/GestionMessagerie/migrations/0001_initial.py index 8697cb2..9f4045b 100644 --- a/Back-End/GestionMessagerie/migrations/0001_initial.py +++ b/Back-End/GestionMessagerie/migrations/0001_initial.py @@ -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.utils.timezone diff --git a/Back-End/GestionNotification/migrations/0001_initial.py b/Back-End/GestionNotification/migrations/0001_initial.py index ad8449d..e3f2d8f 100644 --- a/Back-End/GestionNotification/migrations/0001_initial.py +++ b/Back-End/GestionNotification/migrations/0001_initial.py @@ -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 from django.conf import settings diff --git a/Back-End/Planning/migrations/0001_initial.py b/Back-End/Planning/migrations/0001_initial.py index 55cb4bf..66d1bd8 100644 --- a/Back-End/Planning/migrations/0001_initial.py +++ b/Back-End/Planning/migrations/0001_initial.py @@ -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 from django.db import migrations, models diff --git a/Back-End/School/migrations/0001_initial.py b/Back-End/School/migrations/0001_initial.py index a2ab6af..346bd41 100644 --- a/Back-End/School/migrations/0001_initial.py +++ b/Back-End/School/migrations/0001_initial.py @@ -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.db.models.deletion @@ -99,6 +99,7 @@ class Migration(migrations.Migration): ('number_of_students', models.PositiveIntegerField(blank=True, null=True)), ('teaching_language', models.CharField(blank=True, max_length=255)), ('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)), ('type', models.IntegerField(choices=[(1, 'Annuel'), (2, 'Semestriel'), (3, 'Trimestriel')], default=1)), ('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')), ], ), + 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( name='Teacher', fields=[ diff --git a/Back-End/Settings/migrations/0001_initial.py b/Back-End/Settings/migrations/0001_initial.py index 944dc3b..a4dde10 100644 --- a/Back-End/Settings/migrations/0001_initial.py +++ b/Back-End/Settings/migrations/0001_initial.py @@ -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 from django.db import migrations, models diff --git a/Back-End/Subscriptions/migrations/0001_initial.py b/Back-End/Subscriptions/migrations/0001_initial.py index 228e5b4..51cb628 100644 --- a/Back-End/Subscriptions/migrations/0001_initial.py +++ b/Back-End/Subscriptions/migrations/0001_initial.py @@ -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 django.db.models.deletion @@ -40,6 +40,8 @@ class Migration(migrations.Migration): ('birth_place', models.CharField(blank=True, default='', max_length=200)), ('birth_postal_code', models.IntegerField(blank=True, default=0)), ('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')), ], ), @@ -90,6 +92,7 @@ class Migration(migrations.Migration): fields=[ ('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)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), ('last_update', models.DateTimeField(auto_now=True)), ('school_year', models.CharField(blank=True, default='', max_length=9)), ('notes', models.CharField(blank=True, max_length=200)), @@ -209,6 +212,8 @@ class Migration(migrations.Migration): ('score', models.IntegerField(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)), + ('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')), ('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')}, }, ), + 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')}, + }, + ), ] diff --git a/Back-End/Subscriptions/models.py b/Back-End/Subscriptions/models.py index ce5ff6c..bb3ac15 100644 --- a/Back-End/Subscriptions/models.py +++ b/Back-End/Subscriptions/models.py @@ -403,7 +403,9 @@ class RegistrationSchoolFileMaster(models.Model): and isinstance(self.formMasterData, dict) 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: # Pour les forms existants, le nom attendu est self.name + extension du fichier existant extension = os.path.splitext(old_filename)[1] @@ -438,16 +440,9 @@ class RegistrationSchoolFileMaster(models.Model): except RegistrationSchoolFileMaster.DoesNotExist: pass - # --- Traitement PDF dynamique AVANT le super().save() --- - if ( - self.formMasterData - and isinstance(self.formMasterData, dict) - and self.formMasterData.get("fields") - ): - from Subscriptions.util import generate_form_json_pdf - pdf_filename = f"{self.name}.pdf" - pdf_file = generate_form_json_pdf(self, self.formMasterData) - self.file.save(pdf_filename, pdf_file, save=False) + # IMPORTANT: pour les formulaires dynamiques, le fichier du master doit + # rester le document source uploadé (PDF/image). La génération du PDF final + # est faite au niveau des templates (par élève), pas sur le master. 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) file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to) formTemplateData = models.JSONField(default=list, blank=True, null=True) - isValidated = models.BooleanField(default=False) + # Tri-etat: None=en attente, True=valide, False=refuse + isValidated = models.BooleanField(null=True, blank=True, default=None) def __str__(self): 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) registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True) file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to) - isValidated = models.BooleanField(default=False) + # Tri-etat: None=en attente, True=valide, False=refuse + isValidated = models.BooleanField(null=True, blank=True, default=None) def __str__(self): return self.master.name if self.master else f"ParentFile_{self.pk}" diff --git a/Back-End/Subscriptions/serializers.py b/Back-End/Subscriptions/serializers.py index 12257a2..ed0a2f0 100644 --- a/Back-End/Subscriptions/serializers.py +++ b/Back-End/Subscriptions/serializers.py @@ -39,10 +39,15 @@ class AbsenceManagementSerializer(serializers.ModelSerializer): class RegistrationSchoolFileMasterSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False) + file_url = serializers.SerializerMethodField() + class Meta: model = RegistrationSchoolFileMaster fields = '__all__' + def get_file_url(self, obj): + return obj.file.url if obj.file else None + class RegistrationParentFileMasterSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False) class Meta: @@ -52,6 +57,7 @@ class RegistrationParentFileMasterSerializer(serializers.ModelSerializer): class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False) file_url = serializers.SerializerMethodField() + master_file_url = serializers.SerializerMethodField() class Meta: model = RegistrationSchoolFileTemplate @@ -61,6 +67,12 @@ class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer): # Retourne l'URL complète du fichier si disponible 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): id = serializers.IntegerField(required=False) file_url = serializers.SerializerMethodField() diff --git a/Back-End/Subscriptions/util.py b/Back-End/Subscriptions/util.py index 75b5d92..1db31c9 100644 --- a/Back-End/Subscriptions/util.py +++ b/Back-End/Subscriptions/util.py @@ -8,18 +8,22 @@ from N3wtSchool import renderers from N3wtSchool import bdd from io import BytesIO +import base64 from reportlab.pdfgen import canvas 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 import File from pathlib import Path import os from enum import Enum +from urllib.parse import unquote_to_bytes import random import string from rest_framework.parsers import JSONParser -from PyPDF2 import PdfMerger, PdfReader +from PyPDF2 import PdfMerger, PdfReader, PdfWriter from PyPDF2.errors import PdfReadError import shutil @@ -29,9 +33,79 @@ import json from django.http import QueryDict from rest_framework.response import Response from rest_framework import status +from svglib.svglib import svg2rlg 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): """ 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 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): """ 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] 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 if m.file and hasattr(m.file, 'name') and m.file.name: file_name = os.path.basename(m.file.name) @@ -551,55 +746,196 @@ def getHistoricalYears(count=5): 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) - et l'associe au RegistrationSchoolFileTemplate. - Le PDF contient le titre, les labels et types de champs. - Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier. + Génère un PDF composite du formulaire dynamique: + - le document source uploadé (PDF/image) si présent, + - puis un rendu du formulaire (similaire à l'aperçu), + - 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(" ", "_") filename = f"{form_name}.pdf" + fields = form_json.get("fields", []) if isinstance(form_json, dict) else [] - # Générer le PDF - buffer = BytesIO() - c = canvas.Canvas(buffer, pagesize=A4) - y = 800 + # Compatibilité ascendante : charger depuis un chemin si nécessaire + if base_pdf_content is None and base_pdf_path and os.path.exists(base_pdf_path): + 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}") - # Titre - c.setFont("Helvetica-Bold", 20) - c.drawString(100, y, form_json.get("title", "Formulaire")) - y -= 40 + writer = PdfWriter() + has_source_document = False + source_is_image = False + source_image_reader = None + source_image_size = None - # Champs - c.setFont("Helvetica", 12) - fields = form_json.get("fields", []) + # 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: - label = field.get("label", field.get("id", "")) - ftype = field.get("type", "") - value = field.get("value", "") - # Afficher la valeur si elle existe - if value not in (None, ""): - c.drawString(100, y, f"{label} [{ftype}] : {value}") - else: - c.drawString(100, y, f"{label} [{ftype}]") - y -= 25 - if y < 100: - c.showPage() - y = 800 + if field.get("type") == "signature": + value = field.get("value") + if isinstance(value, str) and value.startswith("data:image"): + signatures.append(value) - c.save() - buffer.seek(0) - pdf_content = buffer.read() + 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) - # Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage) - if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name: - existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/')) - if os.path.exists(existing_file_path): - os.remove(existing_file_path) - register_form.registration_file.delete(save=False) + packet = BytesIO() + c_overlay = canvas.Canvas(packet, pagesize=(page_width, page_height)) - # Retourner le ContentFile avec uniquement le nom du fichier - return ContentFile(pdf_content, name=os.path.basename(filename)) + 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 + + c.setFont("Helvetica-Bold", 18) + c.drawString(60, y, form_json.get("title", "Formulaire")) + y -= 35 + + c.setFont("Helvetica", 11) + for field in fields_to_render: + 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", "") + + if ftype == "file": + c.drawString(60, y, f"{label}") + y -= 18 + + if source_is_image and source_image_reader and source_image_size: + 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() + 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() + layout_buffer.seek(0) + 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}") + + # 4) Fallback minimal si aucune page n'a été créée + if len(writer.pages) == 0: + fallback = BytesIO() + c_fb = canvas.Canvas(fallback, pagesize=A4) + c_fb.setFont("Helvetica-Bold", 16) + 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) + + out = BytesIO() + writer.write(out) + out.seek(0) + return ContentFile(out.read(), name=os.path.basename(filename)) diff --git a/Back-End/Subscriptions/views/registration_parent_file_templates_views.py b/Back-End/Subscriptions/views/registration_parent_file_templates_views.py index eed518e..cf1a715 100644 --- a/Back-End/Subscriptions/views/registration_parent_file_templates_views.py +++ b/Back-End/Subscriptions/views/registration_parent_file_templates_views.py @@ -58,6 +58,8 @@ class RegistrationParentFileTemplateView(APIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class RegistrationParentFileTemplateSimpleView(APIView): + parser_classes = [MultiPartParser, FormParser, JSONParser] + @swagger_auto_schema( operation_description="Récupère un template d'inscription spécifique", responses={ @@ -82,11 +84,15 @@ class RegistrationParentFileTemplateSimpleView(APIView): } ) 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) if template is None: return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) - serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True) + serializer = RegistrationParentFileTemplateSerializer(template, data=payload, partial=True) if serializer.is_valid(): serializer.save() return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK) diff --git a/Back-End/Subscriptions/views/registration_school_file_masters_views.py b/Back-End/Subscriptions/views/registration_school_file_masters_views.py index 2521536..682047b 100644 --- a/Back-End/Subscriptions/views/registration_school_file_masters_views.py +++ b/Back-End/Subscriptions/views/registration_school_file_masters_views.py @@ -125,6 +125,25 @@ class RegistrationSchoolFileMasterSimpleView(APIView): if 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}") serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True) diff --git a/Back-End/Subscriptions/views/registration_school_file_templates_views.py b/Back-End/Subscriptions/views/registration_school_file_templates_views.py index 50dc0fd..b68fe25 100644 --- a/Back-End/Subscriptions/views/registration_school_file_templates_views.py +++ b/Back-End/Subscriptions/views/registration_school_file_templates_views.py @@ -16,6 +16,20 @@ import Subscriptions.util as util 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): @swagger_auto_schema( operation_description="Récupère tous les templates d'inscription pour un établissement donné", @@ -95,15 +109,26 @@ class RegistrationSchoolFileTemplateSimpleView(APIView): responses = None if "responses" in formTemplateData: resp = formTemplateData["responses"] - if isinstance(resp, dict) and "responses" in resp: - responses = resp["responses"] - elif isinstance(resp, dict): - responses = resp + responses = _extract_nested_responses(resp) + + # Nettoyer les meta-cles qui ne sont pas des reponses de champs + 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: for field in formTemplateData["fields"]: field_id = field.get("id") if field_id and field_id in responses: 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 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) # Cas 2 : Formulaire dynamique (JSON) - serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload) + serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload, partial=True) if serializer.is_valid(): serializer.save() # Régénérer le PDF si besoin @@ -148,19 +173,50 @@ class RegistrationSchoolFileTemplateSimpleView(APIView): and formTemplateData.get("fields") and hasattr(template, "file") ): - old_pdf_name = None - if template.file and template.file.name: - old_pdf_name = os.path.basename(template.file.name) + # Lire le contenu du fichier source en mémoire AVANT suppression. + # Priorité au fichier master (document source admin) pour éviter + # 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: + 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) - if os.path.exists(template.file.path): - os.remove(template.file.path) + if os.path.exists(old_path): + os.remove(old_path) except Exception as e: logger.error(f"Erreur lors de la suppression du fichier existant: {e}") from Subscriptions.util import generate_form_json_pdf - pdf_file = generate_form_json_pdf(template.registration_form, formTemplateData) - pdf_filename = old_pdf_name or f"{template.name}_{template.id}.pdf" - template.file.save(pdf_filename, pdf_file, save=True) + pdf_file = generate_form_json_pdf( + template.registration_form, + 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(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/Back-End/start.py b/Back-End/start.py index a843c0e..a34ed77 100644 --- a/Back-End/start.py +++ b/Back-End/start.py @@ -61,12 +61,6 @@ if __name__ == "__main__": if run_command(command) != 0: exit(1) - if flush_data: - for command in flush_data_cmd: - if run_command(command) != 0: - exit(1) - - for command in migrate_commands: if run_command(command) != 0: exit(1) @@ -75,6 +69,11 @@ if __name__ == "__main__": if run_command(command) != 0: exit(1) + if flush_data: + for command in flush_data_cmd: + if run_command(command) != 0: + exit(1) + if test_mode: for test_command in test_commands: if run_command(test_command) != 0: diff --git a/Front-End/src/app/[locale]/admin/structure/FormBuilder/page.js b/Front-End/src/app/[locale]/admin/structure/FormBuilder/page.js new file mode 100644 index 0000000..77ab593 --- /dev/null +++ b/Front-End/src/app/[locale]/admin/structure/FormBuilder/page.js @@ -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 ( +
+

Chargement...

+
+ ); + } + + return ( +
+ {/* Header sticky */} +
+ +

+ {isEditing ? 'Modifier le formulaire' : 'Créer un formulaire personnalisé'} +

+
+ +
+ {/* FormTemplateBuilder */} + setUploadedFile(file)} + /> +
+
+ ); +} diff --git a/Front-End/src/app/actions/registerFileGroupAction.js b/Front-End/src/app/actions/registerFileGroupAction.js index 7298369..03bc510 100644 --- a/Front-End/src/app/actions/registerFileGroupAction.js +++ b/Front-End/src/app/actions/registerFileGroupAction.js @@ -26,6 +26,10 @@ export const fetchRegistrationSchoolFileMasters = (establishment) => { return fetchWithAuth(url); }; +export const fetchRegistrationSchoolFileMasterById = (id) => { + return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${id}`); +}; + export const fetchRegistrationParentFileMasters = (establishment) => { const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`; return fetchWithAuth(url); diff --git a/Front-End/src/components/Form/AddFieldModal.js b/Front-End/src/components/Form/AddFieldModal.js index c98ef6b..cd45381 100644 --- a/Front-End/src/components/Form/AddFieldModal.js +++ b/Front-End/src/components/Form/AddFieldModal.js @@ -3,6 +3,7 @@ import { useForm, Controller } from 'react-hook-form'; import InputTextIcon from './InputTextIcon'; import SelectChoice from './SelectChoice'; import Button from './Button'; +import FileUpload from './FileUpload'; import IconSelector from './IconSelector'; import * as LucideIcons from 'lucide-react'; import { FIELD_TYPES } from './FormTypes'; @@ -14,6 +15,8 @@ export default function AddFieldModal({ onSubmit, editingField = null, editingIndex = -1, + hasMasterFile = false, + onMasterFileUpload, }) { const isEditing = editingIndex >= 0; @@ -29,6 +32,7 @@ export default function AddFieldModal({ acceptTypes: '', maxSize: 5, // 5MB par défaut checked: false, + masterFileToUpload: null, validation: { pattern: '', minLength: '', @@ -56,6 +60,7 @@ export default function AddFieldModal({ acceptTypes: '', maxSize: 5, checked: false, + masterFileToUpload: null, signatureData: '', backgroundColor: '#ffffff', penColor: '#000000', @@ -492,6 +497,31 @@ export default function AddFieldModal({ {currentField.type === 'file' && ( <> +
+ + { + setCurrentField((prev) => ({ + ...prev, + masterFileToUpload: file, + })); + if (onMasterFileUpload) { + onMasterFileUpload(file); + } + }} + enable + /> + {!hasMasterFile && !currentField.masterFileToUpload && ( +

+ Uploadez un document avant d'ajouter ce type de champ. +

+ )} +
+ { alert(JSON.stringify(data, null, 2)); }, // 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 { handleSubmit, control, @@ -57,8 +73,7 @@ export default function FormRenderer({ const hasFiles = Object.keys(data).some((key) => { return ( data[key] instanceof FileList || - (data[key] && data[key][0] instanceof File) || - (typeof data[key] === 'string' && data[key].startsWith('data:image')) + (data[key] && data[key][0] instanceof File) ); }); @@ -83,29 +98,6 @@ export default function FormRenderer({ formData.append(`files.${key}`, value[i]); } } - } else if ( - typeof value === 'string' && - value.startsWith('data:image') - ) { - // Gérer les signatures (SVG ou images base64) - if (value.includes('svg+xml')) { - // Gérer les signatures SVG - const svgData = value.split(',')[1]; - const svgBlob = new Blob([atob(svgData)], { - type: 'image/svg+xml', - }); - formData.append(`files.${key}`, svgBlob, `signature_${key}.svg`); - } else { - // Gérer les images base64 classiques - const byteString = atob(value.split(',')[1]); - const ab = new ArrayBuffer(byteString.length); - const ia = new Uint8Array(ab); - for (let i = 0; i < byteString.length; i++) { - ia[i] = byteString.charCodeAt(i); - } - const blob = new Blob([ab], { type: 'image/png' }); - formData.append(`files.${key}`, blob, `signature_${key}.png`); - } } else { // Gérer les autres types de champs formData.append( @@ -356,24 +348,39 @@ export default function FormRenderer({ control={control} rules={{ required: field.required }} render={({ field: { onChange, value } }) => ( - { - // Créer un objet de type FileList similaire pour la compatibilité - const dataTransfer = new DataTransfer(); - dataTransfer.items.add(file); - onChange(dataTransfer.files); - }} - errorMsg={ - errors[field.id] - ? field.required - ? `${field.label} est requis` - : 'Champ invalide' - : '' - } - /> + masterFileUrl ? ( +
+ {field.label && ( +

+ {field.label} +

+ )} +