from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _, get_language from django.utils.html import strip_tags from html import unescape import re import math import requests import os import io import pytz import logging import time from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed from fpdf import FPDF from PIL import Image, ImageDraw, ImageFont from interventions.models import ( Intervention, InterventionAsset, InterventionLocation, InterventionNote, Operation, STATUS_CHOICES, MAINTAIN_CHOICES, TYPE_CHOICES, ORIGIN_TYPE_CHOICES, PRIORITY_CHOICES ) from interventions.permissions import intervention_permission_required from common.models import UserConfig, AdrPoint from common.utils import get_short_name from assets.models import TrafficLightIntersection, PublicLightingStreet, NatureLocation, StructureLocation from assets.models import ControlCenterLocation, ITSLocation, RoadStreet, SignStreet logger = logging.getLogger(__name__) BASE_FONT_DIR = Path(settings.BASE_DIR, "common", "static", "common") # Extensions reconnues comme images _IMAGE_EXTENSIONS = frozenset({'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'}) def _collect_intervention_photos(intervention): """ Retourne une liste de tuples (img_bytes: bytes, caption: str) pour toutes les photos attachées à une intervention (documents images + photos d'opérations). """ photos = [] # 1. Documents images directement liés à l'intervention try: docs = intervention.documents.all() for doc in docs: file_obj = getattr(doc, 'file', None) if not file_obj or not getattr(file_obj, 'name', None): continue ext = os.path.splitext(file_obj.name.lower())[1] if ext not in _IMAGE_EXTENSIONS: continue try: file_obj.seek(0) img_bytes = file_obj.read() except Exception: try: img_bytes = file_obj.read() except Exception: continue if not img_bytes: continue caption = getattr(doc, 'description', '') or os.path.basename(file_obj.name) photos.append((img_bytes, caption)) except Exception as exc: logger.warning("Could not collect intervention document photos: %s", exc) # 2. Photos liées aux opérations try: for op in intervention.operations.prefetch_related('photos').all(): for op_photo in op.photos.all(): photo_field = op_photo.photo if not photo_field or not getattr(photo_field, 'name', None): continue try: photo_field.seek(0) img_bytes = photo_field.read() except Exception: try: img_bytes = photo_field.read() except Exception: continue if not img_bytes: continue caption = op_photo.description or op.name_fr or '' photos.append((img_bytes, caption)) except Exception as exc: logger.warning("Could not collect operation photos: %s", exc) return photos # ========== UTILITAIRE IMAGE PDF ========== def _pdf_image_buf(path, max_px=800): """ Ouvre une image, la redimensionne si elle dépasse max_px sur son plus grand côté, et retourne un BytesIO JPEG optimisé pour l'intégration dans le PDF. """ import io with Image.open(path) as img: img = img.convert("RGB") if max(img.width, img.height) > max_px: img.thumbnail((max_px, max_px), Image.LANCZOS) buf = io.BytesIO() img.save(buf, format="JPEG", quality=85, optimize=True) buf.seek(0) return buf # ========== FONCTIONS CARTOGRAPHIQUES ========== TILE_SIZE = 512 # Taille d'une tuile en pixels 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 meters_per_lon_degree = 111320 * math.cos(math.radians(lat)) return meters_per_lon_degree, meters_per_lat_degree def adjust_bbox_for_point(lon, lat, buffer_meters=100, img_size=(800, 400)): """Crée une bounding box autour d'un point avec un buffer en mètres.""" meters_per_lon, meters_per_lat = meters_per_degree(lat) # Calculer le ratio d'aspect cible en mètres target_aspect_ratio = img_size[0] / img_size[1] # Calculer les dimensions en mètres pour avoir le bon ratio # On part du buffer en latitude et on ajuste la longitude height_meters = buffer_meters * 2 width_meters = height_meters * target_aspect_ratio # Convertir en degrés half_height_deg = buffer_meters / meters_per_lat half_width_deg = (width_meters / 2) / meters_per_lon min_lon = lon - half_width_deg max_lon = lon + half_width_deg min_lat = lat - half_height_deg max_lat = lat + half_height_deg return min_lon, min_lat, max_lon, max_lat def calculate_zoom_for_bbox(bbox, img_size, max_zoom=18, zoom_offset=0): """ Détermine le niveau de zoom optimal pour une bbox. Args: bbox: Bounding box (min_lon, min_lat, max_lon, max_lat) img_size: Taille de l'image finale (largeur, hauteur) max_zoom: Zoom maximum à considérer zoom_offset: Offset négatif pour réduire le zoom (ex: -1 pour zoom-1, divise par ~4 le nombre de tuiles) Returns: Niveau de zoom optimal """ min_lon, min_lat, max_lon, max_lat = bbox tile_size = TILE_SIZE for zoom in range(max_zoom, 0, -1): 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: # Appliquer l'offset pour réduire le zoom (et donc le nombre de tuiles) return max(1, zoom + zoom_offset) return 1 def fetch_map_tile(x, y, zoom): """Télécharge une tuile de carte.""" 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": "StreetUp/1.0"} try: response = requests.get(url, stream=True, headers=headers, verify=False, timeout=10) if response.status_code == 200: return Image.open(response.raw) except Exception as e: logger.warning(f"Failed to fetch map tile: {e}") return None def generate_location_map(lon, lat, img_size=(1600, 700), buffer_meters=150): """ Génère une image de carte centrée sur un point donné. Args: lon: Longitude du point lat: Latitude du point img_size: Taille de l'image (largeur, hauteur) en pixels buffer_meters: Buffer autour du point en mètres Returns: Image PIL de la carte avec un marqueur, ou None en cas d'échec """ start_time = time.time() try: zoom = 15 # Créer la bounding box t0 = time.time() bbox = adjust_bbox_for_point(lon, lat, buffer_meters, img_size) min_lon, min_lat, max_lon, max_lat = bbox # Obtenir les tuiles nécessaires # latlon_to_tile(lat, lon) retourne les indices de la tuile contenant ce point x_min, y_min = latlon_to_tile(max_lat, min_lon, zoom) x_max, y_max = latlon_to_tile(min_lat, max_lon, zoom) # S'assurer que la mosaïque sera assez grande pour l'image finale # Calculer le nombre minimum de tuiles nécessaires tile_size = TILE_SIZE min_tiles_x = math.ceil(img_size[0] / tile_size) + 1 min_tiles_y = math.ceil(img_size[1] / tile_size) + 1 # Ajouter des tuiles si nécessaire (de chaque côté pour garder le centrage) current_tiles_x = x_max - x_min + 1 current_tiles_y = y_max - y_min + 1 if current_tiles_x < min_tiles_x: extra = min_tiles_x - current_tiles_x extra_left = extra // 2 extra_right = extra - extra_left x_min -= extra_left x_max += extra_right if current_tiles_y < min_tiles_y: extra = min_tiles_y - current_tiles_y extra_top = extra // 2 extra_bottom = extra - extra_top y_min -= extra_top y_max += extra_bottom # Télécharger et assembler les tuiles num_tiles_x = x_max - x_min + 1 num_tiles_y = y_max - y_min + 1 total_tiles = num_tiles_x * num_tiles_y t1 = time.time() full_image = Image.new("RGB", (num_tiles_x * tile_size, num_tiles_y * tile_size), (240, 240, 240)) # Téléchargement parallèle des tuiles tiles_downloaded = 0 with ThreadPoolExecutor(max_workers=6) as executor: # Créer une liste de futures pour tous les téléchargements future_to_coords = {} for x in range(x_min, x_max + 1): for y in range(y_min, y_max + 1): future = executor.submit(fetch_map_tile, x, y, zoom) future_to_coords[future] = (x, y) # Récupérer les résultats au fur et à mesure for future in as_completed(future_to_coords): x, y = future_to_coords[future] tile = future.result() if tile: full_image.paste(tile, ((x - x_min) * tile_size, (y - y_min) * tile_size)) tiles_downloaded += 1 # Calculer les coordonnées géographiques exactes de la mosaïque de tuiles # tile_to_latlon(x, y) retourne le coin NORD-OUEST de la tuile (x, y) # Donc pour la mosaïque complète: # - Coin nord-ouest = tile_to_latlon(x_min, y_min) -> (lat_max, lon_min) # - Coin sud-est = tile_to_latlon(x_max+1, y_max+1) -> (lat_min, lon_max) mosaic_nw_lat, mosaic_nw_lon = tile_to_latlon(x_min, y_min, zoom) mosaic_se_lat, mosaic_se_lon = tile_to_latlon(x_max + 1, y_max + 1, zoom) mosaic_width_deg = mosaic_se_lon - mosaic_nw_lon mosaic_height_deg = mosaic_nw_lat - mosaic_se_lat # lat diminue vers le sud # Position du point cible en pixels dans l'image complète # X: de gauche (lon_min) à droite (lon_max) # Y: de haut (lat_max) à bas (lat_min) target_px_x = ((lon - mosaic_nw_lon) / mosaic_width_deg) * full_image.width target_px_y = ((mosaic_nw_lat - lat) / mosaic_height_deg) * full_image.height # Calculer le crop centré sur le point half_width = img_size[0] / 2.0 half_height = img_size[1] / 2.0 # Coordonnées de crop initiales (centrées sur le point) crop_left = target_px_x - half_width crop_top = target_px_y - half_height crop_right = target_px_x + half_width crop_bottom = target_px_y + half_height # Ajuster si on dépasse les bords (décaler la fenêtre sans changer sa taille) if crop_left < 0: # Décaler vers la droite crop_right = crop_right - crop_left # Ajouter la valeur absolue de crop_left crop_left = 0 if crop_top < 0: # Décaler vers le bas crop_bottom = crop_bottom - crop_top crop_top = 0 if crop_right > full_image.width: # Décaler vers la gauche excess = crop_right - full_image.width crop_left = crop_left - excess crop_right = full_image.width if crop_bottom > full_image.height: # Décaler vers le haut excess = crop_bottom - full_image.height crop_top = crop_top - excess crop_bottom = full_image.height # S'assurer que les valeurs restent dans les limites après tous les ajustements crop_left = max(0, min(crop_left, full_image.width - img_size[0])) crop_top = max(0, min(crop_top, full_image.height - img_size[1])) crop_right = crop_left + img_size[0] crop_bottom = crop_top + img_size[1] # Convertir en entiers pour le crop crop_left_int = int(round(crop_left)) crop_top_int = int(round(crop_top)) crop_right_int = crop_left_int + img_size[0] crop_bottom_int = crop_top_int + img_size[1] # Vérifier que le crop ne dépasse pas l'image if crop_right_int > full_image.width: crop_left_int = full_image.width - img_size[0] crop_right_int = full_image.width if crop_bottom_int > full_image.height: crop_top_int = full_image.height - img_size[1] crop_bottom_int = full_image.height final_image = full_image.crop((crop_left_int, crop_top_int, crop_right_int, crop_bottom_int)) # Position exacte du marqueur dans l'image finale marker_x = target_px_x - crop_left_int marker_y = target_px_y - crop_top_int # Dessiner le marqueur via supersampling 4× pour des bords parfaitement lisses marker_radius = 18 marker_color = (13, 110, 253, 255) # Bleu Bootstrap outline_color = (255, 255, 255, 255) SSAA = 4 # facteur de surechantillonnage outline_extra = 4 # pixels de bordure blanche total_r = (marker_radius + outline_extra) * SSAA canvas_sz = total_r * 2 + 2 # +2 pour marge marker_img = Image.new('RGBA', (canvas_sz, canvas_sz), (0, 0, 0, 0)) mdraw = ImageDraw.Draw(marker_img) cx = cy = canvas_sz // 2 # Ombre portée douce (cercle gris semi-transparent légèrement décalé) shadow_r = (marker_radius + outline_extra) * SSAA mdraw.ellipse( [(cx - shadow_r + SSAA * 2, cy - shadow_r + SSAA * 2), (cx + shadow_r + SSAA * 2, cy + shadow_r + SSAA * 2)], fill=(0, 0, 0, 60), ) # Cercle blanc extérieur outer_r = (marker_radius + outline_extra) * SSAA mdraw.ellipse( [(cx - outer_r, cy - outer_r), (cx + outer_r, cy + outer_r)], fill=outline_color, ) # Cercle bleu central inner_r = marker_radius * SSAA mdraw.ellipse( [(cx - inner_r, cy - inner_r), (cx + inner_r, cy + inner_r)], fill=marker_color, ) # Point blanc central dot_r = 5 * SSAA mdraw.ellipse( [(cx - dot_r, cy - dot_r), (cx + dot_r, cy + dot_r)], fill=outline_color, ) # Réduire au quart de la taille avec LANCZOS → antialiasing parfait display_sz = canvas_sz // SSAA marker_img = marker_img.resize((display_sz, display_sz), Image.LANCZOS) paste_x = int(round(marker_x)) - display_sz // 2 paste_y = int(round(marker_y)) - display_sz // 2 final_image.paste(marker_img, (paste_x, paste_y), marker_img) # Attribution attribution_text = getattr(settings, 'MAP_ATTRIBUTION', None) or '© OpenStreetMap contributors' try: font = ImageFont.truetype(str(BASE_FONT_DIR / "arial.ttf"), 12) except: font = ImageFont.load_default() draw = ImageDraw.Draw(final_image, 'RGBA') text_bbox = draw.textbbox((0, 0), attribution_text, font=font) text_width = text_bbox[2] - text_bbox[0] text_height = text_bbox[3] - text_bbox[1] text_x = final_image.width - text_width - 8 text_y = final_image.height - text_height - 8 # Fond blanc pour le texte draw.rectangle( [(text_x - 4, text_y - 2), (final_image.width - 4, final_image.height - 4)], fill=(255, 255, 255, 230) ) draw.text((text_x, text_y), attribution_text, font=font, fill=(80, 80, 80)) return final_image except Exception as e: logger.error(f"Failed to generate location map: {e}") return None class InterventionPDF(FPDF): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._fonts_registered = False def _ensure_fonts(self): if self._fonts_registered: return font_specs = [ ("arial", "", "arial.ttf"), ("arial", "B", "arial_bold.ttf"), ("arial", "I", "arial_italic.ttf"), ] for family, style, filename in font_specs: fname = BASE_FONT_DIR / filename if not fname.exists(): logger.warning("Font file missing for PDF export: %s", fname) continue try: self.add_font(family, style=style, fname=str(fname), uni=True) except Exception as exc: logger.error("Unable to register font %s (%s): %s", family, fname, exc) self._fonts_registered = True def header(self): self._ensure_fonts() logo_path = os.path.join(settings.BASE_DIR, "common", "static", settings.ORGANIZATION_LOGO_PNG) if os.path.exists(logo_path): self.image( logo_path, x=10, y=8, w=60, ) self.set_fill_color(245) self.rect(105, 7, 85, 20, "F") self.set_y(8) self.set_x(110) self.set_font(family="arial", style="", size=8) self.set_text_color(*settings.ORGANIZATION_PRIMARY_COLOR_RGB) self.cell(40, 10, settings.ORGANIZATION_NAME_FR, align="R") self.set_x(151) self.cell(2, 10, "-", align="C") self.cell(40, 10, settings.ORGANIZATION_NAME_NL, align="L") self.ln(4) self.set_x(110) self.set_text_color(60, 130, 0) self.cell(40, 10, "Permanence technique", align="R") self.set_x(151) self.cell(2, 10, "-", align="C") self.cell(40, 10, "Technische permanentie", align="L") self.ln(5) self.set_x(111) self.set_font(family="arial", style="", size=8) self.set_text_color(*settings.ORGANIZATION_PRIMARY_COLOR_RGB) self.cell(40, 10, getattr(settings, 'EMAIL_TECH_SUPPORT', 'BM.PERM.TECH@sprb.brussels').upper(), align="R") self.set_x(153) self.set_font(family="arial", style="B", size=8) self.cell(40, 10, f"Tel: {getattr(settings, 'EMAIL_TECH_SUPPORT_PHONE', '02 204 22 86')}", align="L") self.ln(15) def add_photos_pages(self, photos): """ Ajoute une ou plusieurs pages supplémentaires avec les photos de l'intervention. photos : liste de tuples (img_bytes: bytes, caption: str) Disposition : 2 photos côte à côte par rangée, plusieurs rangées par page. """ if not photos: return self._ensure_fonts() # Dimensions (mm) – page A4 : 210 x 297 MARGIN_L = 10 MARGIN_B = 15 # marge basse CELL_W = 90 # largeur d'une cellule photo GAP = 10 # espace entre les deux colonnes MAX_PHOTO_H = 110 # hauteur max disponible pour l'image CAPTION_LINE_H = 4 # hauteur d'une ligne de légende ROW_GAP = 8 # espace vertical entre deux rangées # Hauteur minimale réservée pour une rangée (photo + légende estimée) MIN_ROW_H = MAX_PHOTO_H + CAPTION_LINE_H * 2 + ROW_GAP def _start_new_page(): self.add_page() self._ensure_fonts() self.set_font(family="arial", style="B", size=10) self.set_text_color(*settings.ORGANIZATION_PRIMARY_COLOR_RGB) self.cell(0, 7, "Annexe photographique / Fotografisch bijlage", align="C") self.set_text_color(0, 0, 0) self.ln(10) _start_new_page() for i in range(0, len(photos), 2): # Vérifier si la place est suffisante pour une nouvelle rangée ; si non, nouvelle page if i > 0 and self.get_y() + MIN_ROW_H > self.h - MARGIN_B: _start_new_page() row_y = self.get_y() pair = photos[i:i + 2] row_h = 0 # hauteur effective de la rangée (max des deux photos) for j, (img_bytes, caption) in enumerate(pair): x = MARGIN_L + j * (CELL_W + GAP) try: buf_in = io.BytesIO(img_bytes) with Image.open(buf_in) as img: orig_w, orig_h = img.size # Calcul des dimensions d'affichage en conservant le ratio ratio = orig_w / orig_h if orig_h else 1 disp_w = CELL_W disp_h = disp_w / ratio if disp_h > MAX_PHOTO_H: disp_h = MAX_PHOTO_H disp_w = disp_h * ratio # Réduction de la résolution pour limiter la taille du PDF img = img.convert("RGB") max_px = 1200 if max(orig_w, orig_h) > max_px: img.thumbnail((max_px, max_px), Image.LANCZOS) buf_out = io.BytesIO() img.save(buf_out, format="JPEG", quality=80, optimize=True) buf_out.seek(0) # Centrer horizontalement dans la cellule img_x = x + (CELL_W - disp_w) / 2 self.image(buf_out, x=img_x, y=row_y, w=disp_w, h=disp_h) # Légende caption_y = row_y + disp_h + 2 self.set_xy(x, caption_y) self.set_font(family="arial", style="", size=7) self.set_text_color(80, 80, 80) self.multi_cell(CELL_W, CAPTION_LINE_H, str(caption or ""), align="C", border=0) self.set_text_color(0, 0, 0) row_h = max(row_h, disp_h + CAPTION_LINE_H * 2) except Exception as exc: logger.warning("Impossible d'intégrer une photo dans le PDF : %s", exc) # Avancer le curseur sous la rangée self.set_y(row_y + row_h + ROW_GAP) def add_intervention_data( self, intervention, *, is_reminder=False, status_labels=None, modification_reason=None, ): brussels = pytz.timezone("Europe/Brussels") self.set_x(10) self.set_font(family="arial", style="B", size=14) #self.cell(0, 10, "", align="C", border=1) self.set_x(10) if modification_reason: self.set_draw_color(200, 200, 200) self.set_fill_color(252, 243, 207) self.set_text_color(33, 37, 41) self.set_font(family="arial", style="", size=9) self.multi_cell( 190, 7, _("Motif de la modification : %(reason)s") % {"reason": modification_reason}, border=1, align="L", fill=True, ) self.ln(4) if intervention.maintain_type not in ["preventive", "ameliorative"]: self.set_text_color(0, 0, 0) origin_type_fr = "AVARIE" origin_type_nl = "AVERIJ" if intervention.origin_type == "defect": origin_type_fr = "DÉFAUT" origin_type_nl = "DEFECT" elif intervention.origin_type == "demand": origin_type_fr = "DEMANDE" origin_type_nl = "VERZOEK" self.cell(93, 10, origin_type_fr, align="C", border=1) self.set_x(104) self.cell(2, 10, "", align="C") self.cell(93, 10, origin_type_nl, align="C", border=1) self.ln(15) y = self.get_y() self.set_font(family="arial", style="", size=8) self.cell(20, 5, "Origine:") is_observation_source = False if intervention.source_category: possible_names = [ getattr(intervention.source_category, attr, "") or "" for attr in ("name_fr", "name_nl") ] is_observation_source = any( name.strip().lower() == "observation" for name in possible_names if isinstance(name, str) ) source_text_fr = intervention.source_category.name_fr if intervention.source_category else "" source_text_nl = intervention.source_category.name_nl if intervention.source_category else "" if is_observation_source and intervention.source_ref: try: from observations.models import Observation source_observation = Observation.objects.select_related("created_by").filter( pk=int(intervention.source_ref) ).first() except (TypeError, ValueError): source_observation = None if source_observation: creator_name = get_short_name(source_observation.created_by) company_name = "" user_config = getattr(source_observation.created_by, "config", None) if user_config and user_config.company: company_name = user_config.company.name or "" if company_name: creator_name = f"{creator_name} ({company_name})" source_text_fr = creator_name source_text_nl = creator_name self.multi_cell(70, 5, source_text_fr, align="L", new_y="LAST") new_y = self.get_y() self.set_y(y) self.set_x(106) self.cell(30, 5, "Oorsprong:") self.multi_cell(55, 5, source_text_nl, align="L", new_y="LAST") new_y = max(new_y, self.get_y()) self.set_x(10) self.set_y(new_y + 5) if intervention.source_category and intervention.source_category.has_reference: label_fr = "Référence:" label_nl = "Referentie:" if is_observation_source and intervention.source_ref: text_value = f"Observation {intervention.source_ref}" elif intervention.source_category.name_fr == "PREGO": text_value = f"MNT-{intervention.origin_prego_nbr}" if intervention.origin_prego_nbr else "" else: text_value = intervention.source_ref or "" else: label_fr = "Détail: " label_nl = "Detail: " text_value = intervention.source_detail or "" self.cell(20, 5, label_fr) self.set_font(family="arial", style="", size=8) self.cell(70, 5, text_value, align="L") self.set_x(106) self.cell(30, 5, label_nl) self.cell(60, 5, text_value, align="L") self.ln(5) if intervention.init_call_confirmed_time: local_call_time = intervention.init_call_confirmed_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M") else: local_call_time = "" self.set_font(family="arial", style="", size=8) self.cell(40, 5, "Date d'appel au prestataire:") self.cell(50, 5, local_call_time, align="L") self.set_x(106) self.cell(45, 5, "Datum van oproep naar aanbieder:") self.cell(45, 5, local_call_time, align="L") self.ln(10) y = self.get_y() self.set_draw_color(150, 150, 150) self.line(10, y, 200, y) self.set_y(y + 5) first_intervention_asset = InterventionAsset.objects.filter(intervention=intervention).first() if first_intervention_asset: first_asset = first_intervention_asset.asset installation_fr = first_asset.name_fr or "" installation_nl = first_asset.name_nl or "" installation_code = first_asset.code or "" if installation_code == installation_fr: installation_text_fr = installation_fr installation_text_nl = installation_nl else: installation_text_fr = f"{installation_code}\n{installation_fr}" installation_text_nl = f"{installation_code}\n{installation_nl}" y = self.get_y() self.set_font(family="arial", style="", size=8) self.cell(20, 5, "Installation:") self.set_font(family="arial", style="B", size=8) self.multi_cell(70, 5, installation_text_fr, align="L", new_y="LAST", border=1) new_y = self.get_y() self.set_y(y) self.set_x(106) self.set_font(family="arial", style="", size=8) self.cell(20, 5, "Installatie:") self.set_font(family="arial", style="B", size=8) self.multi_cell(70, 5, installation_text_nl, align="L", new_y="LAST", border=1) new_y = max(new_y, self.get_y()) self.set_y(new_y + 8) y = self.get_y() self.set_font(family="arial", style="", size=8) self.cell(20, 5, "Thématique:") self.multi_cell(70, 5, intervention.thematic.name_fr, align="L", new_y="LAST", border=0) new_y = self.get_y() self.set_y(y) self.set_x(106) self.cell(20, 5, "Thematiek:") self.multi_cell(70, 5, intervention.thematic.name_nl, align="L", new_y="LAST", border=0) new_y = max(new_y, self.get_y()) self.set_y(new_y + 5) asset_category_fr = "" asset_category_nl = "" if intervention.asset_category: asset_category_fr = intervention.asset_category.name_fr or "" asset_category_nl = intervention.asset_category.name_nl or "" y = self.get_y() self.set_font(family="arial", style="", size=8) self.cell(20, 5, "Catégorie:") self.multi_cell(70, 5, asset_category_fr, align="L", new_y="LAST", border=0) new_y = self.get_y() self.set_y(y) self.set_x(106) self.cell(20, 5, "Categorie:") self.multi_cell(70, 5, asset_category_nl, align="L", new_y="LAST", border=0) new_y = max(new_y, self.get_y()) self.set_y(new_y + 5) # mapping thématique -> modèle de localisation attendu MODEL_BY_THEMATIC = { "trafficlights": TrafficLightIntersection, "publiclighting": PublicLightingStreet, "nature": NatureLocation, "structures": StructureLocation, "controlcenters": ControlCenterLocation, "its": ITSLocation, "roads": RoadStreet, "sign": SignStreet, } location_fr = "" location_nl = "" location_code = "" # 1) si on connait la thématique, on cible d'abord ce type code = getattr(getattr(intervention, "thematic", None), "code", None) loc_obj = None if code in MODEL_BY_THEMATIC: Model = MODEL_BY_THEMATIC[code] ct_id = ContentType.objects.get_for_model(Model).id # on récupère l'object_id d'une localisation de ce type link = ( InterventionLocation.objects .filter(intervention=intervention, content_type_id=ct_id) .values("object_id") .first() ) if link: loc_obj = ( Model.objects .filter(pk=link["object_id"]) .only("code", "name_fr", "name_nl") .first() ) # 2) fallback : si rien trouvé pour le type ciblé, on prend n’importe quelle localisation liée if loc_obj is None: link = ( InterventionLocation.objects .filter(intervention=intervention) .select_related("content_type") .first() ) if link: loc_obj = link.location # la GFK # 3) formater FR/NL + code if loc_obj: code_val = getattr(loc_obj, "code", "") or "" name_fr = getattr(loc_obj, "name_fr", "") or "" name_nl = getattr(loc_obj, "name_nl", "") or "" location_code = code_val # "CODE - Nom" (sans tiret superflu si l'un est vide) location_fr = " - ".join([p for p in (code_val, name_fr) if p]) location_nl = " - ".join([p for p in (code_val, name_nl) if p]) else: location_fr = "" location_nl = "" location_code = "" y = self.get_y() self.set_font(family="arial", style="", size=8) self.cell(20, 5, "Localisation:") self.set_font(family="arial", style="B", size=8) self.multi_cell(70, 5, location_fr, align="L", new_y="LAST", border=0) new_y = self.get_y() self.set_y(y) self.set_x(106) self.set_font(family="arial", style="", size=8) self.cell(20, 5, "Localisatie:") self.set_font(family="arial", style="B", size=8) self.multi_cell(70, 5, location_nl, align="L", new_y="LAST", border=0) new_y = max(new_y, self.get_y()) self.set_y(new_y + 10) y = self.get_y() self.set_draw_color(150, 150, 150) self.line(10, y, 200, y) self.set_y(y + 5) y = self.get_y() self.set_font(family="arial", style="", size=8) self.cell(25, 5, "Type de problème:") self.set_font(family="arial", style="", size=8) self.multi_cell(65, 5, intervention.symptom.name_fr, align="L", new_y="LAST", border=0) new_y = self.get_y() self.set_y(y) self.set_x(106) self.cell(25, 5, "Type of probleem:") self.multi_cell(65, 5, intervention.symptom.name_nl, align="L", new_y="LAST", border=0) new_y = max(new_y, self.get_y()) self.set_y(new_y + 7) y = self.get_y() self.set_font(family="arial", style="", size=8) self.cell(20, 5, "Message:") self.multi_cell(70, 5, "", align="L", new_y="LAST", border=0) new_y = self.get_y() self.set_y(y) self.set_x(106) self.cell(20, 5, "Bericht:") self.multi_cell(70, 5, "", align="L", new_y="LAST", border=0) new_y = max(new_y, self.get_y()) self.set_y(new_y + 5) self.set_font(family="arial", style="B", size=8) # --- Construire le contenu "Message" + "Notes" --- title = (intervention.title or "").strip() init_desc = (intervention.init_description or "").strip() # Récupérer uniquement le contenu des notes (sans métadonnées) notes_qs = list(getattr(intervention, "notes", []).all()) # Trier par priorité (si dispo) puis date croissante notes_qs.sort( key=lambda n: ( getattr(n, "type_priority_value", 99), getattr(n, "note_time", None) or 0 ) ) # Construire la section Notes : une puce "•" devant chaque note notes_lines = [] for note in notes_qs: content = (note.content or "").strip() if not content: continue # Convertir les retours à la ligne pour indentation propre content = content.replace("\r\n", "\n").replace("\r", "\n") lines = content.split("\n") formatted = f"• {lines[0]}" if len(lines) > 1: formatted += "\n" + "\n".join([f" {ln}" for ln in lines[1:]]) notes_lines.append(formatted) # Fusion finale : titre + description + notes (toutes dans un même cadre) message_text = title if notes_lines: message_text += "\n\n" + "\n".join(notes_lines) # Affichage du bloc message complet self.multi_cell( 0, 5, message_text, align="L", border=1, max_line_height=4, padding=2 ) self.ln(5) y = self.get_y() self.set_font(family="arial", style="", size=8) self.cell(30, 5, "Intervenant:") self.multi_cell(60, 5, intervention.assigned_provider.name if intervention.assigned_provider else '', align="L", new_y="LAST", border=0) new_y = self.get_y() self.set_y(y) self.set_x(106) self.cell(30, 5, "Tussenkomende partij:") self.multi_cell(60, 5, intervention.assigned_provider.name if intervention.assigned_provider else '', align="L", new_y="LAST", border=0) new_y = max(new_y, self.get_y()) contract_officer_email = "" if intervention.contract: user_config = UserConfig.objects.filter(user=intervention.contract.executive_officer).first() if user_config: contract_officer_email = user_config.generic_email or "" # self.set_y(new_y + 5) # y = self.get_y() # self.set_font(family="arial", style="", size=8) # self.cell(30, 5, "Contact SPRB:") # self.multi_cell(60, 5, contract_officer_email, align="L", new_y="LAST", border=0) # new_y = self.get_y() # self.set_y(y) # self.set_x(106) # self.cell(30, 5, "Contact GOB:") # self.multi_cell(60, 5, contract_officer_email, align="L", new_y="LAST", border=0) # new_y = max(new_y, self.get_y()) self.set_y(new_y + 5) if intervention.transmission_time: local_transmission_time = intervention.transmission_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M") else: local_transmission_time = "" y = self.get_y() self.set_font(family="arial", style="", size=8) self.cell(30, 5, "Date d'émission:") self.multi_cell(60, 5, local_transmission_time, align="L", new_y="LAST", border=0) new_y = self.get_y() self.set_y(y) self.set_x(106) self.cell(30, 5, "Datum opdracht:") self.multi_cell(60, 5, local_transmission_time, align="L", new_y="LAST", border=0) new_y = max(new_y, self.get_y()) self.set_y(new_y + 5) y = self.get_y() self.set_font(family="arial", style="", size=8) self.cell(30, 5, "Fiche créée par:") self.multi_cell(60, 5, get_short_name(intervention.created_by), align="L", new_y="LAST", border=0) new_y = self.get_y() self.set_y(y) self.set_x(106) self.cell(30, 5, "Fiche gemaakt door:") self.multi_cell(60, 5, get_short_name(intervention.created_by), align="L", new_y="LAST", border=0) new_y = max(new_y, self.get_y()) self.set_y(new_y + 10) self.set_font(family="arial", style="", size=8) self.cell(94, 5, "N° de fiche:", align="R") self.set_x(106) self.cell(94, 5, "Fiche nr:", align="L") self.ln(5) self.set_font(family="arial", style="B", size=12) self.set_x(84) fiche_number = intervention.code if is_reminder: fiche_number = f"{fiche_number} ({_('Rappel')})" self.multi_cell(40, 7, fiche_number, align="C", border=1, padding=1) # Afficher les labels de statut (ex: ANNULÉ / GEANNULEERD) sous le numéro de fiche if status_labels: labels = status_labels if isinstance(status_labels, (list, tuple)) else (status_labels,) self.ln(5) self.set_text_color(200, 0, 0) # rouge self.set_font(family="arial", style="B", size=10) text = " / ".join([str(lbl).strip().upper() for lbl in labels if lbl]) if text: self.cell(0, 8, text, align="C") self.set_text_color(0, 0, 0) # retour au noir def generate_intervention_pdf( intervention, output_path, *, is_reminder=False, status_labels=None, modification_reason=None, include_photos=True, ): pdf = InterventionPDF() pdf.add_page() pdf.add_intervention_data( intervention, is_reminder=is_reminder, status_labels=status_labels, modification_reason=modification_reason, ) if include_photos: photos = _collect_intervention_photos(intervention) if photos: pdf.add_photos_pages(photos) pdf.output(output_path) def preview_intervention_pdf(request, intervention_id): intervention = get_object_or_404(Intervention, id=intervention_id) buffer = io.BytesIO() generate_intervention_pdf(intervention, buffer) buffer.seek(0) return HttpResponse(buffer.read(), content_type="application/pdf") # ============================================================================ # RAPPORT D'INTERVENTION (PDF complet sans prestations) # ============================================================================ HTML_BREAK_RE = re.compile(r"<\s*br\s*/?>", re.IGNORECASE) HTML_PARAGRAPH_CLOSE_RE = re.compile(r"", re.IGNORECASE) HTML_PARAGRAPH_OPEN_RE = re.compile(r"<\s*p[^>]*?>", re.IGNORECASE) def _html_to_plain_text(value: str) -> str: """Convertit le contenu HTML en texte brut lisible.""" if not value: return "" text = HTML_BREAK_RE.sub("\n", value) text = HTML_PARAGRAPH_CLOSE_RE.sub("\n\n", text) text = HTML_PARAGRAPH_OPEN_RE.sub("", text) text = strip_tags(text) text = unescape(text) text = text.replace("\r\n", "\n").replace("\r", "\n") text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() class InterventionReportPDF(FPDF): """Classe PDF pour le rapport d'intervention complet (sans prestations).""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._fonts_registered = False self.intervention = None def _ensure_fonts(self): if self._fonts_registered: return font_specs = [ ("arial", "", "arial.ttf"), ("arial", "B", "arial_bold.ttf"), ("arial", "I", "arial_italic.ttf"), ] for family, style, filename in font_specs: fname = BASE_FONT_DIR / filename if not fname.exists(): logger.warning("Font file missing for PDF export: %s", fname) continue try: self.add_font(family, style=style, fname=str(fname), uni=True) except Exception as exc: logger.error("Unable to register font %s (%s): %s", family, fname, exc) self._fonts_registered = True def header(self): self._ensure_fonts() # Logo SPRB logo_path = os.path.join(settings.BASE_DIR, "common", "static", settings.ORGANIZATION_LOGO_PNG) if os.path.exists(logo_path): self.image(logo_path, x=10, y=8, w=50) # Titre du rapport avec le code de l'intervention self.set_y(10) self.set_x(70) self.set_font(family="arial", style="B", size=14) self.set_text_color(*settings.ORGANIZATION_PRIMARY_COLOR_RGB) if self.intervention: # Titre et code sur la même ligne title_text = _("Rapport d'intervention") + " " + self.intervention.code self.cell(0, 10, title_text, align="C") else: self.cell(0, 10, _("Rapport d'intervention"), align="C") self.ln(15) def footer(self): self.set_y(-15) self.set_font(family="arial", style="I", size=8) self.set_text_color(128, 128, 128) page_txt = _("Page") self.cell(0, 10, f"{page_txt} {self.page_no()}/{{nb}}", align="C") def _add_section_title(self, title): """Ajoute un titre de section stylisé.""" self._ensure_fonts() # S'assurer que les fonts sont chargées # Vérifier s'il reste assez d'espace pour le titre + au moins 20mm de contenu # Si non, forcer un saut de page avant le titre if self.get_y() > self.h - 40: # 40mm = marge bas (15) + titre (10) + espace minimum (15) self.add_page() # Ajouter un espace avant la section self.ln(3) # Réinitialiser X à la marge gauche (10mm par défaut) self.set_left_margin(10) self.set_x(10) self.set_font(family="arial", style="B", size=11) # Bleu plus clair et plus moderne (au lieu de 47, 62, 139) self.set_fill_color(100, 130, 200) self.set_text_color(255, 255, 255) # Ajouter un peu plus de padding vertical self.cell(0, 10, f" {title}", ln=True, fill=True) self.set_text_color(0, 0, 0) self.ln(3) def _add_field(self, label, value, bold_value=False, value_color=None, value_fill_color=None): """Ajoute un champ label: valeur.""" self._ensure_fonts() # S'assurer que les fonts sont chargées # Réinitialiser X à la marge gauche (10mm par défaut) self.set_left_margin(10) self.set_x(10) self.set_font(family="arial", style="B", size=9) # Couleur bleu foncé pour le label self.set_text_color(60, 80, 120) label_text = f"{label}:" # Utiliser une largeur fixe raisonnable pour le label label_width = 50 self.cell(label_width, 6, label_text, align="L") self.set_font(family="arial", style="B" if bold_value else "", size=9) val_str = str(value) if value else "-" if value_fill_color and value_color: self.set_fill_color(*value_fill_color) self.set_text_color(*value_color) badge_text = f" {val_str} " badge_width = self.get_string_width(badge_text) self.cell(badge_width, 6, badge_text, fill=True, align="C") self.ln(7) else: # Réinitialiser la couleur pour la valeur self.set_text_color(0, 0, 0) # Calculer explicitement la largeur restante (page width - current X - right margin) value_width = self.w - self.get_x() - 10 self.multi_cell(value_width, 6, val_str, align="L") # Petit espace entre les champs self.ln(1) # Toujours restaurer les couleurs par défaut self.set_text_color(0, 0, 0) def _get_localized_value(self, value_fr, value_nl): """Retourne la valeur dans la langue active.""" lang = get_language() or "fr" if lang.startswith("nl"): return value_nl if value_nl else value_fr return value_fr if value_fr else value_nl def _is_observation_source(self, source_category): """Retourne True si la catégorie de source correspond à Observation.""" if not source_category: return False possible_names = [ getattr(source_category, attr, "") or "" for attr in ("name_fr", "name_nl") ] return any(name.strip().lower() == "observation" for name in possible_names if isinstance(name, str)) def _format_observation_creator(self, observation): """Retourne le créateur de l'observation avec son organisme si disponible.""" if not observation or not observation.created_by: return "" creator_name = get_short_name(observation.created_by) company_name = "" user_config = getattr(observation.created_by, "config", None) if user_config and user_config.company: company_name = user_config.company.name or "" if company_name: return f"{creator_name} ({company_name})" return creator_name def add_intervention_report_data(self, intervention): """Génère le contenu complet du rapport d'intervention.""" self.intervention = intervention brussels = pytz.timezone("Europe/Brussels") lang = get_language() or "fr" # ========== MENTION PRESTATAIRE (si assigné) ========== if intervention.assigned_provider: # Afficher le logo du prestataire si disponible if intervention.assigned_provider.logo: try: logo_provider_path = intervention.assigned_provider.logo.path if os.path.exists(logo_provider_path): # Ouvrir l'image pour obtenir ses dimensions réelles with Image.open(logo_provider_path) as img: img_width, img_height = img.size aspect_ratio = img_height / img_width # Définir la largeur du logo et calculer la hauteur proportionnelle logo_width = 30 # mm logo_height = logo_width * aspect_ratio # Centrer le logo horizontalement x_center = (self.w - logo_width) / 2 y_position = self.get_y() self.image(logo_provider_path, x=x_center, y=y_position, w=logo_width, h=logo_height) self.ln(logo_height + 3) # Espace réduit après le logo except Exception as e: logger.warning(f"Unable to load company logo: {e}") # Afficher le texte du prestataire self.set_font(family="arial", style="B", size=12) self.set_text_color(60, 80, 120) self.set_fill_color(240, 245, 250) provider_text = _("Intervention réalisée par : {}").format(intervention.assigned_provider.name) # Ajouter du padding et un meilleur espacement self.cell(0, 12, provider_text, ln=True, align="C", fill=True) self.set_text_color(0, 0, 0) self.ln(6) # ========== INFORMATIONS GÉNÉRALES ========== self._add_section_title(_("Informations générales")) self._add_field(_("Code"), intervention.code, bold_value=True) self._add_field(_("Titre"), intervention.title) # Statut avec couleur status_display = dict(STATUS_CHOICES).get(intervention.status, intervention.status) self._add_field(_("Statut"), status_display) # Type de maintenance maintain_display = dict(MAINTAIN_CHOICES).get(intervention.maintain_type, intervention.maintain_type) self._add_field(_("Type de maintenance"), maintain_display) # Type d'intervention if intervention.type: type_display = dict(TYPE_CHOICES).get(intervention.type, intervention.type) self._add_field(_("Type d'intervention"), type_display) # Priorité priority_display = dict(PRIORITY_CHOICES).get(intervention.priority, intervention.priority) priority_colors = { '1': { 'fill': (250, 224, 227), # Soft red 'text': (220, 53, 69), # Red }, '2': { 'fill': (255, 236, 220), # Soft orange 'text': (253, 126, 20), # Orange }, '3': { 'fill': (217, 232, 255), # Soft blue 'text': (13, 110, 253), # Blue }, '4': { 'fill': (233, 234, 236), # Soft grey 'text': (108, 117, 125), # Grey } } colors = priority_colors.get(intervention.priority, {'fill': None, 'text': None}) self._add_field( _("Priorité"), priority_display, value_color=colors['text'], value_fill_color=colors['fill'] ) # Type d'origine if intervention.origin_type and intervention.maintain_type not in ["preventive", "ameliorative"]: origin_display = dict(ORIGIN_TYPE_CHOICES).get(intervention.origin_type, intervention.origin_type) self._add_field(_("Type d'origine"), origin_display) self.ln(3) # ========== LOCALISATION ========== self._add_section_title(_("Localisation")) if intervention.address: # Tenter de récupérer l'adresse complète depuis AdrPoint address_display = intervention.address try: # Si l'address est un ID numérique, récupérer l'objet AdrPoint if intervention.address.isdigit(): addr_point = AdrPoint.objects.filter(fid=intervention.address).first() if addr_point: # Utiliser l'adresse complète dans la langue appropriée if lang.startswith('nl'): address_display = addr_point.full_address_nl or addr_point.full_address_fr or str(addr_point) else: address_display = addr_point.full_address_fr or addr_point.full_address_nl or str(addr_point) except (ValueError, AttributeError): pass self._add_field(_("Adresse"), address_display) if intervention.location_code: self._add_field(_("Code de localisation"), intervention.location_code) # Thématique if intervention.thematic: thematic_name = self._get_localized_value( intervention.thematic.name_fr, intervention.thematic.name_nl ) self._add_field(_("Thématique"), thematic_name) # Catégorie d'asset if intervention.asset_category: category_name = self._get_localized_value( intervention.asset_category.name_fr, intervention.asset_category.name_nl ) self._add_field(_("Catégorie"), category_name) # Localisations liées locations = InterventionLocation.objects.filter(intervention=intervention).select_related("content_type") if locations.exists(): self.set_left_margin(10) self.set_x(10) self.set_font(family="arial", style="B", size=9) self.set_text_color(60, 80, 120) self.cell(0, 6, _("Localisations liées:"), ln=True) self.set_text_color(0, 0, 0) self.set_font(family="arial", style="", size=9) for loc_link in locations: loc_obj = loc_link.location if loc_obj: code = getattr(loc_obj, "code", "") name = loc_obj.get_name() if hasattr(loc_obj, "get_name") else str(loc_obj) self.set_x(10) loc_text = f" • {code} - {name}" if code else f" • {name}" self.multi_cell(0, 5, loc_text) # Carte de localisation if intervention.lon and intervention.lat: self.ln(5) try: # Taille optimale : 1280x560 (bon compromis qualité/performance) # Ratio préservé : 1280/560 ≈ 1600/700 ≈ 2.29 img_width_px = 1280 img_height_px = 560 map_image = generate_location_map( lon=intervention.lon, lat=intervention.lat, img_size=(img_width_px, img_height_px), buffer_meters=150 ) if map_image: # Calculer les dimensions en mm en préservant le ratio map_width_mm = 190 # Largeur en mm map_height_mm = map_width_mm * (img_height_px / img_width_px) x_position = (self.w - map_width_mm) / 2 # Forcer largeur ET hauteur pour éviter la déformation self.image(map_image, x=x_position, w=map_width_mm, h=map_height_mm) self.ln(3) except Exception as e: logger.warning(f"Failed to add location map to PDF: {e}") self.ln(3) # ========== PRESTATAIRE ET CONTRAT ========== self._add_section_title(_("Prestataire et contrat")) if intervention.assigned_provider: self._add_field(_("Prestataire"), intervention.assigned_provider.name) if intervention.assigned_team: self._add_field(_("Équipe assignée"), intervention.assigned_team.name) if intervention.assigned_member: self._add_field(_("Membre assigné"), intervention.assigned_member.name) if intervention.contract: self._add_field(_("Contrat"), intervention.contract.contract_number) if intervention.intervention_manager: manager_name = intervention.intervention_manager.get_full_name() or intervention.intervention_manager.username self._add_field(_("Gestionnaire d'intervention"), manager_name) if intervention.order: self._add_field(_("Commande"), intervention.order.order_code) self.ln(3) # ========== DATES ET PLANIFICATION ========== self._add_section_title(_("Dates et planification")) if intervention.creation_time: self._add_field(_("Date de création"), intervention.creation_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M")) if intervention.transmission_time: self._add_field(_("Date de transmission"), intervention.transmission_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M")) if intervention.expected_begin_time: self._add_field(_("Début prévu"), intervention.expected_begin_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M")) if intervention.expected_end_time: self._add_field(_("Fin prévue"), intervention.expected_end_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M")) if intervention.planned_begin_time: self._add_field(_("Début planifié"), intervention.planned_begin_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M")) if intervention.planned_end_time: self._add_field(_("Fin planifiée"), intervention.planned_end_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M")) if intervention.begin_time: self._add_field(_("Début réel"), intervention.begin_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M")) if intervention.end_time: self._add_field(_("Fin réelle"), intervention.end_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M")) self.ln(3) # ========== ORIGINE (pour maintenance corrective) ========== if intervention.maintain_type == 'corrective': self._add_section_title(_("Origine")) if intervention.symptom: symptom_name = self._get_localized_value( intervention.symptom.name_fr, intervention.symptom.name_nl ) self._add_field(_("Symptôme"), symptom_name) is_observation_source = self._is_observation_source(intervention.source_category) if intervention.source_category and not is_observation_source: source_name = self._get_localized_value( intervention.source_category.name_fr, intervention.source_category.name_nl ) self._add_field(_("Source"), source_name) if is_observation_source and intervention.source_ref: try: from observations.models import Observation source_observation = Observation.objects.select_related("created_by").filter( pk=int(intervention.source_ref) ).first() except (TypeError, ValueError): source_observation = None if source_observation: self._add_field(_("Source"), self._format_observation_creator(source_observation)) self._add_field(_("Référence"), f"Observation {source_observation.pk}") elif intervention.source_category: source_name = self._get_localized_value( intervention.source_category.name_fr, intervention.source_category.name_nl ) self._add_field(_("Source"), source_name) self._add_field(_("Référence source"), intervention.source_ref) else: self._add_field(_("Référence source"), intervention.source_ref) elif intervention.source_ref: self._add_field(_("Référence source"), intervention.source_ref) if intervention.source_detail: self._add_field(_("Détail source"), intervention.source_detail) if intervention.origin_prego_nbr: self._add_field("PREGO", f"MNT-{intervention.origin_prego_nbr}") if intervention.origin_fms_nbr: self._add_field("Fix My Street", intervention.origin_fms_nbr) if intervention.init_call_confirmed_time: self._add_field(_("Appel confirmé"), intervention.init_call_confirmed_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M")) self.ln(3) # ========== DESCRIPTION ========== # if intervention.description: # self._add_section_title(_("Description")) # self.set_font(family="arial", style="", size=9) # desc_text = _html_to_plain_text(intervention.description) # self.multi_cell(0, 5, desc_text) # self.ln(3) # ========== NOTES ========== notes = intervention.notes.all().order_by('note_time') if notes.exists(): self._add_section_title(_("Notes")) for note in notes: # Type de note note_type_display = note.get_note_type_display() note_time = note.note_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M") if note.note_time else "" author = get_short_name(note.note_author) if note.note_author else "" # En-tête de la note avec un style amélioré self.set_font(family="arial", style="B", size=9) self.set_fill_color(235, 240, 245) self.set_text_color(60, 80, 120) header_text = f" {note_type_display} - {note_time}" if author: header_text += f" - {author}" self.cell(0, 7, header_text, ln=True, fill=True) # Contenu de la note avec un léger retrait self.set_text_color(0, 0, 0) self.set_font(family="arial", style="", size=9) note_content = _html_to_plain_text(note.content) if note_content: self.set_x(15) # Légère indentation content_width = self.w - 15 - 10 # Ajuster la largeur self.multi_cell(content_width, 5, note_content) self.ln(3) self.ln(2) # ========== OPÉRATIONS ========== operations = intervention.operations.all().order_by('template_group', 'order') if operations.exists(): self._add_section_title(_("Opérations")) # Séparer les opérations générales (sans asset) des opérations par équipement general_operations = [op for op in operations if op.asset is None] equipment_operations = [op for op in operations if op.asset is not None] # Helper pour afficher une opération def render_operation(op, indent=10): # Nom de l'opération (localisé) op_name = self._get_localized_value(op.name_fr, op.name_nl) # Statut (localisé) if op.status: status_label = self._get_localized_value(op.status.label_fr, op.status.label_nl) else: status_label = "-" # Afficher l'opération self.set_x(indent) self.set_font(family="arial", style="", size=9) BOOTSTRAP_RGB = { 'success': (25, 135, 84), # Green 'danger': (220, 53, 69), # Red 'warning': (217, 131, 0), # Dark Yellow/Orange 'info': (13, 110, 253), # Blue 'primary': (13, 110, 253), # Blue 'secondary': (108, 117, 125), # Gray 'dark': (33, 37, 41), # Near-black 'light': (108, 117, 125), # Gray } # Si c'est une mesure, afficher la valeur mesurée if op.is_measurement() and op.measured_value is not None: unit = op.measurement_definition.unit if op.measurement_definition else "" self.cell(100, 5, f" {op_name}", border=0) # Display measurement value in red if out of range if op.is_measurement_out_of_range(): self.set_text_color(255, 0, 0) # Red self.cell(40, 5, f"{op.measured_value} {unit}", border=0) self.set_text_color(0, 0, 0) # Reset to black else: self.cell(40, 5, f"{op.measured_value} {unit}", border=0) if op.status and op.status.bootstrap_color: rgb = BOOTSTRAP_RGB.get(op.status.bootstrap_color, (0, 0, 0)) self.set_text_color(*rgb) self.set_font(family="arial", style="B", size=9) self.cell(0, 5, status_label, border=0, ln=True) self.set_text_color(0, 0, 0) self.set_font(family="arial", style="", size=9) else: self.cell(140, 5, f" {op_name}", border=0) if op.status and op.status.bootstrap_color: rgb = BOOTSTRAP_RGB.get(op.status.bootstrap_color, (0, 0, 0)) self.set_text_color(*rgb) self.set_font(family="arial", style="B", size=9) self.cell(0, 5, status_label, border=0, ln=True) self.set_text_color(0, 0, 0) self.set_font(family="arial", style="", size=9) # Commentaire si présent if op.comment: self.set_x(indent + 10) self.set_font(family="arial", style="I", size=8) self.multi_cell(0, 4, op.comment) # Draw a thin light-gray separator line self.ln(1) y = self.get_y() self.set_draw_color(220, 220, 220) # Light gray self.set_line_width(0.1) # Very thin self.line(indent, y, self.w - 10, y) self.set_draw_color(0, 0, 0) # Reset draw color self.set_line_width(0.2) # Reset line width self.ln(2) # 1. Opérations générales if general_operations: current_group = None for op in general_operations: # Afficher le groupe si nouveau if op.template_group and op.template_group != current_group: current_group = op.template_group self.set_x(10) self.set_font(family="arial", style="B", size=9) self.set_fill_color(230, 230, 230) self.cell(0, 6, f" {current_group}", ln=True, fill=True) render_operation(op, indent=10) if equipment_operations: self.ln(2) # 2. Opérations par équipement if equipment_operations: # Grouper par asset from collections import OrderedDict ops_by_asset = OrderedDict() for op in equipment_operations: asset_key = (op.asset_content_type_id, op.asset_object_id) if asset_key not in ops_by_asset: ops_by_asset[asset_key] = { 'asset': op.asset, 'operations': [] } ops_by_asset[asset_key]['operations'].append(op) # Afficher par équipement for asset_key, data in ops_by_asset.items(): asset = data['asset'] if asset: # Nom de l'équipement asset_code = getattr(asset, 'code', '') or '' asset_name = asset.get_name() if hasattr(asset, 'get_name') else str(asset) equipment_label = f"{asset_code} - {asset_name}" if asset_code else asset_name else: equipment_label = _("Équipement inconnu") # En-tête de l'équipement self.set_x(10) self.set_font(family="arial", style="B", size=9) self.set_fill_color(200, 220, 240) self.cell(0, 6, f" {equipment_label}", ln=True, fill=True) # Opérations de cet équipement current_group = None for op in data['operations']: # Afficher le groupe si nouveau if op.template_group and op.template_group != current_group: current_group = op.template_group self.set_x(15) self.set_font(family="arial", style="B", size=8) self.set_fill_color(240, 240, 240) self.cell(0, 5, f" {current_group}", ln=True, fill=True) render_operation(op, indent=15) self.ln(1) self.ln(3) # ========== ASSETS LIÉS ========== linked_assets = InterventionAsset.objects.filter(intervention=intervention) if linked_assets.exists(): self._add_section_title(_("Assets liés")) # Table des assets self.set_font(family="arial", style="B", size=9) self.set_fill_color(220, 220, 220) self.cell(40, 6, _("Code"), border=1, fill=True) self.cell(80, 6, _("Nom"), border=1, fill=True) self.cell(60, 6, _("Modèle"), border=1, fill=True, ln=True) self.set_font(family="arial", style="", size=9) for ia in linked_assets: asset = ia.asset if asset: code = getattr(asset, 'code', '') or '' name = asset.get_name() if hasattr(asset, 'get_name') else str(asset) model = str(asset.model) if hasattr(asset, 'model') and asset.model else '' self.cell(40, 5, code[:25], border=1) self.cell(80, 5, (name or '')[:50], border=1) self.cell(60, 5, model[:35], border=1, ln=True) self.ln(3) # ========== DOCUMENTS ========== documents = intervention.documents.all() if documents.exists(): self._add_section_title(_("Documents joints")) self.set_font(family="arial", style="", size=9) doc_count = documents.count() self.cell(0, 6, f"{doc_count} {_('document(s) attaché(s)')}", ln=True) # Afficher les miniatures des images (si possible) x_start = 10 x_end = self.w - 10 # marge droite x = x_start y = self.get_y() + 2 # Photos plus grandes pour améliorer la lisibilité dans le rapport. img_max_width = 85 img_max_height = 70 margin = 6 images_in_row = 0 row_max_height = 0 for doc in documents: if doc.file and hasattr(doc.file, 'path'): file_path = doc.file.path ext = os.path.splitext(file_path)[1].lower() # N'afficher que les images if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']: try: # Utiliser le fichier original (meilleure qualité) img_path = file_path if not os.path.exists(img_path): continue # Calculer les dimensions en préservant le ratio with Image.open(img_path) as img: iw, ih = img.size scale = min(img_max_width / iw, img_max_height / ih) actual_w = iw * scale actual_h = ih * scale # Passer à la ligne si l'image ne tient plus horizontalement if images_in_row > 0 and x + actual_w > x_end: x = x_start y += row_max_height + margin images_in_row = 0 row_max_height = 0 # Vérifier si on a besoin d'une nouvelle page if y + actual_h > self.h - 20: self.add_page() y = self.get_y() x = x_start images_in_row = 0 row_max_height = 0 self.image(_pdf_image_buf(img_path), x=x, y=y, w=actual_w, h=actual_h) x += actual_w + margin images_in_row += 1 row_max_height = max(row_max_height, actual_h) except Exception as e: logger.warning(f"Impossible d'ajouter l'image {file_path}: {e}") if images_in_row > 0: self.set_y(y + row_max_height + margin) self.ln(3) # ========== STOCK ========== self._add_section_title(_("Stock")) preparation_orders = ( intervention.preparation_orders .all() .order_by("-date") .prefetch_related("preparationorderitem_set__product") ) if preparation_orders.exists(): for order in preparation_orders: order_date = ( order.date.astimezone(brussels).strftime("%d/%m/%Y %H:%M") if order.date else "" ) order_header = _("Bon de préparation") + f" {order.order_number}" order_header += f" - {order.get_status_display()}" if order_date: order_header += f" - {order_date}" self.set_font(family="arial", style="B", size=9) self.set_fill_color(235, 240, 245) self.set_text_color(60, 80, 120) self.cell(0, 7, f" {order_header}", ln=True, fill=True) self.set_text_color(0, 0, 0) self.set_font(family="arial", style="", size=9) order_items = order.preparationorderitem_set.all() if order_items.exists(): for item in order_items: product_name = item.product.name if item.product else _("Produit inconnu") quantity = item.quantity if item.quantity is not None else 0 unit = item.product.get_unit_display() if item.product else "" item_line = f" • {product_name}: {quantity}" if unit: item_line += f" {unit}" self.set_x(12) self.multi_cell(0, 7, item_line) self.ln(1) else: self.set_x(12) self.multi_cell(0, 5, _("Aucun produit sur ce bon de préparation.")) self.ln(2) else: self._add_field(_("Bons de préparation"), _("Aucun bon de préparation lié à cette intervention.")) self.ln(1) # ========== INFORMATIONS COMPLÉMENTAIRES ========== has_extra_info = any([ intervention.osiris, intervention.linked_folder, intervention.created_by ]) if has_extra_info: self._add_section_title(_("Informations complémentaires")) if intervention.osiris: self._add_field("Osiris", intervention.osiris) if intervention.linked_folder: self._add_field(_("Dossier lié"), intervention.linked_folder) if intervention.created_by: self._add_field(_("Créé par"), get_short_name(intervention.created_by)) self.ln(3) # ========== PIED DE PAGE DU RAPPORT ========== self.ln(5) self.set_font(family="arial", style="I", size=8) self.set_text_color(128, 128, 128) from django.utils import timezone generation_time = timezone.now().astimezone(brussels).strftime("%d/%m/%Y %H:%M") self.cell(0, 5, f"{_('Rapport généré le')} {generation_time}", align="R") def generate_intervention_report_pdf(intervention, output_path): """Génère le rapport d'intervention PDF complet (sans prestations).""" pdf = InterventionReportPDF() pdf.intervention = intervention pdf.alias_nb_pages() pdf.add_page() pdf.add_intervention_report_data(intervention) pdf.output(output_path) @intervention_permission_required def download_intervention_report_pdf(request, intervention_id): """Vue pour télécharger le rapport d'intervention en PDF.""" intervention = get_object_or_404(Intervention, id=intervention_id) buffer = io.BytesIO() generate_intervention_report_pdf(intervention, buffer) buffer.seek(0) response = HttpResponse(buffer.read(), content_type="application/pdf") filename = f"rapport_intervention_{intervention.code}.pdf" response['Content-Disposition'] = f'attachment; filename="{filename}"' return response @intervention_permission_required def preview_intervention_report_pdf(request, intervention_id): """Vue pour prévisualiser le rapport d'intervention en PDF dans le navigateur.""" intervention = get_object_or_404(Intervention, id=intervention_id) buffer = io.BytesIO() generate_intervention_report_pdf(intervention, buffer) buffer.seek(0) response = HttpResponse(buffer.read(), content_type="application/pdf") response['Content-Disposition'] = f'inline; filename="rapport_intervention_{intervention.code}.pdf"' return response # ========== RAPPORT D'EXÉCUTION (MULTI-INTERVENTIONS) ========== def _hex_to_rgb(hex_color): """Convertit une couleur hexadécimale en tuple RGB.""" hex_color = hex_color.lstrip('#') return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) class ExecutionReportPDF(FPDF): """PDF multi-pages pour le rapport d'exécution (plusieurs interventions).""" def __init__(self, generated_at=None, report_title=None, *args, **kwargs): super().__init__(*args, **kwargs) self._fonts_registered = False self.generated_at = generated_at or "" self.report_title = report_title or "" def _ensure_fonts(self): if self._fonts_registered: return font_specs = [ ("arial", "", "arial.ttf"), ("arial", "B", "arial_bold.ttf"), ("arial", "I", "arial_italic.ttf"), ] for family, style, filename in font_specs: fname = BASE_FONT_DIR / filename if not fname.exists(): continue try: self.add_font(family, style=style, fname=str(fname), uni=True) except Exception: pass self._fonts_registered = True def header(self): self._ensure_fonts() logo_path = os.path.join(settings.BASE_DIR, "common", "static", settings.ORGANIZATION_LOGO_PNG) if os.path.exists(logo_path): self.image(logo_path, x=10, y=6, w=45) self.set_y(8) self.set_x(65) self.set_font(family="arial", style="B", size=13) self.set_text_color(*settings.ORGANIZATION_PRIMARY_COLOR_RGB) self.cell(0, 8, _("Rapport d'exécution"), align="C") self.ln(8) self.set_x(65) self.set_font(family="arial", style="", size=9) self.set_text_color(80, 80, 80) period_text = f"{_('Généré le')} {self.generated_at}" self.cell(0, 5, period_text, align="C") self.ln(10) # Ligne de séparation y = self.get_y() self.set_draw_color(100, 130, 200) self.set_line_width(0.5) self.line(10, y, self.w - 10, y) self.set_line_width(0.2) self.set_draw_color(0, 0, 0) self.ln(5) def footer(self): self.set_y(-15) self._ensure_fonts() self.set_font(family="arial", style="I", size=8) self.set_text_color(128, 128, 128) self.cell(0, 10, f"{_('Page')} {self.page_no()}/{{nb}}", align="C") def _section_title(self, title): self._ensure_fonts() if self.get_y() > self.h - 40: self.add_page() self.ln(2) self.set_x(10) self.set_font(family="arial", style="B", size=10) self.set_fill_color(100, 130, 200) self.set_text_color(255, 255, 255) self.cell(0, 8, f" {title}", ln=True, fill=True) self.set_text_color(0, 0, 0) self.ln(2) def add_intervention(self, intervention): """Ajoute une intervention au rapport (nouvelle page).""" self._ensure_fonts() brussels = pytz.timezone("Europe/Brussels") from interventions.models import STATUS_CHOICES, STATUS_COLORS, STATUS_COLORS_DARK_TEXT self.add_page() # ---- EN-TÊTE DE LA FICHE ---- # Numéro + statut status_label = str(STATUS_CHOICES.get(intervention.status, intervention.status)) hex_color = STATUS_COLORS.get(intervention.status, '#6c757d') r, g, b = _hex_to_rgb(hex_color) dark_text = intervention.status in STATUS_COLORS_DARK_TEXT self.set_x(10) self.set_font(family="arial", style="B", size=12) self.set_text_color(*settings.ORGANIZATION_PRIMARY_COLOR_RGB) code_text = intervention.code or str(intervention.pk) self.cell(40, 8, code_text, align="L") # Badge statut self.set_fill_color(r, g, b) if dark_text: self.set_text_color(33, 37, 41) else: self.set_text_color(255, 255, 255) self.set_font(family="arial", style="B", size=9) badge_w = min(60, self.get_string_width(status_label) + 8) self.cell(badge_w, 8, f" {status_label} ", align="C", fill=True) self.set_text_color(0, 0, 0) self.ln(9) # Titre self.set_x(10) self.set_font(family="arial", style="B", size=11) self.set_text_color(33, 37, 41) self.multi_cell(0, 6, intervention.title or "", align="L") self.set_text_color(0, 0, 0) self.ln(3) # ---- LOCALISATION ---- loc_name = "" loc_address = intervention.address or "" loc_lon = intervention.lon loc_lat = intervention.lat # Use the prefetched locations queryset to avoid an extra query per intervention. first_loc_link = next(iter(intervention.locations.all()), None) if first_loc_link: loc_obj = first_loc_link.location if loc_obj: code_val = getattr(loc_obj, "code", "") or "" name_fr = getattr(loc_obj, "name_fr", "") or "" loc_name = " - ".join(p for p in (code_val, name_fr) if p) if loc_name or loc_address: self._section_title(_("Localisation")) self.set_x(10) self.set_font(family="arial", style="", size=9) if loc_name: self.set_font(family="arial", style="B", size=9) self.cell(25, 5, _("Localisation") + ":", align="L") self.set_font(family="arial", style="", size=9) self.multi_cell(0, 5, loc_name, align="L") if loc_address: self.set_x(10) self.set_font(family="arial", style="B", size=9) self.cell(25, 5, _("Adresse") + ":", align="L") self.set_font(family="arial", style="", size=9) self.multi_cell(0, 5, loc_address, align="L") self.ln(2) # Mini-carte if loc_lon and loc_lat: try: map_img = generate_location_map(loc_lon, loc_lat, img_size=(800, 350), buffer_meters=120) if map_img: buf = io.BytesIO() map_img.save(buf, format="PNG") buf.seek(0) map_w = 130 map_h = map_w * (350 / 800) x_pos = (self.w - map_w) / 2 self.image(buf, x=x_pos, w=map_w, h=map_h) self.ln(3) except Exception as e: logger.warning("Impossible de générer la carte pour %s: %s", intervention.code, e) # ---- NOTES ---- notes = list(intervention.notes.all().order_by('note_time')) if notes: self._section_title(_("Notes")) for note in notes: note_type_display = note.get_note_type_display() note_time_str = note.note_time.astimezone(brussels).strftime("%d/%m/%Y %H:%M") if note.note_time else "" author = get_short_name(note.note_author) if note.note_author else "" # En-tête de note self.set_x(10) self.set_font(family="arial", style="B", size=9) self.set_fill_color(235, 240, 248) self.set_text_color(60, 80, 120) header = f" {note_type_display}" if note_time_str: header += f" — {note_time_str}" if author: header += f" ({author})" self.cell(0, 6, header, ln=True, fill=True) self.set_text_color(0, 0, 0) # Contenu content = _html_to_plain_text(note.content) if content: self.set_x(14) self.set_font(family="arial", style="", size=9) self.multi_cell(self.w - 24, 5, content) self.ln(1) self.ln(2) # ---- PHOTOS ---- # Photos depuis les documents joints (images) photo_paths = [] for doc in intervention.documents.all(): if doc.file and hasattr(doc.file, 'path'): fp = doc.file.path ext = os.path.splitext(fp)[1].lower() if ext in ('.jpg', '.jpeg', '.jfif', '.png', '.gif', '.bmp', '.webp'): # Utiliser le fichier original (meilleure qualité) if os.path.exists(fp): photo_paths.append(fp) # Photos depuis les opérations for op in intervention.operations.all(): for op_photo in op.photos.all(): if op_photo.photo and hasattr(op_photo.photo, 'path'): fp = op_photo.photo.path if os.path.exists(fp): photo_paths.append(fp) if photo_paths: self._section_title(_("Photos")) x_start = 10 x_end = self.w - 10 x = x_start y = self.get_y() img_max_w = 58 img_max_h = 58 gap = 4 row_h = 0 for fp in photo_paths: try: with Image.open(fp) as img: iw, ih = img.size scale = min(img_max_w / iw, img_max_h / ih) w = iw * scale h = ih * scale if x + w > x_end and x > x_start: x = x_start y += row_h + gap row_h = 0 if y + h > self.h - 20: self.add_page() y = self.get_y() x = x_start row_h = 0 self.image(_pdf_image_buf(fp), x=x, y=y, w=w, h=h) x += w + gap row_h = max(row_h, h) except Exception as e: logger.warning("Impossible d'ajouter la photo %s: %s", fp, e) if row_h > 0: self.set_y(y + row_h + gap) self.ln(3) def generate_bulk_execution_report_pdf(interventions, output, report_title=None, progress_callback=None): """Génère le rapport d'exécution multi-interventions. progress_callback(current, total) est appelé après chaque page d'intervention pour permettre le suivi de progression en temps réel. """ from datetime import date as _date generated_at = _date.today().strftime("%d/%m/%Y") pdf = ExecutionReportPDF(generated_at=generated_at, report_title=report_title or "") pdf.alias_nb_pages() total = len(interventions) # Page de titre (si un titre a été fourni) if pdf.report_title: pdf.add_page() pdf._ensure_fonts() brussels = pytz.timezone("Europe/Brussels") # Grand titre centré pdf.ln(20) pdf.set_font(family="arial", style="B", size=20) pdf.set_text_color(*settings.ORGANIZATION_PRIMARY_COLOR_RGB) pdf.multi_cell(0, 12, pdf.report_title, align="C") pdf.ln(8) # Sous-titre : date de génération pdf.set_font(family="arial", style="", size=10) pdf.set_text_color(100, 100, 100) pdf.cell(0, 8, f"{_('Généré le')} {generated_at}", align="C", ln=True) pdf.ln(6) # Ligne décorative y = pdf.get_y() pdf.set_draw_color(100, 130, 200) pdf.set_line_width(0.8) pdf.line(30, y, pdf.w - 30, y) pdf.set_draw_color(0, 0, 0) pdf.set_line_width(0.2) # Page de garde / sommaire pdf.add_page() pdf._ensure_fonts() brussels = pytz.timezone("Europe/Brussels") pdf.set_font(family="arial", style="B", size=13) pdf.set_text_color(*settings.ORGANIZATION_PRIMARY_COLOR_RGB) pdf.cell(0, 10, _("Sommaire"), ln=True, align="C") pdf.ln(4) pdf.set_font(family="arial", style="", size=9) pdf.set_text_color(0, 0, 0) pdf.cell(15, 6, "#", border=1, align="C", fill=False) pdf.cell(30, 6, _("Code"), border=1, align="C", fill=False) pdf.cell(80, 6, _("Titre"), border=1, align="C", fill=False) pdf.cell(55, 6, _("Statut"), border=1, align="C", fill=False, ln=True) from interventions.models import STATUS_CHOICES, STATUS_COLORS, STATUS_COLORS_DARK_TEXT for idx, intv in enumerate(interventions, start=1): status_label = str(STATUS_CHOICES.get(intv.status, intv.status)) hex_c = STATUS_COLORS.get(intv.status, '#6c757d') r, g, b = _hex_to_rgb(hex_c) dark = intv.status in STATUS_COLORS_DARK_TEXT pdf.set_font(family="arial", style="", size=9) pdf.set_fill_color(255, 255, 255) pdf.set_text_color(0, 0, 0) pdf.cell(15, 6, str(idx), border=1, align="C") pdf.cell(30, 6, intv.code or "", border=1, align="C") title_short = (intv.title or "")[:55] pdf.cell(80, 6, title_short, border=1, align="L") pdf.set_fill_color(r, g, b) if dark: pdf.set_text_color(33, 37, 41) else: pdf.set_text_color(255, 255, 255) pdf.set_font(family="arial", style="B", size=8) pdf.cell(55, 6, status_label, border=1, align="C", fill=True, ln=True) pdf.set_text_color(0, 0, 0) pdf.set_fill_color(255, 255, 255) # Générer une page par intervention for idx, intv in enumerate(interventions, start=1): pdf.add_intervention(intv) if progress_callback: progress_callback(idx, total) # Pied de page global pdf.add_page() pdf._ensure_fonts() from django.utils import timezone as dtz gen_time = dtz.now().astimezone(brussels).strftime("%d/%m/%Y %H:%M") pdf.set_font(family="arial", style="I", size=9) pdf.set_text_color(128, 128, 128) pdf.cell(0, 10, f"{_('Rapport généré le')} {gen_time}", align="C") pdf.output(output) def _build_execution_report_qs(request): """ Construit le queryset des interventions pour le rapport d'exécution. Retourne (qs, error_response) ; si error_response est non-None, la retourner directement. """ import json as _json from django.http import QueryDict, HttpResponse as _HR from interventions.permissions import filter_viewable_interventions_for_user from interventions.views import apply_intervention_filters_from_querydict ids_param = request.POST.get("ids", "[]") filters_raw = request.POST.get("filters", "") date_from_raw = request.POST.get("date_from", "").strip() date_to_raw = request.POST.get("date_to", "").strip() try: ids = _json.loads(ids_param) if not isinstance(ids, list): raise ValueError ids = [int(x) for x in ids] if ids else [] except (ValueError, TypeError, _json.JSONDecodeError): from django.http import HttpResponseBadRequest return None, HttpResponseBadRequest(_("Paramètre 'ids' invalide.")) qs = filter_viewable_interventions_for_user(request.user) qs = qs.exclude(status='canceled') if ids: qs = qs.filter(id__in=ids) else: filter_params = QueryDict(filters_raw.lstrip('?')) if filters_raw else QueryDict() qs = apply_intervention_filters_from_querydict(qs, filter_params) # --- filtre de période (date du dernier changement de statut) --- # On utilise InterventionTimeLine (event_type='status_change') plutôt que # end_time qui n'est pas toujours renseigné. from django.utils.timezone import make_aware, get_current_timezone from datetime import datetime as _dt from django.db.models import Exists, OuterRef from interventions.models import InterventionTimeLine if date_from_raw or date_to_raw: tl_qs = InterventionTimeLine.objects.filter( intervention=OuterRef('pk'), event_type='status_change', to_status__in=['in_progress', 'on_pause', 'finished'], ) if date_from_raw: try: dt = _dt.strptime(date_from_raw, "%Y-%m-%d") dt = make_aware(dt, get_current_timezone()) tl_qs = tl_qs.filter(event_time__gte=dt) except ValueError: pass if date_to_raw: try: dt = _dt.strptime(date_to_raw, "%Y-%m-%d") dt = dt.replace(hour=23, minute=59, second=59, microsecond=999999) dt = make_aware(dt, get_current_timezone()) tl_qs = tl_qs.filter(event_time__lte=dt) except ValueError: pass qs = qs.filter(Exists(tl_qs)) count = qs.count() if count > 50: return None, _HR( _("Le rapport ne peut pas être généré car %(count)s interventions correspondent aux filtres actifs. Veuillez affiner les filtres ou sélectionner manuellement au plus 50 interventions.") % {'count': count}, status=400, content_type="text/plain; charset=utf-8", ) if count == 0: return None, _HR(_("Aucune intervention trouvée."), status=404, content_type="text/plain; charset=utf-8") from django.db.models import Prefetch qs = ( qs .select_related("thematic") .prefetch_related( "notes", "notes__note_author", "documents", "operations", "operations__photos", Prefetch("locations", queryset=InterventionLocation.objects.select_related("content_type")), ) .order_by("begin_time") ) return qs, None # --------------------------------------------------------------------------- # Suivi de progression multi-worker — état persisté dans un fichier JSON # temporaire plutôt qu'un dict en mémoire (compatible avec plusieurs workers # gunicorn qui ne partagent pas leur espace mémoire). # --------------------------------------------------------------------------- import tempfile as _tempfile import json as _json_mod import os as _os_mod def _state_path(token): return _os_mod.path.join(_tempfile.gettempdir(), f"streetup_report_{token}.json") def _pdf_path(token): return _os_mod.path.join(_tempfile.gettempdir(), f"streetup_report_{token}.pdf") def _write_state(token, **fields): """Écrit atomiquement l'état JSON du job (lecture par tous les workers).""" path = _state_path(token) tmp = path + ".tmp" try: existing = _read_state(token) or {} except Exception: existing = {} existing.update(fields) with open(tmp, 'w', encoding='utf-8') as f: _json_mod.dump(existing, f) _os_mod.replace(tmp, path) # os.replace est atomique sur Linux/Windows def _read_state(token): """Lit le fichier d'état JSON ; retourne None si absent ou invalide.""" try: with open(_state_path(token), 'r', encoding='utf-8') as f: return _json_mod.load(f) except (FileNotFoundError, _json_mod.JSONDecodeError): return None def start_bulk_execution_report(request): """ POST : valide la requête, lance la génération PDF dans un thread de fond et retourne immédiatement { token, total } pour que le client puisse surveiller la progression. L'état est stocké dans un fichier temporaire ─ accessible par tous les workers. """ if request.method != "POST": from django.http import HttpResponseNotAllowed return HttpResponseNotAllowed(["POST"]) import uuid, threading from django.http import JsonResponse report_title = (request.POST.get("report_title") or "").strip() qs, err = _build_execution_report_qs(request) if err is not None: return JsonResponse( {'error': err.content.decode('utf-8', errors='replace')}, status=err.status_code, ) interventions_list = list(qs) total = len(interventions_list) token = str(uuid.uuid4()) tmp_path = _pdf_path(token) _write_state(token, status='running', current=0, total=total, error=None) # Capture la langue active avant le thread (Django gettext est thread-local). from django.utils import translation as _trans _lang = get_language() or settings.LANGUAGE_CODE def _run(): _trans.activate(_lang) try: def _cb(current, _total): _write_state(token, current=current) generate_bulk_execution_report_pdf( interventions_list, tmp_path, report_title=report_title, progress_callback=_cb, ) _write_state(token, status='done', current=total) except Exception as exc: logger.error("Erreur génération rapport d'exécution (token=%s): %s", token, exc, exc_info=True) _write_state(token, status='error', error=str(exc)) threading.Thread(target=_run, daemon=True).start() return JsonResponse({'token': token, 'total': total}) def poll_bulk_execution_report(request, token): """GET : retourne l'état courant d'une génération en cours (lu depuis le fichier JSON).""" import re from django.http import JsonResponse, HttpResponseNotFound if not re.fullmatch(r'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', token): return HttpResponseNotFound() state = _read_state(token) if state is None: return HttpResponseNotFound() return JsonResponse({ 'status': state.get('status', 'running'), 'current': state.get('current', 0), 'total': state.get('total', 0), 'error': state.get('error'), }) def download_bulk_execution_report(request, token): """GET : sert le fichier PDF temporaire identifié par le token, puis supprime les deux fichiers.""" import re from django.http import HttpResponse as _HR, HttpResponseNotFound if not re.fullmatch(r'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', token): return HttpResponseNotFound() state = _read_state(token) if not state or state.get('status') != 'done': return HttpResponseNotFound(_("Rapport introuvable ou expiré.")) tmp_path = _pdf_path(token) if not _os_mod.path.exists(tmp_path): return HttpResponseNotFound(_("Fichier du rapport introuvable.")) with open(tmp_path, 'rb') as f: pdf_bytes = f.read() # Nettoyage : PDF + fichier d'état for p in (tmp_path, _state_path(token)): try: _os_mod.remove(p) except Exception: pass from datetime import date as _date fname = f"rapport_execution_{_date.today().strftime('%Y%m%d')}.pdf" resp = _HR(pdf_bytes, content_type="application/pdf") resp["Content-Disposition"] = f'attachment; filename="{fname}"' return resp def bulk_execution_report_pdf(request): """ Génère et télécharge le rapport d'exécution PDF pour une sélection d'interventions. POST params: - ids: JSON liste d'IDs (optionnel – si absent, applique les filtres actifs) - filters: query string des filtres actifs (window.location.search), utilisé quand ids est vide """ if request.method != "POST": from django.http import HttpResponseNotAllowed return HttpResponseNotAllowed(["POST"]) report_title = (request.POST.get("report_title") or "").strip() qs, err = _build_execution_report_qs(request) if err is not None: return err interventions_list = list(qs) from datetime import date as _date buffer = io.BytesIO() generate_bulk_execution_report_pdf(interventions_list, buffer, report_title=report_title) buffer.seek(0) from django.http import HttpResponse as _HR response = _HR(buffer.read(), content_type="application/pdf") fname = f"rapport_execution_{_date.today().strftime('%Y%m%d')}.pdf" response["Content-Disposition"] = f'attachment; filename="{fname}"' return response