From 10dc684cfa54cbd392840672a59dddbd756eb779 Mon Sep 17 00:00:00 2001 From: Julian Dehm Date: Mon, 27 Jan 2025 11:53:42 +0100 Subject: [PATCH] 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. --- adhocracy4/maps/serializers.py | 37 ++++ adhocracy4/maps/validators.py | 29 +++ ...eos_point_project_house_number_and_more.py | 40 ++++ .../migrations/0049_project_geos_point.py | 77 +++++++ adhocracy4/projects/models.py | 8 +- changelog/8472.md | 8 + requirements/base.txt | 1 + tests/maps/test_map_validators.py | 44 ++++ tests/project/polygon.py | 195 ++++++++++++++++++ tests/project/settings.py | 6 +- tests/project/travis.py | 2 +- 11 files changed, 443 insertions(+), 4 deletions(-) create mode 100644 adhocracy4/maps/serializers.py create mode 100644 adhocracy4/maps/validators.py create mode 100644 adhocracy4/projects/migrations/0048_project_geos_point_project_house_number_and_more.py create mode 100644 adhocracy4/projects/migrations/0049_project_geos_point.py create mode 100644 changelog/8472.md create mode 100644 tests/maps/test_map_validators.py create mode 100644 tests/project/polygon.py diff --git a/adhocracy4/maps/serializers.py b/adhocracy4/maps/serializers.py new file mode 100644 index 000000000..0b9ecee63 --- /dev/null +++ b/adhocracy4/maps/serializers.py @@ -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 diff --git a/adhocracy4/maps/validators.py b/adhocracy4/maps/validators.py new file mode 100644 index 000000000..f9ccc9be4 --- /dev/null +++ b/adhocracy4/maps/validators.py @@ -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 + ) diff --git a/adhocracy4/projects/migrations/0048_project_geos_point_project_house_number_and_more.py b/adhocracy4/projects/migrations/0048_project_geos_point_project_house_number_and_more.py new file mode 100644 index 000000000..4df37b5ea --- /dev/null +++ b/adhocracy4/projects/migrations/0048_project_geos_point_project_house_number_and_more.py @@ -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), + ), + ] diff --git a/adhocracy4/projects/migrations/0049_project_geos_point.py b/adhocracy4/projects/migrations/0049_project_geos_point.py new file mode 100644 index 000000000..70fe3a57e --- /dev/null +++ b/adhocracy4/projects/migrations/0049_project_geos_point.py @@ -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", + ), + ] diff --git a/adhocracy4/projects/models.py b/adhocracy4/projects/models.py index ab8c031f9..491a8d539 100644 --- a/adhocracy4/projects/models.py +++ b/adhocracy4/projects/models.py @@ -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 @@ -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 @@ -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?"), @@ -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, diff --git a/changelog/8472.md b/changelog/8472.md new file mode 100644 index 000000000..da28409e9 --- /dev/null +++ b/changelog/8472.md @@ -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 diff --git a/requirements/base.txt b/requirements/base.txt index 4be64d273..7c07498c1 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -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 diff --git a/tests/maps/test_map_validators.py b/tests/maps/test_map_validators.py new file mode 100644 index 000000000..56aa6e7d7 --- /dev/null +++ b/tests/maps/test_map_validators.py @@ -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) diff --git a/tests/project/polygon.py b/tests/project/polygon.py new file mode 100644 index 000000000..301e5de71 --- /dev/null +++ b/tests/project/polygon.py @@ -0,0 +1,195 @@ +BERLIN_POLYGON = { + "features": [ + { + "geometry": { + "coordinates": [ + [ + [13.47551254985644, 52.66878186412850], + [13.4756476943205, 52.6662806556946], + [13.48821932203047, 52.6705511650083], + [13.4852948383627, 52.6591313184541], + [13.49083140972433, 52.6547287329975], + [13.51278152919391, 52.6455619490685], + [13.51887316955025, 52.64747079290455], + [13.52310012320358, 52.645430194070], + [13.51769865735421, 52.6296359183979], + [13.50581735115305, 52.6258283812089], + [13.49675228461664, 52.6050935381621], + [13.50815361527170, 52.5921832557480], + [13.52243021204765, 52.5928525016777], + [13.54711209986707, 52.58788476538483], + [13.56756949831588, 52.5737401267818], + [13.58153970188728, 52.57088194177744], + [13.58749430855951, 52.55526701776870], + [13.58538711774429, 52.54847164183179], + [13.63535874890461, 52.54162879240169], + [13.62673238446994, 52.5377485708105], + [13.62590681405656, 52.5301463739158], + [13.65689556728097, 52.5298695113785], + [13.6585724640499, 52.5258111515478], + [13.63331882981516, 52.51182618683948], + [13.62467074769431, 52.4951842623379], + [13.6308004349988, 52.4941200504789], + [13.61501081462424, 52.4808250554547], + [13.61639738431556, 52.47489352940948], + [13.61151729313924, 52.4706273335476], + [13.62550862642468, 52.4683565075655], + [13.62580803771906, 52.4737133550199], + [13.64061312353399, 52.4794846317953], + [13.64821500984158, 52.478781959103], + [13.67066101945844, 52.47306199022395], + [13.68259937930192, 52.46618484991720], + [13.69682492987295, 52.46416044582899], + [13.6984327010084, 52.45508021843881], + [13.70536529411957, 52.4557664271711], + [13.70132413255186, 52.4681454816044], + [13.71644765434718, 52.4621942886764], + [13.72911216028255, 52.4505968229147], + [13.75321991093874, 52.44759765857591], + [13.75410720984658, 52.4431717430295], + [13.75902864393226, 52.442579364583], + [13.7595872183079, 52.4362718823468], + [13.75229608809821, 52.4370814607238], + [13.75421510289298, 52.4416114987071], + [13.75053611761034, 52.4414589552040], + [13.74311812998032, 52.43399868867271], + [13.73795859513104, 52.4341377584145], + [13.7413845258278, 52.4269159630436], + [13.7295135359784, 52.4173934664772], + [13.73874910747478, 52.4073217759860], + [13.73453774624779, 52.4019760684248], + [13.72334480404065, 52.3985861140822], + [13.71643662892613, 52.3995274363320], + [13.6875617731858, 52.3858235159905], + [13.68806198613023, 52.38291036570609], + [13.69863315287863, 52.381585040138], + [13.69999728320836, 52.3751213401578], + [13.6905641034697, 52.3673787101830], + [13.68031876604502, 52.3693159137457], + [13.67108036066419, 52.3663556565990], + [13.64840072050701, 52.33824183487645], + [13.63629107105115, 52.34703792463357], + [13.63863753231862, 52.36016167822242], + [13.64710542789269, 52.36687546722088], + [13.6468060908087, 52.3701605262758], + [13.64169695514917, 52.37095394202865], + [13.64336463289718, 52.3773909255589], + [13.63339138644673, 52.3762838889605], + [13.62794992360012, 52.381790668872], + [13.60695908735289, 52.37498143220593], + [13.59276211655155, 52.3941696868763], + [13.56490528578061, 52.3882858396881], + [13.5356636132792, 52.3890104545449], + [13.53837936718677, 52.4006790639424], + [13.52826323784186, 52.3983962153520], + [13.5151650860563, 52.4014349123701], + [13.479884997689, 52.3959966333453], + [13.46833747586563, 52.41943196644863], + [13.46232160238298, 52.4206244356477], + [13.41877268561141, 52.4099174058766], + [13.42758536517431, 52.38682399172477], + [13.42098487585958, 52.37624714140610], + [13.38852078167641, 52.3779684655105], + [13.38721684118924, 52.3885841633282], + [13.37045423085531, 52.3884638993464], + [13.37213521590491, 52.39382382438767], + [13.3430284778419, 52.4076741688992], + [13.34324865284548, 52.4116199383708], + [13.31192609725129, 52.3991854713994], + [13.29613013160132, 52.41645128482860], + [13.27509994793896, 52.4052931496972], + [13.24984400348291, 52.4049647890518], + [13.24579729139539, 52.4208011454616], + [13.2222879821333, 52.4203566808027], + [13.19556683949282, 52.4150948270754], + [13.15934575071114, 52.4028799475076], + [13.15782747598388, 52.3963621565525], + [13.17135668854137, 52.3977629612759], + [13.17152155560021, 52.395833461317], + [13.15888870350185, 52.3939088980181], + [13.14261228774228, 52.3965447877649], + [13.130989522373, 52.3872248516327], + [13.1274649015897, 52.39159455243857], + [13.13811666925351, 52.3953927103099], + [13.13620177012171, 52.3986114129578], + [13.12487568418023, 52.3968795906346], + [13.11163576548908, 52.4042232789888], + [13.1079318136284, 52.409477522881], + [13.11259639288390, 52.410019075076], + [13.1115705749854, 52.4131366188073], + [13.10322506469197, 52.41022242158841], + [13.0972215065827, 52.41245465527027], + [13.09729425606538, 52.4093844819044], + [13.09063599683758, 52.4118138830970], + [13.08833321786777, 52.4196114349798], + [13.0995968844625, 52.4253047519153], + [13.10461862576225, 52.4240532166838], + [13.12317654263721, 52.4393645461878], + [13.10922327610247, 52.4506195082119], + [13.11079867671213, 52.46606301139516], + [13.1177982999609, 52.47896793572678], + [13.12528457323742, 52.48021768041821], + [13.16645760602601, 52.5101242888099], + [13.14301019275782, 52.5196755246799], + [13.11739292015149, 52.5169953257302], + [13.13061743831494, 52.55635396849858], + [13.13629581365867, 52.5527476324541], + [13.14566477898211, 52.5529113573032], + [13.15295308528006, 52.5727814253755], + [13.1496032657000, 52.58331095442416], + [13.13209246513323, 52.5796990415934], + [13.12898729114535, 52.5874421028372], + [13.14685169737853, 52.5907408192208], + [13.16426315719952, 52.5989003294822], + [13.2066263079647, 52.58674246166060], + [13.21733644150834, 52.5874555915398], + [13.21781950137233, 52.5932217092928], + [13.20160648894926, 52.60638132766547], + [13.21698464925872, 52.62011856103034], + [13.22068327203508, 52.62817871973826], + [13.26427472703923, 52.6269279691694], + [13.26215627531724, 52.64075298743773], + [13.28244070899102, 52.64129535697439], + [13.28288465178745, 52.6607846665979], + [13.31003389218788, 52.65737600751500], + [13.30037755511819, 52.65346984054808], + [13.3092574211162, 52.6431403899200], + [13.30786040925631, 52.6376207425922], + [13.30311183621051, 52.63666918729149], + [13.308309029455, 52.6296915325797], + [13.30242260293777, 52.6275606009507], + [13.31312183825852, 52.6283827130697], + [13.33648052125503, 52.6226710873883], + [13.3446134210686, 52.6247654938213], + [13.35729605679328, 52.6231746725358], + [13.3759929298861, 52.6292925936467], + [13.37437534740554, 52.63152159871212], + [13.38868271166368, 52.6376138925285], + [13.3967120928858, 52.6483013954878], + [13.40699743969849, 52.6424209979265], + [13.41282815283099, 52.6435854249661], + [13.42436518479456, 52.6356304596162], + [13.43268299132920, 52.6375076188812], + [13.43410410919631, 52.6442854870769], + [13.43989854561689, 52.6453325216004], + [13.44093207234682, 52.64912301922096], + [13.45984841933212, 52.6481031759245], + [13.47354518426134, 52.65403940079648], + [13.47389406063868, 52.6566136126278], + [13.46233317034646, 52.6574644975699], + [13.45084091655469, 52.662722173439], + [13.45951142627063, 52.66893558707466], + [13.46596538109960, 52.6671485583023], + [13.47539391700665, 52.6749171487583], + [13.47878997721379, 52.67345251981177], + [13.47551254985644, 52.66878186412850], + ] + ], + "type": "Polygon", + }, + "type": "Feature", + } + ], + "type": "FeatureCollection", + "source": "\u00a9 GeoBasis-DE / BKG 2013 (Daten ver\u00e4ndert)", +} diff --git a/tests/project/settings.py b/tests/project/settings.py index 84aeb242c..f864a2850 100644 --- a/tests/project/settings.py +++ b/tests/project/settings.py @@ -13,6 +13,8 @@ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) import os +from .polygon import BERLIN_POLYGON # noqa: F403, F401 + PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.dirname(PROJECT_DIR)) @@ -67,6 +69,7 @@ "allauth.account", "django.contrib.contenttypes", "django.contrib.sessions", + "django.contrib.gis", "django.contrib.messages", "django.contrib.sites", "django.contrib.staticfiles", @@ -120,7 +123,7 @@ DATABASES = { "default": { - "ENGINE": "django.db.backends.sqlite3", + "ENGINE": "django.contrib.gis.db.backends.spatialite", "NAME": os.path.join(BASE_DIR, "db.sqlite3"), "TEST": { "NAME": os.path.join(BASE_DIR, "test_db.sqlite3"), @@ -267,6 +270,7 @@ CAPTCHA_URL = "https://captcheck.netsyms.com/api.php" CAPTCHA_TEST_ACCEPTED_ANSWER = "testpass" + try: from .local import * # noqa: F403, F401 except ImportError: diff --git a/tests/project/travis.py b/tests/project/travis.py index cda35f026..295e90918 100644 --- a/tests/project/travis.py +++ b/tests/project/travis.py @@ -2,7 +2,7 @@ DATABASES = { "default": { - "ENGINE": "django.db.backends.postgresql_psycopg2", + "ENGINE": "django.contrib.gis.db.backends.postgis", "USER": "postgres", "NAME": "django", "TEST": {"NAME": "django_test"},