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)