feat: Finalisation formulaire dynamique

This commit is contained in:
N3WT DE COMPET
2026-04-04 20:08:25 +02:00
parent ae06b6fef7
commit 90b0d14418
29 changed files with 1071 additions and 306 deletions

View File

@ -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

View File

@ -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

View File

@ -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/'):]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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=[

View File

@ -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

View File

@ -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')},
},
),
]

View File

@ -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}"

View File

@ -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()

View File

@ -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))

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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: