refactor: automate street synchronization and spatial inheritance for sign poles and panels

This commit is contained in:
kdeterme 2026-09-11 15:57:54 +02:00
parent 2c207fcfbb
commit 48d7796e45
4 changed files with 429 additions and 49 deletions

View file

@ -25,7 +25,9 @@ class SignPoleForm(forms.ModelForm):
lat = self.cleaned_data.get('lat')
lon = self.cleaned_data.get('lon')
if lat and lon:
instance.geom = Point(lon, lat, srid=4326)
instance.lon = float(lon)
instance.lat = float(lat)
instance.geom = Point(float(lon), float(lat), srid=4326)
instance.geojson = instance.geom.geojson
instance.geom.transform(3812)
if commit:
@ -59,7 +61,11 @@ class SignPanelForm(forms.ModelForm):
lat = self.cleaned_data.get('lat')
lon = self.cleaned_data.get('lon')
if lat and lon:
instance.geom = Point(lon, lat, srid=4326)
instance.lon = float(lon)
instance.lat = float(lat)
instance.geom = Point(float(lon), float(lat), srid=4326)
instance.geojson = instance.geom.geojson
instance.geom.transform(3812)
if commit:
instance.save()
return instance

View file

@ -70,8 +70,19 @@ class SignStreet(AbstractLocation):
street_level = models.CharField(max_length=50, null=True, blank=True)
def get_active_assets(self):
signpoles = self.signpoles.filter(status="active")
signpanels = self.signpanels.filter(status="active")
from django.db.models import Q
if hasattr(self, 'signpoles'):
signpoles = self.signpoles.filter(status="active")
signpanels = self.signpanels.filter(status="active")
else:
poles_qs = SignPole.objects.filter(status="active")
panels_qs = SignPanel.objects.filter(status="active")
if self.geom:
signpoles = poles_qs.filter(Q(geom__intersects=self.geom) | Q(street__name_fr__iexact=self.name_fr)).distinct()
signpanels = panels_qs.filter(Q(geom__intersects=self.geom) | Q(street__name_fr__iexact=self.name_fr)).distinct()
else:
signpoles = poles_qs.filter(street__name_fr__iexact=self.name_fr)
signpanels = panels_qs.filter(street__name_fr__iexact=self.name_fr)
return {"signpoles": signpoles, "signpanels": signpanels}
def get_absolute_url(self):
@ -153,6 +164,23 @@ class SignPole(AbstractGeoAsset):
class Meta:
indexes = [models.Index(fields=["code"])]
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.geom and hasattr(self, 'sign_app_panels'):
for panel in self.sign_app_panels.all():
needs_save = False
if panel.geom != self.geom or panel.lon != self.lon or panel.lat != self.lat:
panel.geom = self.geom
panel.lon = self.lon
panel.lat = self.lat
panel.geojson = self.geojson
needs_save = True
if panel.street_id != self.street_id and self.street_id:
panel.street = self.street
needs_save = True
if needs_save:
panel.save()
def get_absolute_url(self):
return reverse("sign:sign_assets_detail", kwargs={"asset_model": "signpole", "asset_id": self.id})
@ -239,6 +267,26 @@ class SignPanel(AbstractGeoAsset):
class Meta:
indexes = [models.Index(fields=["code"])]
def clean(self):
super().clean()
if self.pole and getattr(self.pole, 'geom', None):
self.geom = self.pole.geom
self.lon = self.pole.lon
self.lat = self.pole.lat
self.geojson = self.pole.geojson
if self.pole and self.pole.street and not self.street_id:
self.street = self.pole.street
def save(self, *args, **kwargs):
if self.pole and getattr(self.pole, 'geom', None):
self.geom = self.pole.geom
self.lon = self.pole.lon
self.lat = self.pole.lat
self.geojson = self.pole.geojson
if self.pole and self.pole.street and not self.street_id:
self.street = self.pole.street
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("sign:sign_assets_detail", kwargs={"asset_model": "signpanel", "asset_id": self.id})

View file

