mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-05 12:41:27 +00:00
feat: Finalisation formulaire dynamique
This commit is contained in:
@ -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))
|
||||
|
||||
Reference in New Issue
Block a user