From 35dac5070bc3422cd2c6c2f46a28d1a1af2a2c50 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Sat, 8 Nov 2014 17:08:12 +0100 Subject: [PATCH] Added a new GeoJSON serialization format for GeoDjango Thanks Reinout van Rees for the review. --- django/contrib/gis/apps.py | 5 ++ django/contrib/gis/serializers/__init__.py | 0 django/contrib/gis/serializers/geojson.py | 68 ++++++++++++++++ django/contrib/gis/tests/geoapp/models.py | 6 ++ .../gis/tests/geoapp/test_serializers.py | 81 +++++++++++++++++++ django/core/serializers/json.py | 5 +- docs/ref/contrib/gis/serializers.txt | 69 ++++++++++++++++ docs/ref/contrib/gis/utils.txt | 1 + docs/releases/1.8.txt | 7 +- docs/topics/serialization.txt | 5 ++ tests/serializers_regress/tests.py | 2 +- 11 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 django/contrib/gis/serializers/__init__.py create mode 100644 django/contrib/gis/serializers/geojson.py create mode 100644 django/contrib/gis/tests/geoapp/test_serializers.py create mode 100644 docs/ref/contrib/gis/serializers.txt diff --git a/django/contrib/gis/apps.py b/django/contrib/gis/apps.py index 225efb8bb5..6ca51e9b42 100644 --- a/django/contrib/gis/apps.py +++ b/django/contrib/gis/apps.py @@ -1,4 +1,5 @@ from django.apps import AppConfig +from django.core import serializers from django.utils.translation import ugettext_lazy as _ @@ -6,3 +7,7 @@ from django.utils.translation import ugettext_lazy as _ class GISConfig(AppConfig): name = 'django.contrib.gis' verbose_name = _("GIS") + + def ready(self): + if 'geojson' not in serializers.BUILTIN_SERIALIZERS: + serializers.BUILTIN_SERIALIZERS['geojson'] = "django.contrib.gis.serializers.geojson" diff --git a/django/contrib/gis/serializers/__init__.py b/django/contrib/gis/serializers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/django/contrib/gis/serializers/geojson.py b/django/contrib/gis/serializers/geojson.py new file mode 100644 index 0000000000..573f85c045 --- /dev/null +++ b/django/contrib/gis/serializers/geojson.py @@ -0,0 +1,68 @@ +from __future__ import unicode_literals + +from django.contrib.gis.gdal import HAS_GDAL +from django.core.serializers.base import SerializerDoesNotExist, SerializationError +from django.core.serializers.json import Serializer as JSONSerializer + +if HAS_GDAL: + from django.contrib.gis.gdal import CoordTransform, SpatialReference + + +class Serializer(JSONSerializer): + """ + Convert a queryset to GeoJSON, http://geojson.org/ + """ + def _init_options(self): + super(Serializer, self)._init_options() + self.geometry_field = self.json_kwargs.pop('geometry_field', None) + self.srs = SpatialReference(self.json_kwargs.pop('srid', 4326)) + + def start_serialization(self): + if not HAS_GDAL: + # GDAL is needed for the geometry.geojson call + raise SerializationError("The geojson serializer requires the GDAL library.") + self._init_options() + self._cts = {} # cache of CoordTransform's + self.stream.write( + '{"type": "FeatureCollection", "crs": {"type": "name", "properties": {"name": "EPSG:%d"}},' + ' "features": [' % self.srs.srid) + + def end_serialization(self): + self.stream.write(']}') + + def start_object(self, obj): + super(Serializer, self).start_object(obj) + self._geometry = None + if self.geometry_field is None: + # Find the first declared geometry field + for field in obj._meta.fields: + if hasattr(field, 'geom_type'): + self.geometry_field = field.name + break + + def get_dump_object(self, obj): + data = { + "type": "Feature", + "properties": self._current, + } + if self._geometry: + if self._geometry.srid != self.srs.srid: + # If needed, transform the geometry in the srid of the global geojson srid + if self._geometry.srid not in self._cts: + self._cts[self._geometry.srid] = CoordTransform(self._geometry.srs, self.srs) + self._geometry.transform(self._cts[self._geometry.srid]) + data["geometry"] = eval(self._geometry.geojson) + else: + data["geometry"] = None + return data + + def handle_field(self, obj, field): + if field.name == self.geometry_field: + self._geometry = field._get_val_from_obj(obj) + else: + super(Serializer, self).handle_field(obj, field) + + +class Deserializer(object): + def __init__(self, *args, **kwargs): + raise SerializerDoesNotExist("geojson is a serialization-only serializer") diff --git a/django/contrib/gis/tests/geoapp/models.py b/django/contrib/gis/tests/geoapp/models.py index 59e54272fd..38e19cdea6 100644 --- a/django/contrib/gis/tests/geoapp/models.py +++ b/django/contrib/gis/tests/geoapp/models.py @@ -46,6 +46,12 @@ class Track(NamedModel): line = models.LineStringField() +class MultiFields(NamedModel): + city = models.ForeignKey(City) + point = models.PointField() + poly = models.PolygonField() + + class Truth(models.Model): val = models.BooleanField(default=False) diff --git a/django/contrib/gis/tests/geoapp/test_serializers.py b/django/contrib/gis/tests/geoapp/test_serializers.py new file mode 100644 index 0000000000..18809264f8 --- /dev/null +++ b/django/contrib/gis/tests/geoapp/test_serializers.py @@ -0,0 +1,81 @@ +from __future__ import unicode_literals + +import json + +from django.contrib.gis.geos import HAS_GEOS +from django.core import serializers +from django.test import TestCase, skipUnlessDBFeature + +from .models import City, MultiFields, PennsylvaniaCity + +if HAS_GEOS: + from django.contrib.gis.geos import LinearRing, Point, Polygon + + +@skipUnlessDBFeature("gis_enabled") +class GeoJSONSerializerTests(TestCase): + fixtures = ['initial'] + + def test_builtin_serializers(self): + """ + 'geojson' should be listed in available serializers. + """ + all_formats = set(serializers.get_serializer_formats()) + public_formats = set(serializers.get_public_serializer_formats()) + + self.assertIn('geojson', all_formats), + self.assertIn('geojson', public_formats) + + def test_serialization_base(self): + geojson = serializers.serialize('geojson', City.objects.all().order_by('name')) + try: + geodata = json.loads(geojson) + except Exception: + self.fail("Serialized output is not valid JSON") + self.assertEqual(len(geodata['features']), len(City.objects.all())) + self.assertEqual(geodata['features'][0]['geometry']['type'], 'Point') + self.assertEqual(geodata['features'][0]['properties']['name'], 'Chicago') + + def test_geometry_field_option(self): + """ + When a model has several geometry fields, the 'geometry_field' option + can be used to specify the field to use as the 'geometry' key. + """ + MultiFields.objects.create( + city=City.objects.first(), name='Name', point=Point(5, 23), + poly=Polygon(LinearRing((0, 0), (0, 5), (5, 5), (5, 0), (0, 0)))) + + geojson = serializers.serialize('geojson', MultiFields.objects.all()) + geodata = json.loads(geojson) + self.assertEqual(geodata['features'][0]['geometry']['type'], 'Point') + + geojson = serializers.serialize('geojson', MultiFields.objects.all(), + geometry_field='poly') + geodata = json.loads(geojson) + self.assertEqual(geodata['features'][0]['geometry']['type'], 'Polygon') + + def test_fields_option(self): + """ + The fields option allows to define a subset of fields to be present in + the 'properties' of the generated output. + """ + PennsylvaniaCity.objects.create(name='Mansfield', county='Tioga', point='POINT(-77.071445 41.823881)') + geojson = serializers.serialize('geojson', PennsylvaniaCity.objects.all(), + fields=('county', 'point')) + geodata = json.loads(geojson) + self.assertIn('county', geodata['features'][0]['properties']) + self.assertNotIn('founded', geodata['features'][0]['properties']) + + def test_srid_option(self): + geojson = serializers.serialize('geojson', City.objects.all().order_by('name'), srid=2847) + geodata = json.loads(geojson) + self.assertEqual( + [int(c) for c in geodata['features'][0]['geometry']['coordinates']], + [1564802, 5613214]) + + def test_deserialization_exception(self): + """ + GeoJSON cannot be deserialized. + """ + with self.assertRaises(serializers.base.SerializerDoesNotExist): + serializers.deserialize('geojson', '{}') diff --git a/django/core/serializers/json.py b/django/core/serializers/json.py index 72053e1d49..614427f9a7 100644 --- a/django/core/serializers/json.py +++ b/django/core/serializers/json.py @@ -24,7 +24,7 @@ class Serializer(PythonSerializer): """ internal_use_only = False - def start_serialization(self): + def _init_options(self): if json.__version__.split('.') >= ['2', '1', '3']: # Use JS strings to represent Python Decimal instances (ticket #16850) self.options.update({'use_decimal': False}) @@ -35,6 +35,9 @@ class Serializer(PythonSerializer): if self.options.get('indent'): # Prevent trailing spaces self.json_kwargs['separators'] = (',', ': ') + + def start_serialization(self): + self._init_options() self.stream.write("[") def end_serialization(self): diff --git a/docs/ref/contrib/gis/serializers.txt b/docs/ref/contrib/gis/serializers.txt new file mode 100644 index 0000000000..4e445f03ad --- /dev/null +++ b/docs/ref/contrib/gis/serializers.txt @@ -0,0 +1,69 @@ +.. _ref-geojson-serializer: + +================== +GeoJSON Serializer +================== + +.. versionadded:: 1.8 + +.. module:: django.contrib.gis.serializers.geojson + :synopsis: Serialization of GeoDjango models in the GeoJSON format. + +GeoDjango provides a specific serializer for the `GeoJSON`__ format. The GDAL +library is required for this serializer. See :doc:`/topics/serialization` for +more information on serialization. + +__ http://geojson.org/ + +The ``geojson`` serializer is not meant for round-tripping data, as it has no +deserializer equivalent. For example, you cannot use :djadmin:`loaddata` to +reload the output produced by this serializer. If you plan to reload the +outputted data, use the plain :ref:`json serializer ` +instead. + +In addition to the options of the ``json`` serializer, the ``geojson`` +serializer accepts the following additional option when it is called by +``serializers.serialize()``: + +* ``geometry_field``: A string containing the name of a geometry field to use + for the ``geometry`` key of the GeoJSON feature. This is only needed when you + have a model with more than one geometry field and you don't want to use the + first defined geometry field (by default, the first geometry field is picked). + +* ``srid``: The SRID to use for the ``geometry`` content. Defaults to 4326 + (WGS 84). + +The :ref:`fields ` option can be used to limit fields that +will be present in the ``properties`` key, as it works with all other +serializers. + +Example:: + + from django.core.serializers import serialize + from my_app.models import City + + serialize('geojson', City.objects.all(), + geometry_field='point', + fields=('name',)) + +Would output:: + + { + 'type': 'FeatureCollection', + 'crs': { + 'type': 'name', + 'properties': {'name': 'EPSG:4326'} + }, + 'features': [ + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [-87.650175, 41.850385] + }, + 'properties': { + 'name': 'Chicago' + } + } + ] + } diff --git a/docs/ref/contrib/gis/utils.txt b/docs/ref/contrib/gis/utils.txt index fee6325437..539a4ca9a2 100644 --- a/docs/ref/contrib/gis/utils.txt +++ b/docs/ref/contrib/gis/utils.txt @@ -15,3 +15,4 @@ useful in creating geospatial Web applications. layermapping ogrinspect + serializers diff --git a/docs/releases/1.8.txt b/docs/releases/1.8.txt index 6bfe9435a6..dc3b4211c8 100644 --- a/docs/releases/1.8.txt +++ b/docs/releases/1.8.txt @@ -131,12 +131,15 @@ Minor features :mod:`django.contrib.gis` ^^^^^^^^^^^^^^^^^^^^^^^^^^ -* Compatibility shims for ``SpatialRefSys`` and ``GeometryColumns`` changed in - Django 1.2 have been removed. +* A new :doc:`GeoJSON serializer ` is now + available. * The Spatialite backend now supports ``Collect`` and ``Extent`` aggregates when the database version is 3.0 or later. +* Compatibility shims for ``SpatialRefSys`` and ``GeometryColumns`` changed in + Django 1.2 have been removed. + :mod:`django.contrib.messages` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/topics/serialization.txt b/docs/topics/serialization.txt index 8ddf6a6b57..514b472b01 100644 --- a/docs/topics/serialization.txt +++ b/docs/topics/serialization.txt @@ -47,6 +47,8 @@ This is useful if you want to serialize data directly to a file-like object :ref:`format ` will raise a ``django.core.serializers.SerializerDoesNotExist`` exception. +.. _subset-of-fields: + Subset of fields ~~~~~~~~~~~~~~~~ @@ -257,6 +259,9 @@ In particular, :ref:`lazy translation objects ` need a return force_text(obj) return super(LazyEncoder, self).default(obj) +Also note that GeoDjango provides a :doc:`customized GeoJSON serializer +`. + .. _special encoder: http://docs.python.org/library/json.html#encoders-and-decoders .. _ecma-262: http://www.ecma-international.org/ecma-262/5.1/#sec-15.9.1.15 diff --git a/tests/serializers_regress/tests.py b/tests/serializers_regress/tests.py index 655fbe32a0..86b03c26f4 100644 --- a/tests/serializers_regress/tests.py +++ b/tests/serializers_regress/tests.py @@ -578,7 +578,7 @@ def naturalKeyTest(format, self): for format in [f for f in serializers.get_serializer_formats() - if not isinstance(serializers.get_serializer(f), serializers.BadSerializer)]: + if not isinstance(serializers.get_serializer(f), serializers.BadSerializer) and not f == 'geojson']: setattr(SerializerTests, 'test_' + format + '_serializer', curry(serializerTest, format)) setattr(SerializerTests, 'test_' + format + '_natural_key_serializer', curry(naturalKeySerializerTest, format)) setattr(SerializerTests, 'test_' + format + '_serializer_fields', curry(fieldsTest, format))