loko/streetup/interventions/views/views_pdf_reporting.py
2026-07-22 14:48:40 +02:00

2547 lines
No EOL
102 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

from django.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"</\s*p\s*>", 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