loko/streetup/sign/views.py
2026-07-22 14:48:40 +02:00

3357 lines
134 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

from django.shortcuts import render, get_object_or_404, redirect
from django.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("<br />", "\n").replace("<br/>", "\n").replace("<br>", "\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("<br />", "\n").replace("<br/>", "\n").replace("<br>", "\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("<br />", "\n").replace("<br/>", "\n").replace("<br>", "\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})