feat: implement GeoSyncMixin to automatically synchronize geometry, coordinates, and GeoJSON fields in location models

This commit is contained in:
kdeterme 2026-08-05 14:34:11 +02:00
parent fd54d5765d
commit 0492d5ca1d
2 changed files with 208 additions and 8 deletions

View file

@ -1,11 +1,134 @@
from django.contrib.gis.db import models from django.contrib.gis.db import models
from django.contrib.gis.geos import Point
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import get_language, gettext_lazy as _ from django.utils.translation import get_language, gettext_lazy as _
from common.models import Thematic 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 # Abstract base model for locations
class AbstractLocation(models.Model): class AbstractLocation(GeoSyncMixin, models.Model):
code = models.CharField(max_length=50) code = models.CharField(max_length=50)
name_fr = models.CharField(max_length=200,null=True,blank=True) name_fr = models.CharField(max_length=200,null=True,blank=True)
name_nl = 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 # Abstract base model for geo assets
class AbstractGeoAsset(AbstractAsset): class AbstractGeoAsset(GeoSyncMixin, AbstractAsset):
lon = models.FloatField(null=True,blank=True) lon = models.FloatField(null=True,blank=True)
lat = models.FloatField(null=True,blank=True) lat = models.FloatField(null=True,blank=True)
geom = models.PointField(srid=3812, null=True,blank=True) geom = models.PointField(srid=3812, null=True,blank=True)

View file

@ -302,18 +302,16 @@ class TrafficLightRadarTest(TestCase):
# Move the radar pole # Move the radar pole
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
new_geom = Point(648100, 670100, srid=3812) 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.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() self.radar_pole.save()
# Check if the child radar position updated automatically # Check if the child radar position updated automatically
self.radar.refresh_from_db() self.radar.refresh_from_db()
self.assertEqual(self.radar.geom, new_geom) self.assertEqual(self.radar.geom, new_geom)
self.assertEqual(self.radar.lon, 4.3520) self.assertAlmostEqual(self.radar.lon, new_geom_4326.x, places=5)
self.assertEqual(self.radar.lat, 50.8475) self.assertAlmostEqual(self.radar.lat, new_geom_4326.y, places=5)
self.assertEqual(self.radar.geojson, '{"type":"Point","coordinates":[4.3520,50.8475]}') self.assertEqual(self.radar.geojson, new_geom_4326.geojson)
@ -2526,6 +2524,85 @@ class PublicBuildingEditViewTest(TestCase):
self.assertEqual(self.building.owner, 'Nouveau Propriétaire') 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)