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)}"})