2547 lines
No EOL
102 KiB
Python
2547 lines
No EOL
102 KiB
Python
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:"
|
||
from common.hooks import hooks
|
||
custom_ref = hooks.get_custom_source_ref_value(intervention)
|
||
if custom_ref is not None:
|
||
text_value = custom_ref
|
||
elif is_observation_source and intervention.source_ref:
|
||
text_value = f"Observation {intervention.source_ref}"
|
||
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)
|
||
|
||
from common.hooks import hooks
|
||
for label, value in hooks.extract_pdf_fields(intervention):
|
||
self._add_field(label, value)
|
||
|
||
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 |