diff --git a/streetup/assets/models/core.py b/streetup/assets/models/core.py index 4df1ca2..a24715d 100644 --- a/streetup/assets/models/core.py +++ b/streetup/assets/models/core.py @@ -1,11 +1,134 @@ from django.contrib.gis.db import models +from django.contrib.gis.geos import Point from django.contrib.contenttypes.models import ContentType from django.utils.translation import get_language, gettext_lazy as _ from common.models import Thematic + +class GeoSyncMixin: + """ + Mixin providing spatial field synchronization (geom, lon, lat, geojson). + Automatically updates geojson and lon/lat when geom or lon/lat change. + """ + + def sync_geom_and_geojson(self): + modified_fields = set() + + old_lon = None + old_lat = None + old_geom = None + + if self.pk: + try: + old_instance = type(self).objects.get(pk=self.pk) + old_lon = getattr(old_instance, 'lon', None) + old_lat = getattr(old_instance, 'lat', None) + old_geom = getattr(old_instance, 'geom', None) + except Exception: + pass + + cur_geom = getattr(self, 'geom', None) + cur_lon = getattr(self, 'lon', None) + cur_lat = getattr(self, 'lat', None) + + # Check if lon/lat were modified while geom remained unchanged + lon_lat_modified = ( + cur_lon is not None and cur_lat is not None and + (cur_lon != old_lon or cur_lat != old_lat) and + (cur_geom == old_geom) + ) + + if lon_lat_modified: + # lon/lat were explicitly updated: rebuild geom from lon/lat + try: + pt_4326 = Point(cur_lon, cur_lat, srid=4326) + new_geojson = pt_4326.geojson + if getattr(self, 'geojson', None) != new_geojson: + self.geojson = new_geojson + modified_fields.add('geojson') + + if hasattr(self, 'geom'): + field = type(self)._meta.get_field('geom') + geom_type = getattr(field, 'geom_type', '').upper() + if geom_type in ('POINT', 'GEOMETRY'): + srid = getattr(field, 'srid', 3812) + pt_target = pt_4326 if srid == 4326 else pt_4326.transform(srid, clone=True) + if getattr(self, 'geom', None) != pt_target: + self.geom = pt_target + modified_fields.add('geom') + except Exception: + pass + + elif cur_geom is not None: + # geom is present and is the source of truth + try: + geom_4326 = cur_geom.transform(4326, clone=True) + new_geojson = geom_4326.geojson + if getattr(self, 'geojson', None) != new_geojson: + self.geojson = new_geojson + modified_fields.add('geojson') + + if geom_4326.geom_type == 'Point': + target_point = geom_4326 + else: + centroid = geom_4326.centroid + if geom_4326.intersects(centroid) or geom_4326.contains(centroid): + target_point = centroid + else: + target_point = geom_4326.point_on_surface + + new_lon = target_point.x + new_lat = target_point.y + + if getattr(self, 'lon', None) != new_lon: + self.lon = new_lon + modified_fields.add('lon') + if getattr(self, 'lat', None) != new_lat: + self.lat = new_lat + modified_fields.add('lat') + except Exception: + pass + + elif cur_lon is not None and cur_lat is not None: + # geom is None, but lon and lat are provided + try: + pt_4326 = Point(cur_lon, cur_lat, srid=4326) + new_geojson = pt_4326.geojson + if getattr(self, 'geojson', None) != new_geojson: + self.geojson = new_geojson + modified_fields.add('geojson') + + if hasattr(self, 'geom'): + field = type(self)._meta.get_field('geom') + geom_type = getattr(field, 'geom_type', '').upper() + if geom_type in ('POINT', 'GEOMETRY'): + srid = getattr(field, 'srid', 3812) + pt_target = pt_4326 if srid == 4326 else pt_4326.transform(srid, clone=True) + if getattr(self, 'geom', None) != pt_target: + self.geom = pt_target + modified_fields.add('geom') + except Exception: + pass + + elif cur_geom is None and cur_lon is None and cur_lat is None: + if getattr(self, 'geojson', None) is not None: + self.geojson = None + modified_fields.add('geojson') + + return modified_fields + + def save(self, *args, **kwargs): + modified = self.sync_geom_and_geojson() + if 'update_fields' in kwargs and kwargs['update_fields'] is not None: + update_fields = set(kwargs['update_fields']) + update_fields.update(modified) + kwargs['update_fields'] = list(update_fields) + super().save(*args, **kwargs) + + # Abstract base model for locations -class AbstractLocation(models.Model): +class AbstractLocation(GeoSyncMixin, models.Model): code = models.CharField(max_length=50) name_fr = models.CharField(max_length=200,null=True,blank=True) name_nl = models.CharField(max_length=200,null=True,blank=True) @@ -195,7 +318,7 @@ class AbstractAsset(models.Model): # Abstract base model for geo assets -class AbstractGeoAsset(AbstractAsset): +class AbstractGeoAsset(GeoSyncMixin, AbstractAsset): lon = models.FloatField(null=True,blank=True) lat = models.FloatField(null=True,blank=True) geom = models.PointField(srid=3812, null=True,blank=True) diff --git a/streetup/assets/tests.py b/streetup/assets/tests.py index 161dfda..c4ddac4 100644 --- a/streetup/assets/tests.py +++ b/streetup/assets/tests.py @@ -302,18 +302,16 @@ class TrafficLightRadarTest(TestCase): # Move the radar pole from django.contrib.gis.geos import Point new_geom = Point(648100, 670100, srid=3812) + new_geom_4326 = new_geom.transform(4326, clone=True) self.radar_pole.geom = new_geom - self.radar_pole.lon = 4.3520 - self.radar_pole.lat = 50.8475 - self.radar_pole.geojson = '{"type":"Point","coordinates":[4.3520,50.8475]}' self.radar_pole.save() # Check if the child radar position updated automatically self.radar.refresh_from_db() self.assertEqual(self.radar.geom, new_geom) - self.assertEqual(self.radar.lon, 4.3520) - self.assertEqual(self.radar.lat, 50.8475) - self.assertEqual(self.radar.geojson, '{"type":"Point","coordinates":[4.3520,50.8475]}') + self.assertAlmostEqual(self.radar.lon, new_geom_4326.x, places=5) + self.assertAlmostEqual(self.radar.lat, new_geom_4326.y, places=5) + self.assertEqual(self.radar.geojson, new_geom_4326.geojson) @@ -2526,6 +2524,85 @@ class PublicBuildingEditViewTest(TestCase): self.assertEqual(self.building.owner, 'Nouveau Propriétaire') +class GeoSyncMixinTest(TestCase): + def setUp(self): + from common.models import Municipality + from assets.models import PublicLightingStreet, PublicLightingPoleModel, PublicLightingPole, ITSLocation + + self.municipality = Municipality.objects.create(name_fr="Bruxelles", name_nl="Brussel", fid="1000") + self.street = PublicLightingStreet.objects.create( + code="STREET-001", + municipality=self.municipality, + ) + self.pole_model = PublicLightingPoleModel.objects.create( + code="POLEMODEL-001", + name_fr="Modèle Poteau", + ) + self.pole_class = PublicLightingPole + self.its_location_class = ITSLocation + + def test_update_lon_lat_updates_geojson_and_geom(self): + pole = self.pole_class.objects.create( + code="PLPOLE-SYNC-1", + street=self.street, + model=self.pole_model, + lon=4.3520, + lat=50.8475, + ) + self.assertIsNotNone(pole.geojson) + self.assertIn('"type": "Point"', pole.geojson) + self.assertIsNotNone(pole.geom) + self.assertEqual(pole.geom.srid, 3812) + + def test_update_geom_updates_geojson_and_coords(self): + from django.contrib.gis.geos import Point + pt_3812 = Point(648819, 670788, srid=3812) + pole = self.pole_class.objects.create( + code="PLPOLE-SYNC-2", + street=self.street, + model=self.pole_model, + geom=pt_3812, + ) + self.assertIsNotNone(pole.geojson) + self.assertIsNotNone(pole.lon) + self.assertIsNotNone(pole.lat) + self.assertAlmostEqual(pole.lon, 4.35, places=1) + self.assertAlmostEqual(pole.lat, 50.85, places=1) + + def test_polygon_concave_uses_point_on_surface(self): + from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon + # U-shaped polygon in WGS84 transformed to 3812 + u_poly_wgs84 = GEOSGeometry('POLYGON ((4.3500 50.8500, 4.3500 50.8510, 4.3510 50.8510, 4.3510 50.8508, 4.3502 50.8508, 4.3502 50.8500, 4.3500 50.8500))', srid=4326) + u_poly_3812 = MultiPolygon(u_poly_wgs84.transform(3812, clone=True)) + + location = self.its_location_class.objects.create( + code="ITSLOC-SYNC-1", + geom=u_poly_3812, + ) + self.assertIsNotNone(location.geojson) + self.assertIsNotNone(location.lon) + self.assertIsNotNone(location.lat) + + point_result = Point(location.lon, location.lat, srid=4326) + self.assertTrue(u_poly_wgs84.intersects(point_result) or u_poly_wgs84.contains(point_result)) + + def test_update_fields_includes_spatial_changes(self): + pole = self.pole_class.objects.create( + code="PLPOLE-SYNC-3", + street=self.street, + model=self.pole_model, + lon=4.3520, + lat=50.8475, + ) + pole.lon = 4.3600 + pole.save(update_fields=['lon']) + + pole.refresh_from_db() + self.assertAlmostEqual(pole.lon, 4.3600, places=4) + self.assertIn('4.36', pole.geojson) + + +