from django.shortcuts import render, get_object_or_404, redirect from django.contrib.auth.decorators import login_required from django.http import JsonResponse, HttpResponse from django.core.exceptions import PermissionDenied from django.utils.translation import gettext as _, get_language from django.utils import timezone from django.contrib.gis.geos import GEOSGeometry from django.db.models import F, Q, Max from django.urls import reverse from django.views.decorators.http import require_http_methods from collections import defaultdict from datetime import date, timedelta import io import json import re import xml.etree.ElementTree as ET try: from PIL import Image except ImportError: Image = None from common.models import UserConfig, UserThematics, Municipality from .models import ( SignPanelType, SignStreet, SignPole, SignPanel, ComplementaryPoliceRegulation, Organization, RegulationArticle, RegulationMeasurePanel, SignPanelTypeArticle, RegulationArticleVoirie, ) from .permissions import user_can_manage_regulations, user_can_edit_regulation, user_can_submit_regulation, user_can_edit_esign_assets, user_can_view_regulation from .forms import SignPoleForm, SignPanelForm def get_grouped_panel_types(): panel_types = list(SignPanelType.objects.all().order_by('code')) PANEL_CLASSES = { 'danger': _("Signaux de danger"), 'priority': _("Signaux de priorité"), 'prohibition': _("Signaux d'interdiction"), 'obligation': _("Signaux d'obligation"), 'parking': _("Arrêt / Stationnement"), 'indication': _("Signaux d'indication"), 'zone': _("Signaux de zone"), 'additional': _("Signaux additionnels"), 'other': _("Autres"), } def get_panel_class_key(code): code_upper = code.strip().upper() if not code_upper or code_upper == '?': return 'other' if code_upper.startswith('A'): return 'danger' elif code_upper.startswith('B'): return 'priority' elif code_upper.startswith('C'): return 'prohibition' elif code_upper.startswith('D'): return 'obligation' elif code_upper.startswith('E'): return 'parking' elif code_upper.startswith('F'): return 'indication' elif code_upper.startswith('Z'): return 'zone' else: return 'additional' panel_types_grouped = defaultdict(list) for pt in panel_types: code = pt.code.strip() if not code: continue group_key = get_panel_class_key(code) panel_types_grouped[group_key].append({ 'id': pt.id, 'code': pt.code, 'name': pt.get_name(), 'image_url': None }) group_order = ['danger', 'priority', 'prohibition', 'obligation', 'parking', 'indication', 'zone', 'additional', 'other'] panel_types_groups = [] for g in group_order: if g in panel_types_grouped: panel_types_groups.append((g, PANEL_CLASSES[g])) return dict(panel_types_grouped), panel_types_groups @login_required def index(request): """ Sign view - a dedicated view for road sign management. This view provides: - A split layout with 1/3 info panel and 2/3 map - No sidebar navigation - Tasks, filters, and detail panels in the left section - Map displaying sign assets """ user_config = get_object_or_404(UserConfig, user=request.user) # Check if user has access to sign view if not user_config.can_access_view('sign'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) lang = get_language() # Get filter options for the sign view using database ordering name_field = 'name_nl' if lang == 'nl' else 'name_fr' municipalities = Municipality.objects.all().order_by(name_field) municipality_choices = [(m.id, m.name) for m in municipalities] panel_types = list(SignPanelType.objects.all().order_by('code')) panel_type_choices = [(m.id, m.code) for m in panel_types] panel_types_grouped, panel_types_groups = get_grouped_panel_types() # Organizations filter choices organizations = Organization.objects.all() language = get_language() or "fr" if language == "nl": organizations = organizations.order_by('name_nl') else: organizations = organizations.order_by('name_fr') organization_choices = [(o.id, o.name) for o in organizations] # Get selected filters from URL selected_municipalities = request.GET.getlist('municipality') selected_panel_types = request.GET.getlist('panel_type') selected_year = request.GET.get('installation_year') selected_organizations = request.GET.getlist('organization') context = { 'user_config': user_config, 'municipality_choices': municipality_choices, 'panel_type_choices': panel_type_choices, 'panel_types_grouped': panel_types_grouped, 'panel_types_groups': panel_types_groups, 'selected_municipalities': selected_municipalities, 'selected_panel_types': selected_panel_types, 'selected_year': selected_year, 'organization_choices': organization_choices, 'selected_organizations': selected_organizations, 'active_panel': request.GET.get('panel', 'tasks'), # tasks, filters, regulations, detail } return render(request, "sign/index.html", context) def ensure_list(value): if isinstance(value, list): return value if value is None: return [] return [value] @login_required def sign_streets_geojson(request): user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) 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) 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 qs = SignStreet.objects.all() if municipalities: qs = qs.filter(municipality_id__in=municipalities) if filter_geom: qs = qs.filter(geom__intersects=filter_geom) 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') ) 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('sign:sign_streets_detail', kwargs={'street_id': r['id']}), } } features = [obj_to_feature(location) for location in qs] geojson_data = {"type": "FeatureCollection", "features": features} return JsonResponse(geojson_data, safe=False) @login_required def municipalities_geojson(request): user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) from django.contrib.gis.db.models.functions import Transform, AsGeoJSON municipalities = Municipality.objects.exclude(geom=None).annotate( geom_geojson=AsGeoJSON(Transform('geom', 4326)) ) features = [] for m in municipalities: features.append({ "type": "Feature", "id": m.id, "geometry": json.loads(m.geom_geojson) if m.geom_geojson else None, "properties": { "id": m.id, "name": m.name, "niscode": m.niscode or "", } }) geojson_data = {"type": "FeatureCollection", "features": features} return JsonResponse(geojson_data, safe=False) MAX_ASSET_FEATURES = 5000 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 SIMPLE_PANEL_ICON_SIZE_FACTOR = 1.0 TEXT_LINE_HEIGHT_PX = 20 TEXT_VERTICAL_PADDING_PX = 14 SYMBOL_LINE_EXTRA_HEIGHT_PX = 12 ZONE_EMBEDDED_ICON_HEIGHT_PX = 70 PANEL_HEIGHT_ADJUST_PX = {} STANDARD_PANEL_STACK_GAP_PX = 4 TEXT_PANEL_BASE_WIDTH_PX = 72 ZONE_TEXT_PANEL_BASE_WIDTH_PX = 64 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 "" text = text.replace("\\n", "\n") text = text.replace("
", "\n").replace("
", "\n").replace("
", "\n") 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 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) if code.startswith("TYPE"): normalized = normalized.replace('|', '\n') lines = [line for line in normalized.splitlines() if line.strip()] if code.startswith("Z"): normalized = normalized.replace('|', '\n') lines = [line.strip() for line in normalized.splitlines() if line.strip()] filtered = [l for l in lines if l.upper() != 'ZONE'] remainder = code[1:].replace("’", "").replace("'", "") filtered = [l for l in filtered if l.upper() != remainder.upper()] 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 * SIMPLE_PANEL_ICON_SIZE_FACTOR * 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) symbol_lines = 0 if panel_text: normalized = normalize_panel_text(panel_text) for line in normalized.splitlines(): if '\u0001PEDESTRIAN\u0002' in line or '\u0001CAR\u0002' in line or '\u0001BIKE\u0002' in line: symbol_lines += 1 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 * SIMPLE_PANEL_ICON_SIZE_FACTOR * scale_factor @login_required def sign_poles_overview_geojson(request): """Lightweight endpoint returning only poles (minimal props) filtered by bbox. Intended for low-zoom overview display. Uses .values() to avoid model instantiation and N+1 queries.""" user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) if request.method != "POST": return JsonResponse({"type": "FeatureCollection", "features": []}) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({"error": "Invalid JSON"}, status=400) filter_geom_raw = data.get("filter_geom") organizations = ensure_list(data.get("organization")) municipalities = ensure_list(data.get("municipality")) qs = SignPole.objects.all() if organizations: qs = qs.filter(organization_id__in=organizations) if municipalities: qs = qs.filter(street__municipality_id__in=municipalities) if filter_geom_raw: try: filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326) qs = qs.filter(geom__intersects=filter_geom) except Exception as e: return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400) # Use values() to fetch only the needed columns in a single query, # and build the detail URL without get_absolute_url() to avoid instantiation. poles = qs.values('id', 'code', 'lon', 'lat') features = [] for pole in poles.iterator(chunk_size=2000): lon, lat = pole['lon'], pole['lat'] geometry = {"type": "Point", "coordinates": [lon, lat]} if lon is not None and lat is not None else None features.append({ "type": "Feature", "id": pole['id'], "geometry": geometry, "properties": { "asset_type": "signpole", "code": pole['code'], "lat": lat, "lon": lon, "detail_url": f"/sign/assets/signpole/{pole['id']}/", } }) return JsonResponse({"type": "FeatureCollection", "features": features}) @login_required def sign_assets_geojson(request): user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) 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_sel = ensure_list(data.get('model')) panel_types = ensure_list(data.get('panel_type')) organizations = ensure_list(data.get('organization')) 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) 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_sel = request.GET.getlist('model', []) panel_types = request.GET.getlist('panel_type', []) organizations = request.GET.getlist('organization', []) municipalities = request.GET.getlist('municipality', []) filter_geom = None _search_q = (data.get('q') or '').strip() if request.method == 'POST' else '' if not sign_location_ids and not categories and not models_sel and not filter_geom and not panel_types and not organizations and not _search_q and not municipalities: geojson_data = {"type": "FeatureCollection", "features": []} return JsonResponse(geojson_data) def obj_to_feature(obj, asset_type=None): geometry = json.loads(obj.geojson) if obj.geojson else None panel_type_code = obj.signpanel_type.code.strip() if hasattr(obj, 'signpanel_type') and obj.signpanel_type and obj.signpanel_type.code else None icon_code = panel_type_code if panel_type_code: upper_code = panel_type_code.upper() if upper_code == "XA": icon_code = "GXA" elif upper_code == "XB": icon_code = "GXB" elif upper_code == "XD": icon_code = "GXD" # 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 panel_icon_image_scaled = f"{panel_icon_image}-x12" if panel_icon_image 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, "lon": obj.lon, "lat": obj.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, }, } if asset_type == "signpole": 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) # Calculate offset based on orientation to align arrow base with pole top # The offset is multiplied by icon-size (0.3 at zoom 17), so we need to compensate # Arrow is 32px tall, we want the base to align with pole top at -64px # At zoom 17: icon-size = 0.3, so we need offset values / 0.3 to get the real pixel values base_icon_size = 0.3 # icon-size at zoom 17 arrow_length = 32 # pixels pole_offset = 16 # pixels if orientation_value is not None: # Convert orientation to radians (0° = North, clockwise) import math angle_rad = math.radians(orientation_value) # Calculate offset components (negative Y because screen coords) offset_x = 0 offset_y = -math.cos(angle_rad) * arrow_length pole_orientation_offset = [round(offset_x, 1), round(offset_y, 1)] else: pole_orientation_offset = [0, pole_offset] feature["properties"].update({ "pole_orientation_offset": pole_orientation_offset, "show_orientation_arrow": show_orientation_arrow, }) if asset_type == "signpanel": panel_meta = panel_render_meta.get(obj.id, {}) panel_text = feature["properties"].get("panel_text") can_render_text = panel_meta.get("can_render_text", False) render_width = panel_meta.get("render_width", 72) render_height = panel_meta.get("render_height", DEFAULT_PANEL_ICON_HEIGHT_PX) 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) vertical_order = obj.vertical_order if hasattr(obj, "vertical_order") else None 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({ "pole_id": getattr(obj, "pole_id", None), "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 features.append(obj_to_feature(obj, asset_type=asset_type)) feature_count += 1 return feature_count, False signpole_qs = SignPole.objects.select_related('street', 'category', 'model').all() signpanel_qs = SignPanel.objects.select_related('street', 'category', 'model', 'signpanel_type').all() if municipalities: signpole_qs = signpole_qs.filter(street__municipality_id__in=municipalities) signpanel_qs = signpanel_qs.filter(street__municipality_id__in=municipalities) 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 models_sel: signpole_qs = signpole_qs.filter(model__id__in=models_sel) signpanel_qs = signpanel_qs.filter(model__id__in=models_sel) if categories: signpole_qs = signpole_qs.filter(category__id__in=categories) signpanel_qs = signpanel_qs.filter(category__id__in=categories) if organizations: signpole_qs = signpole_qs.filter(organization_id__in=organizations) signpanel_qs = signpanel_qs.filter(organization_id__in=organizations) if filter_geom: signpole_qs = signpole_qs.filter(geom__intersects=filter_geom) signpanel_qs = signpanel_qs.filter(geom__intersects=filter_geom) if panel_types: signpole_qs = signpole_qs.filter(id__in=[]) signpanel_qs = signpanel_qs.filter(signpanel_type__id__in=panel_types) if _search_q and request.method == 'POST': from assets.views.core import apply_search signpole_qs = apply_search(signpole_qs, data, fields=['code']) signpanel_qs = apply_search(signpanel_qs, data, fields=['code']) 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) scale_factor = panel.get_scale_factor() panel_width_cm = panel.get_width() panel_height_cm = panel.get_height() # 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 getattr(panel, "height", None) is None and not panel_can_render_text: if panel.signpanel_type and (panel.signpanel_type.default_height or panel.signpanel_type.default_diameter): effective_height_cm = panel_height_cm else: 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, } 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) @login_required def sign_streets_list(request): user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) name_field = 'name_nl' if get_language() == 'nl' else 'name_fr' streets = SignStreet.objects.all().order_by(name_field) return render(request, "sign/sign_streets_list.html", {"streets": streets}) @login_required def sign_streets_detail(request, street_id: int): user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) street = get_object_or_404(SignStreet, id=street_id) assets = street.get_active_assets() if hasattr(street, "get_active_assets") else { "signpoles": SignPole.objects.filter(street=street, status='active'), "signpanels": SignPanel.objects.filter(street=street, status='active'), } return render( request, "sign/sign_streets_detail.html", {"street": street, "assets": assets}, ) @login_required def sign_assets_detail(request, asset_model: str, asset_id: int): user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) model_map = { "signpole": SignPole, "signpanel": SignPanel, } model_cls = model_map.get(asset_model) if not model_cls: raise PermissionDenied(_("Type d'actif inconnu.")) asset = get_object_or_404(model_cls, id=asset_id) return render(request, "sign/sign_asset_detail.html", {"asset": asset, "asset_model": asset_model, "user_config": user_config}) @login_required def regulations_list(request): user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) regs = ComplementaryPoliceRegulation.objects.all().order_by("-decision_date", "reference") if not request.user.is_superuser: regs = regs.filter(organization__in=user_config.esign_organizations.all()) return render(request, "sign/regulations_list.html", {"regulations": regs}) @login_required def regulations_search(request): """Retourne jusqu'à 500 règlements, filtrés par q sur référence/titre/description.""" user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) q = (request.GET.get("q") or "").strip() qs = ComplementaryPoliceRegulation.objects.all() if not request.user.is_superuser: qs = qs.filter(organization__in=user_config.esign_organizations.all()) if q: qs = qs.filter( Q(reference__icontains=q) | Q(title__icontains=q) | Q(description__icontains=q) ) limited = qs.count() > 500 qs = qs.order_by("-decision_date", "-uploaded_at", "-id")[:500] data = [ { "id": reg.id, "code": reg.reference or "", "title": reg.title, "status": reg.status_label or "", "status_class": reg.status_badge_class, } for reg in qs ] return JsonResponse({"results": data, "limited": limited}) @login_required def regulation_detail(request, regulation_id: int): """Retourne le détail d'un règlement avec ses panneaux associés en GeoJSON.""" user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) reg = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_view_regulation(request.user, reg): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) def normalize_panel_text(text: str | None) -> str: if not text: return "" text = text.replace("\\n", "\n") text = text.replace("
", "\n").replace("
", "\n").replace("
", "\n") text = text.replace("\r\n", "\n").replace("\r", "\n") return text # Récupérer les panneaux associés panels = reg.panels.all().select_related('street', 'pole', 'signpanel_type') abrogated_ids = set(reg.abrogated_panels.values_list('id', flat=True)) features = [] for panel in panels: geom = json.loads(panel.geojson) if panel.geojson else None panel_type_code = panel.signpanel_type.code.strip() if panel.signpanel_type and panel.signpanel_type.code else None icon_code = panel_type_code if panel_type_code: upper_code = panel_type_code.upper() if upper_code == "XA": icon_code = "GXA" elif upper_code == "XB": icon_code = "GXB" elif upper_code == "XD": icon_code = "GXD" # 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 features.append({ "type": "Feature", "id": panel.id, "geometry": geom, "properties": { "code": panel.code, "panel_type": panel_type_code, "panel_text": normalize_panel_text(panel.signpanel_text or ""), "panel_icon_image": panel_icon_image, "panel_icon_image_scaled": panel_icon_image_scaled, "street": panel.street.get_name() if panel.street else "-", "pole": panel.pole.code if panel.pole else "-", "status": panel.status, "support_type": panel.support_type, "is_abrogated": panel.id in abrogated_ids, } }) abrogated_count = len(abrogated_ids) regulation_data = { "id": reg.id, "title": reg.title, "reference": reg.reference or "", "status": reg.status_label, "status_class": reg.status_badge_class, "description": reg.description or "", "decision_date": reg.decision_date.isoformat() if reg.decision_date else None, "organization": reg.organization.name if reg.organization else "-", "panels_count": len(features), "abrogated_count": abrogated_count, "panels": { "type": "FeatureCollection", "features": features } } return JsonResponse(regulation_data) # ======================== # Regulation Management # ======================== @login_required def regulation_create(request): """Create a new complementary police regulation.""" user_config = get_object_or_404(UserConfig, user=request.user) if not user_can_manage_regulations(request.user): raise PermissionDenied(_("Vous n'avez pas la permission de créer des règlements.")) if request.method == 'POST': title = request.POST.get('title', '').strip() reference = request.POST.get('reference', '').strip() description = request.POST.get('description', '').strip() organization_id = request.POST.get('organization') if not title or not reference or not organization_id: return JsonResponse({ 'error': _('Le titre, la référence et le gestionnaire sont obligatoires.') }, status=400) if not request.user.is_superuser: if not user_config.esign_organizations.filter(id=organization_id).exists(): return JsonResponse({ 'error': _("Vous n'avez pas l'autorisation de créer un règlement pour ce gestionnaire.") }, status=403) try: regulation = ComplementaryPoliceRegulation.objects.create( title=title, reference=reference, description=description, organization_id=organization_id, uploaded_by=request.user, status='in_preparation' ) return JsonResponse({ 'id': regulation.id, 'title': regulation.title, 'reference': regulation.reference, 'status': regulation.status_label, 'success': True, 'redirect_url': reverse('sign:regulation_edit', kwargs={'regulation_id': regulation.id}) }) except Exception as e: return JsonResponse({ 'error': str(e) }, status=400) # GET request: the UX is handled inline in sign/index.html return redirect(f"{reverse('sign:index')}?panel=regulations") @login_required def regulation_edit(request, regulation_id: int): """Edit an existing complementary police regulation.""" user_config = get_object_or_404(UserConfig, user=request.user) regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement. Il doit être en statut 'En préparation'.")) if request.method == 'POST': regulation.title = request.POST.get('title', '').strip() regulation.reference = request.POST.get('reference', '').strip() regulation.description = request.POST.get('description', '').strip() organization_id = request.POST.get('organization') if not regulation.title or not regulation.reference or not organization_id: return JsonResponse({ 'error': _('Le titre, la référence et le gestionnaire sont obligatoires.') }, status=400) if not request.user.is_superuser: if not user_config.esign_organizations.filter(id=organization_id).exists(): return JsonResponse({ 'error': _("Vous n'avez pas l'autorisation d'associer ce règlement à ce gestionnaire.") }, status=403) regulation.organization_id = organization_id # Bilingual text sections for field in ( 'text_introduction_fr', 'text_introduction_nl', 'text_abrogations_fr', 'text_abrogations_nl', 'text_nouvelles_dispositions_fr', 'text_nouvelles_dispositions_nl', 'text_dispositions_finales_fr', 'text_dispositions_finales_nl', ): setattr(regulation, field, request.POST.get(field, '').strip() or None) if not regulation.title or not regulation.reference: return JsonResponse({ 'error': _('Le titre et la référence sont obligatoires.') }, status=400) try: regulation.save() return JsonResponse({ 'success': True, 'title': regulation.title, 'reference': regulation.reference, 'message': _('Règlement mis à jour avec succès.') }) except Exception as e: return JsonResponse({ 'error': str(e) }, status=400) # GET request: render the full regulation editor page language = get_language() or "fr" organizations = Organization.objects.all().order_by('name_nl' if language == 'nl' else 'name_fr') return render(request, 'sign/regulation_form.html', { 'regulation': regulation, 'organizations': organizations, }) @login_required @require_http_methods(["POST"]) def regulation_submit(request, regulation_id: int): """Submit a regulation for approval.""" regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_submit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas soumettre ce règlement pour approbation.")) try: regulation.status = 'to_be_approved' regulation.save() return JsonResponse({ 'success': True, 'status': regulation.status_label, 'message': _('Règlement soumis pour approbation.') }) except Exception as e: return JsonResponse({ 'error': str(e) }, status=400) @login_required @require_http_methods(["POST"]) def regulation_panels_update(request, regulation_id: int): """Update panels associated with a regulation (add/remove).""" regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier les panneaux de ce règlement.")) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'error': _('JSON invalide.')}, status=400) action = data.get('action') # 'add' or 'remove' panel_id = data.get('panel_id') if not action or not panel_id: return JsonResponse({'error': _('Action et panel_id requis.')}, status=400) try: panel = SignPanel.objects.get(id=panel_id) if action == 'add': regulation.panels.add(panel) return JsonResponse({ 'success': True, 'action': 'added', 'panel_id': panel_id, 'panels_count': regulation.panels.count() }) elif action == 'remove': regulation.panels.remove(panel) return JsonResponse({ 'success': True, 'action': 'removed', 'panel_id': panel_id, 'panels_count': regulation.panels.count() }) else: return JsonResponse({'error': _('Action non valide.')}, status=400) except SignPanel.DoesNotExist: return JsonResponse({'error': _('Panneau non trouvé.')}, status=404) except Exception as e: return JsonResponse({'error': str(e)}, status=400) @login_required @require_http_methods(["POST"]) def regulation_panels_bulk_update(request, regulation_id: int): """Bulk update panels associated with a regulation (add/remove many in one call).""" regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier les panneaux de ce règlement.")) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'error': _('JSON invalide.')}, status=400) action = data.get('action') # 'add' or 'remove' panel_ids = data.get('panel_ids') if action not in {'add', 'remove'}: return JsonResponse({'error': _('Action non valide.')}, status=400) if not isinstance(panel_ids, list) or not panel_ids: return JsonResponse({'error': _('panel_ids doit être une liste non vide.')}, status=400) # Normalize ids try: panel_ids_int = [int(pid) for pid in panel_ids] except (TypeError, ValueError): return JsonResponse({'error': _('panel_ids invalide.')}, status=400) panels = list(SignPanel.objects.filter(id__in=panel_ids_int)) if not panels: return JsonResponse({'error': _('Aucun panneau trouvé.')}, status=404) try: if action == 'add': regulation.panels.add(*panels) else: regulation.panels.remove(*panels) return JsonResponse({ 'success': True, 'action': action, 'panel_ids': panel_ids_int, 'panels_count': regulation.panels.count(), }) except Exception as e: return JsonResponse({'error': str(e)}, status=400) @login_required def regulation_panels_geojson(request, regulation_id: int): """ Get GeoJSON of panels for regulation editing. Returns all panels in the visible map area (like sign_assets_geojson) with association status. """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) # Get associated panel IDs (derived from measure panels and the M2M relation) associated_panel_ids = set( RegulationMeasurePanel.objects .filter(regulation=regulation) .values_list('panel_id', flat=True) .distinct() ) associated_panel_ids.update(regulation.panels.values_list('id', flat=True)) # Parse filter from request (map bounds) filter_geom = None if request.method == "POST": try: data = json.loads(request.body) filter_geom_raw = data.get("filter_geom") if filter_geom_raw: filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326) except (json.JSONDecodeError, Exception) as e: return JsonResponse({"error": f"Invalid request: {str(e)}"}, status=400) # Get abrogated panel IDs for this regulation abrogated_panel_ids = set(regulation.abrogated_panels.values_list('id', flat=True)) # Read optional removed-panel parameters from the POST body include_removed = False removed_months = None if request.method == 'POST': try: _body = json.loads(request.body) include_removed = bool(_body.get('include_removed', False)) _raw_months = _body.get('removed_months') if _raw_months is not None: removed_months = int(_raw_months) except (json.JSONDecodeError, ValueError, TypeError): pass # Build queryset panels_qs = SignPanel.objects.select_related('street', 'signpanel_type', 'pole', 'category', 'model') if filter_geom: panels_qs = panels_qs.filter(geom__intersects=filter_geom) # By default hide removed panels; include them only when explicitly requested if include_removed: if removed_months: cutoff = timezone.now() - timedelta(days=removed_months * 30) panels_qs = panels_qs.filter( Q(status='removed', updated_at__gte=cutoff) | ~Q(status='removed') ) # else: include all statuses (no extra filter) else: panels_qs = panels_qs.exclude(status='removed') # Limit results to avoid overload panels_qs = panels_qs[:5000] panels_list = list(panels_qs) # Build panel rendering metadata (same as in sign_assets_geojson) panel_render_meta = {} for panel in panels_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) scale_factor = panel.get_scale_factor() panel_width_cm = panel.get_width() panel_height_cm = panel.get_height() # 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 getattr(panel, "height", None) is None and not panel_can_render_text: if panel.signpanel_type and (panel.signpanel_type.default_height or panel.signpanel_type.default_diameter): effective_height_cm = panel_height_cm else: 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, } def normalize_panel_text(text): if not text: return "" text = text.replace("\\n", "\n") text = text.replace("
", "\n").replace("
", "\n").replace("
", "\n") text = text.replace("\r\n", "\n").replace("\r", "\n") return text def obj_to_feature(panel): geometry = json.loads(panel.geojson) if panel.geojson else None panel_type_code = panel.signpanel_type.code.strip() if panel.signpanel_type and panel.signpanel_type.code else None icon_code = panel_type_code if panel_type_code: upper_code = panel_type_code.upper() if upper_code == "XA": icon_code = "GXA" elif upper_code == "XB": icon_code = "GXB" elif upper_code == "XD": icon_code = "GXD" 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 is_associated = panel.id in associated_panel_ids is_abrogated = panel.id in abrogated_panel_ids panel_meta = panel_render_meta.get(panel.id, {}) can_render_text = panel_meta.get("can_render_text", False) render_width = panel_meta.get("render_width", 72) render_height = panel_meta.get("render_height", DEFAULT_PANEL_ICON_HEIGHT_PX) 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) vertical_order = panel.vertical_order if hasattr(panel, "vertical_order") else None 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}-{panel.id}" if can_render_text else None aspect_ratio = panel_meta.get("aspect_ratio", 1.0) orientation_value = panel.orientation show_orientation_arrow = orientation_value is not None and getattr(panel, "orientation_arrow", False) if orientation_value is not None: import math angle_rad = math.radians(orientation_value) offset_x = 0 offset_y = -math.cos(angle_rad) * 32 panel_orientation_offset = [round(offset_x, 1), round(offset_y, 1)] else: panel_orientation_offset = [0, 16] return { "type": "Feature", "id": panel.id, "geometry": geometry, "properties": { "code": panel.code, "panel_type": panel_type_code, "panel_text": normalize_panel_text(panel.signpanel_text or ""), "street": panel.street.get_name() if panel.street else "-", "status": panel.status, "asset_type": "signpanel", "panel_icon_image": panel_icon_image, "panel_icon_image_scaled": panel_icon_image_scaled, "is_associated": is_associated, "is_abrogated": is_abrogated, "lon": panel.lon, "lat": panel.lat, "pole_id": getattr(panel, "pole_id", None), "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, "panel_orientation_offset": panel_orientation_offset, "show_orientation_arrow": show_orientation_arrow, } } features = [obj_to_feature(panel) for panel in panels_list] geojson_data = { "type": "FeatureCollection", "features": features } return JsonResponse(geojson_data, safe=False) @login_required def regulation_associated_panels(request, regulation_id: int): """Get bbox of all panels associated via measures (for initial map zoom).""" regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) panel_ids = set( RegulationMeasurePanel.objects .filter(regulation=regulation) .values_list('panel_id', flat=True) .distinct() ) panel_ids.update(regulation.panels.values_list('id', flat=True)) lons, lats = [], [] for panel in SignPanel.objects.filter(id__in=panel_ids): if panel.lon is not None and panel.lat is not None: lons.append(float(panel.lon)) lats.append(float(panel.lat)) bbox = None if lons: pad = 0.002 bbox = [min(lons) - pad, min(lats) - pad, max(lons) + pad, max(lats) + pad] return JsonResponse({ "count": len(panel_ids), "bbox": bbox, }) @login_required def regulation_generate_articles_text(request, regulation_id: int): """ Returns the articles text pre-generated from the panels of a regulation, formatted as plain text for the 'Nouvelles dispositions' section. """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) lang = get_language() or "fr" panels = regulation.panels.select_related('signpanel_type').all() panel_type_ids = list({p.signpanel_type_id for p in panels if p.signpanel_type_id}) direct_articles_qs = ( RegulationArticle.objects .filter(panel_types__panel_type_id__in=panel_type_ids) .distinct() .select_related('parent', 'parent__parent') .order_by('ordering') ) all_article_ids: set[int] = set() for art in direct_articles_qs: all_article_ids.add(art.id) for ancestor in art.get_ancestors(): all_article_ids.add(ancestor.id) articles_ordered = ( RegulationArticle.objects .filter(id__in=all_article_ids) .select_related('parent') .order_by('ordering', 'article_id') ) @login_required def regulation_abrogated_panels_list(request, regulation_id: int): """ GET → returns the current abrogated-panel selection for this regulation + a search over the full panel catalogue. Query param: ?q=search term (optional, ≥2 chars) Each item: id, code, panel_type, street, status, selected """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) selected_ids = set(regulation.abrogated_panels.values_list('id', flat=True)) q = (request.GET.get('q') or '').strip() if q and len(q) >= 2: panels_qs = ( SignPanel.objects .filter( Q(code__icontains=q) | Q(signpanel_type__code__icontains=q) | Q(street__name_fr__icontains=q) | Q(street__name_nl__icontains=q) ) .select_related('signpanel_type', 'street') .order_by('code')[:30] ) elif selected_ids: # No search: return only already-selected panels panels_qs = ( SignPanel.objects .filter(id__in=selected_ids) .select_related('signpanel_type', 'street') .order_by('code') ) else: panels_qs = SignPanel.objects.none() results = [] for p in panels_qs: results.append({ 'id': p.id, 'code': p.code, 'panel_type': p.signpanel_type.code if p.signpanel_type else '', 'street': p.street.get_name() if p.street else '–', 'status': p.status or '', 'selected': p.id in selected_ids, }) return JsonResponse({'panels': results, 'selected_count': len(selected_ids)}) @login_required @require_http_methods(["POST"]) def regulation_abrogated_panels_save(request, regulation_id: int): """ POST body JSON: { "panel_ids": [1, 2, …] } Replaces the abrogated_panels M2M with the provided list. """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'error': _('JSON invalide.')}, status=400) panel_ids = [int(i) for i in data.get('panel_ids', []) if str(i).isdigit()] panels = SignPanel.objects.filter(id__in=panel_ids) regulation.abrogated_panels.set(panels) return JsonResponse({'success': True, 'count': regulation.abrogated_panels.count()}) @login_required @require_http_methods(["POST"]) def regulation_abrogated_panel_toggle(request, regulation_id: int): """ POST body JSON: { "panel_id": 123 } Adds or removes a single panel from abrogated_panels. Returns: { action: "add"|"remove", panel_id: 123, is_abrogated: bool, count: N } """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) try: data = json.loads(request.body) panel_id = int(data['panel_id']) except (json.JSONDecodeError, KeyError, ValueError, TypeError): return JsonResponse({'error': _('panel_id invalide.')}, status=400) panel = get_object_or_404(SignPanel, id=panel_id) also_disassociate = bool(data.get('also_disassociate', False)) already = regulation.abrogated_panels.filter(id=panel_id).exists() if already: regulation.abrogated_panels.remove(panel) if also_disassociate: regulation.panels.remove(panel) action = 'remove' else: regulation.abrogated_panels.add(panel) # Also associate the panel so it appears in the associated panels list regulation.panels.add(panel) action = 'add' return JsonResponse({ 'success': True, 'action': action, 'panel_id': panel_id, 'is_abrogated': not already, 'count': regulation.abrogated_panels.count(), }) @login_required def regulation_articles_tree(request, regulation_id: int): """ Returns a hierarchical tree of RegulationArticles relevant to this regulation (derived from the associated panel types), with selection state. Each node: id, article_id, level, text_fr, text_nl, from_panels, selected, children[] If no articles have been explicitly saved yet, all from_panels articles are pre-selected as a default. """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) # Articles suggested by panel types panels = regulation.panels.select_related('signpanel_type').all() panel_type_ids = list({p.signpanel_type_id for p in panels if p.signpanel_type_id}) from_panels_ids: set[int] = set() if panel_type_ids: direct_qs = ( RegulationArticle.objects .filter(panel_types__panel_type_id__in=panel_type_ids) .distinct() .select_related('parent', 'parent__parent', 'parent__parent__parent') ) for art in direct_qs: from_panels_ids.add(art.id) for ancestor in art.get_ancestors(): from_panels_ids.add(ancestor.id) # Current saved selection selected_ids = set(regulation.selected_articles.values_list('id', flat=True)) # Also include regulation-specific sub-articles (created via the panel-selection flow). # Only include custom articles whose parent is already selected for this section, # to avoid bleeding articles across nouvelle_disposition and abrogation. custom_sub_ids = set( RegulationArticle.objects .filter(regulation=regulation, parent_id__in=selected_ids) .values_list('id', flat=True) ) # Only show explicitly selected articles and regulation-specific sub-articles. # from_panels_ids is kept for the visual indicator but does NOT expand the tree. scope_ids = selected_ids | custom_sub_ids if not scope_ids: return JsonResponse({'tree': [], 'empty': True}) all_articles = list( RegulationArticle.objects .filter(id__in=scope_ids) .order_by('ordering', 'article_id') ) # Build tree # Pre-fetch voiries for this regulation (all articles at once) voiries_qs = RegulationArticleVoirie.objects.filter( regulation=regulation, article_id__in=scope_ids, section='nouvelle_disposition', ).order_by('ordering', 'id') voiries_by_article: dict[int, list] = {} for v in voiries_qs: voiries_by_article.setdefault(v.article_id, []).append({ 'id': v.id, 'text_fr': v.text_fr, 'text_nl': v.text_nl, 'ordering': v.ordering, }) # Pre-fetch measure panels for this regulation (all articles at once) measure_panels_qs = ( RegulationMeasurePanel.objects .filter(regulation=regulation, article_id__in=scope_ids, section='nouvelle_disposition') .select_related('panel__signpanel_type', 'panel__street') .order_by('panel__code') ) measure_panels_by_article: dict[int, list] = {} for mp in measure_panels_qs: measure_panels_by_article.setdefault(mp.article_id, []).append({ 'id': mp.panel_id, 'code': mp.panel.code, 'panel_type': mp.panel.signpanel_type.code if mp.panel.signpanel_type else None, 'street': mp.panel.street.get_name() if mp.panel.street else '-', }) # Which articles in scope have reference children (for the "+" button) ref_children_parent_ids: set[int] = set( RegulationArticle.objects .filter(parent_id__in=scope_ids, regulation__isnull=True) .values_list('parent_id', flat=True) .distinct() ) article_map: dict[int, dict] = {} for art in all_articles: article_map[art.id] = { 'id': art.id, 'article_id': art.article_id, 'level': art.level, 'text_fr': art.text_fr or '', 'text_nl': art.text_nl or '', 'from_panels': art.id in from_panels_ids, 'selected': art.id in selected_ids, 'is_custom': art.regulation_id is not None, 'has_ref_children': art.id in ref_children_parent_ids, 'voiries': voiries_by_article.get(art.id, []), 'measure_panels': measure_panels_by_article.get(art.id, []), 'parent_id': art.parent_id, 'parent_article_id': None, 'children': [], } root_nodes: list[dict] = [] for art in all_articles: node = article_map[art.id] if art.parent_id and art.parent_id in article_map: node['parent_article_id'] = article_map[art.parent_id]['article_id'] article_map[art.parent_id]['children'].append(node) else: root_nodes.append(node) return JsonResponse({'tree': root_nodes, 'empty': False}) @login_required @require_http_methods(["GET"]) def regulation_articles_search(request, regulation_id: int): """ Full-text search across RegulationArticle.article_id / text_fr / text_nl. Returns a flat list of matching articles (up to 20) with their ancestors as context. Query param: ?q=... """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) q = (request.GET.get('q') or '').strip() if len(q) < 2: return JsonResponse({'results': []}) qs = ( RegulationArticle.objects .filter( Q(article_id__icontains=q) | Q(text_fr__icontains=q) | Q(text_nl__icontains=q) ) .order_by('ordering', 'article_id')[:20] ) already_in_tree_ids = set() # Fetch current scope so we know which are already loaded panels = regulation.panels.select_related('signpanel_type').all() panel_type_ids = list({p.signpanel_type_id for p in panels if p.signpanel_type_id}) if panel_type_ids: direct_qs = RegulationArticle.objects.filter(panel_types__panel_type_id__in=panel_type_ids).distinct() for art in direct_qs: already_in_tree_ids.add(art.id) for anc in art.get_ancestors(): already_in_tree_ids.add(anc.id) selected_ids = set(regulation.selected_articles.values_list('id', flat=True)) results = [] for art in qs: breadcrumb = ' › '.join(a.article_id for a in art.get_ancestors()) + (' › ' if art.get_ancestors() else '') + art.article_id results.append({ 'id': art.id, 'article_id': art.article_id, 'breadcrumb': breadcrumb, 'text_fr': art.text_fr or '', 'text_nl': art.text_nl or '', 'level': art.level, 'parent_id': art.parent_id, 'from_panels': art.id in already_in_tree_ids, 'selected': art.id in selected_ids, 'voiries': [], 'children': [], }) return JsonResponse({'results': results}) @login_required @require_http_methods(["POST"]) def regulation_article_voiries_save(request, regulation_id: int, article_id: int): """ Replace all voiries for a given article within a regulation. Body JSON: { "voiries": [{"text_fr": "...", "text_nl": "...", "ordering": 0}, ...] } """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) article = get_object_or_404(RegulationArticle, id=article_id) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'error': _('JSON invalide.')}, status=400) voiries_data = data.get('voiries', []) # Replace existing entries entirely (nouvelle_disposition section only) RegulationArticleVoirie.objects.filter( regulation=regulation, article=article, section='nouvelle_disposition' ).delete() created = [] for i, v in enumerate(voiries_data): text_fr = (v.get('text_fr') or '').strip() text_nl = (v.get('text_nl') or '').strip() if not text_fr and not text_nl: continue obj = RegulationArticleVoirie.objects.create( regulation=regulation, article=article, section='nouvelle_disposition', text_fr=text_fr, text_nl=text_nl, ordering=v.get('ordering', i), ) created.append({'id': obj.id, 'text_fr': obj.text_fr, 'text_nl': obj.text_nl, 'ordering': obj.ordering}) return JsonResponse({'success': True, 'voiries': created}) @login_required @require_http_methods(["GET"]) def regulation_article_voiries_suggest(request, regulation_id: int, article_id: int): """ Suggest text for a custom article based on the streets of the measure panels directly associated with this article (nouvelle_disposition section). Returns: { "suggestions": [{"text_fr": "...", "text_nl": "..."}] } """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) article = get_object_or_404(RegulationArticle, id=article_id) panels = ( RegulationMeasurePanel.objects .filter(regulation=regulation, article=article, section='nouvelle_disposition') .select_related('panel__street') ) seen = set() suggestions = [] for mp in panels: panel = mp.panel if panel.street_id and panel.street_id not in seen: seen.add(panel.street_id) suggestions.append({ 'text_fr': panel.street.name_fr or '', 'text_nl': panel.street.name_nl or '', }) return JsonResponse({'suggestions': suggestions}) @login_required @require_http_methods(["POST"]) def regulation_save_articles_selection(request, regulation_id: int): """Save the selected articles for the 'Nouvelles Dispositions' section.""" regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'error': _('JSON invalide.')}, status=400) article_ids = data.get('article_ids', []) articles = RegulationArticle.objects.filter(id__in=article_ids) regulation.selected_articles.set(articles) return JsonResponse({'success': True, 'count': regulation.selected_articles.count()}) @login_required @require_http_methods(["GET"]) def regulation_abrogation_articles_tree(request, regulation_id: int): """ Returns a hierarchical tree of RegulationArticles for the Abrogations section. 'from_panels' is derived from abrogated panels' panel types. """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) # Articles suggested by abrogated panels' panel types abrogated_panels = regulation.abrogated_panels.select_related('signpanel_type').all() panel_type_ids = list({p.signpanel_type_id for p in abrogated_panels if p.signpanel_type_id}) from_panels_ids: set[int] = set() if panel_type_ids: direct_qs = ( RegulationArticle.objects .filter(panel_types__panel_type_id__in=panel_type_ids) .distinct() .select_related('parent', 'parent__parent', 'parent__parent__parent') ) for art in direct_qs: from_panels_ids.add(art.id) for ancestor in art.get_ancestors(): from_panels_ids.add(ancestor.id) # Current saved selection selected_ids = set(regulation.selected_abrogation_articles.values_list('id', flat=True)) # Also include regulation-specific sub-articles (created via the panel-selection flow). # Only include custom articles whose parent is already selected for this section, # to avoid bleeding articles across nouvelle_disposition and abrogation. custom_sub_ids = set( RegulationArticle.objects .filter(regulation=regulation, parent_id__in=selected_ids) .values_list('id', flat=True) ) # Only show explicitly selected articles and regulation-specific sub-articles. scope_ids = selected_ids | custom_sub_ids if not scope_ids: return JsonResponse({'tree': [], 'empty': True}) all_articles = list( RegulationArticle.objects .filter(id__in=scope_ids) .order_by('ordering', 'article_id') ) # Pre-fetch voiries (abrogation section) voiries_qs = RegulationArticleVoirie.objects.filter( regulation=regulation, article_id__in=scope_ids, section='abrogation', ).order_by('ordering', 'id') voiries_by_article: dict[int, list] = {} for v in voiries_qs: voiries_by_article.setdefault(v.article_id, []).append({ 'id': v.id, 'text_fr': v.text_fr, 'text_nl': v.text_nl, 'ordering': v.ordering, }) # Pre-fetch measure panels (abrogation section) measure_panels_qs = ( RegulationMeasurePanel.objects .filter(regulation=regulation, article_id__in=scope_ids, section='abrogation') .select_related('panel__signpanel_type', 'panel__street') .order_by('panel__code') ) measure_panels_by_article: dict[int, list] = {} for mp in measure_panels_qs: measure_panels_by_article.setdefault(mp.article_id, []).append({ 'id': mp.panel_id, 'code': mp.panel.code, 'panel_type': mp.panel.signpanel_type.code if mp.panel.signpanel_type else None, 'street': mp.panel.street.get_name() if mp.panel.street else '-', }) # Which articles in scope have reference children (for the "+" button) ref_children_parent_ids: set[int] = set( RegulationArticle.objects .filter(parent_id__in=scope_ids, regulation__isnull=True) .values_list('parent_id', flat=True) .distinct() ) article_map: dict[int, dict] = {} for art in all_articles: article_map[art.id] = { 'id': art.id, 'article_id': art.article_id, 'level': art.level, 'text_fr': art.text_fr or '', 'text_nl': art.text_nl or '', 'from_panels': art.id in from_panels_ids, 'selected': art.id in selected_ids, 'is_custom': art.regulation_id is not None, 'has_ref_children': art.id in ref_children_parent_ids, 'voiries': voiries_by_article.get(art.id, []), 'measure_panels': measure_panels_by_article.get(art.id, []), 'parent_id': art.parent_id, 'parent_article_id': None, 'children': [], } root_nodes: list[dict] = [] for art in all_articles: node = article_map[art.id] if art.parent_id and art.parent_id in article_map: node['parent_article_id'] = article_map[art.parent_id]['article_id'] article_map[art.parent_id]['children'].append(node) else: root_nodes.append(node) return JsonResponse({'tree': root_nodes, 'empty': False}) @login_required @require_http_methods(["GET"]) def regulation_abrogation_articles_search(request, regulation_id: int): """Search RegulationArticles for the Abrogations section.""" regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) q = (request.GET.get('q') or '').strip() if len(q) < 2: return JsonResponse({'results': []}) qs = ( RegulationArticle.objects .filter( Q(article_id__icontains=q) | Q(text_fr__icontains=q) | Q(text_nl__icontains=q) ) .order_by('ordering', 'article_id')[:20] ) # Determine which articles are already in the abrogation tree scope abrogated_panels = regulation.abrogated_panels.select_related('signpanel_type').all() panel_type_ids = list({p.signpanel_type_id for p in abrogated_panels if p.signpanel_type_id}) already_in_tree_ids = set() if panel_type_ids: direct_qs = RegulationArticle.objects.filter(panel_types__panel_type_id__in=panel_type_ids).distinct() for art in direct_qs: already_in_tree_ids.add(art.id) for anc in art.get_ancestors(): already_in_tree_ids.add(anc.id) selected_ids = set(regulation.selected_abrogation_articles.values_list('id', flat=True)) results = [] for art in qs: breadcrumb = ' › '.join(a.article_id for a in art.get_ancestors()) + (' › ' if art.get_ancestors() else '') + art.article_id results.append({ 'id': art.id, 'article_id': art.article_id, 'breadcrumb': breadcrumb, 'text_fr': art.text_fr or '', 'text_nl': art.text_nl or '', 'level': art.level, 'selected': art.id in selected_ids, 'in_tree': art.id in already_in_tree_ids, 'from_panels': art.id in already_in_tree_ids, 'children': [], 'voiries': [], }) return JsonResponse({'results': results}) @login_required @require_http_methods(["POST"]) def regulation_save_abrogation_articles_selection(request, regulation_id: int): """Save the selected articles for the 'Abrogations' section.""" regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'error': _('JSON invalide.')}, status=400) article_ids = data.get('article_ids', []) articles = RegulationArticle.objects.filter(id__in=article_ids) regulation.selected_abrogation_articles.set(articles) return JsonResponse({'success': True, 'count': regulation.selected_abrogation_articles.count()}) @login_required @require_http_methods(["POST"]) def regulation_abrogation_article_voiries_save(request, regulation_id: int, article_id: int): """ Replace all voiries (abrogation section) for a given article within a regulation. Body JSON: { "voiries": [{"text_fr": "...", "text_nl": "...", "ordering": 0}, ...] } """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) article = get_object_or_404(RegulationArticle, id=article_id) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'error': _('JSON invalide.')}, status=400) voiries_data = data.get('voiries', []) RegulationArticleVoirie.objects.filter( regulation=regulation, article=article, section='abrogation' ).delete() created = [] for i, v in enumerate(voiries_data): text_fr = (v.get('text_fr') or '').strip() text_nl = (v.get('text_nl') or '').strip() if not text_fr and not text_nl: continue obj = RegulationArticleVoirie.objects.create( regulation=regulation, article=article, section='abrogation', text_fr=text_fr, text_nl=text_nl, ordering=v.get('ordering', i), ) created.append({'id': obj.id, 'text_fr': obj.text_fr, 'text_nl': obj.text_nl, 'ordering': obj.ordering}) return JsonResponse({'success': True, 'voiries': created}) @login_required @require_http_methods(["GET"]) def regulation_abrogation_article_voiries_suggest(request, regulation_id: int, article_id: int): """ Suggest text for a custom abrogation article based on the streets of the measure panels directly associated with this article (abrogation section). """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) article = get_object_or_404(RegulationArticle, id=article_id) panels = ( RegulationMeasurePanel.objects .filter(regulation=regulation, article=article, section='abrogation') .select_related('panel__street') ) seen = set() suggestions = [] for mp in panels: panel = mp.panel if panel.street_id and panel.street_id not in seen: seen.add(panel.street_id) suggestions.append({ 'text_fr': panel.street.name_fr or '', 'text_nl': panel.street.name_nl or '', }) return JsonResponse({'suggestions': suggestions}) @login_required def sign_pole_create(request): from django.contrib.gis.geos import Point from django.contrib.gis.db.models.functions import Distance user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign') or not user_can_edit_esign_assets(request.user): raise PermissionDenied(_("Vous n'avez pas la permission d'ajouter un poteau.")) if request.method == 'POST': form = SignPoleForm(request.POST) if form.is_valid(): try: # Note: form.save(commit=False) already creates a NEW instance pole = form.save(commit=False) lat = form.cleaned_data.get('lat') lon = form.cleaned_data.get('lon') # But let's ensure it's correct with explicit float conversion if lat and lon: pole.lon = float(lon) pole.lat = float(lat) pole.geom = Point(float(lon), float(lat), srid=4326) pole.geojson = pole.geom.geojson pole.geom.transform(3852) # If street is not provided, find the closest street if not pole.street_id: if lat and lon: pole_point = Point(float(lon), float(lat), srid=4326) closest_street = SignStreet.objects.annotate( distance=Distance('geom', pole_point) ).order_by('distance').first() if closest_street: pole.street = closest_street else: return JsonResponse({'success': False, 'errors': {'street': [_('Aucune rue trouvée à proximité.')]}}, status=400) else: return JsonResponse({'success': False, 'errors': {'geom': [_('Les coordonnées sont requises.')]}}, status=400) # Now save for the FIRST time (commit=True) pole.save() return JsonResponse({'success': True, 'id': pole.id, 'message': _('Poteau créé avec succès.')}) except Exception as e: import traceback traceback.print_exc() return JsonResponse({'success': False, 'errors': {'__all__': [str(e)]}}, status=500) else: return JsonResponse({'success': False, 'errors': form.errors}, status=400) else: lat = request.GET.get('lat') lon = request.GET.get('lon') initial = {} if lat and lon: initial = {'lat': lat, 'lon': lon} form = SignPoleForm(initial=initial) return render(request, 'sign/sign_pole_form.html', {'form': form}) @login_required def sign_pole_edit(request, pole_id): from django.contrib.gis.geos import Point user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign') or not user_can_edit_esign_assets(request.user): raise PermissionDenied(_("Vous n'avez pas la permission de modifier un poteau.")) pole = get_object_or_404(SignPole, id=pole_id) if request.method == 'POST': form = SignPoleForm(request.POST, instance=pole) if form.is_valid(): try: pole = form.save(commit=False) lat = form.cleaned_data.get('lat') lon = form.cleaned_data.get('lon') if lat and lon: pole.lon = float(lon) pole.lat = float(lat) pole.geom = Point(float(lon), float(lat), srid=4326) pole.geojson = pole.geom.geojson pole.geom.transform(3852) pole.save() return JsonResponse({'success': True, 'id': pole.id, 'message': _('Poteau modifié avec succès.')}) except Exception as e: import traceback traceback.print_exc() return JsonResponse({'success': False, 'errors': {'__all__': [str(e)]}}, status=500) else: return JsonResponse({'success': False, 'errors': form.errors}, status=400) else: # Extract coordinates from pole if pole.geom: pole.geom.transform(4326) initial = { 'lat': pole.geom.y, 'lon': pole.geom.x, } else: initial = {} form = SignPoleForm(instance=pole, initial=initial) return render(request, 'sign/sign_pole_edit_form.html', { 'form': form, 'pole': pole, 'pole_id': pole_id }) @login_required def sign_panel_create(request): from django.contrib.gis.geos import Point from django.contrib.gis.db.models.functions import Distance user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign') or not user_can_edit_esign_assets(request.user): raise PermissionDenied(_("Vous n'avez pas la permission d'ajouter un panneau.")) if request.method == 'POST': form = SignPanelForm(request.POST) if form.is_valid(): panel = form.save(commit=False) lat = form.cleaned_data.get('lat') lon = form.cleaned_data.get('lon') # If pole_id is provided, link it and inherit its street pole_id = request.POST.get('pole_id') if pole_id: try: pole = SignPole.objects.get(id=pole_id) panel.pole = pole panel.street = pole.street except SignPole.DoesNotExist: pass # If street is still not set and we have coordinates, find the closest street if lat and lon and not panel.street_id: panel_point = Point(lon, lat, srid=4326) closest_street = SignStreet.objects.annotate( distance=Distance('geom', panel_point) ).order_by('distance').first() if closest_street: panel.street = closest_street panel.save() return JsonResponse({'success': True, 'id': panel.id, 'message': _('Panneau créé avec succès.')}) else: return JsonResponse({'success': False, 'errors': form.errors}, status=400) else: lat = request.GET.get('lat') lon = request.GET.get('lon') pole_id = request.GET.get('pole_id') initial = {} if lat and lon: initial = {'lat': lat, 'lon': lon} form = SignPanelForm(initial=initial) panel_types_grouped, panel_types_groups = get_grouped_panel_types() return render(request, 'sign/sign_panel_form.html', { 'form': form, 'pole_id': pole_id, 'panel_types_grouped': panel_types_grouped, 'panel_types_groups': panel_types_groups, }) # ======================== # Regulation Document Generation # ======================== @login_required def regulation_generate_docx(request, regulation_id: int): """ Génère un document Word (.docx) bilingue (FR | NL) pour un règlement complémentaire. Structure: Introduction / Abrogations / Nouvelles Dispositions / Dispositions Finales """ from docx import Document as DocxDocument from docx.shared import Pt, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign'): raise PermissionDenied(_("Vous n'avez pas accès à cette vue.")) reg = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_view_regulation(request.user, reg): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) # Selected articles (ordered by ordering) selected_articles = ( reg.selected_articles .order_by('ordering', 'article_id') ) # If none selected, fall back to articles from panel types if not selected_articles.exists(): panels = reg.panels.select_related('signpanel_type').all() panel_type_ids = list({p.signpanel_type_id for p in panels if p.signpanel_type_id}) if panel_type_ids: direct_qs = ( RegulationArticle.objects .filter(panel_types__panel_type_id__in=panel_type_ids) .distinct() .select_related('parent', 'parent__parent') .order_by('ordering') ) all_ids: set[int] = set() for art in direct_qs: all_ids.add(art.id) for anc in art.get_ancestors(): all_ids.add(anc.id) selected_articles = ( RegulationArticle.objects .filter(id__in=all_ids) .order_by('ordering', 'article_id') ) # ------------------------------------------------------------------ # Document Word # ------------------------------------------------------------------ doc = DocxDocument() for section in doc.sections: section.top_margin = Cm(2.5) section.bottom_margin = Cm(2.5) section.left_margin = Cm(2.5) section.right_margin = Cm(2.5) # Helper: add a bilingual paragraph (FR left, NL right in a 2-col table) def add_bilingual_block(text_fr: str, text_nl: str): if not text_fr and not text_nl: return tbl = doc.add_table(rows=1, cols=2) tbl.style = 'Table Grid' cell_fr = tbl.cell(0, 0) cell_nl = tbl.cell(0, 1) cell_fr.text = text_fr or '' cell_nl.text = text_nl or '' for cell in (cell_fr, cell_nl): for para in cell.paragraphs: para.paragraph_format.space_after = Pt(0) doc.add_paragraph('') # ---- Titre principal bilingue ---- title_p = doc.add_heading("RÈGLEMENT COMPLÉMENTAIRE DE POLICE / AANVULLEND POLITIEREGLEMENT", level=0) title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER # ---- Métadonnées ---- doc.add_paragraph('') meta_table = doc.add_table(rows=4, cols=3) meta_table.style = 'Table Grid' meta_rows = [ ("Référence / Referentie", reg.reference or "_________________", ""), ("Titre / Titel", reg.title, ""), ("Organisation / Organisatie", reg.organization.name if reg.organization else "_________________", ""), ("Date de décision / Beslissingsdatum", reg.decision_date.strftime("%d/%m/%Y") if reg.decision_date else "_________________", ""), ] for i, (label, val, unused_col) in enumerate(meta_rows): row = meta_table.rows[i] row.cells[0].text = label row.cells[1].text = val row.cells[2].text = '' for run in row.cells[0].paragraphs[0].runs: run.bold = True doc.add_paragraph('') # ---- 1. Introduction ---- doc.add_heading("INTRODUCTION", level=2) default_intro_fr = ( "Vu l'article 117 de la nouvelle loi communale ;\n" "Vu l'article 3 de l'Ordonnance du 3 avril 2014 relative aux règlements " "complémentaires sur la circulation routière ;\n" "Vu l'article 60 et suivants de l'Arrêté royal du 1er décembre 1975 ;\n" "Considérant … ;\n\nARRÊTE :" ) default_intro_nl = ( "Gelet op artikel 117 van de nieuwe gemeentewet ;\n" "Gelet op artikel 3 van de Ordonnantie van 3 april 2014 ;\n" "Gelet op artikel 60 en volgende van het Koninklijk besluit van 1 december 1975 ;\n" "Overwegende … ;\n\nBESLUIT :" ) add_bilingual_block( reg.text_introduction_fr or default_intro_fr, reg.text_introduction_nl or default_intro_nl, ) # ---- 2. Abrogations ---- abrogated = list(reg.abrogated_panels.select_related('signpanel_type', 'street').order_by('code')) selected_abrogation_articles = reg.selected_abrogation_articles.order_by('ordering', 'article_id') if abrogated or reg.text_abrogations_fr or reg.text_abrogations_nl or selected_abrogation_articles.exists(): doc.add_page_break() doc.add_heading("ABROGATIONS / INGETROKKEN BEPALINGEN", level=2) if abrogated: tbl = doc.add_table(rows=1 + len(abrogated), cols=4) tbl.style = 'Table Grid' hdr = tbl.rows[0].cells hdr[0].text = 'Code' hdr[1].text = 'Type' hdr[2].text = 'Voirie / Weg' hdr[3].text = 'Statut / Status' for cell in tbl.rows[0].cells: for paragraph in cell.paragraphs: for run in paragraph.runs: run.bold = True for i, panel in enumerate(abrogated): row = tbl.rows[i + 1].cells row[0].text = panel.code or '' row[1].text = panel.signpanel_type.code if panel.signpanel_type else '' row[2].text = panel.street.get_name() if panel.street else '' if panel.status == 'removed': row[3].text = 'Retiré / Verwijderd' else: row[3].text = 'À retirer / Te verwijderen' doc.add_paragraph('') if reg.text_abrogations_fr or reg.text_abrogations_nl: add_bilingual_block(reg.text_abrogations_fr or '', reg.text_abrogations_nl or '') # Selected abrogation articles with voiries if selected_abrogation_articles.exists(): abr_voiries_map: dict[int, list] = {} for v in RegulationArticleVoirie.objects.filter( regulation=reg, article__in=selected_abrogation_articles, section='abrogation', ).order_by('ordering', 'id'): abr_voiries_map.setdefault(v.article_id, []).append(v) for art in selected_abrogation_articles: text_fr = art.text_fr or '' text_nl = art.text_nl or '' level = art.level if level <= 2: doc.add_heading(f"{art.article_id} | {art.article_id}", level=min(level + 1, 4)) add_bilingual_block(f"{art.article_id} – {text_fr}", f"{art.article_id} – {text_nl}") else: add_bilingual_block(f"{art.article_id}. {text_fr}", f"{art.article_id}. {text_nl}") for voirie in abr_voiries_map.get(art.id, []): add_bilingual_block( f" {voirie.text_fr}" if voirie.text_fr else '', f" {voirie.text_nl}" if voirie.text_nl else '', ) # ---- 3. Nouvelles Dispositions ---- doc.add_page_break() doc.add_heading("NOUVELLES DISPOSITIONS / NIEUWE BEPALINGEN", level=2) if selected_articles: # Pre-fetch voiries for all selected articles voiries_map: dict[int, list] = {} for v in RegulationArticleVoirie.objects.filter( regulation=reg, article__in=selected_articles, section='nouvelle_disposition', ).order_by('ordering', 'id'): voiries_map.setdefault(v.article_id, []).append(v) for art in selected_articles: text_fr = art.text_fr or '' text_nl = art.text_nl or '' level = art.level if level <= 2: h = doc.add_heading(f"{art.article_id} | {art.article_id}", level=min(level + 1, 4)) # Add bilingual content below heading add_bilingual_block(f"{art.article_id} – {text_fr}", f"{art.article_id} – {text_nl}") else: add_bilingual_block(f"{art.article_id}. {text_fr}", f"{art.article_id}. {text_nl}") # Voiries for this article for voirie in voiries_map.get(art.id, []): add_bilingual_block( f" {voirie.text_fr}" if voirie.text_fr else '', f" {voirie.text_nl}" if voirie.text_nl else '', ) else: doc.add_paragraph("Aucun article sélectionné. / Geen artikelen geselecteerd.") # Supplementary notes if reg.text_nouvelles_dispositions_fr or reg.text_nouvelles_dispositions_nl: doc.add_paragraph('') add_bilingual_block( reg.text_nouvelles_dispositions_fr or '', reg.text_nouvelles_dispositions_nl or '', ) # ---- 4. Dispositions Finales ---- doc.add_page_break() doc.add_heading("DISPOSITIONS FINALES / SLOTBEPALINGEN", level=2) default_finale_fr = ( "La signalisation prévue ci-dessus sera placée conformément aux dispositions " "de l'Arrêté Royal du 1er décembre 1975 et de l'Arrêté ministériel du " "11 octobre 1976.\n\nLe présent règlement sera soumis à l'approbation du " "Ministre compétent après avis de la Commission Consultative pour la " "Circulation Routière." ) default_finale_nl = ( "De hierboven voorziene signalisatie zal worden geplaatst overeenkomstig de " "bepalingen van het Koninklijk Besluit van 1 december 1975 en het " "Ministerieel Besluit van 11 oktober 1976.\n\nDit reglement zal ter " "goedkeuring worden voorgelegd aan de bevoegde Minister na advies van de " "Overlegcommissie voor het Wegverkeer." ) add_bilingual_block( reg.text_dispositions_finales_fr or default_finale_fr, reg.text_dispositions_finales_nl or default_finale_nl, ) # ---- Signatures ---- doc.add_paragraph('') sig_table = doc.add_table(rows=3, cols=2) sig_table.style = 'Table Grid' for i, lbl in enumerate(["Pour le Président / Voor de Voorzitter", "Pour le Secrétaire / Voor de Secretaris"]): cell = sig_table.cell(0, i) cell.text = lbl for run in cell.paragraphs[0].runs: run.bold = True for i in range(2): sig_table.cell(1, i).text = "Nom / Naam : _________________________________" sig_table.cell(2, i).text = "Date / Datum : _________________________________" # ------------------------------------------------------------------ # Renvoyer le fichier # ------------------------------------------------------------------ buffer = io.BytesIO() doc.save(buffer) buffer.seek(0) from django.utils.text import slugify filename = slugify(f"RC-{reg.reference or reg.id}-{reg.title}") filename = f"{filename}.docx" response = HttpResponse( buffer.read(), content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", ) response["Content-Disposition"] = f'attachment; filename="{filename}"' return response # ---------------------- # Measure ↔ Panel association views # ---------------------- @login_required @require_http_methods(["GET"]) def regulation_measure_panels(request, regulation_id: int, article_id: int): """ GET ?section=nouvelle_disposition|abrogation Returns the panels currently associated with a specific article (measure) in the given regulation, plus an optional search over all panels. Query params: - section (default: nouvelle_disposition) - q optional search string (≥2 chars) """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) article = get_object_or_404(RegulationArticle, id=article_id) section = request.GET.get('section', 'nouvelle_disposition') if section not in ('nouvelle_disposition', 'abrogation'): return JsonResponse({'error': _('Section invalide.')}, status=400) linked_ids = set( RegulationMeasurePanel.objects .filter(regulation=regulation, article=article, section=section) .values_list('panel_id', flat=True) ) q = (request.GET.get('q') or '').strip() if q and len(q) >= 2: panels_qs = ( SignPanel.objects .filter( Q(code__icontains=q) | Q(signpanel_type__code__icontains=q) | Q(street__name_fr__icontains=q) | Q(street__name_nl__icontains=q) ) .select_related('signpanel_type', 'street') .order_by('code')[:30] ) elif linked_ids: panels_qs = ( SignPanel.objects .filter(id__in=linked_ids) .select_related('signpanel_type', 'street') .order_by('code') ) else: panels_qs = SignPanel.objects.none() results = [] lons, lats = [], [] for p in panels_qs: results.append({ 'id': p.id, 'code': p.code, 'panel_type': p.signpanel_type.code if p.signpanel_type else '', 'street': p.street.get_name() if p.street else '–', 'status': p.status or '', 'selected': p.id in linked_ids, 'lon': float(p.lon) if p.lon is not None else None, 'lat': float(p.lat) if p.lat is not None else None, }) if p.lon is not None and p.lat is not None: lons.append(float(p.lon)) lats.append(float(p.lat)) bbox = None if lons: pad = 0.002 bbox = [min(lons) - pad, min(lats) - pad, max(lons) + pad, max(lats) + pad] return JsonResponse({ 'panels': results, 'selected_count': len(linked_ids), 'bbox': bbox, }) @login_required @require_http_methods(["POST"]) def regulation_measure_panels_save(request, regulation_id: int, article_id: int): """ POST body JSON: { "panel_ids": [1, 2, …], "section": "nouvelle_disposition" } When called from the flux2 "Sélectionner" flow: 1. Adds the article + all ancestor articles to selected_articles (or selected_abrogation_articles). 2. Associates panels with the article. 3. Creates regulation-specific sub-articles for each unique street represented by the selected panels, then associates those panels with their sub-article. """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) article = get_object_or_404(RegulationArticle, id=article_id) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'error': _('JSON invalide.')}, status=400) section = data.get('section', 'nouvelle_disposition') if section not in ('nouvelle_disposition', 'abrogation'): return JsonResponse({'error': _('Section invalide.')}, status=400) panel_ids = [int(i) for i in data.get('panel_ids', []) if str(i).isdigit()] panels = list(SignPanel.objects.select_related('street').filter(id__in=panel_ids)) # 1. Add article + all ancestors to the regulation's article selection articles_to_select = article.get_ancestors() + [article] if section == 'nouvelle_disposition': regulation.selected_articles.add(*articles_to_select) else: regulation.selected_abrogation_articles.add(*articles_to_select) # 2. Associate all selected panels with the parent article # Track panels that were previously linked (to clean up regulation.panels if orphaned) previously_linked_ids = set( RegulationMeasurePanel.objects.filter( regulation=regulation, article=article, section=section ).values_list('panel_id', flat=True) ) RegulationMeasurePanel.objects.filter( regulation=regulation, article=article, section=section ).delete() RegulationMeasurePanel.objects.bulk_create([ RegulationMeasurePanel( regulation=regulation, article=article, section=section, panel=panel, ) for panel in panels ]) # 3. Create regulation-specific sub-articles (one per unique street) sub_articles_info = [] if panels: # Group panels by street (None-street panels share a single "no street" group) street_groups: dict = {} for panel in panels: key = panel.street_id if panel.street_id else 0 if key not in street_groups: street_groups[key] = {'panels': [], 'street': panel.street} street_groups[key]['panels'].append(panel) # Count existing regulation-specific sub-articles under this parent existing_subs = list( RegulationArticle.objects .filter(parent=article, regulation=regulation) .order_by('ordering', 'article_id') ) existing_by_street_fr = {s.text_fr: s for s in existing_subs} next_n = len(existing_subs) + 1 for key, info in street_groups.items(): street = info['street'] text_fr = street.name_fr if (street and getattr(street, 'name_fr', None)) else (str(street) if street else _('Sans voirie')) text_nl = street.name_nl if (street and getattr(street, 'name_nl', None)) else text_fr # Reuse existing sub-article for this street if it already exists sub_article = existing_by_street_fr.get(text_fr) if sub_article is None: sub_article_id = f"{article.article_id}.{next_n}" sub_article = RegulationArticle.objects.create( article_id=sub_article_id, article_number=sub_article_id, parent=article, regulation=regulation, level=article.level + 1, text_fr=text_fr, text_nl=text_nl, ordering=article.ordering * 1000 + next_n, ) next_n += 1 # Associate this street's panels with the sub-article RegulationMeasurePanel.objects.filter( regulation=regulation, article=sub_article, section=section ).delete() RegulationMeasurePanel.objects.bulk_create([ RegulationMeasurePanel( regulation=regulation, article=sub_article, section=section, panel=p, ) for p in info['panels'] ]) sub_articles_info.append({ 'id': sub_article.id, 'article_id': sub_article.article_id, 'text_fr': sub_article.text_fr, }) # Add sub-articles to the regulation's article selection if sub_articles_info: sub_article_objs = RegulationArticle.objects.filter( id__in=[s['id'] for s in sub_articles_info] ) if section == 'nouvelle_disposition': regulation.selected_articles.add(*sub_article_objs) else: regulation.selected_abrogation_articles.add(*sub_article_objs) # 4. Ensure all newly linked panels are associated at regulation level regulation.panels.add(*panels) # 5. Clean up: remove from regulation.panels any previously linked panel # that is no longer linked to any measure in this regulation. new_panel_ids = {p.id for p in panels} removed_panel_ids = previously_linked_ids - new_panel_ids if removed_panel_ids: still_linked_ids = set( RegulationMeasurePanel.objects.filter( regulation=regulation, panel_id__in=removed_panel_ids ).values_list('panel_id', flat=True) ) orphan_ids = removed_panel_ids - still_linked_ids if orphan_ids: regulation.panels.remove(*SignPanel.objects.filter(id__in=orphan_ids)) return JsonResponse({ 'success': True, 'count': len(panels), 'sub_articles': sub_articles_info, }) @login_required @require_http_methods(["POST"]) def regulation_measure_panels_set(request, regulation_id: int, article_id: int): """ Atomically replaces the panel set for a given article+section (map-edit "Terminer" flow). POST body JSON: { "panel_ids": [1, 2, …], "section": "nouvelle_disposition" } Unlike regulation_measure_panels_save, does NOT create sub-articles. Removes the panel from regulation.panels if it's no longer linked to any measure. """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) article = get_object_or_404(RegulationArticle, id=article_id) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'error': _('JSON invalide.')}, status=400) section = data.get('section', 'nouvelle_disposition') if section not in ('nouvelle_disposition', 'abrogation'): return JsonResponse({'error': _('Section invalide.')}, status=400) panel_ids = [int(i) for i in data.get('panel_ids', []) if str(i).isdigit()] panels = list(SignPanel.objects.filter(id__in=panel_ids)) previously_linked_ids = set( RegulationMeasurePanel.objects.filter( regulation=regulation, article=article, section=section ).values_list('panel_id', flat=True) ) RegulationMeasurePanel.objects.filter( regulation=regulation, article=article, section=section ).delete() if panels: RegulationMeasurePanel.objects.bulk_create([ RegulationMeasurePanel( regulation=regulation, article=article, section=section, panel=panel, ) for panel in panels ]) regulation.panels.add(*panels) # Clean up regulation.panels for panels no longer in any measure new_panel_ids = {p.id for p in panels} removed_ids = previously_linked_ids - new_panel_ids if removed_ids: still_linked_ids = set( RegulationMeasurePanel.objects.filter( regulation=regulation, panel_id__in=removed_ids ).values_list('panel_id', flat=True) ) orphan_ids = removed_ids - still_linked_ids if orphan_ids: regulation.panels.remove(*SignPanel.objects.filter(id__in=orphan_ids)) return JsonResponse({'success': True, 'count': len(panels)}) @login_required @require_http_methods(["POST"]) def regulation_measure_panel_toggle(request, regulation_id: int, article_id: int): """ POST body JSON: { "panel_id": 123, "section": "nouvelle_disposition" } Adds or removes a single panel from a measure's panel list. Returns: { action: "add"|"remove", panel_id, selected_count } """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) article = get_object_or_404(RegulationArticle, id=article_id) try: data = json.loads(request.body) panel_id = int(data['panel_id']) except (json.JSONDecodeError, KeyError, ValueError, TypeError): return JsonResponse({'error': _('panel_id invalide.')}, status=400) section = data.get('section', 'nouvelle_disposition') if section not in ('nouvelle_disposition', 'abrogation'): return JsonResponse({'error': _('Section invalide.')}, status=400) panel = get_object_or_404(SignPanel, id=panel_id) existing = RegulationMeasurePanel.objects.filter( regulation=regulation, article=article, section=section, panel=panel ).first() if existing: existing.delete() action = 'remove' # If the panel is no longer linked to any measure in this regulation, remove it at regulation level too still_linked = RegulationMeasurePanel.objects.filter( regulation=regulation, panel=panel ).exists() if not still_linked: regulation.panels.remove(panel) else: RegulationMeasurePanel.objects.create( regulation=regulation, article=article, section=section, panel=panel ) # Ensure the panel is also associated at regulation level regulation.panels.add(panel) action = 'add' count = RegulationMeasurePanel.objects.filter( regulation=regulation, article=article, section=section ).count() return JsonResponse({ 'success': True, 'action': action, 'panel_id': panel_id, 'selected': action == 'add', 'selected_count': count, }) @login_required @require_http_methods(["GET"]) def regulation_measure_panels_geojson(request, regulation_id: int, article_id: int): """ GeoJSON of all panels visible in the map for a specific measure. Panels linked to this measure are flagged as `is_measure_panel: true`. Query params: section, bbox (minLon,minLat,maxLon,maxLat), include_removed """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) get_object_or_404(RegulationArticle, id=article_id) section = request.GET.get('section', 'nouvelle_disposition') if section not in ('nouvelle_disposition', 'abrogation'): return JsonResponse({'error': _('Section invalide.')}, status=400) measure_panel_ids = set( RegulationMeasurePanel.objects .filter(regulation=regulation, article_id=article_id, section=section) .values_list('panel_id', flat=True) ) regulation_panel_ids = set(regulation.panels.values_list('id', flat=True)) # Optional bbox filter bbox_raw = request.GET.get('bbox') filter_geom = None if bbox_raw: try: min_lon, min_lat, max_lon, max_lat = map(float, bbox_raw.split(',')) from django.contrib.gis.geos import Polygon filter_geom = Polygon.from_bbox((min_lon, min_lat, max_lon, max_lat)) filter_geom.srid = 4326 except (ValueError, TypeError): pass include_removed = request.GET.get('include_removed', '').lower() in ('1', 'true') panels_qs = SignPanel.objects.select_related('street', 'signpanel_type') if filter_geom: panels_qs = panels_qs.filter(geom__intersects=filter_geom) if not include_removed: panels_qs = panels_qs.exclude(status='removed') panels_qs = panels_qs[:5000] features = [] for panel in panels_qs: geometry = json.loads(panel.geojson) if panel.geojson else None features.append({ "type": "Feature", "id": panel.id, "geometry": geometry, "properties": { "code": panel.code, "panel_type": panel.signpanel_type.code if panel.signpanel_type else None, "street": panel.street.get_name() if panel.street else "-", "status": panel.status, "asset_type": "signpanel", "is_measure_panel": panel.id in measure_panel_ids, "is_regulation_panel": panel.id in regulation_panel_ids, "lon": panel.lon, "lat": panel.lat, }, }) return JsonResponse({"type": "FeatureCollection", "features": features}, safe=False) @login_required @require_http_methods(["POST"]) def regulation_article_update_text(request, regulation_id: int, article_id: int): """ Updates article_id / text_fr / text_nl on a custom (regulation-specific) article. POST body: { "article_id": "…", "text_fr": "…", "text_nl": "…" } article_id is optional; if omitted the existing value is kept. """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) article = get_object_or_404(RegulationArticle, id=article_id, regulation=regulation) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'error': _('JSON invalide.')}, status=400) new_article_id = (data.get('article_id') or '').strip() or None text_fr = (data.get('text_fr') or '').strip() text_nl = (data.get('text_nl') or '').strip() if not text_fr: return JsonResponse({'error': _('text_fr est requis.')}, status=400) update_fields = ['text_fr', 'text_nl'] article.text_fr = text_fr article.text_nl = text_nl if new_article_id and new_article_id != article.article_id: # Ensure uniqueness among siblings (custom articles under same parent/regulation) conflict = RegulationArticle.objects.filter( article_id=new_article_id, regulation=regulation, parent=article.parent, ).exclude(pk=article.pk).exists() if conflict: return JsonResponse({'error': _('Ce numéro est déjà utilisé par un autre sous-article.')}, status=400) article.article_id = new_article_id article.article_number = new_article_id.split('.')[-1] if '.' in new_article_id else new_article_id update_fields += ['article_id', 'article_number'] article.save(update_fields=update_fields) return JsonResponse({'success': True, 'article_id': article.article_id}) @login_required @require_http_methods(["GET"]) def regulation_article_children_selectable(request, regulation_id: int, article_id: int): """ Returns children of the given article from the global reference, indicating which are already in scope for the regulation (selected or custom). Used by the "add sub-article" picker in the article tree. Query param: ?section=nouvelle_disposition|abrogation """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) parent = get_object_or_404(RegulationArticle, id=article_id) section = request.GET.get('section', 'nouvelle_disposition') if section not in ('nouvelle_disposition', 'abrogation'): return JsonResponse({'error': _('Section invalide.')}, status=400) if section == 'nouvelle_disposition': selected_ids = set(regulation.selected_articles.values_list('id', flat=True)) else: selected_ids = set(regulation.selected_abrogation_articles.values_list('id', flat=True)) custom_ids = set( RegulationArticle.objects.filter(regulation=regulation).values_list('id', flat=True) ) scope_ids = selected_ids | custom_ids children = RegulationArticle.objects.filter(parent=parent).order_by('ordering', 'article_id') has_ref_children = any(c.regulation_id is None for c in children) result = [ { 'id': child.id, 'article_id': child.article_id, 'text_fr': child.text_fr or '', 'text_nl': child.text_nl or '', 'already_selected': child.id in scope_ids, 'is_custom': child.regulation_id is not None, } for child in children ] return JsonResponse({'children': result, 'parent_article_id': parent.article_id, 'has_ref_children': has_ref_children}) @login_required @require_http_methods(["POST"]) def regulation_article_create_custom(request, regulation_id: int, parent_article_id: int): """ Creates a custom (regulation-specific) sub-article under the given parent, then adds it to the regulation's selection. POST body: { "article_id": "Art.5.7.2.2", "text_fr": "…", "text_nl": "…", "section": "…" } """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) parent = get_object_or_404(RegulationArticle, id=parent_article_id) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'error': _('JSON invalide.')}, status=400) new_article_id = (data.get('article_id') or '').strip() text_fr = (data.get('text_fr') or '').strip() text_nl = (data.get('text_nl') or '').strip() section = data.get('section', 'nouvelle_disposition') if not new_article_id or not text_fr: return JsonResponse({'error': _('article_id et text_fr sont requis.')}, status=400) if section not in ('nouvelle_disposition', 'abrogation'): return JsonResponse({'error': _('Section invalide.')}, status=400) # Determine ordering: if the parent has reference children, slot by article_id # comparison so the new article appears in the correct alphabetical position # regardless of what ordering values reference articles carry. # For pure custom subtrees (leaf parents), simply append at the end. ref_children_exist = RegulationArticle.objects.filter( parent=parent, regulation__isnull=True ).exists() if ref_children_exist: siblings = list( RegulationArticle.objects.filter(parent=parent).order_by('ordering', 'article_id') ) # First sibling whose article_id sorts after the new one insert_before = next((s for s in siblings if s.article_id > new_article_id), None) if insert_before: new_ordering = insert_before.ordering # Shift every sibling from that position onwards to make room RegulationArticle.objects.filter( parent=parent, ordering__gte=new_ordering ).update(ordering=F('ordering') + 1) else: new_ordering = (max(s.ordering for s in siblings) + 1) if siblings else 1 else: new_ordering = ( RegulationArticle.objects.filter(parent=parent).aggregate(m=Max('ordering'))['m'] or 0 ) + 1 new_article = RegulationArticle.objects.create( article_id=new_article_id, article_number=new_article_id.split('.')[-1] if '.' in new_article_id else new_article_id, text_fr=text_fr, text_nl=text_nl, parent=parent, regulation=regulation, level=parent.level + 1, ordering=new_ordering, ) if section == 'nouvelle_disposition': regulation.selected_articles.add(new_article) else: regulation.selected_abrogation_articles.add(new_article) return JsonResponse({ 'success': True, 'article': { 'id': new_article.id, 'article_id': new_article.article_id, 'text_fr': new_article.text_fr, 'text_nl': new_article.text_nl, }, }) @login_required @require_http_methods(["POST"]) def regulation_article_select(request, regulation_id: int, article_id: int): """ POST body JSON: { "section": "nouvelle_disposition" } Adds an article and all its ancestors to the regulation's article selection. """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) article = get_object_or_404(RegulationArticle, id=article_id) try: data = json.loads(request.body) except json.JSONDecodeError: data = {} section = data.get('section', 'nouvelle_disposition') if section not in ('nouvelle_disposition', 'abrogation'): return JsonResponse({'error': _('Section invalide.')}, status=400) articles_to_add = article.get_ancestors() + [article] if section == 'nouvelle_disposition': regulation.selected_articles.add(*articles_to_add) else: regulation.selected_abrogation_articles.add(*articles_to_add) return JsonResponse({'success': True}) @login_required @require_http_methods(["POST"]) def regulation_article_deselect(request, regulation_id: int, article_id: int): """ POST body JSON: { "section": "nouvelle_disposition" } Removes an article from the regulation's article selection and deletes all regulation-specific sub-articles (children with regulation FK set) recursively. If the article itself is regulation-specific, it is also deleted. """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous ne pouvez pas modifier ce règlement.")) article = get_object_or_404(RegulationArticle, id=article_id) try: data = json.loads(request.body) except json.JSONDecodeError: data = {} section = data.get('section', 'nouvelle_disposition') if section not in ('nouvelle_disposition', 'abrogation'): return JsonResponse({'error': _('Section invalide.')}, status=400) def collect_all_descendants(parent_article): """Return all descendant articles under parent_article (recursively).""" children = list(RegulationArticle.objects.filter(parent=parent_article)) all_desc = list(children) for child in children: all_desc.extend(collect_all_descendants(child)) return all_desc all_descendants = collect_all_descendants(article) custom_descendants = [a for a in all_descendants if a.regulation_id == regulation.id] all_descendant_ids = [a.id for a in all_descendants] custom_descendant_ids = [a.id for a in custom_descendants] # Remove all descendants from the selection if all_descendant_ids: if section == 'nouvelle_disposition': regulation.selected_articles.remove(*all_descendants) else: regulation.selected_abrogation_articles.remove(*all_descendants) RegulationMeasurePanel.objects.filter( regulation=regulation, article_id__in=all_descendant_ids, section=section ).delete() # Delete custom (regulation-specific) sub-articles from DB if custom_descendant_ids: RegulationArticle.objects.filter(id__in=custom_descendant_ids).delete() # Remove the article itself from the selection if section == 'nouvelle_disposition': regulation.selected_articles.remove(article) else: regulation.selected_abrogation_articles.remove(article) # Delete its measure panels in this section RegulationMeasurePanel.objects.filter( regulation=regulation, article=article, section=section ).delete() # If the article itself is regulation-specific, delete it too if article.regulation_id == regulation_id: article.delete() return JsonResponse({'success': True}) @login_required @require_http_methods(["POST"]) def regulation_measure_suggest_articles(request, regulation_id: int): """ Given a list of panel IDs (selected on the map), return the applicable RegulationArticles (leaf articles and their ancestors) with their selection state, sorted by ordering. POST body JSON: { "panel_ids": [1, 2, …], "section": "nouvelle_disposition" } Returns: { "articles": [ {id, article_id, breadcrumb, text_fr, text_nl, level, selected, from_panels}, … ] } """ regulation = get_object_or_404(ComplementaryPoliceRegulation, id=regulation_id) if not user_can_edit_regulation(request.user, regulation): raise PermissionDenied(_("Vous n'avez pas accès à ce règlement.")) try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'error': _('JSON invalide.')}, status=400) section = data.get('section', 'nouvelle_disposition') if section not in ('nouvelle_disposition', 'abrogation'): return JsonResponse({'error': _('Section invalide.')}, status=400) panel_ids = [int(i) for i in data.get('panel_ids', []) if str(i).isdigit()] if not panel_ids: return JsonResponse({'articles': []}) panel_type_ids = list( SignPanel.objects .filter(id__in=panel_ids) .values_list('signpanel_type_id', flat=True) .distinct() ) panel_type_ids = [pt for pt in panel_type_ids if pt] if not panel_type_ids: return JsonResponse({'articles': []}) # Collect only the directly linked articles (not their ancestors). # Ancestors are added automatically on the backend when the user clicks # "Sélectionner", so they should not appear as separate items in the list. direct_qs = list( RegulationArticle.objects .filter(panel_types__panel_type_id__in=panel_type_ids, regulation=None) .distinct() .order_by('ordering', 'article_id') ) if not direct_qs: return JsonResponse({'articles': []}) # Already linked to these panels for this article (any existing selection) if section == 'nouvelle_disposition': already_selected_ids = set(regulation.selected_articles.values_list('id', flat=True)) else: already_selected_ids = set(regulation.selected_abrogation_articles.values_list('id', flat=True)) results = [] for art in direct_qs: breadcrumb = ' › '.join(a.article_id for a in art.get_ancestors()) if breadcrumb: breadcrumb += ' › ' breadcrumb += art.article_id results.append({ 'id': art.id, 'article_id': art.article_id, 'breadcrumb': breadcrumb, 'text_fr': art.text_fr or '', 'text_nl': art.text_nl or '', 'level': art.level, 'selected': art.id in already_selected_ids, 'from_panels': True, }) return JsonResponse({'articles': results}) @login_required @require_http_methods(["POST"]) def sign_panel_move(request, panel_id): user_config = get_object_or_404(UserConfig, user=request.user) if not user_config.can_access_view('sign') or not user_can_edit_esign_assets(request.user): raise PermissionDenied(_("Vous n'avez pas la permission de modifier un panneau.")) panel = get_object_or_404(SignPanel, id=panel_id) direction = request.GET.get('direction') if not panel.pole: return JsonResponse({'success': False, 'error': 'Panel is not attached to a pole'}) # Ensure vertical_order is set for all siblings siblings = list(panel.pole.sign_app_panels.all().order_by('vertical_order')) for i, p in enumerate(siblings): if p.vertical_order != i + 1: p.vertical_order = i + 1 p.save() # Refresh list after normalization siblings = list(panel.pole.sign_app_panels.all().order_by('vertical_order')) try: # Find panel in the list (it might have been updated) panel = next(p for p in siblings if p.id == panel.id) index = siblings.index(panel) except StopIteration: return JsonResponse({'success': False, 'error': 'Panel not found in siblings'}) if direction == 'up': # Higher vertical order means higher on the pole? Or just list order? # Usually "up" in UI means moving it visually up. # If list is sorted by vertical_order ascending (1, 2, 3), then 1 is top? # Or is 1 bottom? # Let's assume "up" means decreasing index in the list (visually moving up). if index > 0: prev_panel = siblings[index - 1] panel.vertical_order, prev_panel.vertical_order = prev_panel.vertical_order, panel.vertical_order panel.save() prev_panel.save() elif direction == 'down': if index < len(siblings) - 1: next_panel = siblings[index + 1] panel.vertical_order, next_panel.vertical_order = next_panel.vertical_order, panel.vertical_order panel.save() next_panel.save() return JsonResponse({'success': True})