@ -784,3 +784,219 @@ class CopySignDataCommandTests(TestCase):
self.assertEqual(SignPole.objects.filter(code="POL-001").count(), 1)
self.assertEqual(SignPanel.objects.filter(code="PAN-001").count(), 1)
class SignStreetAssignmentTests(TestCase):
"""Tests for resolving AdrStreet and avoiding SignStreet assignment errors."""
def setUp(self):
from django.contrib.gis.geos import Point, LineString, Polygon, MultiPolygon
from common.models import Municipality, AdrStreet, AppView, UserConfig, Thematic, UserThematics
from assets.models import AssetCategory
from sign.models import (
SignPoleModel,
SignPanelModel,
SignPanelType,
SignStreet,
Organization,
)
self.client = Client()
# Create sign view and thematic
self.sign_view, _ = AppView.objects.get_or_create(
code='sign',
defaults={
'name_fr': 'Signalisation',
'name_nl': 'Signalisatie',
'url_name': 'sign:index',
'order': 2,
}
)
self.sign_thematic, _ = Thematic.objects.get_or_create(
code='sign',
defaults={
'name_fr': 'Signalisation',
'name_nl': 'Signalisatie',
'color': '#0022ff',
}
)
# Admin user with full edit access
self.user = User.objects.create_superuser(
username='admin_sign',
password='testpass123',
email='admin@test.com'
)
self.config = UserConfig.objects.create(user=self.user)
self.config.accessible_views.add(self.sign_view)
UserThematics.objects.create(
user_config=self.config,
thematic=self.sign_thematic,
can_view_assets=True,
can_edit_assets=True,
)
# Organization
self.org = Organization.objects.create(
code="GAN",
name_fr="Ganshoren",
name_nl="Ganshoren",
organization_type="municipality",
)
# Category
self.category = AssetCategory.objects.create(
code="sign_cat_test",
name_fr="Catégorie Signalisation Test",
)
# Municipality
self.muni = Municipality.objects.create(
fid="1083",
name_fr="Ganshoren",
name_nl="Ganshoren",
)
# AdrStreet (the actual address street in common)
poly = Polygon(((145000, 172000), (145000, 172500), (145500, 172500), (145500, 172000), (145000, 172000)))
self.adr_street = AdrStreet.objects.create(
fid="ADR-GAN-001",
name_fr="Avenue Charles-Quint",
name_nl="Keizer Karellaan",
municipality=self.muni,
geom=MultiPolygon(poly, srid=3812),
centroid=Point(145250, 172250, srid=3812),
)
# SignStreet (the road network geometry layer)
self.sign_street = SignStreet.objects.create(
code="GAN_R20_001",
name_fr="Avenue Charles-Quint",
name_nl="Keizer Karellaan",
municipality=self.muni,
geom=MultiPolygon(poly, srid=3812),
)
# Pole and panel models
self.pole_model = SignPoleModel.objects.create(
code="PM-TEST",
name_fr="Poteau Standard Test",
category=self.category,
height=3.0,
)
self.panel_model = SignPanelModel.objects.create(
code="PANM-TEST",
name_fr="Modèle C1 Test",
category=self.category,
shape="rond",
)
self.panel_type = SignPanelType.objects.create(
code="C1",
name_fr="Sens interdit",
name_nl="Verboden richting",
default_width=60.0,
default_height=60.0,
)
def test_get_closest_adr_street(self):
from sign.views import get_closest_adr_street
from django.contrib.gis.geos import Point
# Test resolving using SignStreet directly
adr = get_closest_adr_street(sign_street=self.sign_street)
self.assertEqual(adr, self.adr_street)
# Test resolving using spatial coords in Lambert 2008 (3812)
pt_3812 = Point(145250, 172250, srid=3812)
adr_pt = get_closest_adr_street(point=pt_3812)
self.assertEqual(adr_pt, self.adr_street)
def test_sign_pole_create_assigns_adr_street(self):
"""Test adding a pole at Charles-Quint does not crash with SignStreet assignment error."""
from django.contrib.gis.geos import Point
self.client.force_login(self.user)
pt = Point(145250, 172250, srid=3812)
pt.transform(4326)
response = self.client.post(reverse('sign:sign_pole_create'), {
'model': self.pole_model.id,
'organization': self.org.id,
'orientation': 0,
'lat': pt.y,
'lon': pt.x,
})
self.assertEqual(response.status_code, 200, response.content)
data = response.json()
self.assertTrue(data.get('success'))
from sign.models import SignPole
pole = SignPole.objects.get(id=data['id'])
# Must be an instance of AdrStreet, not SignStreet
from common.models import AdrStreet
self.assertIsInstance(pole.street, AdrStreet)
self.assertEqual(pole.street, self.adr_street)
def test_sign_panel_create_assigns_adr_street(self):
"""Test adding a panel at Charles-Quint assigns AdrStreet correctly."""
from django.contrib.gis.geos import Point
self.client.force_login(self.user)
pt = Point(145250, 172250, srid=3812)
pt.transform(4326)
response = self.client.post(reverse('sign:sign_panel_create'), {
'signpanel_type': self.panel_type.id,
'model': self.panel_model.id,
'organization': self.org.id,
'orientation': 0,
'support_type': 'pole',
'lat': pt.y,
'lon': pt.x,
})
self.assertEqual(response.status_code, 200, response.content)
data = response.json()
self.assertTrue(data.get('success'))
from sign.models import SignPanel
panel = SignPanel.objects.get(id=data['id'])
from common.models import AdrStreet
self.assertIsInstance(panel.street, AdrStreet)
self.assertEqual(panel.street, self.adr_street)
def test_sign_panel_create_attached_to_pole(self):
"""Test adding a panel attached to an existing pole inherits pole's street and sets vertical order."""
from django.contrib.gis.geos import Point
from sign.models import SignPole, SignPanel
from common.models import AdrStreet
pt = Point(145250, 172250, srid=3812)
pole = SignPole.objects.create(
street=self.adr_street,
model=self.pole_model,
organization=self.org,
geom=pt,
lon=4.31,
lat=50.86,
)
self.client.force_login(self.user)
response = self.client.post(reverse('sign:sign_panel_create'), {
'pole_id': pole.id,
'signpanel_type': self.panel_type.id,
'model': self.panel_model.id,
'organization': self.org.id,
'orientation': 0,
'support_type': 'pole',
})
self.assertEqual(response.status_code, 200, response.content)
data = response.json()
self.assertTrue(data.get('success'))
panel = SignPanel.objects.get(id=data['id'])
self.assertEqual(panel.pole, pole)
self.assertEqual(panel.street, self.adr_street)
self.assertIsInstance(panel.street, AdrStreet)
self.assertEqual(panel.vertical_order, 1)

