Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[8472] Migrate project point to GeoDjango and add a new serializer mixin to convert between gis and geojson #1765

Merged
merged 2 commits into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/django.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package-lock.json

# dev
.idea/
.vscode/

# tests
/.coverage
Expand Down
8 changes: 8 additions & 0 deletions adhocracy4/dashboard/components/forms/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -10,6 +11,7 @@


class ProjectComponentFormView(
PointFormMixin,
ProjectMixin,
mixins.DashboardBaseMixin,
mixins.DashboardComponentMixin,
Expand All @@ -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"
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions adhocracy4/exports/mixins/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down
100 changes: 100 additions & 0 deletions adhocracy4/maps/mixins.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion adhocracy4/maps/templatetags/maps_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
)
28 changes: 28 additions & 0 deletions adhocracy4/maps/validators.py
Original file line number Diff line number Diff line change
@@ -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
)
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="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),
),
]
80 changes: 80 additions & 0 deletions adhocracy4/projects/migrations/0049_project_geos_point.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will this log warning be showing in sentry? otherwise i am afraid we will forget to check when deloying to prod/stage

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, it won't show up in sentry

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd locally test the migration with the prod db though

# 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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think once data has migrated wth migrate_project_point_field and old point filed is removed you can just rename the field from geos_point back to point, but safer in a new migration. And no need for migrate_project_geos_point_field

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sadly the RenameField migration doesn't work. It just gives a cryptic error. I assume it's because the old field is a normal sqlite field and the new one is a spatialite gis field, but that's more of a guess.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will have a look again just in case I missed something

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries, just leave it as it is then

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 @@ -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?"),
Expand All @@ -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,
Expand Down
Loading
Loading