loko/streetup/controls/views_reports_pdf.py
2026-07-22 14:48:40 +02:00

846 lines
No EOL
34 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 get_object_or_404
from django.http import JsonResponse
from django.core.files.base import ContentFile
from django.db import models
from django.contrib.gis.geos import Point
from django.conf import settings
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django.utils.encoding import force_str
from fpdf.enums import CellBordersLayout as CBL, TableBordersLayout, TableCellStyle, TableBorderStyle
import os
from PIL import Image, ImageDraw, ImageFont
from fpdf import FPDF
import requests
import math
from datetime import datetime
from common.private_files.helpers import build_private_url
from .models import Control, Remark, RemarkImage, RemarkHistory, ControlReport, REMARK_STATUS_CHOICES
from .permissions import control_change_required
# Paramètres de redimensionnement des images
IMAGE_WIDTH = 400 # Largeur max pour l’image dans une cellule (avec marge)
IMAGE_HEIGHT = 400 # Hauteur max pour garder la proportion
TARGET_HEIGHT_MM = 120
DPI = 96
def resize_image(img_path):
"""Ouvre et redimensionne une image pour la table"""
try:
with Image.open(img_path) as img:
width_px, height_px = img.size
# Calcul de l'échelle pour atteindre la hauteur cible
target_height_px = int(TARGET_HEIGHT_MM * DPI / 25.4)
scale = target_height_px / height_px
target_width_px = int(width_px * scale)
img.thumbnail((target_width_px, target_height_px)) # Redimensionner avec ratio conservé
return img.copy(), width_px > height_px # Retourne une copie de l’image compatible avec fpdf
except Exception as e:
print(_("Erreur de chargement d'image: %(e)s") % {"e": e})
return None, False
def generate_status_badge(status_text, color, size=(200, 60)):
"""
Génère une image de badge pour un statut donné.
:param status_text: Le texte du badge ("OK", "NOK", etc.)
:param color: Couleur de fond (RGB)
:param size: Taille de l'image (width, height)
:return: Image PIL
"""
print(status_text)
badge = Image.new("RGB", size, color) # Crée l'image avec le fond coloré
draw = ImageDraw.Draw(badge)
# Police pour le texte (utilise une police système ou une police intégrée)
try:
font = ImageFont.truetype(os.path.join(settings.BASE_DIR, "common", "static", "common", "arial.ttf"), 24)
except IOError:
font = ImageFont.load_default() # Police par défaut si Arial n'est pas trouvée
# Récupérer la taille du texte avec `textbbox()`
bbox = draw.textbbox((0, 0), status_text, font=font)
text_width, text_height = bbox[2] - bbox[0], bbox[3] - bbox[1]
# Calculer la position pour centrer le texte
text_x = (size[0] - text_width) // 2
text_y = (size[1] - text_height) // 2
# Dessiner le texte blanc sur le badge
draw.text((text_x, text_y), status_text, fill=(255, 255, 255), font=font)
return badge
# Définition des couleurs pour les statuts
STATUS_COLORS = {
"information": (13, 202, 240), # Bleu clair (#0dcaf0)
"to_be_processed": (220, 53, 69), # Rouge (#dc3545)
"processed": (13, 110, 253), # Bleu foncé (#0d6efd)
"validated": (25, 135, 84), # Vert (#198754)
"canceled": (108, 117, 125) # Gris (#6c757d)
}
# Convertir les valeurs RGB en format normalisé (entre 0 et 1)
STATUS_COLORS_NORMALIZED = {
status: (r / 255, g / 255, b / 255) for status, (r, g, b) in STATUS_COLORS.items()
}
# Constantes
EARTH_CIRCUMFERENCE = 40075000 # Circonférence de la Terre en mètres
TILE_SIZE = 512 # Taille d'une tuile en pixels
MAX_ZOOM = 19 # Zoom maximal pour OpenStreetMap
def latlon_to_tile(lat, lon, zoom):
"""Convert latitude and longitude to OSM tile numbers."""
n = 2 ** zoom
xtile = int((lon + 180.0) / 360.0 * n)
ytile = int((1.0 - math.log(math.tan(math.radians(lat)) + 1 / math.cos(math.radians(lat))) / math.pi) / 2.0 * n)
return xtile, ytile
def tile_to_latlon(xtile, ytile, zoom):
"""Convert OSM tile numbers to latitude and longitude."""
n = 2.0 ** zoom
lon = xtile / n * 360.0 - 180.0
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
lat = math.degrees(lat_rad)
return lat, lon
def meters_per_degree(lat):
"""Compute meters per degree at a given latitude."""
meters_per_lat_degree = 111132 # Constant for latitude
meters_per_lon_degree = 111320 * math.cos(math.radians(lat)) # Adjusted for longitude
return meters_per_lon_degree, meters_per_lat_degree
def adjust_bbox(bbox, img_size, min_width=100, min_height=50):
"""Ensure the bbox meets minimum dimensions and respects target aspect ratio."""
min_lon, min_lat, max_lon, max_lat = bbox
lat_center = (min_lat + max_lat) / 2
meters_per_lon, meters_per_lat = meters_per_degree(lat_center)
width_m = (max_lon - min_lon) * meters_per_lon
height_m = (max_lat - min_lat) * meters_per_lat
# Ensure minimum size
if width_m < min_width:
expand = (min_width - width_m) / (2 * meters_per_lon)
min_lon -= expand
max_lon += expand
if height_m < min_height:
expand = (min_height - height_m) / (2 * meters_per_lat)
min_lat -= expand
max_lat += expand
# Adjust bbox to match aspect ratio of image
bbox_width = max_lon - min_lon
bbox_height = max_lat - min_lat
target_aspect_ratio = img_size[0] / img_size[1]
current_aspect_ratio = bbox_width / bbox_height
if current_aspect_ratio < target_aspect_ratio:
expand = (bbox_height * target_aspect_ratio - bbox_width) / 2
min_lon -= expand
max_lon += expand
elif current_aspect_ratio > target_aspect_ratio:
expand = (bbox_width / target_aspect_ratio - bbox_height) / 2
min_lat -= expand
max_lat += expand
return min_lon, min_lat, max_lon, max_lat
def calculate_zoom(bbox, img_size, max_zoom=19):
"""Determine the highest reasonable zoom level while keeping a manageable tile count."""
min_lon, min_lat, max_lon, max_lat = bbox
tile_size = TILE_SIZE
for zoom in range(max_zoom, 0, -1): # Start from max zoom and decrease
x_min, y_min = latlon_to_tile(max_lat, min_lon, zoom)
x_max, y_max = latlon_to_tile(min_lat, max_lon, zoom)
num_tiles_x = x_max - x_min + 1
num_tiles_y = y_max - y_min + 1
if num_tiles_x * tile_size <= img_size[0] * 2 and num_tiles_y * tile_size <= img_size[1] * 2:
return zoom
return 1 # Fallback to low zoom if necessary
def fetch_tile(x, y, zoom):
"""Fetch a tile from OpenStreetMap."""
#url = f"https://tile.openstreetmap.org/{zoom}/{x}/{y}.png"
#url = f"https://a.tile.openstreetmap.fr/hot/{zoom}/{x}/{y}.png"
#url = f"https://cartodb-basemaps-1.global.ssl.fastly.net/light_all/{zoom}/{x}/{y}.png"
tile_url_format = getattr(settings, 'MAP_RASTER_TILE_URL', None) or 'https://tile.openstreetmap.org/{zoom}/{x}/{y}.png'
url = tile_url_format.format(zoom=zoom, x=x, y=y)
headers = {
"User-Agent": "BM/1.1"
}
response = requests.get(url, stream=True, headers=headers, verify=False)
if response.status_code == 200:
return Image.open(response.raw)
else:
return None
def assemble_tiles(x_min, y_min, x_max, y_max, zoom, img_size, bbox):
"""Download and assemble tiles into an image while maintaining correct proportions."""
tile_size = TILE_SIZE
num_tiles_x = x_max - x_min + 1
num_tiles_y = y_max - y_min + 1
full_image = Image.new("RGB", (num_tiles_x * tile_size, num_tiles_y * tile_size))
for x in range(x_min, x_max + 1):
for y in range(y_min, y_max + 1):
tile = fetch_tile(x, y, zoom)
if tile:
full_image.paste(tile, ((x - x_min) * tile_size, (y - y_min) * tile_size))
# Resize to at least 1500 width while maintaining aspect ratio
scale_factor = img_size[0] / full_image.width
new_width = img_size[0]
new_height = int(full_image.height * scale_factor)
resized_image = full_image.resize((new_width, new_height), Image.LANCZOS)
# Calculer les coordonnées exactes des tuiles en lat/lon
tile_min_lat, tile_min_lon = tile_to_latlon(x_min, y_max + 1, zoom) # coin sud-ouest
tile_max_lat, tile_max_lon = tile_to_latlon(x_max + 1, y_min, zoom) # coin nord-est
# Calculer la position pixel exacte du bbox original dans l'image redimensionnée
full_width_deg = tile_max_lon - tile_min_lon
full_height_deg = tile_max_lat - tile_min_lat
# Position du centre du bbox original en pixels
bbox_center_lon = (bbox[0] + bbox[2]) / 2
bbox_center_lat = (bbox[1] + bbox[3]) / 2
center_px_x = ((bbox_center_lon - tile_min_lon) / full_width_deg) * new_width
center_px_y = ((tile_max_lat - bbox_center_lat) / full_height_deg) * new_height
# Center the bbox in the crop area
crop_left = max(0, int(center_px_x - img_size[0] / 2))
crop_top = max(0, int(center_px_y - img_size[1] / 2))
crop_right = crop_left + img_size[0]
crop_bottom = crop_top + img_size[1]
# Ensure the crop does not go outside image bounds
if crop_right > resized_image.width:
crop_left = max(0, resized_image.width - img_size[0])
crop_right = resized_image.width
if crop_bottom > resized_image.height:
crop_top = max(0, resized_image.height - img_size[1])
crop_bottom = resized_image.height
final_image = resized_image.crop((crop_left, crop_top, crop_right, crop_bottom))
attribution_text = getattr(settings, 'MAP_ATTRIBUTION', None) or '© OpenStreetMap contributors'
draw = ImageDraw.Draw(final_image)
font = ImageFont.truetype(os.path.join(settings.BASE_DIR, "common", "static", "common", "arial.ttf"), 12)
# Obtenir les dimensions de l'image
image_width, image_height = final_image.size
# Obtenir la taille du texte
bbox = draw.textbbox((x, y), attribution_text, font=font)
text_width = bbox[2] - bbox[0] # Largeur du texte
text_height = bbox[3] - bbox[1] # Hauteur du texte
# Définir la position en bas à droite avec une marge
margin = 10 # Marge en pixels
x = image_width - text_width - margin
y = image_height - text_height - margin
draw.text((x, y), attribution_text, font=font, fill='#cccccc') # Texte principal
# Compute new bounding box after cropping (utilise les variables tile_min/max calculées plus haut)
new_min_lon = tile_min_lon + (crop_left / new_width) * full_width_deg
new_max_lon = new_min_lon + (crop_right - crop_left) / new_width * full_width_deg
new_max_lat = tile_max_lat - (crop_top / new_height) * full_height_deg
new_min_lat = new_max_lat - (crop_bottom - crop_top) / new_height * full_height_deg
exact_bbox = (new_min_lon, new_min_lat, new_max_lon, new_max_lat)
return final_image, exact_bbox
class RemarkBordersLayout(TableBordersLayout):
def __init__(self, row_configs):
self.row_configs = row_configs
# Define border styles
self.border_heavy = TableBorderStyle(thickness=0.35, color=(31, 41, 55)) # slate-gray
self.border_light = TableBorderStyle(thickness=0.12, color=(200, 200, 200)) # light gray
self.border_none = False
def cell_style_getter(self, row_idx, col_idx, col_pos, num_heading_rows, num_rows, num_col_idx, num_col_pos):
# Header row style (row 0)
if row_idx == 0:
return TableCellStyle(
left=self.border_none,
right=self.border_none,
top=self.border_heavy,
bottom=self.border_heavy
)
# Body row styles
config = self.row_configs.get(row_idx)
if not config:
return TableCellStyle(left=self.border_none, right=self.border_none, top=self.border_none, bottom=self.border_none)
is_top_row = config['is_top_row']
is_bottom_row = config['is_bottom_row']
is_first_entry = config['is_first_entry']
is_last_entry = config['is_last_entry']
# Determine horizontal borders
if is_top_row:
top_border = self.border_heavy if is_first_entry else self.border_light
else:
top_border = self.border_none
if is_bottom_row:
if row_idx == num_rows - 1:
bottom_border = self.border_none
else:
bottom_border = self.border_heavy if is_last_entry else self.border_light
else:
bottom_border = self.border_none
# Determine vertical borders
left_border = self.border_none
right_border = self.border_none
# Column 0 (N°): vertical line on the right
if col_pos == 0:
right_border = self.border_light
# Column 1 (Date content starts): vertical line on the left
elif col_pos == 1:
left_border = self.border_light
# Column 2 (Remarque content starts): vertical line on the left
elif col_pos == 2:
left_border = self.border_light
# Column 5 (Statut starts): vertical line on the left
elif col_pos == 5:
left_border = self.border_light
return TableCellStyle(
left=left_border,
right=right_border,
top=top_border,
bottom=bottom_border
)
class ControlPDF(FPDF):
def header(self):
"""Entête du PDF"""
self.add_font("arial", style="", fname=os.path.join(settings.BASE_DIR, "common", "static", "common", "arial.ttf"))
self.set_font(family="arial", style="b", size=10)
logo_image_path = os.path.join(settings.BASE_DIR, 'common', 'static', settings.ORGANIZATION_LOGO_SVG)
if os.path.exists(logo_image_path):
self.image(logo_image_path, x=10, y=5, h=10)
self.cell(0, 10, _("Rapport de suivi"), ln=True, align="R")
self.ln(5)
def footer(self):
"""Pied de page"""
self.set_y(-15)
self.add_font("arial", style="", fname=os.path.join(settings.BASE_DIR, "common", "static", "common", "arial.ttf"))
self.set_font(family="arial", style="i", size=10)
page_txt = _("Page")
self.cell(0, 10, f"{page_txt} {self.page_no()}", align="C")
@control_change_required
def generate_control_pdf(request, control_id):
"""Génère un rapport de contrôle en PDF"""
from django.utils.translation import gettext as _
# Récupérer le contrôle
control = get_object_or_404(Control, id=control_id)
# if control.control_author != request.user:
# return HttpResponseForbidden("Vous n'êtes pas autorisé à accéder à ce contrôle.")
remarks = Remark.objects.filter(control=control, archived=False)
template = control.templates.first()
# Créer le PDF
pdf = ControlPDF()
pdf.set_auto_page_break(auto=True, margin=15)
fname = os.path.join(settings.BASE_DIR, "common", "static", "common", "arial.ttf")
pdf.add_font("arial", style="", fname=fname)
pdf.add_font("arial", style="b", fname=fname)
fname = os.path.join(settings.BASE_DIR, "common", "static", "common", "arial_italic.ttf")
pdf.add_font("arial", style="i", fname=fname)
pdf.add_font("arial", style="bi", fname=fname)
pdf.add_page()
# Titre
pdf.set_font(family="arial", style="b", size=16)
controle_txt = _("Contrôle")
pdf.cell(200, 10, f"{controle_txt} {control.control_code}", align="C")
pdf.ln(15)
# Détails du contrôle
pdf.set_font(family="arial", style="", size=12)
if control.description:
pdf.cell(25, 5, _("Description : "))
pdf.multi_cell(0, 5, f"{control.description}")
pdf.ln()
#pdf.cell(0, 10, f"Statut : {dict(CONTROL_STATUS_CHOICES).get(control.status, 'Inconnu')}")
localisation_txt = _("Localisation")
date_txt = _("Date")
pdf.cell(0, 8, f"{localisation_txt} : {control.location}", new_x='LMARGIN', new_y='NEXT')
pdf.cell(0, 8, f"{date_txt} : {now().strftime('%d/%m/%Y')}", new_x='LMARGIN', new_y='NEXT')
if template and template.header_notes:
pdf.ln(5)
pdf.set_font(family="arial", style="i", size=12)
pdf.multi_cell(0, 5, f"{template.header_notes}")
pdf.ln(20)
# Paramètres des cercles et du texte
text_color = 'white' # Couleur du texte
point_radius = 30 # Rayon des cercles
# Utiliser une police par défaut et la redimensionner
font_size = int(point_radius * 0.8) # Taille de la police proportionnelle au rayon
font = ImageFont.truetype(os.path.join(settings.BASE_DIR, "common", "static", "common", "arial.ttf"), font_size)
# Plans avec les remarques #
# ------------------------ #
reference_plans_with_remarks = control.reference_plans.filter(remarks__isnull=False).distinct()
for plan in reference_plans_with_remarks:
try:
image_path = plan.plan.path
image = Image.open(image_path)
# Définir la nouvelle largeur
new_width = min(3000, image.width) # Largeur max de 3000px
# Calculer la nouvelle hauteur en conservant le ratio d'aspect
ratio = new_width / image.width
new_height = int(image.height * ratio)
new_size = (new_width, new_height)
# Redimensionner l'image
resized_image = image.resize(new_size, Image.Resampling.LANCZOS)
draw = ImageDraw.Draw(resized_image)
georemarks = remarks.filter(x__isnull=False, y__isnull=False, reference_plan=plan)
data = {
'remark_number': [remark.remark_number for remark in georemarks],
'status': [remark.status for remark in georemarks],
'Lon': [remark.x for remark in georemarks],
'Lat': [remark.y for remark in georemarks],
}
img_width = resized_image.width
img_height = resized_image.height
if img_width > img_height:
img_ratio = img_width / img_height
is_landscape = True
else:
img_ratio = img_height / img_width
is_landscape = False
for rem in georemarks:
if is_landscape:
x = img_width * rem.x
y = img_height * (1 - (rem.y * img_ratio ) )
else:
x = img_width * (rem.x * img_ratio)
y = img_height * (1 - rem.y)
# Dessiner un cercle
draw.ellipse(
[(x - point_radius, y - point_radius), (x + point_radius, y + point_radius)],
fill=STATUS_COLORS.get(rem.status, (128, 128, 128)),
outline='black'
)
# Ajouter le texte au centre du cercle
# Obtenir la taille du texte avec textbbox
text = str(rem.remark_number)
bbox = draw.textbbox((x, y), text, font=font)
text_width = bbox[2] - bbox[0] # Largeur du texte
text_height = bbox[3] - bbox[1] # Hauteur du texte
# Centrer le texte dans le cercle
text_position = (x - text_width // 2, y - text_height // 2 - 3) # Décalage de 5px vers le haut
draw.text(text_position, text, fill=text_color, font=font)
pdf.image(resized_image, w=190)
pdf.ln(10)
except Exception as e:
print(_("Erreur lors du chargement ou du dessin sur le plan %(name)s: %(e)s") % {"name": plan.name, "e": e})
# Carte avec les remarques #
# ------------------------ #
georemarks = remarks.filter(lon__isnull=False, lat__isnull=False)
if georemarks.exists():
# Récupérer l'extent des remarques
min_lon, min_lat = georemarks.aggregate(min_lon=models.Min("lon"), min_lat=models.Min("lat")).values()
max_lon, max_lat = georemarks.aggregate(max_lon=models.Max("lon"), max_lat=models.Max("lat")).values()
# Ajouter un buffer autour de l'extent (50m pour un seul point, 10% de l'extent pour plusieurs)
if georemarks.count() == 1:
buffer = Point(min_lon, min_lat).buffer(50 / 111320) # 50 mètres en degrés (approximation)
min_lon, min_lat, max_lon, max_lat = buffer.extent
else:
# Ajouter un buffer de 10% de l'extent pour éviter que les points soient au bord
lon_range = max_lon - min_lon
lat_range = max_lat - min_lat
buffer_lon = max(lon_range * 0.1, 20 / 111320) # minimum 20m
buffer_lat = max(lat_range * 0.1, 20 / 111320) # minimum 20m
min_lon -= buffer_lon
max_lon += buffer_lon
min_lat -= buffer_lat
max_lat += buffer_lat
bbox = (min_lon, min_lat, max_lon, max_lat)
img_size = (1500, 750)
adjusted_bbox = adjust_bbox(bbox, img_size)
zoom = calculate_zoom(adjusted_bbox, img_size)
x_min, y_min = latlon_to_tile(adjusted_bbox[3], adjusted_bbox[0], zoom)
x_max, y_max = latlon_to_tile(adjusted_bbox[1], adjusted_bbox[2], zoom)
map_image, exact_bbox = assemble_tiles(x_min, y_min, x_max, y_max, zoom, img_size, bbox)
draw = ImageDraw.Draw(map_image)
img_width = map_image.width
img_height = map_image.height
x0 = exact_bbox[0]
y0 = exact_bbox[1]
x1 = exact_bbox[2]
y1 = exact_bbox[3]
# Paramètres des cercles et du texte
text_color = 'white' # Couleur du texte
point_radius = 20 # Rayon des cercles
# Utiliser une police par défaut et la redimensionner
font_size = int(point_radius * 0.8) # Taille de la police proportionnelle au rayon
font = ImageFont.truetype(os.path.join(settings.BASE_DIR, "common", "static", "common", "arial.ttf"), font_size)
for rem in georemarks:
x = ((rem.lon - x0) / (x1 - x0)) * img_width
y = ((y1 - rem.lat) / (y1 - y0)) * img_height
# Dessiner un cercle
draw.ellipse(
[(x - point_radius, y - point_radius), (x + point_radius, y + point_radius)],
fill=STATUS_COLORS.get(rem.status, (128, 128, 128)),
outline='black'
)
# Ajouter le texte au centre du cercle
# Obtenir la taille du texte avec textbbox
text = str(rem.remark_number)
bbox = draw.textbbox((x, y), text, font=font)
text_width = bbox[2] - bbox[0] # Largeur du texte
text_height = bbox[3] - bbox[1] # Hauteur du texte
# Centrer le texte dans le cercle
text_position = (x - text_width // 2, y - text_height // 2 - 3) # Décalage de 5px vers le haut
draw.text(text_position, text, fill=text_color, font=font)
pdf.image(map_image, w=190)
# --- helpers pour le wrap en chunks qui tiennent sur une page -----------------
def _split_text_chunks(pdf, text, cell_width, max_lines_per_cell, line_h):
"""
Retourne une liste de blocs de texte (séparés par \n) qui tiennent chacun dans
max_lines_per_cell lignes pour la largeur cell_width, avec la police courante.
"""
# multi_cell split_only calcule les césures comme FPDF les dessinerait
lines = pdf.multi_cell(w=cell_width, h=line_h, txt=text or "", split_only=True)
if not lines:
return [""]
chunks, cur = [], []
for ln in lines:
cur.append(ln)
if len(cur) >= max_lines_per_cell:
chunks.append("\n".join(cur))
cur = []
if cur:
chunks.append("\n".join(cur))
return chunks
# -----------------------------------------------------------------------------
# Générer le tableau avec `pdf.table()`
# -------------------------------------
if reference_plans_with_remarks.exists() or georemarks.exists():
pdf.add_page()
pdf.set_auto_page_break(True, margin=pdf.b_margin) # sécurité générale
pdf.set_font(family="arial", size=10)
# Configurer les largeurs de colonnes: N° (0.4), Date (0.9), Remarque (3 colonnes de 1.5, 1.2, 1.2), Statut (0.9)
col_widths = (0.4, 0.9, 1.5, 1.2, 1.2, 0.9)
desc_col_w = pdf.epw * 3.9 / sum(col_widths) # largeur cumulée des 3 colonnes de Remarque (1.5 + 1.2 + 1.2 = 3.9)
line_h = 10 #pdf.font_size * 1.35
# Hauteur utile max approximative d'une ligne de tableau sur une page
# (on reste large: hauteur de page utile / hauteur de ligne - petite marge)
page_max_row_h = pdf.h - pdf.t_margin - pdf.b_margin
max_lines_per_cell = max(1, int(page_max_row_h // line_h) - 3)
# Pré-calcul de la configuration des bordures pour chaque ligne physique
row_configs = {}
current_row_idx = 1 # la ligne 0 est l'en-tête
for remark in remarks:
history_list = list(remark.history.all().order_by('created_at'))
if not history_list:
class MockHistoryEntry:
def __init__(self, r):
self.status = r.status
self.description = r.description
self.created_at = r.created_at
self.images = r.images.filter(history_entry__isnull=True)
history_list = [MockHistoryEntry(remark)]
for idx, entry in enumerate(history_list):
is_first_entry = (idx == 0)
is_last_entry = (idx == len(history_list) - 1)
# -- IMAGES
images = entry.images.all()
img_objects = []
for img in images:
img_path = os.path.join(settings.PRIVATE_MEDIA_ROOT, img.file.name)
resized_img, is_landscape = resize_image(img_path)
if resized_img:
img_objects.append((resized_img, 2 if is_landscape else 1))
num_img_rows = 0
cur_w = 0
for img, col_span in img_objects:
if cur_w + col_span > 4:
num_img_rows += 1
cur_w = 0
cur_w += col_span
if cur_w > 0:
num_img_rows += 1
# Formatage du texte de l'étape
entry_date_str = entry.created_at.strftime('%d/%m/%Y')
entry_status_display = dict(REMARK_STATUS_CHOICES).get(entry.status, entry.status)
if entry.description:
entry_text = f"{entry_status_display} - {entry.description}"
else:
entry_text = f"{entry_status_display}"
# Chunks de texte
desc_chunks = _split_text_chunks(
pdf,
entry_text,
cell_width=desc_col_w,
max_lines_per_cell=max_lines_per_cell,
line_h=line_h,
)
entry_rows_count = len(desc_chunks) + num_img_rows
for r_offset in range(entry_rows_count):
row_configs[current_row_idx + r_offset] = {
'is_top_row': (r_offset == 0),
'is_bottom_row': (r_offset == entry_rows_count - 1),
'is_first_entry': is_first_entry,
'is_last_entry': is_last_entry,
}
current_row_idx += entry_rows_count
borders_layout = RemarkBordersLayout(row_configs)
from fpdf.fonts import FontFace
with pdf.table(borders_layout=borders_layout, col_widths=(0.4, 0.9, 1.5, 1.2, 1.2, 0.9)) as table:
# En-tête (Bleu et texte blanc)
header_style = FontFace(fill_color=(0, 91, 153), color=(255, 255, 255), emphasis="B")
row = table.row(style=header_style)
row.cell("N°", align="C")
row.cell(_("Date"), align="C")
row.cell(_("Remarque"), align="C", colspan=3)
row.cell(_("Statut"), align="C")
for remark in remarks:
history_list = list(remark.history.all().order_by('created_at'))
if not history_list:
class MockHistoryEntry:
def __init__(self, r):
self.status = r.status
self.description = r.description
self.created_at = r.created_at
self.images = r.images.filter(history_entry__isnull=True)
history_list = [MockHistoryEntry(remark)]
for idx, entry in enumerate(history_list):
is_first_entry = (idx == 0)
is_last_entry = (idx == len(history_list) - 1)
# -- IMAGES : récupération et "colspan" (2 si paysage, 1 si portrait)
images = entry.images.all()
img_objects = []
for img in images:
img_path = os.path.join(settings.PRIVATE_MEDIA_ROOT, img.file.name)
resized_img, is_landscape = resize_image(img_path)
if resized_img:
img_objects.append((resized_img, 2 if is_landscape else 1))
# Calcul du nombre de lignes d'images
num_img_rows = 0
cur_w = 0
for img, col_span in img_objects:
if cur_w + col_span > 4:
num_img_rows += 1
cur_w = 0
cur_w += col_span
if cur_w > 0:
num_img_rows += 1
# Badge statut
status_text = dict(REMARK_STATUS_CHOICES).get(entry.status, _("Inconnu"))
status_text = force_str(status_text) # garantit une str pour PIL
status_color = STATUS_COLORS.get(entry.status, (128, 128, 128))
status_badge = generate_status_badge(status_text, status_color)
# Formatage du texte de l'étape
entry_date_str = entry.created_at.strftime('%d/%m/%Y')
entry_status_display = dict(REMARK_STATUS_CHOICES).get(entry.status, entry.status)
if entry.description:
entry_text = f"{entry_status_display} - {entry.description}"
else:
entry_text = f"{entry_status_display}"
# --- TEXTE : split en chunks qui tiennent sur une page ----------------
desc_chunks = _split_text_chunks(
pdf,
entry_text,
cell_width=desc_col_w,
max_lines_per_cell=max_lines_per_cell,
line_h=line_h,
)
# 1) Première ligne du bloc : N° (uniquement 1er suivi) | Date | Remarque(chunk 1) | Statut (badge)
row = table.row()
row.cell(str(remark.remark_number) if is_first_entry else "", align="C")
date_style = FontFace(color=(10, 45, 90))
row.cell(entry_date_str, align="C", style=date_style)
row.cell(desc_chunks[0], align="L", colspan=3)
row.cell(img=status_badge, align="C", img_fill_width=True, padding=(3,4,3,4))
# 2) Lignes de description suivantes (si plusieurs chunks)
for i_chunk in range(1, len(desc_chunks)):
row = table.row()
row.cell("")
row.cell("")
row.cell(desc_chunks[i_chunk], align="L", colspan=3)
row.cell("")
# 3) Lignes d'images (si présentes)
i = 0
total = len(img_objects)
while i < total:
row = table.row()
col_count = 0
row_imgs = []
while col_count < 4 and i < total:
img, col_span = img_objects[i]
if col_count + col_span <= 4:
row_imgs.append((img, col_span))
col_count += col_span
i += 1
else:
break
# colonne N°
row.cell("")
# 4 colonnes d’images (qui fusionnent la colonne Date et les colonnes Remarque, colspan=4)
for (img, col_span) in row_imgs:
row.cell(img=img, colspan=col_span, align="C",
img_fill_width=True, padding=(2,2,2,2))
if col_count < 4:
row.cell(text="", colspan=(4 - col_count))
# colonne Statut
row.cell("")
if template and template.footer_notes:
pdf.ln(10)
pdf.set_font(family="arial", style="i", size=12)
pdf.multi_cell(0, 5, f"{template.footer_notes}")
try:
# Sauvegarde du PDF dans un fichier temporaire
pdf_output = bytes(pdf.output())
pdf_file = ContentFile(pdf_output)
# Ajouter un timestamp au nom du fichier
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{control.control_code}_{timestamp}.pdf"
# Créer une instance de ControlReport et sauvegarder le fichier PDF
control_report = ControlReport(
control=control,
created_by=request.user,
status='active'
)
control_report.document.save(filename, pdf_file)
pdf_url = build_private_url("controls", "ControlReport", control_report.id, "document")
# Retourner une réponse JSON indiquant le succès
return JsonResponse({"success": True, "message": _("PDF généré et sauvegardé avec succès."),
"filename": filename, "pdf_url": pdf_url,
"created_at": control_report.created_at, "created_by": control_report.created_by.username,
"id": control_report.id})
except Exception as e:
# Retourner une réponse JSON indiquant l'échec
error_txt = _("Erreur lors de la génération du PDF")
return JsonResponse({"success": False, "message": f"{error_txt}: {str(e)}"})