Skip to content

Commit 10dc684

Browse files
author
Julian Dehm
committed
maps: add custom PointSerializerMixin which allows to save geojson
points as GeoDjango to the database for better filtering capabilites and serializes them as geojson features. - add PointInPolygon validator which validates that a given point is in the provided polygon.
1 parent b0cfac3 commit 10dc684

File tree

11 files changed

+443
-4
lines changed

11 files changed

+443
-4
lines changed

adhocracy4/maps/serializers.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import json
2+
from collections import OrderedDict
3+
4+
5+
class PointSerializerMixin:
6+
"""Serializes a GeoDjango Point field into a geojson feature. Requires the field `geo_field`
7+
on the Meta class of the serializer to be set to the name of the model field containing the point.
8+
"""
9+
10+
def to_internal_value(self, data):
11+
if self.Meta.geo_field and self.Meta.geo_field in data:
12+
geo_field = data[self.Meta.geo_field]
13+
point = json.loads(geo_field)
14+
data = data.copy()
15+
if "geometry" in point:
16+
data[self.Meta.geo_field] = json.dumps(point["geometry"])
17+
properties = self.get_properties()
18+
if "properties" in point:
19+
for property in properties:
20+
if property in point["properties"]:
21+
data[property] = point["properties"][property]
22+
return super().to_internal_value(data)
23+
24+
def to_representation(self, instance):
25+
data = super().to_representation(instance)
26+
if self.Meta.geo_field and self.Meta.geo_field in data:
27+
feature = OrderedDict()
28+
feature["type"] = "Feature"
29+
feature["geometry"] = data[self.Meta.geo_field]
30+
props = OrderedDict()
31+
properties = self.get_properties()
32+
for property in properties:
33+
if hasattr(instance, property):
34+
props[property] = getattr(instance, property)
35+
feature["properties"] = props
36+
data[self.Meta.geo_field] = feature
37+
return data

adhocracy4/maps/validators.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from django.contrib.gis.geos import Polygon
2+
from django.core.exceptions import ValidationError
3+
from django.utils.deconstruct import deconstructible
4+
from django.utils.translation import gettext_lazy as _
5+
6+
7+
@deconstructible
8+
class PointInPolygonValidator:
9+
"""Validate that the given point is within the polygon, otherwise raise ValidationError."""
10+
11+
polygon: Polygon = None
12+
message = _("Point is not inside the specified area")
13+
code = "invalid"
14+
15+
def __init__(self, polygon):
16+
self.polygon = polygon
17+
18+
def __call__(self, value):
19+
""""""
20+
if not self.polygon.contains(value):
21+
raise ValidationError(message=self.message, code=self.code)
22+
23+
def __eq__(self, other):
24+
return (
25+
isinstance(other, PointInPolygonValidator)
26+
and self.message == other.message
27+
and self.code == other.code
28+
and self.polygon == other.polygon
29+
)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Generated by Django 4.2.17 on 2025-01-28 14:21
2+
3+
import django.contrib.gis.db.models.fields
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("a4projects", "0047_alter_project_image_alter_project_tile_image"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="project",
16+
name="geos_point",
17+
field=django.contrib.gis.db.models.fields.PointField(
18+
blank=True,
19+
help_text="Locate your project. Click inside the marked area or type in an address to set the marker. A set marker can be dragged when pressed.",
20+
null=True,
21+
srid=4326,
22+
verbose_name="Can your project be located on the map?",
23+
),
24+
),
25+
migrations.AddField(
26+
model_name="project",
27+
name="house_number",
28+
field=models.CharField(blank=True, max_length=10, null=True),
29+
),
30+
migrations.AddField(
31+
model_name="project",
32+
name="streetname",
33+
field=models.CharField(blank=True, max_length=200, null=True),
34+
),
35+
migrations.AddField(
36+
model_name="project",
37+
name="zip_code",
38+
field=models.CharField(blank=True, max_length=20, null=True),
39+
),
40+
]
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Generated by Django 4.2.17 on 2025-01-27 15:11
2+
3+
import json
4+
import logging
5+
import django.contrib.gis.db.models.fields
6+
7+
from django.contrib.gis.geos import GEOSGeometry
8+
from django.contrib.gis.geos import Point
9+
from django.db import migrations, models
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
def migrate_project_point_field(apps, schema_editor):
15+
project = apps.get_model("a4projects", "Project")
16+
for project in project.objects.all():
17+
geojson_point = project.point
18+
if not "geometry" in geojson_point:
19+
logger.warning(
20+
"error migrating point of project "
21+
+ project.name
22+
+ ": "
23+
+ str(geojson_point)
24+
)
25+
continue
26+
project.geos_point = GEOSGeometry(json.dumps(geojson_point["geometry"]))
27+
if "properties" in geojson_point:
28+
properties = geojson_point["properties"]
29+
if "strname" in properties:
30+
project.street_name = properties["strname"]
31+
if "hsnr" in properties:
32+
project.house_number = properties["hsnr"]
33+
if "plz" in properties:
34+
project.zip_code = properties["plz"]
35+
project.save()
36+
37+
38+
def migrate_project_geos_point_field(apps, schema_editor):
39+
project = apps.get_model("a4projects", "Project")
40+
for project in project.objects.all():
41+
project.point = project.geos_point
42+
project.save()
43+
44+
45+
class Migration(migrations.Migration):
46+
47+
dependencies = [
48+
("a4projects", "0048_project_geos_point_project_house_number_and_more"),
49+
]
50+
51+
operations = [
52+
migrations.RunPython(
53+
migrate_project_point_field, reverse_code=migrations.RunPython.noop
54+
),
55+
migrations.RemoveField(
56+
model_name="project",
57+
name="point",
58+
),
59+
migrations.AddField(
60+
model_name="project",
61+
name="point",
62+
field=django.contrib.gis.db.models.fields.PointField(
63+
blank=True,
64+
help_text="Locate your project. Click inside the marked area or type in an address to set the marker. A set marker can be dragged when pressed.",
65+
null=True,
66+
srid=4326,
67+
verbose_name="Can your project be located on the map?",
68+
),
69+
),
70+
migrations.RunPython(
71+
migrate_project_geos_point_field, reverse_code=migrations.RunPython.noop
72+
),
73+
migrations.RemoveField(
74+
model_name="project",
75+
name="geos_point",
76+
),
77+
]

