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