diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index f4d65f740..e697e6d47 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-24.04 services: postgres: - image: postgres:16 + image: postgis/postgis:16-3.5 env: POSTGRES_PASSWORD: postgres ports: @@ -62,6 +62,9 @@ jobs: ${{ runner.os }}- - name: Install Dependencies run: | + sudo apt update + sudo apt install -y gdal-bin + sudo apt install libsqlite3-mod-spatialite npm install pip install -r requirements/dev.txt pip install coveralls diff --git a/.gitignore b/.gitignore index 681d5989e..0d9f3d3eb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ package-lock.json # dev .idea/ +.vscode/ # tests /.coverage diff --git a/adhocracy4/dashboard/components/forms/views.py b/adhocracy4/dashboard/components/forms/views.py index 4abfb4971..d0acc40ad 100644 --- a/adhocracy4/dashboard/components/forms/views.py +++ b/adhocracy4/dashboard/components/forms/views.py @@ -2,6 +2,7 @@ from django.utils.translation import gettext_lazy as _ from django.views import generic +from adhocracy4.maps.mixins import PointFormMixin from adhocracy4.modules import models as module_models from adhocracy4.projects import models as project_models from adhocracy4.projects.mixins import ProjectMixin @@ -10,6 +11,7 @@ class ProjectComponentFormView( + PointFormMixin, ProjectMixin, mixins.DashboardBaseMixin, mixins.DashboardComponentMixin, @@ -18,6 +20,9 @@ class ProjectComponentFormView( generic.UpdateView, ): + class Meta: + geo_field = "point" + permission_required = "a4projects.change_project" model = project_models.Project template_name = "a4dashboard/base_form_project.html" @@ -29,6 +34,9 @@ class ProjectComponentFormView( form_class = None form_template_name = "" + def get_properties(self): + return {"strname": "street_name", "hsnr": "house_number", "plz": "zip_code"} + def get_object(self, queryset=None): return self.project diff --git a/adhocracy4/exports/mixins/general.py b/adhocracy4/exports/mixins/general.py index 33ca7b743..a86571b89 100644 --- a/adhocracy4/exports/mixins/general.py +++ b/adhocracy4/exports/mixins/general.py @@ -59,6 +59,8 @@ def get_virtual_fields(self, virtual): def get_location_lon_data(self, item): if hasattr(item, "point"): point = item.point + if hasattr(point, "geojson"): + return point.x try: if "geometry" in point: return point["geometry"]["coordinates"][0] @@ -69,6 +71,8 @@ def get_location_lon_data(self, item): def get_location_lat_data(self, item): if hasattr(item, "point"): point = item.point + if hasattr(point, "geojson"): + return point.y try: if "geometry" in point: return point["geometry"]["coordinates"][1] diff --git a/adhocracy4/maps/mixins.py b/adhocracy4/maps/mixins.py new file mode 100644 index 000000000..c001d0366 --- /dev/null +++ b/adhocracy4/maps/mixins.py @@ -0,0 +1,100 @@ +import json +from collections import OrderedDict + + +class GeoJsonPointMixin: + """ + A mixin that processes GeoJSON point data for compatibility with GeoDjango models. + + Classes using this mixin must define a `Meta` class with a `geo_field` attribute, + specifying the name of the model field that stores the point data. + + Additionally, if `get_properties` returns a non-empty dictionary, the corresponding + GeoJSON properties are extracted and mapped to the specified model fields. + """ + + def get_properties(self): + """ + Defines a mapping of GeoJSON properties to model fields. + + Returns: + dict: A dictionary where keys are GeoJSON property names and values are + the corresponding model field names. If a model field name is identical + to the GeoJSON property name, its value can be set to None. + """ + return {} + + def unpack_geojson(self, data): + """ + Extracts and reformats GeoJSON point data for use in a GeoDjango model. + + Args: + data (dict): A dictionary containing GeoJSON data, including a point field + specified by `Meta.geo_field`. + + Returns: + dict: A modified version of `data` where: + - The geometry of the GeoJSON point is extracted and stored in `geo_field`. + - Relevant GeoJSON properties are mapped to their corresponding model fields, + based on the mapping defined in `get_properties`. + """ + 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: + point_properties = point["properties"] + + for prop, mapping in properties.items(): + if prop in point_properties: + field = mapping if mapping else prop + data[field] = point_properties[prop] + + return data + + +class PointFormMixin(GeoJsonPointMixin): + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + + if "data" in kwargs: + kwargs["data"] = self.unpack_geojson(kwargs["data"]) + + return kwargs + + +class PointSerializerMixin(GeoJsonPointMixin): + """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): + data = self.unpack_geojson(data) + 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 prop, mapping in properties.items(): + field = mapping if mapping else prop + print(field) + if hasattr(instance, field): + props[prop] = getattr(instance, field) + + if props: + feature["properties"] = props + data[self.Meta.geo_field] = feature + return data diff --git a/adhocracy4/maps/templatetags/maps_tags.py b/adhocracy4/maps/templatetags/maps_tags.py index 4a8bcdbc6..a1e75a4f4 100644 --- a/adhocracy4/maps/templatetags/maps_tags.py +++ b/adhocracy4/maps/templatetags/maps_tags.py @@ -116,6 +116,11 @@ def map_display_point(point, polygon, pin_src=None): omt_token = "" attribution = "" + if hasattr(point, "geojson"): + point = point.geojson + else: + point = json.dumps(point) + if hasattr(settings, "A4_MAP_ATTRIBUTION"): attribution = settings.A4_MAP_ATTRIBUTION @@ -148,7 +153,7 @@ def map_display_point(point, polygon, pin_src=None): mapbox_token=mapbox_token, omt_token=omt_token, attribution=attribution, - point=json.dumps(point), + point=point, polygon=json.dumps(polygon), pin_src=json.dumps(pin_src), ) diff --git a/adhocracy4/maps/validators.py b/adhocracy4/maps/validators.py new file mode 100644 index 000000000..37aa76915 --- /dev/null +++ b/adhocracy4/maps/validators.py @@ -0,0 +1,28 @@ +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..a259067eb --- /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="street_name", + 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..dbd090678 --- /dev/null +++ b/adhocracy4/projects/migrations/0049_project_geos_point.py @@ -0,0 +1,80 @@ +# 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 + # Existing points have a set of properties (from the address search on the map) + # They are in German and are never used again. For sake of preserving the data + # we map them to new english fields on the model + 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 a10e2f752..257dca332 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 @@ -98,7 +98,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?"), @@ -110,6 +110,10 @@ class Meta: ), ) + street_name = 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..171e86f39 --- /dev/null +++ b/changelog/8472.md @@ -0,0 +1,11 @@ +### 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 + +- **BREAKING CHANGE** Migrate the point field of the `Project` / +`ProjectLocationMixin` to GeoDjango. This requires the usage of a database with +geospatial support (.e.g spatialite, postgresql with postgis) +- use spatialite as database to support GeoDjango diff --git a/docs/location.md b/docs/location.md new file mode 100644 index 000000000..97ac414d0 --- /dev/null +++ b/docs/location.md @@ -0,0 +1,41 @@ +# Migrating from Custom JSONField to GeoDjango PointField + +## Overview + +In the past we stored geospatial data as GeoJSON within a custom `JSONField`, primarily for representing `Point` locations. While functional for basic storage, this approach lacked spatial query capabilities. To enhance performance and enable geospatial operations, we migrated the `point` field in the `Project` model to Django's `PointField` from GeoDjango. + +## Why Migrate? + +The key motivations for this migration include: + +- **Improved Querying**: `PointField` supports spatial operations such as distance filtering, intersection detection, and bounding box queries directly at the database level. +- **Performance Boost**: Native spatial indexing and operations significantly improve query efficiency compared to handling geospatial data as raw JSON. + +## GeoDjango Enhancements + +### Spatial Queries + +With `PointField`, we can now efficiently perform spatial queries. For example, retrieving projects within a 10km radius of a given location: + +```python +from django.contrib.gis.geos import Point +from django.contrib.gis.db.models.functions import Distance + +point = Point(-104.9903, 39.7392) # Example coordinates (longitude, latitude) +nearby_projects = Project.objects.filter(location__distance_lte=(point, 10000)) # 10km range +``` + +### Point Validation and Conversion + +To enhance data validation and handling, we introduced: + +- **`PointInPolygonValidator`**: Ensures points fall within a predefined polygon, validating both form and serializer inputs. +- **`PointFormMixin` & `PointSerializerMixin`**: These mixins facilitate conversion between a GeoDjango `Point` object and a GeoJSON representation, streamlining data transformations between the database and API layers. They also enable mapping GeoJSON properties to model fields and vice versa for seamless serialization. + +## Retaining JSONField for Other Use Cases + +While we migrated `Project.point` to `PointField`, we continue using `JSONField` where complex GeoJSON structures (such as polygons or metadata) are required. + +**Future Considerations:** We should evaluate migrating other fields currently using `JSONField` to appropriate GIS field types for improved performance and spatial query support. + +For further details or troubleshooting, refer to the [GeoDjango documentation](https://docs.djangoproject.com/en/stable/ref/contrib/gis/) or contact the development team. 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/conftest.py b/tests/conftest.py index 14bfa5188..667d16e07 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import pytest from celery import Celery +from django.contrib.gis.geos import Point from pytest_factoryboy import register from rest_framework.test import APIClient @@ -26,6 +27,23 @@ def image_factory(): return img_factories.ImageFactory() +@pytest.fixture +def gis_point(): + return Point(13.397788148643649, 52.52958586909979) + + +@pytest.fixture +def geojson_point(): + return { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [13.397788148643649, 52.52958586909979], + }, + "properties": {"strname": "Unknown Road"}, + } + + register(OrganisationFactory) register(factories.UserFactory) register(MemberFactory) diff --git a/tests/maps/test_map_mixins.py b/tests/maps/test_map_mixins.py new file mode 100644 index 000000000..6fbb1774c --- /dev/null +++ b/tests/maps/test_map_mixins.py @@ -0,0 +1,56 @@ +import json + +import pytest +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from adhocracy4.maps.mixins import PointSerializerMixin +from adhocracy4.projects.models import Project + + +class TestPointSerializer(PointSerializerMixin, serializers.ModelSerializer): + + def get_properties(self): + return {"strname": "street_name"} + + class Meta: + geo_field = "point" + fields = ["point", "street_name"] + model = Project + + +@pytest.mark.django_db +def test_valid_point_correctly_serialized_to_internal_value(geojson_point, gis_point): + del geojson_point["properties"] + serializer = TestPointSerializer() + data = serializer.to_internal_value(data={"point": json.dumps(geojson_point)}) + assert data["point"].equals(gis_point) + assert "street_name" not in data + + +@pytest.mark.django_db +def test_valid_point_with_properties_to_internal_value(geojson_point, gis_point): + serializer = TestPointSerializer() + data = serializer.to_internal_value(data={"point": json.dumps(geojson_point)}) + assert data["point"].equals(gis_point) + assert data["street_name"] == "Unknown Road" + + +@pytest.mark.django_db +def test_invalid_point_to_internal_value_throws_error(): + geojson_point = { + "type": "Feature", + } + serializer = TestPointSerializer() + with pytest.raises(ValidationError): + serializer.to_internal_value(data={"point": json.dumps(geojson_point)}) + + +@pytest.mark.django_db +def test_valid_point_to_representation(project, geojson_point, gis_point): + project.point = gis_point + project.street_name = geojson_point["properties"]["strname"] + project.save() + + data = TestPointSerializer(project).data + assert data["point"] == geojson_point 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..2fef04a8d 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)) @@ -65,8 +67,10 @@ "django.contrib.auth", "allauth", "allauth.account", + "rest_framework_gis", "django.contrib.contenttypes", "django.contrib.sessions", + "django.contrib.gis", "django.contrib.messages", "django.contrib.sites", "django.contrib.staticfiles", @@ -120,7 +124,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 +271,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"},