adhocracy4/projects/models.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from autoslug import AutoSlugField
44
from django.conf import settings
55
from django.contrib.auth.models import Group
6+
from django.contrib.gis.db import models as gis_models
67
from django.core.validators import RegexValidator
78
from django.db import models
89
from django.urls import reverse
@@ -17,7 +18,6 @@
1718
from adhocracy4.administrative_districts.models import AdministrativeDistrict
1819
from adhocracy4.images import fields
1920
from adhocracy4.images.validators import ImageAltTextValidator
20-
from adhocracy4.maps.fields import PointField
2121
from adhocracy4.models import base
2222

2323
from .enums import Access
@@ -87,7 +87,7 @@ class ProjectLocationMixin(models.Model):
8787
class Meta:
8888
abstract = True
8989

90-
point = PointField(
90+
point = gis_models.PointField(
9191
null=True,
9292
blank=True,
9393
verbose_name=_("Can your project be located on the map?"),
@@ -99,6 +99,10 @@ class Meta:
9999
),
100100
)
101101

102+
streetname = models.CharField(null=True, blank=True, max_length=200)
103+
house_number = models.CharField(null=True, blank=True, max_length=10)
104+
zip_code = models.CharField(null=True, blank=True, max_length=20)
105+
102106
administrative_district = models.ForeignKey(
103107
AdministrativeDistrict,
104108
on_delete=models.CASCADE,

changelog/8472.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
### Added
2+
3+
- add new `PointSerializerMixin` which enables a serializer to correctly save geojson features as GeoDjango
4+
in the database and serialize it back as geojson feature.
5+
6+
### Changed
7+
8+
- use spatialite as database to support GeoDjango

requirements/base.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ django-enumfield==3.1
88
django-filter==24.3
99
django-widget-tweaks==1.5.0
1010
djangorestframework==3.15.2
11+
djangorestframework-gis==1.1.0
1112
easy-thumbnails[svg]==2.10
1213
html5lib==1.1
1314
jsonfield==3.1.0

tests/maps/test_map_validators.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import json
2+
3+
import pytest
4+
from django.conf import settings
5+
from django.contrib.gis.geos import GEOSGeometry
6+
from django.forms import ValidationError
7+
8+
from adhocracy4.maps.validators import PointInPolygonValidator
9+
10+
11+
@pytest.mark.django_db
12+
def test_point_in_polygon_validator_valid_point():
13+
polygon = GEOSGeometry(
14+
json.dumps(settings.BERLIN_POLYGON["features"][0]["geometry"])
15+
)
16+
validator = PointInPolygonValidator(polygon=polygon)
17+
18+
# point within the berlin polygon
19+
geojson_point = {
20+
"type": "Feature",
21+
"geometry": {
22+
"type": "Point",
23+
"coordinates": [13.411924777644563, 52.499598134440944],
24+
},
25+
}
26+
point = GEOSGeometry(json.dumps(geojson_point["geometry"]))
27+
validator(point)
28+
29+
30+
@pytest.mark.django_db
31+
def test_point_in_polygon_validator_invalid_point():
32+
polygon = GEOSGeometry(
33+
json.dumps(settings.BERLIN_POLYGON["features"][0]["geometry"])
34+
)
35+
validator = PointInPolygonValidator(polygon=polygon)
36+
37+
# point outside the berlin polygon
38+
geojson_point = {
39+
"type": "Feature",
40+
"geometry": {"type": "Point", "coordinates": [13.459894, 51.574425]},
41+
}
42+
point = GEOSGeometry(json.dumps(geojson_point["geometry"]))
43+
with pytest.raises(ValidationError):
44+
validator(point)

0 commit comments

Comments
 (0)