feat: implement GeoSyncMixin to automatically synchronize geometry, coordinates, and GeoJSON fields in location models
This commit is contained in:
parent
fd54d5765d
commit
0492d5ca1d
2 changed files with 208 additions and 8 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue