loko/streetup/assets/views/sign.py

1032 lines
No EOL
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

from django.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: <br>, <br/>, <br />
text = text.replace("<br />", "\n").replace("<br/>", "\n").replace("<br>", "\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)