From 48d7796e45a4805b4830a97147b75de2bb65b134 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Fri, 11 Sep 2026 15:57:54 +0200 Subject: [PATCH] refactor: automate street synchronization and spatial inheritance for sign poles and panels --- loko/sign/forms.py | 10 +- loko/sign/models.py | 52 ++++++++++- loko/sign/tests.py | 216 ++++++++++++++++++++++++++++++++++++++++++++ loko/sign/views.py | 200 +++++++++++++++++++++++++++++++--------- 4 files changed, 429 insertions(+), 49 deletions(-) diff --git a/loko/sign/forms.py b/loko/sign/forms.py index 0abd32f..7eff401 100644 --- a/loko/sign/forms.py +++ b/loko/sign/forms.py @@ -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 diff --git a/loko/sign/models.py b/loko/sign/models.py index bbea328..fc09bbb 100644 --- a/loko/sign/models.py +++ b/loko/sign/models.py @@ -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}) diff --git a/loko/sign/tests.py b/loko/sign/tests.py index 51910b4..a425d50 100644 --- a/loko/sign/tests.py +++ b/loko/sign/tests.py @@ -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) + + diff --git a/loko/sign/views.py b/loko/sign/views.py index e10a0ae..8fd534c 100644 --- a/loko/sign/views.py +++ b/loko/sign/views.py @@ -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: