Skip to content

Commit

Permalink
maps: add custom PointSerializerMixin which allows to save geojson
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
goapunk committed Jan 28, 2025
1 parent b0cfac3 commit 10dc684
Show file tree
Hide file tree
Showing 11 changed files with 443 additions and 4 deletions.
37 changes: 37 additions & 0 deletions adhocracy4/maps/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import json
from collections import OrderedDict


class PointSerializerMixin:
"""Serializes a GeoDjango Point field into a geojson feature. Requires the field `geo_field`
on the Meta class of the serializer to be set to the name of the model field containing the point.
"""

def to_internal_value(self, data):
if self.Meta.geo_field and self.Meta.geo_field in data:
geo_field = data[self.Meta.geo_field]
point = json.loads(geo_field)
data = data.copy()
if "geometry" in point:
data[self.Meta.geo_field] = json.dumps(point["geometry"])
properties = self.get_properties()
if "properties" in point:
for property in properties:
if property in point["properties"]:
data[property] = point["properties"][property]
return super().to_internal_value(data)

def to_representation(self, instance):
data = super().to_representation(instance)
if self.Meta.geo_field and self.Meta.geo_field in data:
feature = OrderedDict()
feature["type"] = "Feature"
feature["geometry"] = data[self.Meta.geo_field]
props = OrderedDict()
properties = self.get_properties()
for property in properties:
if hasattr(instance, property):
props[property] = getattr(instance, property)
feature["properties"] = props
data[self.Meta.geo_field] = feature
return data
29 changes: 29 additions & 0 deletions adhocracy4/maps/validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from django.contrib.gis.geos import Polygon
from django.core.exceptions import ValidationError
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _


@deconstructible
class PointInPolygonValidator:
"""Validate that the given point is within the polygon, otherwise raise ValidationError."""

polygon: Polygon = None
message = _("Point is not inside the specified area")
code = "invalid"

def __init__(self, polygon):
self.polygon = polygon

def __call__(self, value):
""""""
if not self.polygon.contains(value):
raise ValidationError(message=self.message, code=self.code)

def __eq__(self, other):
return (
isinstance(other, PointInPolygonValidator)
and self.message == other.message
and self.code == other.code
and self.polygon == other.polygon
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Generated by Django 4.2.17 on 2025-01-28 14:21

import django.contrib.gis.db.models.fields
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("a4projects", "0047_alter_project_image_alter_project_tile_image"),
]

operations = [
migrations.AddField(
model_name="project",
name="geos_point",
field=django.contrib.gis.db.models.fields.PointField(
blank=True,
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.",
null=True,
srid=4326,
verbose_name="Can your project be located on the map?",
),
),
migrations.AddField(
model_name="project",
name="house_number",
field=models.CharField(blank=True, max_length=10, null=True),
),
migrations.AddField(
model_name="project",
name="streetname",
field=models.CharField(blank=True, max_length=200, null=True),
),
migrations.AddField(
model_name="project",
name="zip_code",
field=models.CharField(blank=True, max_length=20, null=True),
),
]
77 changes: 77 additions & 0 deletions adhocracy4/projects/migrations/0049_project_geos_point.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Generated by Django 4.2.17 on 2025-01-27 15:11

import json
import logging
import django.contrib.gis.db.models.fields

from django.contrib.gis.geos import GEOSGeometry
from django.contrib.gis.geos import Point
from django.db import migrations, models

logger = logging.getLogger(__name__)


def migrate_project_point_field(apps, schema_editor):
project = apps.get_model("a4projects", "Project")
for project in project.objects.all():
geojson_point = project.point
if not "geometry" in geojson_point:
logger.warning(
"error migrating point of project "
+ project.name
+ ": "
+ str(geojson_point)
)
continue
project.geos_point = GEOSGeometry(json.dumps(geojson_point["geometry"]))
if "properties" in geojson_point:
properties = geojson_point["properties"]
if "strname" in properties:
project.street_name = properties["strname"]
if "hsnr" in properties:
project.house_number = properties["hsnr"]
if "plz" in properties:
project.zip_code = properties["plz"]
project.save()


def migrate_project_geos_point_field(apps, schema_editor):
project = apps.get_model("a4projects", "Project")
for project in project.objects.all():
project.point = project.geos_point
project.save()


class Migration(migrations.Migration):

dependencies = [
("a4projects", "0048_project_geos_point_project_house_number_and_more"),
]

operations = [
migrations.RunPython(
migrate_project_point_field, reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name="project",
name="point",
),
migrations.AddField(
model_name="project",
name="point",
field=django.contrib.gis.db.models.fields.PointField(
blank=True,
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.",
null=True,
srid=4326,
verbose_name="Can your project be located on the map?",
),
),
migrations.RunPython(
migrate_project_geos_point_field, reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name="project",
name="geos_point",
),
]
8 changes: 6 additions & 2 deletions adhocracy4/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from autoslug import AutoSlugField
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.gis.db import models as gis_models
from django.core.validators import RegexValidator
from django.db import models
from django.urls import reverse
Expand All @@ -17,7 +18,6 @@
from adhocracy4.administrative_districts.models import AdministrativeDistrict
from adhocracy4.images import fields
from adhocracy4.images.validators import ImageAltTextValidator
from adhocracy4.maps.fields import PointField
from adhocracy4.models import base

from .enums import Access
Expand Down Expand Up @@ -87,7 +87,7 @@ class ProjectLocationMixin(models.Model):
class Meta:
abstract = True

point = PointField(
point = gis_models.PointField(
null=True,
blank=True,
verbose_name=_("Can your project be located on the map?"),
Expand All @@ -99,6 +99,10 @@ class Meta:
),
)

streetname = models.CharField(null=True, blank=True, max_length=200)
house_number = models.CharField(null=True, blank=True, max_length=10)
zip_code = models.CharField(null=True, blank=True, max_length=20)

administrative_district = models.ForeignKey(
AdministrativeDistrict,
on_delete=models.CASCADE,
Expand Down
8 changes: 8 additions & 0 deletions changelog/8472.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
### Added

- add new `PointSerializerMixin` which enables a serializer to correctly save geojson features as GeoDjango
in the database and serialize it back as geojson feature.

### Changed

- use spatialite as database to support GeoDjango
1 change: 1 addition & 0 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ django-enumfield==3.1
django-filter==24.3
django-widget-tweaks==1.5.0
djangorestframework==3.15.2
djangorestframework-gis==1.1.0
easy-thumbnails[svg]==2.10
html5lib==1.1
jsonfield==3.1.0
Expand Down
44 changes: 44 additions & 0 deletions tests/maps/test_map_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import json

import pytest
from django.conf import settings
from django.contrib.gis.geos import GEOSGeometry
from django.forms import ValidationError

from adhocracy4.maps.validators import PointInPolygonValidator


@pytest.mark.django_db
def test_point_in_polygon_validator_valid_point():
polygon = GEOSGeometry(
json.dumps(settings.BERLIN_POLYGON["features"][0]["geometry"])
)
validator = PointInPolygonValidator(polygon=polygon)

# point within the berlin polygon
geojson_point = {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [13.411924777644563, 52.499598134440944],
},
}
point = GEOSGeometry(json.dumps(geojson_point["geometry"]))
validator(point)


@pytest.mark.django_db
def test_point_in_polygon_validator_invalid_point():
polygon = GEOSGeometry(
json.dumps(settings.BERLIN_POLYGON["features"][0]["geometry"])
)
validator = PointInPolygonValidator(polygon=polygon)

# point outside the berlin polygon
geojson_point = {
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [13.459894, 51.574425]},
}
point = GEOSGeometry(json.dumps(geojson_point["geometry"]))
with pytest.raises(ValidationError):
validator(point)
Loading

0 comments on commit 10dc684

Please sign in to comment.