-
Notifications
You must be signed in to change notification settings - Fork 17
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,6 +17,7 @@ package-lock.json | |
|
||
# dev | ||
.idea/ | ||
.vscode/ | ||
|
||
# tests | ||
/.coverage | ||
|
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 |
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), | ||
), | ||
] |
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 | ||
# 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() | ||
goapunk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
|
||
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", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think once data has migrated wth There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sadly the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I will have a look again just in case I missed something There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
), | ||
] |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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