View file

@ -19,7 +19,7 @@ try:
except ImportError:
Image = None
from common.models import UserConfig, UserThematics, Municipality
from common.models import UserConfig, UserThematics, Municipality, AdrStreet
from .models import (
SignPanelType,
SignStreet,
@ -36,6 +36,103 @@ from .permissions import user_can_manage_regulations, user_can_edit_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'))
@ -2096,7 +2193,6 @@ def regulation_abrogation_article_voiries_suggest(request, regulation_id: int, a
@login_required
def sign_pole_create(request):
from django.contrib.gis.geos import Point
from django.contrib.gis.db.models.functions import Distance
user_config = get_object_or_404(UserConfig, user=request.user)
if not user_config.can_access_view('sign') or not user_can_edit_esign_assets(request.user):
@ -2111,29 +2207,20 @@ def sign_pole_create(request):
lat = form.cleaned_data.get('lat')
lon = form.cleaned_data.get('lon')
# But let's ensure it's correct with explicit float conversion
# 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(3852)
pole.geom.transform(3812)
# If street is not provided, find the closest street
if not pole.street_id:
if lat and lon:
pole_point = Point(float(lon), float(lat), srid=4326)
closest_street = SignStreet.objects.annotate(
distance=Distance('geom', pole_point)
).order_by('distance').first()
if closest_street:
pole.street = closest_street
else:
return JsonResponse({'success': False, 'errors': {'street': [_('Aucune rue trouvée à proximité.')]}}, status=400)
else:
return JsonResponse({'success': False, 'errors': {'geom': [_('Les coordonnées sont requises.')]}}, status=400)
# 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
# Now save for the FIRST time (commit=True)
pole.save()
return JsonResponse({'success': True, 'id': pole.id, 'message': _('Poteau créé avec succès.')})
except Exception as e:
@ -2174,7 +2261,11 @@ def sign_pole_edit(request, pole_id):
pole.lat = float(lat)
pole.geom = Point(float(lon), float(lat), srid=4326)
pole.geojson = pole.geom.geojson
pole.geom.transform(3852)
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.')})
@ -2205,7 +2296,6 @@ def sign_pole_edit(request, pole_id):
@login_required
def sign_panel_create(request):
from django.contrib.gis.geos import Point
from django.contrib.gis.db.models.functions import Distance
user_config = get_object_or_404(UserConfig, user=request.user)
if not user_config.can_access_view('sign') or not user_can_edit_esign_assets(request.user):
@ -2214,31 +2304,51 @@ def sign_panel_create(request):
if request.method == 'POST':
form = SignPanelForm(request.POST)
if form.is_valid():
panel = form.save(commit=False)
lat = form.cleaned_data.get('lat')
lon = form.cleaned_data.get('lon')
# If pole_id is provided, link it and inherit its street
pole_id = request.POST.get('pole_id')
if pole_id:
try:
pole = SignPole.objects.get(id=pole_id)
panel.pole = pole
panel.street = pole.street
except SignPole.DoesNotExist:
pass
# If street is still not set and we have coordinates, find the closest street
if lat and lon and not panel.street_id:
panel_point = Point(lon, lat, srid=4326)
closest_street = SignStreet.objects.annotate(
distance=Distance('geom', panel_point)
).order_by('distance').first()
if closest_street:
panel.street = closest_street
panel.save()
return JsonResponse({'success': True, 'id': panel.id, 'message': _('Panneau créé avec succès.')})
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: