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.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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue