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

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