from django.shortcuts import render,get_object_or_404,get_list_or_404 from .models import RegistrationForm, Student, Guardian, Sibling import time from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo from django.conf import settings 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, PdfWriter from PyPDF2.errors import PdfReadError import shutil import logging 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. Évite les suffixes automatiques Django (ex: fichier_N5QdZpk.pdf). Args: file_field: Le FileField Django (ex: registerForm.registration_file) filename: Le nom du fichier à sauvegarder content: Le contenu du fichier (File, BytesIO, ContentFile, etc.) save: Si True, sauvegarde l'instance parente """ # Supprimer l'ancien fichier s'il existe if file_field and file_field.name: try: if hasattr(file_field, 'path') and os.path.exists(file_field.path): os.remove(file_field.path) logger.debug(f"[save_file] Ancien fichier supprimé: {file_field.path}") except Exception as e: logger.error(f"[save_file] Erreur suppression ancien fichier: {e}") # 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. - supporte multipart/form-data où le front envoie 'data' (JSON string) ou un fichier JSON + fichiers - supporte application/json ou form-data simple Retour: (payload_dict, None) ou (None, Response erreur) """ # Si c'est du JSON pur (Content-Type: application/json) if hasattr(request, 'content_type') and 'application/json' in request.content_type: try: # request.data contient déjà le JSON parsé par Django REST payload = dict(request.data) if hasattr(request.data, 'items') else request.data logger.info(f"JSON payload extracted: {payload}") return payload, None except Exception as e: logger.error(f'Error processing JSON: {e}') return None, Response({'error': "Invalid JSON", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) # Cas multipart/form-data avec champ 'data' data_field = request.data.get('data') if hasattr(request.data, 'get') else None if data_field: try: # Si 'data' est un fichier (InMemoryUploadedFile ou fichier similaire), lire et décoder if hasattr(data_field, 'read'): raw = data_field.read() if isinstance(raw, (bytes, bytearray)): text = raw.decode('utf-8') else: text = raw payload = json.loads(text) # Si 'data' est bytes déjà elif isinstance(data_field, (bytes, bytearray)): payload = json.loads(data_field.decode('utf-8')) # Si 'data' est une string JSON elif isinstance(data_field, str): payload = json.loads(data_field) else: # type inattendu raise ValueError(f"Unsupported 'data' type: {type(data_field)}") except (json.JSONDecodeError, ValueError, UnicodeDecodeError) as e: logger.error(f'Invalid JSON in "data": {e}') return None, Response({'error': "Invalid JSON in 'data'", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) else: payload = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data) if isinstance(payload, QueryDict): payload = payload.dict() # Attacher les fichiers présents (ex: photo, files.*, etc.), sauf 'data' (déjà traité) for f_key, f_val in request.FILES.items(): if f_key == 'data': # remettre le pointeur au début si besoin (déjà lu) — non indispensable ici mais sûr try: f_val.seek(0) except Exception: pass # ne pas mettre le fichier 'data' dans le payload (c'est le JSON) continue payload[f_key] = f_val return payload, None def create_templates_for_registration_form(register_form): """ Idempotent: - supprime les templates existants qui ne correspondent pas aux masters du fileGroup courant du register_form (et supprime leurs fichiers). - crée les templates manquants pour les masters du fileGroup courant. Retourne la liste des templates créés. """ from Subscriptions.models import ( RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate, registration_school_file_upload_to, ) created = [] logger.info("util.create_templates_for_registration_form - create_templates_for_registration_form") # Récupérer les masters du fileGroup courant current_group = getattr(register_form, "fileGroup", None) if not current_group: # Si plus de fileGroup, supprimer tous les templates existants pour ce RF school_existing = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form) for t in school_existing: try: if getattr(t, "file", None): logger.info("Deleted school template %s for RF %s", t.pk, register_form.pk) t.file.delete(save=False) except Exception: logger.exception("Erreur suppression fichier school template %s", getattr(t, "pk", None)) t.delete() parent_existing = RegistrationParentFileTemplate.objects.filter(registration_form=register_form) for t in parent_existing: try: if getattr(t, "file", None): t.file.delete(save=False) except Exception: logger.exception("Erreur suppression fichier parent template %s", getattr(t, "pk", None)) t.delete() return created school_masters = RegistrationSchoolFileMaster.objects.filter(groups=current_group).distinct() parent_masters = RegistrationParentFileMaster.objects.filter(groups=current_group).distinct() school_master_ids = {m.pk for m in school_masters} parent_master_ids = {m.pk for m in parent_masters} # Supprimer les school templates obsolètes for tmpl in RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form): if not tmpl.master_id or tmpl.master_id not in school_master_ids: try: if getattr(tmpl, "file", None): tmpl.file.delete(save=False) except Exception: logger.exception("Erreur suppression fichier school template obsolète %s", getattr(tmpl, "pk", None)) tmpl.delete() logger.info("Deleted obsolete school template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk) # Supprimer les parent templates obsolètes for tmpl in RegistrationParentFileTemplate.objects.filter(registration_form=register_form): if not tmpl.master_id or tmpl.master_id not in parent_master_ids: try: if getattr(tmpl, "file", None): tmpl.file.delete(save=False) except Exception: logger.exception("Erreur suppression fichier parent template obsolète %s", getattr(tmpl, "pk", None)) tmpl.delete() logger.info("Deleted obsolete parent template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk) # Créer les school templates manquants ou mettre à jour les existants si le master a changé for m in school_masters: tmpl_qs = RegistrationSchoolFileTemplate.objects.filter(master=m, registration_form=register_form) tmpl = tmpl_qs.first() if tmpl_qs.exists() else None 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) elif m.file: file_name = str(m.file) else: try: pdf_file = generate_form_json_pdf(register_form, m.formMasterData) file_name = os.path.basename(pdf_file.name) except Exception as e: logger.error(f"Erreur lors de la génération du PDF pour le template: {e}") file_name = None master_file_path = m.file.path if m.file and hasattr(m.file, 'path') else None def _build_upload_path(template_pk): """Génère le chemin relatif et absolu pour un template avec un pk connu.""" rel = registration_school_file_upload_to( type("Tmp", (), { "registration_form": register_form, "pk": template_pk, })(), file_name, ) return rel, os.path.join(settings.MEDIA_ROOT, rel) if tmpl: upload_rel_path, abs_path = _build_upload_path(tmpl.pk) template_file_name = os.path.basename(tmpl.file.name) if tmpl.file and tmpl.file.name else None master_file_changed = template_file_name != file_name # --- GESTION FORM EXISTANT : suppression ancien template si nom ou contenu master changé --- if master_file_changed or ( master_file_path and os.path.exists(master_file_path) and (not tmpl.file or not os.path.exists(abs_path) or os.path.getmtime(master_file_path) > os.path.getmtime(abs_path)) ): # Supprimer l'ancien fichier du template (même si le nom change) if tmpl.file and tmpl.file.name: old_template_path = os.path.join(settings.MEDIA_ROOT, tmpl.file.name) if os.path.exists(old_template_path): try: os.remove(old_template_path) logger.info(f"util.create_templates_for_registration_form - Suppression ancien fichier template: {old_template_path}") except Exception as e: logger.error(f"Erreur suppression ancien fichier template: {e}") # Copier le nouveau fichier du master (form existant) if master_file_path and os.path.exists(master_file_path): try: os.makedirs(os.path.dirname(abs_path), exist_ok=True) import shutil shutil.copy2(master_file_path, abs_path) logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}") tmpl.file.name = upload_rel_path tmpl.name = m.name or "" tmpl.slug = slug tmpl.formTemplateData = m.formMasterData or [] tmpl.save() except Exception as e: logger.error(f"Erreur lors de la copie du fichier master pour mise à jour du template: {e}") created.append(tmpl) logger.info("util.create_templates_for_registration_form - Mise à jour school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk) continue # Sinon, création du template — sauvegarder d'abord pour obtenir un pk tmpl = RegistrationSchoolFileTemplate( master=m, registration_form=register_form, name=m.name or "", formTemplateData=m.formMasterData or [], slug=slug, ) tmpl.save() # pk attribué ici if file_name: upload_rel_path, abs_path = _build_upload_path(tmpl.pk) # Copier le fichier du master si besoin if master_file_path and not os.path.exists(abs_path): try: import shutil os.makedirs(os.path.dirname(abs_path), exist_ok=True) shutil.copy2(master_file_path, abs_path) logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}") except Exception as e: logger.error(f"Erreur lors de la copie du fichier master pour le template: {e}") tmpl.file.name = upload_rel_path tmpl.save() created.append(tmpl) logger.info("util.create_templates_for_registration_form - Created school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk) # Créer les parent templates manquants for m in parent_masters: exists = RegistrationParentFileTemplate.objects.filter(master=m, registration_form=register_form).exists() if exists: continue tmpl = RegistrationParentFileTemplate.objects.create( master=m, registration_form=register_form, file=None, ) created.append(tmpl) logger.info("Created parent template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk) return created def recupereListeFichesInscription(): """ Retourne la liste complète des fiches d’inscription. """ context = { "ficheInscriptions_list": bdd.getAllObjects(RegistrationForm), } return context def recupereListeFichesInscriptionEnAttenteSEPA(): """ Retourne les fiches d’inscription avec paiement SEPA en attente. """ ficheInscriptionsSEPA_list = RegistrationForm.objects.filter(modePaiement="Prélèvement SEPA").filter(etat=RegistrationForm.RegistrationFormStatus['SEPA_ENVOYE']) return ficheInscriptionsSEPA_list def _now(): """ Retourne la date et l’heure en cours, avec fuseau. """ return datetime.now(ZoneInfo(settings.TZ_APPLI)) def convertToStr(dateValue, dateFormat): """ Convertit un objet datetime en chaîne selon un format donné. """ return dateValue.strftime(dateFormat) def convertToDate(date_time): """ Convertit une chaîne en objet datetime selon le format '%d-%m-%Y %H:%M'. """ format = '%d-%m-%Y %H:%M' datetime_str = datetime.strptime(date_time, format) return datetime_str def convertTelephone(telephoneValue, separator='-'): """ Reformate un numéro de téléphone en y insérant un séparateur donné. """ return f"{telephoneValue[:2]}{separator}{telephoneValue[2:4]}{separator}{telephoneValue[4:6]}{separator}{telephoneValue[6:8]}{separator}{telephoneValue[8:10]}" def genereRandomCode(length): """ Génère un code aléatoire de longueur spécifiée. """ return ''.join(random.choice(string.ascii_letters) for i in range(length)) def calculeDatePeremption(_start, nbDays): """ Calcule la date de fin à partir d’un point de départ et d’un nombre de jours. """ return convertToStr(_start + timedelta(days=nbDays), settings.DATE_FORMAT) # Fonction permettant de retourner la valeur du QueryDict # QueryDict [ index ] -> Dernière valeur d'une liste # dict (QueryDict [ index ]) -> Toutes les valeurs de la liste def _(liste): """ Retourne la première valeur d’une liste extraite d’un QueryDict. """ return liste[0] def getArgFromRequest(_argument, _request): """ Extrait la valeur d’un argument depuis la requête (JSON). """ resultat = None data=JSONParser().parse(_request) resultat = data[_argument] return resultat def merge_files_pdf(file_paths): """ Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire. Les fichiers non-PDF (images) sont convertis en PDF avant fusion. Les fichiers invalides sont ignorés avec un log d'erreur. """ merger = PdfMerger() files_added = 0 # Extensions d'images supportées image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif'} for file_path in file_paths: # Vérifier que le fichier existe if not os.path.exists(file_path): logger.warning(f"[merge_files_pdf] Fichier introuvable, ignoré: {file_path}") continue file_ext = os.path.splitext(file_path)[1].lower() # Si c'est une image, la convertir en PDF if file_ext in image_extensions: try: from PIL import Image from reportlab.lib.utils import ImageReader img = Image.open(file_path) img_width, img_height = img.size # Créer un PDF en mémoire avec l'image img_pdf = BytesIO() c = canvas.Canvas(img_pdf, pagesize=(img_width, img_height)) c.drawImage(file_path, 0, 0, width=img_width, height=img_height) c.save() img_pdf.seek(0) merger.append(img_pdf) files_added += 1 logger.debug(f"[merge_files_pdf] Image convertie et ajoutée: {file_path}") except Exception as e: logger.error(f"[merge_files_pdf] Erreur lors de la conversion de l'image {file_path}: {e}") continue # Sinon, essayer de l'ajouter comme PDF try: # Valider que c'est un PDF lisible with open(file_path, 'rb') as f: PdfReader(f, strict=False) # Si la validation passe, ajouter au merger merger.append(file_path) files_added += 1 logger.debug(f"[merge_files_pdf] PDF ajouté: {file_path}") except PdfReadError as e: logger.error(f"[merge_files_pdf] Fichier PDF invalide, ignoré: {file_path} - {e}") except Exception as e: logger.error(f"[merge_files_pdf] Erreur lors de la lecture du fichier {file_path}: {e}") if files_added == 0: logger.warning("[merge_files_pdf] Aucun fichier valide à fusionner") # Retourner un PDF vide empty_pdf = BytesIO() c = canvas.Canvas(empty_pdf, pagesize=A4) c.drawString(100, 750, "Aucun document à afficher") c.save() empty_pdf.seek(0) return empty_pdf # Sauvegarder le fichier fusionné en mémoire merged_pdf = BytesIO() merger.write(merged_pdf) merger.close() # Revenir au début du fichier en mémoire merged_pdf.seek(0) return merged_pdf def rfToPDF(registerForm, filename): """ Génère le PDF d'un dossier d'inscription et l'associe au RegistrationForm. """ filename = filename.replace(" ", "_") data = { 'pdf_title': f"Dossier d'inscription de {registerForm.student.first_name}", 'signatureDate': convertToStr(_now(), '%d-%m-%Y'), 'signatureTime': convertToStr(_now(), '%H:%M'), 'student': registerForm.student, 'establishment': registerForm.establishment, 'school_year': registerForm.school_year, } # Générer le PDF pdf = renderers.render_to_pdf('pdfs/fiche_eleve.html', data) if not pdf: raise ValueError("Erreur lors de la génération du PDF.") # Enregistrer directement le fichier dans le champ registration_file (écrase l'existant) try: save_file_replacing_existing( registerForm.registration_file, os.path.basename(filename), File(BytesIO(pdf.content)), save=True ) except Exception as e: logger.error(f"Erreur lors de la sauvegarde du fichier PDF : {e}") raise return registerForm.registration_file def generateRegistrationPDF(registerForm): """ Génère le PDF d'un dossier d'inscription à la volée et retourne le contenu binaire. Ne sauvegarde pas le fichier sur disque. """ data = { 'pdf_title': f"Dossier d'inscription de {registerForm.student.first_name}", 'signatureDate': convertToStr(_now(), '%d-%m-%Y'), 'signatureTime': convertToStr(_now(), '%H:%M'), 'student': registerForm.student, 'establishment': registerForm.establishment, 'school_year': registerForm.school_year, } pdf = renderers.render_to_pdf('pdfs/fiche_eleve.html', data) if not pdf: raise ValueError("Erreur lors de la génération du PDF.") return pdf.content def delete_registration_files(registerForm): """ Supprime le fichier et le dossier associés à un RegistrationForm. """ base_dir = f"registration_files/dossier_rf_{registerForm.pk}" if registerForm.registration_file and os.path.exists(registerForm.registration_file.path): os.remove(registerForm.registration_file.path) registerForm.registration_file.delete(save=False) if os.path.exists(base_dir): shutil.rmtree(base_dir) from datetime import datetime def getCurrentSchoolYear(): """ Retourne l'année scolaire en cours au format "YYYY-YYYY". Exemple : Si nous sommes en octobre 2023, retourne "2023-2024". """ now = datetime.now() current_year = now.year current_month = now.month # Si nous sommes avant septembre, l'année scolaire a commencé l'année précédente start_year = current_year if current_month >= 9 else current_year - 1 return f"{start_year}-{start_year + 1}" def getNextSchoolYear(): """ Retourne l'année scolaire suivante au format "YYYY-YYYY". Exemple : Si nous sommes en octobre 2023, retourne "2024-2025". """ current_school_year = getCurrentSchoolYear() start_year, end_year = map(int, current_school_year.split('-')) return f"{start_year + 1}-{end_year + 1}" def getHistoricalYears(count=5): """ Retourne un tableau des années scolaires passées au format "YYYY-YYYY". Exemple : ["2022-2023", "2021-2022", "2020-2021"]. :param count: Le nombre d'années scolaires passées à inclure. """ current_school_year = getCurrentSchoolYear() start_year = int(current_school_year.split('-')[0]) historical_years = [] for i in range(1, count + 1): historical_start_year = start_year - i historical_years.append(f"{historical_start_year}-{historical_start_year + 1}") return historical_years 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 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. """ 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 [] # 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}") writer = PdfWriter() has_source_document = False source_is_image = False source_image_reader = None source_image_size = None # 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: if field.get("type") == "signature": value = field.get("value") if isinstance(value, str) and value.startswith("data:image"): signatures.append(value) 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) packet = BytesIO() c_overlay = canvas.Canvas(packet, pagesize=(page_width, page_height)) 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))