mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-05 20:51:26 +00:00
feat: Finalisation formulaire dynamique
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||
|
||||
import 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user