Skip to content

Commit

Permalink
Experimental rewrite of the API code
Browse files Browse the repository at this point in the history
- no longer uses tastypie (though it's still a dependency for some utilities)
- geospatial fields are no longer included in standard responses -- they're a separate request
- much faster performances in some cases (e.g. /boundary-set/)
- different URL structure: boundaries are /boundary/federal-electoral-districts/outremont/
- support KML and WKT output
- et cetera!

Models and the loader and slightly changed (and so incompatible) but most of the code
is the same.
  • Loading branch information
michaelmulley committed Dec 25, 2011
1 parent 3171a64 commit db2cdaa
Show file tree
Hide file tree
Showing 9 changed files with 373 additions and 248 deletions.
4 changes: 2 additions & 2 deletions boundaryservice/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ class BoundarySetAdmin(admin.ModelAdmin):
admin.site.register(BoundarySet, BoundarySetAdmin)

class BoundaryAdmin(OSMGeoAdmin):
list_display = ('kind', 'name', 'external_id')
list_display = ('name', 'external_id', 'set')
list_display_links = ('name', 'external_id')
list_filter = ('kind',)
list_filter = ('set',)

admin.site.register(Boundary, BoundaryAdmin)
208 changes: 208 additions & 0 deletions boundaryservice/base_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
""" A mini API framework.
"""

import re

from django.contrib.gis.measure import D
from django.http import HttpResponse, Http404
from django.template.defaultfilters import escapejs
from django.utils import simplejson as json
from django.views.generic import View

from tastypie.paginator import Paginator

from boundaryservice import kml

class RawJSONResponse(object):
"""APIView subclasses can return these if they have
already-serialized JSON to return"""
def __init__(self, content):
self.content = content

class APIView(View):
"""Base view class that serializes subclass responses to JSON.
Subclasses should define get/post/etc. methods."""

allow_jsonp = True
content_type = 'application/json; charset=utf-8'

def dispatch(self, request, *args, **kwargs):
result = super(APIView, self).dispatch(request, *args, **kwargs)
if isinstance(result, HttpResponse):
return result
resp = HttpResponse(content_type=self.content_type)
callback = ''
if self.allow_jsonp and 'callback' in request.GET:
callback = re.sub(r'[^a-zA-Z0-9_]', '', request.GET['callback'])
resp.write(callback + '(')
if isinstance(result, RawJSONResponse):
resp.write(result.content)
else:
json.dump(result, resp, indent=4)
if callback:
resp.write(');')
return resp

class ModelListView(APIView):
"""Base API class for a list of resources.
Subclasses should set the 'model' attribute to the appropriate model class.
Set the filterable_fields attribute to a list of field names users should
be able to filter on.
Compatible model classes should define a static method called get_dicts that,
given a list of objects, returns a list of dicts suitable for serialization.
By default, those will be model objects, but the model can also define a static
method called 'prepare_queryset_for_get_dicts' that accepts a queryset and returns
a sliceable iterable of objects that will later be passed to get_dicts."""

filter_types = ['exact', 'iexact', 'contains', 'icontains',
'startswith', 'istartswith', 'endswith', 'iendswith', 'isnull']

def get_qs(self, request):
return self.model.objects.all()

def filter(self, request, qs):
for (f, val) in request.GET.items():
if '__' in f:
(filter_field, filter_type) = f.split('__')
else:
(filter_field, filter_type) = (f, 'exact')
if filter_field in getattr(self, 'filterable_fields', []) and filter_type in self.filter_types:
if val in ['true', 'True']:
val = True
elif val in ['false', 'False']:
val = False
qs = qs.filter(**{filter_field + '__' + filter_type: val})
return qs

def get(self, request, **kwargs):
qs = self.get_qs(request, **kwargs)
qs = self.filter(request, qs)
if hasattr(self.model, 'prepare_queryset_for_get_dicts'):
qs = self.model.prepare_queryset_for_get_dicts(qs)
paginator = Paginator(request.GET, qs, resource_uri=request.path)
result = paginator.page()
result['objects'] = self.model.get_dicts(result['objects'])
return result

class ModelGeoListView(ModelListView):
"""Adds geospatial support to ModelListView.
Subclasses must set the 'allowed_geo_fields' attribute to a list
of geospatial field names which we're allowed to provide.
'name_field' should be the name of the field on objects that
contains a name value
To enable a couple of default geospatial filters, the
default_geo_filter_field attribute should be set to the name
of the geometry field to filter on.
To access a geospatial field, the field name must be provided
by the URLconf in the 'geo_field' keyword argument."""

name_field = 'name'
default_geo_filter_field = None

def filter(self, request, qs):
qs = super(ModelGeoListView, self).filter(request, qs)

if self.default_geo_filter_field:
if 'contains' in request.GET:
lat, lon = re.sub(r'[^\d.,-]', '', request.GET['contains']).split(',')
wkt_pt = 'POINT(%s %s)' % (lon, lat)
qs = qs.filter(**{self.default_geo_filter_field + "__contains" : wkt_pt})

if 'near' in request.GET:
lat, lon, range = request.GET['near'].split(',')
wkt_pt = 'POINT(%s %s)' % (float(lon), float(lat))
numeral = re.match('([0-9]+)', range).group(1)
unit = range[len(numeral):]
numeral = int(numeral)
kwargs = {unit: numeral}
qs = qs.filter(**{self.default_geo_filter_field + "__distance_lte" :(wkt_pt, D(**kwargs))})

return qs

def get(self, request, **kwargs):
if 'geo_field' not in kwargs:
# If it's not a geo request, let ModelListView handle it.
return super(ModelGeoListView, self).get(request, **kwargs)

field = kwargs.pop('geo_field')
if field not in self.allowed_geo_fields:
raise Http404
qs = self.get_qs(request, **kwargs)
qs = self.filter(request, qs)

format = request.GET.get('format', 'json')

if format == 'json':
strings = [u'{ "objects" : [ ']
strings.append(','.join( (u'{"name": "%s","%s":%s}' % (escapejs(x[1]),field,x[0].geojson)
for x in qs.values_list(field, self.name_field) )))
strings.append(u']}')
return RawJSONResponse(u''.join(strings))
elif format == 'wkt':
return HttpResponse("\n".join((geom.wkt for geom in qs.values_list(field, flat=True))), mimetype="text/plain")
elif format == 'kml':
placemarks = [kml.generate_placemark(x[1], x[0]) for x in qs.values_list(field, self.name_field)]
return HttpResponse(kml.generate_kml_document(placemarks), mimetype="application/vnd.google-earth.kml+xml")
else:
raise NotImplementedError

class ModelDetailView(APIView):
"""Return the API representation of a single object.
Subclasses must set the 'model' attribute to the appropriate model class.
Subclasses must define a 'get_object' method to return a single model
object. Its argument will be the request, a QuerySet of objects from
which to select, and any keyword arguments provided by the URLconf.
Compatible model classes must define an as_dict instance method which
returns a serializable dict of the object's data."""

def __init__(self):
super(ModelDetailView, self).__init__()
self.base_qs = self.model.objects.all()

def get(self, request, **kwargs):
return self.get_object(request, self.base_qs, **kwargs).as_dict()

class ModelGeoDetailView(ModelDetailView):
"""Adds geospatial support to ModelDetailView
Subclasses must set the 'allowed_geo_fields' attribute to a list
of geospatial field names which we're allowed to provide.
To access a geospatial field, the field name must be provided
by the URLconf in the 'geo_field' keyword argument."""

name_field = 'name'

def get(self, request, **kwargs):
if 'geo_field' not in kwargs:
# If it's not a geo request, let ModelDetailView handle it.
return super(ModelGeoDetailView, self).get(request, **kwargs)

field = kwargs.pop('geo_field')
if field not in self.allowed_geo_fields:
raise Http404

obj = self.get_object(request, self.base_qs.only(field, self.name_field), **kwargs)

geom = getattr(obj, field)
name = getattr(obj, self.name_field)
format = request.GET.get('format', 'json')
if format == 'json':
return RawJSONResponse(geom.geojson)
elif format == 'wkt':
return HttpResponse(geom.wkt, mimetype="text/plain")
elif format == 'kml':
return HttpResponse(
kml.generate_kml_document([kml.generate_placemark(name, geom)]),
mimetype="application/vnd.google-earth.kml+xml")
else:
raise NotImplementedError
15 changes: 15 additions & 0 deletions boundaryservice/kml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from xml.sax.saxutils import escape

def generate_placemark(name, geom):
return u"<Placemark><name>%s</name>%s</Placemark>" %(
escape(name),
geom.kml
)

def generate_kml_document(placemarks):
return """<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
%s
</Document>
</kml>""" % u"\n".join(placemarks)
3 changes: 2 additions & 1 deletion boundaryservice/management/commands/loadshapefiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from django.contrib.gis.gdal import CoordTransform, DataSource, OGRGeometry, OGRGeomType
from django.core.management.base import BaseCommand
from django.db import connections, DEFAULT_DB_ALIAS
from django.template.defaultfilters import slugify

from boundaryservice.models import BoundarySet, Boundary

Expand Down Expand Up @@ -182,7 +183,7 @@ def add_boundaries_for_layer(self, config, layer, set, database):

Boundary.objects.create(
set=set,
kind=config['singular'],
set_name=set.singular,
external_id=external_id,
name=feature_name,
display_name=display_name,
Expand Down
70 changes: 65 additions & 5 deletions boundaryservice/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import re

from django.contrib.gis.db import models
from django.core import urlresolvers
from django.template.defaultfilters import slugify

from boundaryservice.fields import ListField, JSONField
from boundaryservice.utils import get_site_url_root
Expand All @@ -9,11 +11,11 @@ class BoundarySet(models.Model):
"""
A set of related boundaries, such as all Wards or Neighborhoods.
"""
slug = models.SlugField(max_length=200, primary_key=True)
slug = models.SlugField(max_length=200, primary_key=True, editable=False)

name = models.CharField(max_length=64, unique=True,
name = models.CharField(max_length=100, unique=True,
help_text='Category of boundaries, e.g. "Community Areas".')
singular = models.CharField(max_length=64,
singular = models.CharField(max_length=100,
help_text='Name of a single boundary, e.g. "Community Area".')
kind_first = models.BooleanField(
help_text='If true, boundary display names will be "<kind> <name>" (e.g. Austin Community Area), otherwise "<name> <kind>" (e.g. 43rd Precinct).')
Expand All @@ -40,16 +42,43 @@ class BoundarySet(models.Model):
class Meta:
ordering = ('name',)

def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
return super(BoundarySet, self).save(*args, **kwargs)

def __unicode__(self):
return self.name

def as_dict(self):
r = {
'boundaries': urlresolvers.reverse('boundaryservice_boundary_list', kwargs={'set_slug': self.slug}),
}
for f in ('name', 'singular', 'authority', 'domain', 'href', 'notes', 'count', 'metadata_fields'):
r[f] = getattr(self, f)
return r

@staticmethod
def get_dicts(sets):
return [
{
'url': urlresolvers.reverse('boundaryservice_set_detail', kwargs={'slug': s.slug}),
'boundaries_url': urlresolvers.reverse('boundaryservice_boundary_list', kwargs={'set_slug': s.slug}),
'boundaries_count': s.count,
'name': s.name,
'domain': s.domain,
'hierarchy': s.get_hierarchy_display(),
} for s in sets
]

class Boundary(models.Model):
"""
A boundary object, such as a Ward or Neighborhood.
"""
boundaryset = models.ForeignKey(BoundarySet, related_name='boundaries',
set = models.ForeignKey(BoundarySet, related_name='boundaries',
help_text='Category of boundaries that this boundary belongs, e.g. "Community Areas".')
slug = models.SlugField(max_length=200)
set_name = models.CharField(max_length=100)
slug = models.SlugField(max_length=200, db_index=True)
external_id = models.CharField(max_length=64,
help_text='The boundaries\' unique id in the source dataset, or a generated one.')
name = models.CharField(max_length=192, db_index=True,
Expand All @@ -71,5 +100,36 @@ class Boundary(models.Model):
class Meta:
unique_together = (('slug', 'set'))

def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
return super(Boundary, self).save(*args, **kwargs)

def __unicode__(self):
return self.display_name

def as_dict(self):
return {
'set_url': urlresolvers.reverse('boundaryservice_set_detail', kwargs={'slug': self.set_id}),
'set_name': self.set_name,
'name': self.name,
'display_name': self.display_name,
'metadata': self.metadata
}

@staticmethod
def prepare_queryset_for_get_dicts(qs):
return qs.values_list('slug', 'set', 'name', 'display_name', 'set_name')

@staticmethod
def get_dicts(boundaries):
return [
{
'url': urlresolvers.reverse('boundaryservice_boundary_detail', kwargs={'slug': b[0], 'set_slug': b[1]}),
'name': b[2],
'display_name': b[3],
'set_url': urlresolvers.reverse('boundaryservice_set_detail', kwargs={'slug': b[1]}),
'set_name': b[4],
} for b in boundaries
]

Loading

0 comments on commit db2cdaa

Please sign in to comment.