3467 lines
138 KiB
Python
3467 lines
138 KiB
Python
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, AdrStreet
|
||
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_closest_adr_street(lat=None, lon=None, point=None, sign_street=None):
|
||
"""
|
||
Resolve corresponding common.AdrStreet from coordinates, Point, or SignStreet.
|
||
1. If sign_street is provided or can be found via spatial proximity, match by name or geom.
|
||
2. Fallback to point geometry directly on AdrStreet.
|
||
"""
|
||
from django.contrib.gis.geos import Point
|
||
from django.contrib.gis.db.models.functions import Distance
|
||
|
||
# 1. If sign_street is not provided, try to find the closest SignStreet spatially
|
||
if sign_street is None and (point is not None or (lat is not None and lon is not None)):
|
||
if point is None:
|
||
point = Point(float(lon), float(lat), srid=4326)
|
||
point_3812 = point.clone()
|
||
if point_3812.srid != 3812:
|
||
point_3812.transform(3812)
|
||
try:
|
||
sign_street = (
|
||
SignStreet.objects.filter(geom__isnull=False)
|
||
.annotate(distance=Distance('geom', point_3812))
|
||
.order_by('distance')
|
||
.first()
|
||
)
|
||
except Exception:
|
||
sign_street = None
|
||
|
||
if sign_street:
|
||
# Match by French name
|
||
if sign_street.name_fr:
|
||
qs = AdrStreet.objects.filter(name_fr__iexact=sign_street.name_fr.strip())
|
||
if sign_street.municipality_id:
|
||
adr = qs.filter(municipality_id=sign_street.municipality_id).first()
|
||
if adr:
|
||
return adr
|
||
adr = qs.first()
|
||
if adr:
|
||
return adr
|
||
|
||
# Match by Dutch name
|
||
if sign_street.name_nl:
|
||
qs = AdrStreet.objects.filter(name_nl__iexact=sign_street.name_nl.strip())
|
||
if sign_street.municipality_id:
|
||
adr = qs.filter(municipality_id=sign_street.municipality_id).first()
|
||
if adr:
|
||
return adr
|
||
adr = qs.first()
|
||
if adr:
|
||
return adr
|
||
|
||
# Match by proximity between SignStreet geom and AdrStreet geom
|
||
if sign_street.geom:
|
||
try:
|
||
adr = (
|
||
AdrStreet.objects.filter(geom__isnull=False)
|
||
.annotate(distance=Distance('geom', sign_street.geom))
|
||
.order_by('distance')
|
||
.first()
|
||
)
|
||
if adr:
|
||
return adr
|
||
except Exception:
|
||
pass
|
||
|
||
# 2. Fallback to point geometry directly on AdrStreet
|
||
if point is not None or (lat is not None and lon is not None):
|
||
if point is None:
|
||
point = Point(float(lon), float(lat), srid=4326)
|
||
point_3812 = point.clone()
|
||
if point_3812.srid != 3812:
|
||
point_3812.transform(3812)
|
||
try:
|
||
adr = (
|
||
AdrStreet.objects.filter(geom__isnull=False)
|
||
.annotate(distance=Distance('geom', point_3812))
|
||
.order_by('distance')
|
||
.first()
|
||
)
|
||
if adr:
|
||
return adr
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
adr = (
|
||
AdrStreet.objects.filter(centroid__isnull=False)
|
||
.annotate(distance=Distance('centroid', point_3812))
|
||
.order_by('distance')
|
||
.first()
|
||
)
|
||
if adr:
|
||
return adr
|
||
except Exception:
|
||
pass
|
||
|
||
return None
|
||
|
||
|
||
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
|
||
|
||
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')
|
||
|
||
# Ensure correct coordinates and projection (Lambert 2008: SRID 3812)
|
||
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(3812)
|
||
|
||
# If street is not provided, find the closest AdrStreet
|
||
if not pole.street_id and lat and lon:
|
||
adr_street = get_closest_adr_street(lat=lat, lon=lon)
|
||
if adr_street:
|
||
pole.street = adr_street
|
||
|
||
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(3812)
|
||
if not pole.street_id:
|
||
adr_street = get_closest_adr_street(lat=lat, lon=lon)
|
||
if adr_street:
|
||
pole.street = adr_street
|
||
|
||
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
|
||
|
||
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():
|
||
try:
|
||
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 and geom
|
||
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
|
||
if pole.geom:
|
||
panel.geom = pole.geom
|
||
panel.lon = pole.lon
|
||
panel.lat = pole.lat
|
||
panel.geojson = pole.geojson
|
||
except SignPole.DoesNotExist:
|
||
pass
|
||
|
||
# If coordinates are provided and geom not yet set
|
||
if lat and lon and not panel.geom:
|
||
panel.lon = float(lon)
|
||
panel.lat = float(lat)
|
||
panel.geom = Point(float(lon), float(lat), srid=4326)
|
||
panel.geojson = panel.geom.geojson
|
||
panel.geom.transform(3812)
|
||
|
||
# If street is still not set and we have coordinates, find the closest AdrStreet
|
||
if not panel.street_id and lat and lon:
|
||
adr_street = get_closest_adr_street(lat=lat, lon=lon)
|
||
if adr_street:
|
||
panel.street = adr_street
|
||
|
||
# Set vertical order if attached to a pole
|
||
if panel.pole and panel.vertical_order is None:
|
||
max_order = panel.pole.sign_app_panels.aggregate(m=Max('vertical_order'))['m'] or 0
|
||
panel.vertical_order = max_order + 1
|
||
|
||
panel.save()
|
||
return JsonResponse({'success': True, 'id': panel.id, 'message': _('Panneau 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')
|
||
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})
|