From a2be52fd2a27b5a39744939c906cd57507aa8f10 Mon Sep 17 00:00:00 2001 From: Justin Bronn Date: Sat, 16 Aug 2008 20:40:47 +0000 Subject: [PATCH] Fixed #6547, added support for GeoRSS feeds in `django.contrib.gis.feeds`; added the `feed_extra_kwargs` and `item_extra_kwargs` to the `Feed` baseclass so that it's possible for subclasses to add dynamic attributes. git-svn-id: http://code.djangoproject.com/svn/django/trunk@8414 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/contrib/gis/feeds.py | 135 ++++++++++++++++++ django/contrib/gis/tests/__init__.py | 37 +++-- django/contrib/gis/tests/geoapp/feeds.py | 52 +++++++ django/contrib/gis/tests/geoapp/test_feeds.py | 76 ++++++++++ django/contrib/gis/tests/geoapp/tests.py | 4 +- .../contrib/gis/tests/geoapp/tests_mysql.py | 2 + django/contrib/gis/tests/geoapp/urls.py | 16 +++ django/contrib/gis/tests/urls.py | 6 + django/contrib/syndication/feeds.py | 16 +++ 9 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 django/contrib/gis/feeds.py create mode 100644 django/contrib/gis/tests/geoapp/feeds.py create mode 100644 django/contrib/gis/tests/geoapp/test_feeds.py create mode 100644 django/contrib/gis/tests/geoapp/urls.py create mode 100644 django/contrib/gis/tests/urls.py diff --git a/django/contrib/gis/feeds.py b/django/contrib/gis/feeds.py new file mode 100644 index 00000000000..4105ef740b5 --- /dev/null +++ b/django/contrib/gis/feeds.py @@ -0,0 +1,135 @@ +from django.contrib.syndication.feeds import Feed as BaseFeed, FeedDoesNotExist +from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed + +class GeoFeedMixin(object): + """ + This mixin provides the necessary routines for SyndicationFeed subclasses + to produce simple GeoRSS or W3C Geo elements. + """ + + def georss_coords(self, coords): + """ + In GeoRSS coordinate pairs are ordered by lat/lon and separated by + a single white space. Given a tuple of coordinates, this will return + a unicode GeoRSS representation. + """ + return u' '.join([u'%f %f' % (coord[1], coord[0]) for coord in coords]) + + def add_georss_point(self, handler, coords, w3c_geo=False): + """ + Adds a GeoRSS point with the given coords using the given handler. + Handles the differences between simple GeoRSS and the more pouplar + W3C Geo specification. + """ + if w3c_geo: + lon, lat = coords[:2] + handler.addQuickElement(u'geo:lat', u'%f' % lat) + handler.addQuickElement(u'geo:lon', u'%f' % lon) + else: + handler.addQuickElement(u'georss:point', self.georss_coords((coords,))) + + def add_georss_element(self, handler, item, w3c_geo=False): + """ + This routine adds a GeoRSS XML element using the given item and handler. + """ + # Getting the Geometry object. + geom = item.get('geometry', None) + if not geom is None: + if isinstance(geom, (list, tuple)): + # Special case if a tuple/list was passed in. The tuple may be + # a point or a box + box_coords = None + if isinstance(geom[0], (list, tuple)): + # Box: ( (X0, Y0), (X1, Y1) ) + if len(geom) == 2: + box_coords = geom + else: + raise ValueError('Only should be two sets of coordinates.') + else: + if len(geom) == 2: + # Point: (X, Y) + self.add_georss_point(handler, geom, w3c_geo=w3c_geo) + elif len(geom) == 4: + # Box: (X0, Y0, X1, Y1) + box_coords = (geom[:2], geom[2:]) + else: + raise ValueError('Only should be 2 or 4 numeric elements.') + # If a GeoRSS box was given via tuple. + if not box_coords is None: + if w3c_geo: raise ValueError('Cannot use simple GeoRSS box in W3C Geo feeds.') + handler.addQuickElement(u'georss:box', self.georss_coords(box_coords)) + else: + # Getting the lower-case geometry type. + gtype = str(geom.geom_type).lower() + if gtype == 'point': + self.add_georss_point(handler, geom.coords, w3c_geo=w3c_geo) + else: + if w3c_geo: raise ValueError('W3C Geo only supports Point geometries.') + # For formatting consistent w/the GeoRSS simple standard: + # http://georss.org/1.0#simple + if gtype in ('linestring', 'linearring'): + handler.addQuickElement(u'georss:line', self.georss_coords(geom.coords)) + elif gtype in ('polygon',): + # Only support the exterior ring. + handler.addQuickElement(u'georss:polygon', self.georss_coords(geom[0].coords)) + else: + raise ValueError('Geometry type "%s" not supported.' % geom.geom_type) + +### SyndicationFeed subclasses ### +class GeoRSSFeed(Rss201rev2Feed, GeoFeedMixin): + def rss_attributes(self): + attrs = super(GeoRSSFeed, self).rss_attributes() + attrs[u'xmlns:georss'] = u'http://www.georss.org/georss' + return attrs + + def add_item_elements(self, handler, item): + super(GeoRSSFeed, self).add_item_elements(handler, item) + self.add_georss_element(handler, item) + + def add_root_elements(self, handler): + super(GeoRSSFeed, self).add_root_elements(handler) + self.add_georss_element(handler, self.feed) + +class GeoAtom1Feed(Atom1Feed, GeoFeedMixin): + def root_attributes(self): + attrs = super(GeoAtom1Feed, self).root_attributes() + attrs[u'xmlns:georss'] = u'http://www.georss.org/georss' + return attrs + + def add_item_elements(self, handler, item): + super(GeoAtom1Feed, self).add_item_elements(handler, item) + self.add_georss_element(handler, item) + + def add_root_elements(self, handler): + super(GeoAtom1Feed, self).add_root_elements(handler) + self.add_georss_element(handler, self.feed) + +class W3CGeoFeed(Rss201rev2Feed, GeoFeedMixin): + def rss_attributes(self): + attrs = super(W3CGeoFeed, self).rss_attributes() + attrs[u'xmlns:geo'] = u'http://www.w3.org/2003/01/geo/wgs84_pos#' + return attrs + + def add_item_elements(self, handler, item): + super(W3CGeoFeed, self).add_item_elements(handler, item) + self.add_georss_element(handler, item, w3c_geo=True) + + def add_root_elements(self, handler): + super(W3CGeoFeed, self).add_root_elements(handler) + self.add_georss_element(handler, self.feed, w3c_geo=True) + +### Feed subclass ### +class Feed(BaseFeed): + """ + This is a subclass of the `Feed` from `django.contrib.syndication`. + This allows users to define a `geometry(obj)` and/or `item_geometry(item)` + methods on their own subclasses so that geo-referenced information may + placed in the feed. + """ + feed_type = GeoRSSFeed + + def feed_extra_kwargs(self, obj): + return {'geometry' : self.__get_dynamic_attr('geometry', obj)} + + def item_extra_kwargs(self, item): + return {'geometry' : self.__get_dynamic_attr('item_geometry', item)} diff --git a/django/contrib/gis/tests/__init__.py b/django/contrib/gis/tests/__init__.py index ee0a803f9d6..f8e3f487394 100644 --- a/django/contrib/gis/tests/__init__.py +++ b/django/contrib/gis/tests/__init__.py @@ -1,5 +1,4 @@ import sys -from copy import copy from unittest import TestSuite, TextTestRunner from django.conf import settings @@ -94,20 +93,36 @@ def run_tests(module_list, verbosity=1, interactive=True): from django.contrib.gis.db.backend import create_spatial_db from django.contrib.gis.tests.utils import mysql from django.db import connection + from django.db.models import loading # Getting initial values. old_debug = settings.DEBUG - old_name = copy(settings.DATABASE_NAME) - old_installed = copy(settings.INSTALLED_APPS) - new_installed = copy(settings.INSTALLED_APPS) + old_name = settings.DATABASE_NAME + old_installed = settings.INSTALLED_APPS + old_root_urlconf = settings.ROOT_URLCONF + + # Based on ALWAYS_INSTALLED_APPS from django test suite -- + # this prevents us from creating tables in our test database + # from locally installed apps. + new_installed = ['django.contrib.contenttypes', + 'django.contrib.auth', + 'django.contrib.sites', + 'django.contrib.flatpages', + 'django.contrib.gis', + 'django.contrib.redirects', + 'django.contrib.sessions', + 'django.contrib.comments', + 'django.contrib.admin', + ] + + # Setting the URLs. + settings.ROOT_URLCONF = 'django.contrib.gis.tests.urls' # Want DEBUG to be set to False. settings.DEBUG = False - from django.db.models import loading - # Creating the test suite, adding the test models to INSTALLED_APPS, and - # adding the model test suites to our suite package. + # adding the model test suites to our suite package. test_suite, test_models = geo_suite() for test_model in test_models: module_name = 'django.contrib.gis.tests.%s' % test_model @@ -117,8 +132,9 @@ def run_tests(module_list, verbosity=1, interactive=True): test_module_name = 'tests' new_installed.append(module_name) - # Getting the test suite - tsuite = getattr(__import__('django.contrib.gis.tests.%s' % test_model, globals(), locals(), [test_module_name]), test_module_name) + # Getting the model test suite + tsuite = getattr(__import__('django.contrib.gis.tests.%s' % test_model, globals(), locals(), [test_module_name]), + test_module_name) test_suite.addTest(tsuite.suite()) # Resetting the loaded flag to take into account what we appended to @@ -138,7 +154,8 @@ def run_tests(module_list, verbosity=1, interactive=True): connection.creation.destroy_test_db(old_name, verbosity) settings.DEBUG = old_debug settings.INSTALLED_APPS = old_installed - + settings.ROOT_URLCONF = old_root_urlconf + # Returning the total failures and errors return len(result.failures) + len(result.errors) diff --git a/django/contrib/gis/tests/geoapp/feeds.py b/django/contrib/gis/tests/geoapp/feeds.py new file mode 100644 index 00000000000..5ab35dc5cd1 --- /dev/null +++ b/django/contrib/gis/tests/geoapp/feeds.py @@ -0,0 +1,52 @@ +from django.contrib.gis import feeds +from django.contrib.gis.tests.utils import mysql +from models import City, Country + +class TestGeoRSS1(feeds.Feed): + link = '/city/' + title = 'Test GeoDjango Cities' + + def items(self): + return City.objects.all() + + def item_link(self, item): + return '/city/%s/' % item.pk + + def item_geometry(self, item): + return item.point + +class TestGeoRSS2(TestGeoRSS1): + def geometry(self, obj): + # This should attach a element for the extent of + # of the cities in the database. This tuple came from + # calling `City.objects.extent()` -- we can't do that call here + # because `extent` is not implemented for MySQL/Oracle. + return (-123.30, -41.32, 174.78, 48.46) + + def item_geometry(self, item): + # Returning a simple tuple for the geometry. + return item.point.x, item.point.y + +class TestGeoAtom1(TestGeoRSS1): + feed_type = feeds.GeoAtom1Feed + +class TestGeoAtom2(TestGeoRSS2): + feed_type = feeds.GeoAtom1Feed + + def geometry(self, obj): + # This time we'll use a 2-tuple of coordinates for the box. + return ((-123.30, -41.32), (174.78, 48.46)) + +class TestW3CGeo1(TestGeoRSS1): + feed_type = feeds.W3CGeoFeed + +# The following feeds are invalid, and will raise exceptions. +class TestW3CGeo2(TestGeoRSS2): + feed_type = feeds.W3CGeoFeed + +class TestW3CGeo3(TestGeoRSS1): + feed_type = feeds.W3CGeoFeed + + def item_geometry(self, item): + from django.contrib.gis.geos import Polygon + return Polygon(((0, 0), (0, 1), (1, 1), (1, 0), (0, 0))) diff --git a/django/contrib/gis/tests/geoapp/test_feeds.py b/django/contrib/gis/tests/geoapp/test_feeds.py new file mode 100644 index 00000000000..fba424c6069 --- /dev/null +++ b/django/contrib/gis/tests/geoapp/test_feeds.py @@ -0,0 +1,76 @@ +import unittest +from xml.dom import minidom + +from django.test import Client +from models import City + +class GeoFeedTest(unittest.TestCase): + client = Client() + + def assertChildNodes(self, elem, expected): + "Taken from regressiontests/syndication/tests.py." + actual = set([n.nodeName for n in elem.childNodes]) + expected = set(expected) + self.assertEqual(actual, expected) + + def test_geofeed_rss(self): + "Tests geographic feeds using GeoRSS over RSSv2." + # Uses `GEOSGeometry` in `item_geometry` + doc1 = minidom.parseString(self.client.get('/geoapp/feeds/rss1/').content) + # Uses a 2-tuple in `item_geometry` + doc2 = minidom.parseString(self.client.get('/geoapp/feeds/rss2/').content) + feed1, feed2 = doc1.firstChild, doc2.firstChild + + # Making sure the box got added to the second GeoRSS feed. + self.assertChildNodes(feed2.getElementsByTagName('channel')[0], + ['title', 'link', 'description', 'language', 'lastBuildDate', 'item', 'georss:box'] + ) + + # Incrementing through the feeds. + for feed in [feed1, feed2]: + # Ensuring the georss namespace was added to the element. + self.assertEqual(feed.getAttribute(u'xmlns:georss'), u'http://www.georss.org/georss') + chan = feed.getElementsByTagName('channel')[0] + items = chan.getElementsByTagName('item') + self.assertEqual(len(items), City.objects.count()) + + # Ensuring the georss element was added to each item in the feed. + for item in items: + self.assertChildNodes(item, ['title', 'link', 'description', 'guid', 'georss:point']) + + def test_geofeed_atom(self): + "Testing geographic feeds using GeoRSS over Atom." + doc1 = minidom.parseString(self.client.get('/geoapp/feeds/atom1/').content) + doc2 = minidom.parseString(self.client.get('/geoapp/feeds/atom2/').content) + feed1, feed2 = doc1.firstChild, doc2.firstChild + + # Making sure the box got added to the second GeoRSS feed. + self.assertChildNodes(feed2, ['title', 'link', 'id', 'updated', 'entry', 'georss:box']) + + for feed in [feed1, feed2]: + # Ensuring the georsss namespace was added to the element. + self.assertEqual(feed.getAttribute(u'xmlns:georss'), u'http://www.georss.org/georss') + entries = feed.getElementsByTagName('entry') + self.assertEqual(len(entries), City.objects.count()) + + # Ensuring the georss element was added to each entry in the feed. + for entry in entries: + self.assertChildNodes(entry, ['title', 'link', 'id', 'summary', 'georss:point']) + + def test_geofeed_w3c(self): + "Testing geographic feeds using W3C Geo." + doc = minidom.parseString(self.client.get('/geoapp/feeds/w3cgeo1/').content) + feed = doc.firstChild + # Ensuring the geo namespace was added to the element. + self.assertEqual(feed.getAttribute(u'xmlns:geo'), u'http://www.w3.org/2003/01/geo/wgs84_pos#') + chan = feed.getElementsByTagName('channel')[0] + items = chan.getElementsByTagName('item') + self.assertEqual(len(items), City.objects.count()) + + # Ensuring the geo:lat and geo:lon element was added to each item in the feed. + for item in items: + self.assertChildNodes(item, ['title', 'link', 'description', 'guid', 'geo:lat', 'geo:lon']) + + # Boxes and Polygons aren't allowed in W3C Geo feeds. + self.assertRaises(ValueError, self.client.get, '/geoapp/feeds/w3cgeo2/') # Box in + self.assertRaises(ValueError, self.client.get, '/geoapp/feeds/w3cgeo3/') # Polygons in diff --git a/django/contrib/gis/tests/geoapp/tests.py b/django/contrib/gis/tests/geoapp/tests.py index 7e377336677..b49bfe2fd7f 100644 --- a/django/contrib/gis/tests/geoapp/tests.py +++ b/django/contrib/gis/tests/geoapp/tests.py @@ -372,8 +372,8 @@ class GeoModelTest(unittest.TestCase): for c in qs: self.assertEqual(True, c.name in cities) def test14_equals(self): - if DISABLE: return "Testing the 'same_as' and 'equals' lookup types." + if DISABLE: return pnt = fromstr('POINT (-95.363151 29.763374)', srid=4326) c1 = City.objects.get(point=pnt) c2 = City.objects.get(point__same_as=pnt) @@ -558,7 +558,9 @@ class GeoModelTest(unittest.TestCase): self.assertEqual(c.mpoly.sym_difference(geom), c.sym_difference) self.assertEqual(c.mpoly.union(geom), c.union) +from test_feeds import GeoFeedTest def suite(): s = unittest.TestSuite() s.addTest(unittest.makeSuite(GeoModelTest)) + s.addTest(unittest.makeSuite(GeoFeedTest)) return s diff --git a/django/contrib/gis/tests/geoapp/tests_mysql.py b/django/contrib/gis/tests/geoapp/tests_mysql.py index 4e7c411f521..dce71c6445d 100644 --- a/django/contrib/gis/tests/geoapp/tests_mysql.py +++ b/django/contrib/gis/tests/geoapp/tests_mysql.py @@ -173,7 +173,9 @@ class GeoModelTest(unittest.TestCase): self.assertRaises(ImproperlyConfigured, State.objects.all().kml, field_name='poly') self.assertRaises(ImproperlyConfigured, Country.objects.all().gml, field_name='mpoly') +from test_feeds import GeoFeedTest def suite(): s = unittest.TestSuite() s.addTest(unittest.makeSuite(GeoModelTest)) + s.addTest(unittest.makeSuite(GeoFeedTest)) return s diff --git a/django/contrib/gis/tests/geoapp/urls.py b/django/contrib/gis/tests/geoapp/urls.py new file mode 100644 index 00000000000..b3fd92972b8 --- /dev/null +++ b/django/contrib/gis/tests/geoapp/urls.py @@ -0,0 +1,16 @@ +from django.conf.urls.defaults import * +from feeds import TestGeoRSS1, TestGeoRSS2, TestGeoAtom1, TestGeoAtom2, TestW3CGeo1, TestW3CGeo2, TestW3CGeo3 + +feed_dict = { + 'rss1' : TestGeoRSS1, + 'rss2' : TestGeoRSS2, + 'atom1' : TestGeoAtom1, + 'atom2' : TestGeoAtom2, + 'w3cgeo1' : TestW3CGeo1, + 'w3cgeo2' : TestW3CGeo2, + 'w3cgeo3' : TestW3CGeo3, +} + +urlpatterns = patterns('', + (r'^feeds/(?P.*)/$', 'django.contrib.syndication.views.feed', {'feed_dict': feed_dict}) +) diff --git a/django/contrib/gis/tests/urls.py b/django/contrib/gis/tests/urls.py new file mode 100644 index 00000000000..95e36c22a48 --- /dev/null +++ b/django/contrib/gis/tests/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls.defaults import * + +urlpatterns = patterns('', + (r'^geoapp/', include('django.contrib.gis.tests.geoapp.urls')), + ) + diff --git a/django/contrib/syndication/feeds.py b/django/contrib/syndication/feeds.py index d4cb42711f1..a1f08107f98 100644 --- a/django/contrib/syndication/feeds.py +++ b/django/contrib/syndication/feeds.py @@ -59,6 +59,20 @@ class Feed(object): return attr() return attr + def feed_extra_kwargs(self, obj): + """ + Returns an extra keyword arguments dictionary that is used when + initializing the feed generator. + """ + return {} + + def item_extra_kwargs(self, item): + """ + Returns an extra keyword arguments dictionary that is used with + the `add_item` call of the feed generator. + """ + return {} + def get_object(self, bits): return None @@ -100,6 +114,7 @@ class Feed(object): feed_copyright = self.__get_dynamic_attr('feed_copyright', obj), feed_guid = self.__get_dynamic_attr('feed_guid', obj), ttl = self.__get_dynamic_attr('ttl', obj), + **self.feed_extra_kwargs(obj) ) try: @@ -158,5 +173,6 @@ class Feed(object): author_link = author_link, categories = self.__get_dynamic_attr('item_categories', item), item_copyright = self.__get_dynamic_attr('item_copyright', item), + **self.item_extra_kwargs(item) ) return feed