Files
n3wt-school/Back-End/Subscriptions/util.py
2026-04-04 20:08:25 +02:00

942 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 dinscription.
"""
context = {
"ficheInscriptions_list": bdd.getAllObjects(RegistrationForm),
}
return context
def recupereListeFichesInscriptionEnAttenteSEPA():
"""
Retourne les fiches dinscription 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 lheure 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 dun point de départ et dun 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 dune liste extraite dun QueryDict.
"""
return liste[0]
def getArgFromRequest(_argument, _request):
"""
Extrait la valeur dun 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))