from django.shortcuts import render, get_object_or_404, redirect
from django.http import JsonResponse, Http404
from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.db.models.functions import Transform
from django.contrib.gis.geos import Point, GEOSGeometry
from django.contrib.gis.db.models.functions import Distance
from django.views.decorators.http import require_GET, require_POST
from django.template.loader import render_to_string
from django.db.models import Value, OuterRef, Subquery, Q, Case, When
from django.db.models import IntegerField, CharField, BooleanField
from django.db.models import Func, F
from django.db.models.functions import Lower
from django.utils.translation import gettext as _, get_language
from django.urls import reverse
import json
import unicodedata
import math
import re
import xml.etree.ElementTree as ET
try:
from PIL import Image
except ImportError:
Image = None
from common.models import Thematic, UserConfig, UserContractAccess, UserThematics, Municipality
from common.utils import check_thematic_access
from assets.models import SignStreet, SignPole, SignPanel, SignPoleModel, SignPanelModel, SignPanelType, SignStreetDocument, AssetCategory
from assets.forms import SignStreetDocumentForm
from interventions.models import InterventionLocation
from interventions.views.drafts import InterventionDraftManager
@check_thematic_access('sign')
def sign_index(request):
thematic = 'sign'
lang = get_language()
# Get filters from URL
statuses = request.GET.getlist('status')
selected_municipalities = request.GET.getlist('municipality')
selected_categories = request.GET.getlist('category')
selected_models = request.GET.getlist('model')
selected_panel_types = request.GET.getlist('panel_type')
# Get preventive draft ID (if any) for selecting assets
preventive_draft_id = request.GET.get('preventive_draft_id')
if preventive_draft_id:
drafts = InterventionDraftManager(request.session)
draft_data = drafts.get(preventive_draft_id)
form_data = draft_data.get('form_data', {})
preventive_element_type = form_data.get('type_of_selected_elements')
if preventive_element_type == 'assets':
selected_assets = draft_data.get('assets', []) or []
else:
selected_locations_ids = draft_data.get('locations', [])
thematic_choices_qs = (
UserThematics.objects
.filter(user_config=request.user.config, can_view_assets=True)
.select_related('thematic')
.order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}')
)
thematics = [tc.thematic for tc in thematic_choices_qs]
selected_thematic = {
"code": thematic,
"name": next((t.name for t in thematics if t.code == thematic), None),
"icon": next((t.icon for t in thematics if t.code == thematic), None),
}
municipalities = list(Municipality.objects.all())
municipalities.sort(key=lambda m: m.name.lower())
municipality_choices = [(m.id, m.name) for m in municipalities]
categories = list(AssetCategory.objects.filter(thematic__code = 'sign'))
categories.sort(key=lambda m: m.get_name().lower())
category_choices = [(m.id, m.get_name()) for m in categories]
models = list(SignPoleModel.objects.all())
models += list(SignPanelModel.objects.all())
models.sort(key=lambda m: (m.get_name() or "").lower())
model_choices = [(m.id, m.get_name()) for m in models]
panel_types = list(SignPanelType.objects.all().order_by('code'))
panel_type_choices = [(m.id, m.code) for m in panel_types]
context = {
'thematics': thematics,
'municipality_choices':municipality_choices,
'category_choices':category_choices,
'model_choices':model_choices,
'panel_type_choices':panel_type_choices,
'selected_thematic': selected_thematic,
'selected_statuses': statuses,
'selected_municipalities': selected_municipalities,
'selected_categories': selected_categories,
'selected_models': selected_models,
'selected_panel_types': selected_panel_types,
'preventive_draft_id': preventive_draft_id,
'preventive_element_type': preventive_element_type if 'preventive_element_type' in locals() else '',
'selected_locations_ids': selected_locations_ids if 'selected_locations_ids' in locals() else [],
"selected_assets_json": json.dumps(selected_assets) if 'selected_assets' in locals() else [],
'locations_tab_title': _("Voiries"),
'locations_assets_title': "Assets",
}
return render(request, "assets/sign_index.html", context)
def sign_streets(request):
pass
def sign_assets(request):
pass
def ensure_list(value):
if isinstance(value, list):
return value
if value is None:
return []
return [value]
@check_thematic_access('sign')
def sign_streets_geojson(request):
if request.method == "POST":
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
municipalities = ensure_list(data.get('municipality'))
filter_geom_raw = data.get("filter_geom")
if filter_geom_raw:
try:
filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326)
filter_geom.transform(3812)
except Exception as e:
return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400)
else:
filter_geom = None
else:
municipalities = request.GET.getlist('municipality')
filter_geom = None
# --------- Point d'entrée du queryset
qs = SignStreet.objects.all()
# --------- Filtre par communes
if municipalities:
qs = qs.filter(municipality_id__in=municipalities)
# --------- Filtre géométrique
if filter_geom:
qs = qs.filter(geom__intersects=filter_geom)
# Recherche plein texte
if request.method == 'POST':
from assets.views.core import apply_search
qs = apply_search(qs, data)
lang = get_language() or "fr"
name_field = 'name_nl' if lang == 'nl' else 'name_fr'
name_expr = F(name_field)
qs = (
qs.annotate(
name=name_expr,
municipality_name=F('municipality__name'),
)
.values('id', 'code', 'lon', 'lat', 'name', 'municipality_name', 'geojson')
.order_by('name')
)
# --------- Conversion en GeoJSON
def obj_to_feature(r):
geom = json.loads(r['geojson']) if r['geojson'] else None
return {
"type": "Feature",
"id": r['id'],
"geometry": geom,
"properties": {
"code": r['code'],
"municipality": r['municipality_name'] or "-",
"name": r['name'],
"lon": r['lon'],
"lat": r['lat'],
"detail_url": reverse('assets:sign_streets_detail', kwargs={'street_id': r['id']}),
"element_type": "Voirie signalisation",
"thematic": "sign",
"is_location": True,
}
}
features = []
for location in qs:
features.append(obj_to_feature(location))
geojson_data = {
"type": "FeatureCollection",
"features": features,
}
return JsonResponse(geojson_data, safe=False)
MAX_ASSET_FEATURES = 5000 # limite imposée
PANEL_VERTICAL_GAP_PX = 65
MIN_PANEL_BASE_UP_OFFSET_PX = 40
ORIENTATION_ARROW_MARGIN_PX = 40
DEFAULT_PANEL_ICON_HEIGHT_PX = PANEL_VERTICAL_GAP_PX
TEXT_LINE_HEIGHT_PX = 20
TEXT_VERTICAL_PADDING_PX = 14
# Height boost for lines containing inline symbols (PEDESTRIAN, CAR, BIKE) that render at 1.5x font size
SYMBOL_LINE_EXTRA_HEIGHT_PX = 12
# Height of embedded icon in Zone panels (Z*) - matches signIconHeight in JS (64px) + spacing (6px)
ZONE_EMBEDDED_ICON_HEIGHT_PX = 70
PANEL_HEIGHT_ADJUST_PX = {
# "XC": 80, # XC needs a taller rendering envelope
# "TYPEXIVF": -20, # TYPEXIVF was too tall; pull it back slightly
# "ZE9A": -40,
}
STANDARD_PANEL_STACK_GAP_PX = 4
TEXT_PANEL_BASE_WIDTH_PX = 72
ZONE_TEXT_PANEL_BASE_WIDTH_PX = 64
@check_thematic_access('sign')
def sign_assets_geojson(request):
if request.method == "POST":
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
sign_location_ids = data.get('locations_ids', [])
statuses = data.get('asset_status', [])
begin_date = data.get('asset_begin_date')
categories = ensure_list(data.get('category'))
models = ensure_list(data.get('model'))
municipalities = ensure_list(data.get('municipality'))
panel_types = ensure_list(data.get('panel_type'))
# Récupération et parsing de la géométrie
filter_geom_raw = data.get("filter_geom")
if filter_geom_raw:
try:
filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326)
filter_geom.transform(3812)
except Exception as e:
return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400)
else:
filter_geom = None
else:
sign_location_id = request.GET.get('location')
sign_location_ids = [sign_location_id] if sign_location_id else []
statuses = request.GET.getlist('asset_status')
begin_date = request.GET.get('asset_begin_date')
categories = request.GET.getlist('category', [])
models = request.GET.getlist('model', [])
municipalities = request.GET.getlist('municipality', [])
panel_types = request.GET.getlist('panel_type', [])
filter_geom = None
# Pas de paramètres = pas de carte (comportement existant conservé)
_search_q = (data.get('q') or '').strip() if request.method == 'POST' else ''
if not request.GET.get('unified') and not sign_location_ids and not categories and not models and not municipalities and not filter_geom and not panel_types and not _search_q:
geojson_data = {"type": "FeatureCollection", "features": []}
return JsonResponse(geojson_data)
# Estimate render heights and offsets for panels to avoid per-type magic numbers
fallback_text_lines = {
"TYPEXIVF": 5,
"TYPE VIIDE": 2,
"TYPEVIIDE": 2,
"TYPEVI": 2,
"TYPEVE": 2,
"XC": 3,
"ICR": 2,
"FXX": 2,
"F35": 2,
}
def normalize_panel_text(text):
if not text:
return ""
# Support multiple line break formats (in priority order):
# 1. Literal \n in plain text (two chars: backslash + n) -> convert to newline
# This handles cases like "Line 1\nLine 2" stored as literal backslash-n
text = text.replace("\\n", "\n")
# 2. HTML tags:
,
,
text = text.replace("
", "\n").replace("
", "\n").replace("
", "\n")
# 3. Carriage returns and other line breaks (Windows/Unix line endings)
text = text.replace("\r\n", "\n").replace("\r", "\n")
return text
def can_panel_render_text(panel_type_code, panel_text):
if not panel_type_code:
return False
code = panel_type_code.upper()
if code.startswith("Z"):
return True
if code in {"F34A", "FXX", "F33C", "F35", "TYPEVE", "TYPE VIIDE", "TYPEVIIDE", "XC", "TYPEXIVF"}:
return bool(panel_text) or code in {"TYPEVE", "TYPEXIVF", "FXX", "TYPE VIIDE", "TYPEVIIDE", "F35"}
if code == "ICR":
return True
if code.startswith("TYPE"):
return bool(panel_text)
return False
def count_panel_text_lines(panel_type_code, panel_text, can_render_text):
if not can_render_text:
return 0
normalized = normalize_panel_text(panel_text)
lines = [line for line in normalized.splitlines() if line.strip()]
code = (panel_type_code or "").upper()
fallback = fallback_text_lines.get(code, 0)
# For TYPE* series, treat '|' as a line break (bilingual separation)
if code.startswith("TYPE"):
normalized = normalized.replace('|', '\n')
lines = [line for line in normalized.splitlines() if line.strip()]
# Z* panels always include a "ZONE" header line
if code.startswith("Z"):
# For Zone panels, treat '|' as a line break
normalized = normalized.replace('|', '\n')
lines = [line.strip() for line in normalized.splitlines() if line.strip()]
# Remove any provided "ZONE" lines to avoid double counting (header is added separately in UI)
filtered = [l for l in lines if l.upper() != 'ZONE']
# Also remove a leading underlying sign code line matching the remainder (e.g., E9B)
remainder = code[1:].replace("’", "").replace("'", "")
filtered = [l for l in filtered if l.upper() != remainder.upper()]
# Count: 1 header line (ZONE) + remaining text lines
return 1 + len(filtered)
if not lines and fallback:
return fallback
if not lines and panel_text:
return 1
return max(len(lines), 1 if fallback and can_render_text else len(lines))
def get_image_aspect_ratio(signpanel_type):
"""Extract aspect ratio (height/width) from SignPanelType image.
Supports raster images and SVG files. Returns 1.0 on failure."""
if not signpanel_type or not signpanel_type.sgv_picture:
return 1.0
def parse_svg_numeric(value):
if not value:
return None
match = re.match(r"\s*([0-9]*\.?[0-9]+)", str(value))
if not match:
return None
try:
return float(match.group(1))
except ValueError:
return None
try:
img_path = signpanel_type.sgv_picture.path
if img_path.lower().endswith('.svg'):
tree = ET.parse(img_path)
root = tree.getroot()
width = parse_svg_numeric(root.attrib.get('width'))
height = parse_svg_numeric(root.attrib.get('height'))
if (not width or not height) and root.attrib.get('viewBox'):
view_box = re.split(r"[\s,]+", root.attrib.get('viewBox').strip())
if len(view_box) == 4:
try:
width = float(view_box[2])
height = float(view_box[3])
except ValueError:
width = width or None
height = height or None
if width and height and width > 0:
return height / width
elif Image:
with Image.open(img_path) as img:
width, height = img.size
if width > 0:
return height / width
except Exception:
pass
return 1.0
def estimate_panel_height(panel_type_code, panel_text, can_render_text, scale_factor=1.0, aspect_ratio=1.0):
code = (panel_type_code or "").upper()
if not can_render_text:
if code == "FXX" and not panel_text:
return max((DEFAULT_PANEL_ICON_HEIGHT_PX - 10) * scale_factor, 40 * scale_factor)
return DEFAULT_PANEL_ICON_HEIGHT_PX * scale_factor * aspect_ratio
line_count = count_panel_text_lines(panel_type_code, panel_text, can_render_text)
text_height = line_count * TEXT_LINE_HEIGHT_PX + (TEXT_VERTICAL_PADDING_PX if line_count else 0)
# Add extra height for lines with inline symbols (rendered at 1.5x font size in canvas)
symbol_lines = 0
if panel_text:
normalized = normalize_panel_text(panel_text)
# Count lines containing symbol markers
for line in normalized.splitlines():
if '\u0001PEDESTRIAN\u0002' in line or '\u0001CAR\u0002' in line or '\u0001BIKE\u0002' in line:
symbol_lines += 1
# Z* panels include an embedded sign icon (64px + 6px spacing)
additional_image_height = 0
if code.startswith("Z"):
additional_image_height = ZONE_EMBEDDED_ICON_HEIGHT_PX
adjusted_height = text_height + (symbol_lines * SYMBOL_LINE_EXTRA_HEIGHT_PX) + additional_image_height + PANEL_HEIGHT_ADJUST_PX.get(code, 0)
return max(DEFAULT_PANEL_ICON_HEIGHT_PX, adjusted_height) * scale_factor
def estimate_panel_width(panel_type_code, can_render_text, scale_factor=1.0):
code = (panel_type_code or "").upper()
if can_render_text:
base_width = ZONE_TEXT_PANEL_BASE_WIDTH_PX if code.startswith("Z") else TEXT_PANEL_BASE_WIDTH_PX
return base_width * scale_factor
return DEFAULT_PANEL_ICON_HEIGHT_PX * scale_factor
def get_panel_dimensions_cm(panel):
"""Return panel width/height in cm with resilient fallbacks.
Prefer explicit panel attributes when available, then type defaults, then model dimensions, then defaults.
"""
if hasattr(panel, 'get_width') and hasattr(panel, 'get_height'):
return panel.get_width(), panel.get_height()
model = getattr(panel, 'model', None)
width = getattr(panel, 'width', None)
if width in (None, 0) and model is not None:
width = getattr(model, 'width', None) or getattr(model, 'diameter', None)
if not width or width <= 0:
width = 50.0
height = getattr(panel, 'height', None)
if height in (None, 0) and model is not None:
height = getattr(model, 'height', None)
if not height or height <= 0:
height = width
return float(width), float(height)
def has_explicit_panel_height(panel):
if getattr(panel, 'height', None) not in (None, 0):
return True
model = getattr(panel, 'model', None)
if model is not None and getattr(model, 'height', None) not in (None, 0):
return True
if getattr(panel, 'signpanel_type', None) is not None:
if getattr(panel.signpanel_type, 'default_height', None) not in (None, 0) or getattr(panel.signpanel_type, 'default_diameter', None) not in (None, 0):
return True
return False
def get_panel_scale_factor(panel_width_cm):
if panel_width_cm and panel_width_cm > 0:
return panel_width_cm / 50.0
return 1.0
from assets.views.core import get_element_type_label
def build_geometry_and_coords(obj):
geometry = None
if obj.geojson:
try:
geometry = json.loads(obj.geojson)
except (TypeError, ValueError, json.JSONDecodeError):
geometry = None
lon = obj.lon
lat = obj.lat
if lon is None or lat is None:
street = getattr(obj, 'street', None)
if street is not None:
if lon is None:
lon = getattr(street, 'lon', None)
if lat is None:
lat = getattr(street, 'lat', None)
if geometry is None and lon is not None and lat is not None:
geometry = {
"type": "Point",
"coordinates": [lon, lat],
}
return geometry, lon, lat
def obj_to_feature(obj, asset_type=None):
geometry, lon, lat = build_geometry_and_coords(obj)
panel_type_code = obj.signpanel_type.code.strip() if hasattr(obj, 'signpanel_type') and obj.signpanel_type and obj.signpanel_type.code else None
# Normalize panel_type_code to match file naming (case-insensitive)
icon_code = panel_type_code
if panel_type_code:
upper_code = panel_type_code.upper()
if upper_code == "XA":
icon_code = "GXA" # use existing SVG icon
elif upper_code == "XB":
icon_code = "GXB"
elif upper_code == "XD":
icon_code = "GXD"
# For Z* types, use the underlying code (e.g., ZE9A -> E9A) for icon mapping
if panel_type_code and panel_type_code.upper().startswith("Z") and len(panel_type_code) > 1:
icon_code = panel_type_code[1:]
panel_icon_image = f"road-sign-{icon_code.lower()}" if icon_code else None
panel_icon_image_scaled = f"{panel_icon_image}-x12" if panel_icon_image else None
feature = {
"type": "Feature",
"id": obj.id,
"geometry": geometry,
"properties": {
"code": obj.code,
"name": obj.code,
"category": obj.category.get_name() if obj.category else None,
"model": obj.model.get_name() if obj.model else None,
"panel_type": panel_type_code,
"panel_text": normalize_panel_text(obj.signpanel_text or ("ICR" if (panel_type_code and panel_type_code.upper() == "ICR") else "")) if hasattr(obj, 'signpanel_text') else None,
"vertical_order": obj.vertical_order if hasattr(obj, 'vertical_order') else None,
"orientation": obj.orientation if hasattr(obj, 'orientation') else None,
"status": obj.status,
"validation_status": obj.validation_status,
"lon": lon,
"lat": lat,
"asset_type": asset_type,
"panel_icon_image": panel_icon_image,
"panel_icon_image_scaled": panel_icon_image_scaled,
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
"element_type": get_element_type_label(asset_type or ''),
"thematic": "sign",
"is_location": False,
},
}
if asset_type == "signpanel":
panel_meta = panel_render_meta.get(obj.id, {})
scale_factor = panel_meta.get("scale_factor", 1.0)
panel_width = panel_meta.get("width", 50.0)
panel_height = panel_meta.get("height", 50.0)
render_width = panel_meta.get("render_width", 72)
render_height = panel_meta.get("render_height", DEFAULT_PANEL_ICON_HEIGHT_PX)
vertical_order = obj.vertical_order if hasattr(obj, "vertical_order") else None
feature["properties"].update({
"pole_id": getattr(obj, "pole_id", None),
})
orientation_value = obj.orientation if hasattr(obj, "orientation") and obj.orientation is not None else None
show_orientation_arrow = bool(
orientation_value is not None and
(vertical_order is None or vertical_order == 1)
)
arrow_vertical_shift = -MIN_PANEL_BASE_UP_OFFSET_PX + ORIENTATION_ARROW_MARGIN_PX
if orientation_value is not None:
offset_x = 0
offset_y = arrow_vertical_shift
else:
offset_x = 0
offset_y = arrow_vertical_shift
# Determine text rendering capabilities and sprite id
can_render_text = panel_meta.get("can_render_text", False)
# Generate appropriate sprite ID based on panel type
if panel_type_code and panel_type_code.upper() == "TYPEVE":
sprite_prefix = "panel-text-typeve"
elif panel_type_code and panel_type_code.upper() in ("TYPE VIIDE", "TYPEVIIDE"):
sprite_prefix = "panel-text-typeviide"
elif panel_type_code and panel_type_code.upper() == "XC":
sprite_prefix = "panel-text-xc"
elif panel_type_code and panel_type_code.upper() == "ICR":
sprite_prefix = "panel-text-icr"
elif panel_type_code and panel_type_code.upper() == "TYPEXIVF":
sprite_prefix = "panel-text-typexivf"
elif panel_type_code and panel_type_code.upper().startswith("Z"):
sprite_prefix = "panel-text-zone"
elif panel_type_code and panel_type_code.upper().startswith("TYPE"):
sprite_prefix = "panel-text-type"
elif panel_type_code and (panel_type_code.upper() == "F33C" or panel_type_code.upper() == "F35"):
sprite_prefix = "panel-text-f33c"
elif panel_type_code and panel_type_code.upper() == "FXX":
sprite_prefix = "panel-text-fxx"
else:
sprite_prefix = "panel-text-f34a"
sprite_id = f"{sprite_prefix}-{obj.id}" if can_render_text else None
aspect_ratio = panel_meta.get("aspect_ratio", 1.0)
feature["properties"].update({
"panel_orientation_offset": [offset_x, offset_y],
"show_orientation_arrow": show_orientation_arrow,
"panel_text_can_render": can_render_text,
"panel_text_image_id": sprite_id,
"panel_hide_icon": can_render_text,
"panel_vertical_order": vertical_order,
"panel_render_width": render_width,
"panel_render_height": render_height,
"panel_scale_factor": scale_factor,
"panel_width_cm": panel_width,
"panel_height_cm": panel_height,
"panel_aspect_ratio": aspect_ratio,
})
return feature
def add_assets_to_features(queryset, asset_type, features, feature_count):
for obj in queryset:
if feature_count >= MAX_ASSET_FEATURES:
return feature_count, True # early stop
features.append(obj_to_feature(obj, asset_type=asset_type))
feature_count += 1
return feature_count, False
# --------- Query de base + optimisations
signpole_qs = (
SignPole.objects
.select_related('street', 'category', 'model')
.all()
)
signpanel_qs = (
SignPanel.objects
.select_related('street', 'category', 'model', 'signpanel_type')
.all()
)
# --------- Filtres métier existants
if sign_location_ids:
signpole_qs = signpole_qs.filter(street__id__in=sign_location_ids)
signpanel_qs = signpanel_qs.filter(street__id__in=sign_location_ids)
if municipalities:
signpole_qs = signpole_qs.filter(street__municipality_id__in=municipalities)
signpanel_qs = signpanel_qs.filter(street__municipality_id__in=municipalities)
if models:
signpole_qs = signpole_qs.filter(model__id__in=models)
signpanel_qs = signpanel_qs.filter(model__id__in=models)
if categories:
signpole_qs = signpole_qs.filter(category__id__in=categories)
signpanel_qs = signpanel_qs.filter(category__id__in=categories)
if filter_geom:
signpole_qs = signpole_qs.filter(
Q(geom__intersects=filter_geom) |
Q(geom__isnull=True, street__geom__intersects=filter_geom)
)
signpanel_qs = signpanel_qs.filter(
Q(geom__intersects=filter_geom) |
Q(geom__isnull=True, street__geom__intersects=filter_geom)
)
if panel_types:
signpole_qs = signpole_qs.filter(id__in=[]) # Aucun pole n'a de type de panneau
signpanel_qs = signpanel_qs.filter(signpanel_type__id__in=panel_types)
# Recherche plein texte
if request.method == 'POST':
from assets.views.core import apply_search
signpole_qs = apply_search(signpole_qs, data)
signpanel_qs = apply_search(signpanel_qs, data)
# --------- Cap de sécurité
signpole_qs = signpole_qs[0:MAX_ASSET_FEATURES+1]
if len(signpole_qs) >= MAX_ASSET_FEATURES:
return JsonResponse({"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES})"}, status=413)
signpanel_qs = signpanel_qs[0:MAX_ASSET_FEATURES+1]
if len(signpanel_qs) >= MAX_ASSET_FEATURES:
return JsonResponse({"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES})"}, status=413)
signpanel_list = list(signpanel_qs)
panel_render_meta = {}
for panel in signpanel_list:
panel_type_code = panel.signpanel_type.code.strip() if getattr(panel, "signpanel_type", None) and panel.signpanel_type.code else None
panel_text_val = getattr(panel, "signpanel_text", None)
panel_can_render_text = can_panel_render_text(panel_type_code, panel_text_val)
panel_width_cm, panel_height_cm = get_panel_dimensions_cm(panel)
scale_factor = get_panel_scale_factor(panel_width_cm)
# Calculate aspect ratio from image if available
aspect_ratio = get_image_aspect_ratio(panel.signpanel_type) if not panel_can_render_text else 1.0
if not has_explicit_panel_height(panel) and not panel_can_render_text:
effective_height_cm = panel_width_cm * aspect_ratio
else:
effective_height_cm = panel_height_cm
render_height = estimate_panel_height(panel_type_code, panel_text_val, panel_can_render_text, scale_factor, aspect_ratio)
render_width = estimate_panel_width(panel_type_code, panel_can_render_text, scale_factor)
panel_render_meta[panel.id] = {
"render_height": render_height,
"render_width": render_width,
"can_render_text": panel_can_render_text,
"panel_text": panel_text_val,
"scale_factor": scale_factor,
"width": panel_width_cm,
"height": panel_height_cm,
"effective_width_cm": panel_width_cm,
"effective_height_cm": effective_height_cm,
"aspect_ratio": aspect_ratio,
}
# --------- Construction GeoJSON
features = []
feature_count = 0
for qs, asset_type in [
(signpanel_list, "signpanel"),
]:
feature_count, limit_reached = add_assets_to_features(qs, asset_type, features, feature_count)
if limit_reached:
return JsonResponse({
"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES}). {_("Limitez votre sélection.")}"
}, status=413)
for qs, asset_type in [
(signpole_qs, "signpole"),
]:
feature_count, limit_reached = add_assets_to_features(qs, asset_type, features, feature_count)
if limit_reached:
return JsonResponse({
"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES}). {_("Limitez votre sélection.")}"
}, status=413)
features = sorted(features, key=lambda f: f["properties"]["code"])
geojson_data = {
"type": "FeatureCollection",
"features": features,
}
return JsonResponse(geojson_data, safe=False)
@check_thematic_access('sign')
def sign_streets_detail(request, street_id):
street = get_object_or_404(SignStreet, pk=street_id)
ct_sign_street = ContentType.objects.get_for_model(SignStreet)
interventions = [obj.intervention for obj in InterventionLocation.objects.filter(content_type=ct_sign_street, object_id=street.id).select_related('intervention').order_by('-intervention__creation_time')]
assets = street.get_active_assets()
as_built_doc = street.documents.filter(document_type='as_built').filter(status='active').order_by('-id').first()
other_docs = street.documents.filter(document_type='other').filter(status='active').order_by('-id')
street_geom_4326 = street.geom.transform(4326, clone=True)
geom = json.loads(street_geom_4326.geojson)
return render(request, "assets/sign_street_detail.html",
{
"street":street,
"interventions":interventions,
"assets":assets,
'main_documents': [as_built_doc,],
"other_documents": other_docs,
'add_document_form': SignStreetDocumentForm(),
"geom": geom
})
@check_thematic_access('sign')
def sign_assets_detail(request, asset_model, asset_id):
try:
# Récupérer le ContentType pour le modèle fourni (spécifier l'app_label 'assets' pour éviter les conflits)
model_name = asset_model.lower().replace('_', '')
content_type = ContentType.objects.get(app_label='assets', model=model_name)
model_class = content_type.model_class()
except ContentType.DoesNotExist:
raise Http404(_("Modèle %(asset_model)s inconnu.") % {'asset_model': asset_model})
# Récupérer l’objet asset
asset = get_object_or_404(model_class, id=asset_id)
# Choisir dynamiquement le template à utiliser
template_name = f"sign/detail_{asset_model}.html"
# Si le template spécifique n’existe pas, tu peux fallback vers un template générique :
from django.template.loader import select_template
try:
template = select_template([template_name, "assets/detail_generic.html"])
except:
raise Http404(_("Aucun template trouvé pour ce type d’asset."))
# Get edit context
from assets.views.core import get_asset_edit_context
context = {
"asset": asset,
"content_type": content_type,
}
context.update(get_asset_edit_context(request, asset, content_type))
# Override thematic_code for sign
context['thematic_code'] = 'sign'
return render(request, template.template.name, context)
# Appelle la fonction PostgreSQL unaccent(...)
class Unaccent(Func):
function = 'public.unaccent_immutable'
output_field = CharField()
def strip_accents_lower(s: str) -> str:
if not s:
return ''
s = ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
return s.lower()
@check_thematic_access('sign', edit_permission_required=True)
@require_POST
def sign_streets_add_document(request, street_id):
signstreet = get_object_or_404(SignStreet, pk=street_id)
count = int(request.POST.get('file_count', 0))
files = request.FILES.getlist('files')
for i in range(count):
file = files[i]
document_type = request.POST.get(f'document_type_{i}', 'other')
SignStreetDocument.objects.create(
street=signstreet,
file=file,
document_type=document_type,
uploaded_by = request.user
)
return redirect('assets:sign_streets_detail', street_id=street_id)
@check_thematic_access('sign', edit_permission_required=True)
@require_POST
def sign_streets_delete_document(request, document_id):
document = get_object_or_404(SignStreetDocument, id=document_id)
street_id = document.street.id # pour rediriger vers la bonne page
# Suppression du fichier si nécessaire (en plus de l'objet DB)
if document.file:
document.file.delete(save=False)
if document.thumbnail:
document.thumbnail.delete(save=False)
document.delete()
#messages.success(request, "Le document a été supprimé avec succès.")
return redirect('assets:sign_streets_detail', structure_id=street_id)
@check_thematic_access('sign')
def fetch_document_history(request):
street_id = request.GET.get('street_id')
document_type = request.GET.get('document_type')
documents = SignStreetDocument.objects.filter(
street_id=street_id,
document_type=document_type
).order_by('-id')
html = render_to_string('assets/document_history_list.html', {'documents': documents})
return JsonResponse({'html': html})
@require_GET
def get_nearest_sign_street(request):
try:
lon = float(request.GET.get('lon'))
lat = float(request.GET.get('lat'))
except (TypeError, ValueError):
return JsonResponse({"error": "Coordonnées invalides"}, status=400)
user_point = Point(lon, lat, srid=4326)
user_point.transform(3812)
nearest = (
SignStreet.objects
.filter(geom__isnull=False)
.annotate(distance=Distance('geom', user_point))
.filter(distance__lte=50) # filtre à 50 mètres
.order_by('distance')
.first()
)
location_ct_id = ContentType.objects.get_for_model(nearest).id
if nearest:
return JsonResponse({
"location_id": nearest.id,
"location_ct": location_ct_id,
"location_label": f"{nearest.code} - {nearest.get_name()}",
"lon": nearest.lon, "lat": nearest.lat
})
else:
return JsonResponse({"error": _("Aucune rue trouvée")})
@require_GET
def sign_assets_autocomplete(request):
raw_query_original = (request.GET.get('q') or '')
raw_query = raw_query_original.strip()
qn = strip_accents_lower(raw_query)
has_search_term = len(qn) > 0
location_id = request.GET.get('location_id') # = id de la rue
category_id = request.GET.get('asset_category')
try:
category_id = int(category_id)
except (TypeError, ValueError):
category_id = None
# Prépare filtre "dans cette rue uniquement" si location_id fourni
street_filter = {}
if location_id:
street_filter = {'street_id': location_id}
def base_qs(model, extra_filter=None):
ann = {
'code_u': Lower(Unaccent('code')),
'name_fr_u': Lower(Unaccent('name_fr')),
'name_nl_u': Lower(Unaccent('name_nl')),
}
q = model.objects.annotate(**ann)
# Si un terme de recherche est fourni, filtrer dessus
if has_search_term:
q = q.filter(
Q(code_u__contains=qn) |
Q(name_fr_u__contains=qn) |
Q(name_nl_u__contains=qn)
)
# Filtrer uniquement les non supprimés
q = q.exclude(status='removed')
if extra_filter:
q = q.filter(**extra_filter)
if category_id is not None:
q = q.filter(
Q(category_id=category_id) |
Q(category__isnull=True, model__category_id=category_id)
)
q = q.select_related('street', 'model')
return q
# Récupère QS pour chaque type (limités grossièrement pour éviter de charger trop, puis triés/merge côté Python)
poles_qs = base_qs(SignPole, extra_filter=street_filter)[:100]
panels_qs = base_qs(SignPanel, extra_filter=street_filter)[:100]
# Concatène en mémoire (on priorise ensuite)
items = []
for a in list(poles_qs) + list(panels_qs):
# priorisation locale (recalcule ici car on merge des modèles différents)
code_u = getattr(a, 'code_u', None)
name_fr_u = getattr(a, 'name_fr_u', None)
name_nl_u = getattr(a, 'name_nl_u', None)
prio = 7
# Priorisation uniquement si un terme de recherche est fourni
if has_search_term:
if code_u == qn:
prio = 0
elif code_u and code_u.startswith(qn):
prio = 1
elif (name_fr_u and name_fr_u.startswith(qn)) or (name_nl_u and name_nl_u.startswith(qn)):
prio = 2
elif (code_u and qn in code_u) or (name_fr_u and qn in name_fr_u) or (name_nl_u and qn in name_nl_u):
prio = 4
else:
# Sans terme de recherche, on trie par code
prio = 5
street = a.street if hasattr(a, 'street') else None
equipment_type = a.model if hasattr(a, 'model') else None
parts = [a.code, a.get_name()]
equipment_label = " - ".join(p for p in parts if p)
code = getattr(a, 'code', '') or ''
label = equipment_label
lon, lat = [getattr(a, 'lon', None), getattr(a, 'lat', None)]
location_id_val = street.id if street else None
location_label = (f"{street.code} - {street.get_name()}" if street else None)
equipment_ct_id = ContentType.objects.get_for_model(a).id
location_ct_id = ContentType.objects.get_for_model(street).id if street else None
items.append({
"prio": prio,
"code": code or "",
"id": getattr(a, 'id', None),
"label": label,
# attendus par ton autocomplete équipements
"equipment_id": getattr(a, 'id', None),
"equipment_label": equipment_label,
"equipment_type": equipment_type,
"equipment_ct": equipment_ct_id,
"location_id": location_id_val,
"location_label": location_label,
"location_ct": location_ct_id,
"lon": lon,
"lat": lat,
})
# Tri global par priorité puis code, coupe à 100
items.sort(key=lambda x: (x["prio"], x["code"]))
data = items[:100]
return JsonResponse(data, safe=False)