From f5599ab24c264dca54bc699e12bf8d33cbcdf12e Mon Sep 17 00:00:00 2001 From: Martin Burchell Date: Thu, 14 Apr 2016 17:58:07 +0100 Subject: [PATCH 1/5] Allow RSS feed to be extended Allow RSS feed classes in webhelpers other than Atom1. Allow additional values to be set on a feed item. --- ckan/controllers/feed.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/ckan/controllers/feed.py b/ckan/controllers/feed.py index 8720e5d2535..1cd91eebb3d 100644 --- a/ckan/controllers/feed.py +++ b/ckan/controllers/feed.py @@ -352,10 +352,10 @@ def output_feed(self, results, feed_title, feed_description, config.get('ckan.site_url', '').strip() # TODO language - feed = _FixedAtom1Feed( - title=feed_title, - link=feed_link, - description=feed_description, + feed = self.create_feed( + feed_title, + feed_link, + feed_description, language=u'en', author_name=author_name, author_link=author_link, @@ -368,6 +368,8 @@ def output_feed(self, results, feed_title, feed_description, ) for pkg in results: + extras = self.get_item_extras(pkg) + feed.add_item( title=pkg.get('title', ''), link=self.base_url + h.url_for(controller='package', @@ -387,11 +389,18 @@ def output_feed(self, results, feed_title, feed_description, id=pkg['name'], ver='2'), unicode(len(json.dumps(pkg))), # TODO fix this - u'application/json') + u'application/json'), + **extras ) response.content_type = feed.mime_type return feed.writeString('utf-8') + def create_feed(self, title, link, description, **kwargs): + return _FixedAtom1Feed(title, link, description, **kwargs) + + def get_item_extras(self, pkg): + return {} + #### CLASS PRIVATE METHODS #### def _feed_url(self, query, controller, action, **kwargs): From a57848b2b71b1d3b7e877c8b2d6ea00e79d36171 Mon Sep 17 00:00:00 2001 From: Martin Burchell Date: Mon, 18 Apr 2016 11:59:01 +0100 Subject: [PATCH 2/5] Document new methods --- ckan/controllers/feed.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ckan/controllers/feed.py b/ckan/controllers/feed.py index 1cd91eebb3d..5c36e38459b 100644 --- a/ckan/controllers/feed.py +++ b/ckan/controllers/feed.py @@ -396,9 +396,15 @@ def output_feed(self, results, feed_title, feed_description, return feed.writeString('utf-8') def create_feed(self, title, link, description, **kwargs): + """ + Allows subclasses to override the feed class. + """ return _FixedAtom1Feed(title, link, description, **kwargs) def get_item_extras(self, pkg): + """ + Allows subclasses to set additional fields on a feed item. + """ return {} #### CLASS PRIVATE METHODS #### From 1049fd162168a6d8d6e84db1ce4f4e7b25a337e8 Mon Sep 17 00:00:00 2001 From: Martin Burchell Date: Wed, 21 Sep 2016 17:40:50 +0100 Subject: [PATCH 3/5] Change to use IFeed interface --- ckan/controllers/feed.py | 31 ++++++++-------- ckan/plugins/interfaces.py | 40 ++++++++++++++++++++- ckan/tests/controllers/test_feed.py | 56 +++++++++++++++++++++++++++++ setup.py | 1 + 4 files changed, 112 insertions(+), 16 deletions(-) diff --git a/ckan/controllers/feed.py b/ckan/controllers/feed.py index ff0efa50000..c7e11dc5ac7 100644 --- a/ckan/controllers/feed.py +++ b/ckan/controllers/feed.py @@ -31,6 +31,7 @@ import ckan.lib.base as base import ckan.lib.helpers as h import ckan.logic as logic +import ckan.plugins as plugins from ckan.common import _, g, c, request, response, json @@ -361,7 +362,15 @@ def output_feed(self, results, feed_title, feed_description, config.get('ckan.site_url', '').strip() # TODO language - feed = self.create_feed( + feed_class = None + for plugin in plugins.PluginImplementations(plugins.IFeed): + if hasattr(plugin, 'get_feed_class'): + feed_class = plugin.get_feed_class() + + if not feed_class: + feed_class = _FixedAtom1Feed + + feed = feed_class( feed_title, feed_link, feed_description, @@ -377,7 +386,11 @@ def output_feed(self, results, feed_title, feed_description, ) for pkg in results: - extras = self.get_item_extras(pkg) + additional_fields = {} + + for plugin in plugins.PluginImplementations(plugins.IFeed): + if hasattr(plugin, 'get_item_additional_fields'): + additional_fields = plugin.get_item_additional_fields(pkg) feed.add_item( title=pkg.get('title', ''), @@ -399,23 +412,11 @@ def output_feed(self, results, feed_title, feed_description, ver='2'), unicode(len(json.dumps(pkg))), # TODO fix this u'application/json'), - **extras + **additional_fields ) response.content_type = feed.mime_type return feed.writeString('utf-8') - def create_feed(self, title, link, description, **kwargs): - """ - Allows subclasses to override the feed class. - """ - return _FixedAtom1Feed(title, link, description, **kwargs) - - def get_item_extras(self, pkg): - """ - Allows subclasses to set additional fields on a feed item. - """ - return {} - #### CLASS PRIVATE METHODS #### def _feed_url(self, query, controller, action, **kwargs): diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index aa4c3c4fce4..aac254b2080 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -10,7 +10,9 @@ 'IMapper', 'ISession', 'IMiddleware', 'IAuthFunctions', - 'IDomainObjectModification', 'IGroupController', + 'IDomainObjectModification', + 'IFeed', + 'IGroupController', 'IOrganizationController', 'IPackageController', 'IPluginObserver', 'IConfigurable', 'IConfigurer', @@ -200,6 +202,42 @@ def notify_after_commit(self, entity, operation): pass +class IFeed(Interface): + """ + Allows extension of the default Atom feeds + """ + + def get_feed_class(self): + """ + Allows plugins to provide a custom class to generate feed items. + + The feed item generator class should accept the following parameters + on the constructor method: + feed_title + feed_link + feed_description + language + author_name + author_link + feed_guid + feed_url + previous_page + next_page + first_page + last_page + """ + + pass + + def get_item_additional_fields(self, dataset_dict): + """ + Allows plugins to set additional fields on a feed item. + + :param dataset_dict, a dict with the dataset metadata + """ + pass + + class IResourceUrlChange(Interface): """ Receives notification of changed urls. diff --git a/ckan/tests/controllers/test_feed.py b/ckan/tests/controllers/test_feed.py index 1e4c516e029..3f905472e29 100644 --- a/ckan/tests/controllers/test_feed.py +++ b/ckan/tests/controllers/test_feed.py @@ -1,6 +1,9 @@ # encoding: utf-8 from routes import url_for +from webhelpers.feedgenerator import GeoAtom1Feed + +import ckan.plugins as plugins import ckan.tests.helpers as helpers import ckan.tests.factories as factories @@ -81,3 +84,56 @@ def test_custom_atom_feed_works(self): assert '{0}'.format(dataset1['title']) in res.body assert '{0}'.format(dataset2['title']) not in res.body + + +class TestFeedInterface(helpers.FunctionalTestBase): + @classmethod + def setup_class(cls): + super(TestFeedInterface, cls).setup_class() + plugins.load('test_feed_plugin') + + @classmethod + def teardown_class(cls): + helpers.reset_db() + plugins.unload('test_feed_plugin') + + def test_custom_class_used(self): + offset = url_for(controller='feed', action='general') + app = self._get_test_app() + res = app.get(offset) + + assert 'xmlns:georss="http://www.georss.org/georss"' in res.body, res.body + + def test_additional_fields_added(self): + metadata = { + 'ymin': '-2373790', + 'xmin': '2937940', + 'ymax': '-1681290', + 'xmax': '3567770', + } + + extras = [ + {'key': k, 'value': v} for (k, v) in + metadata.items() + ] + + factories.Dataset(extras=extras) + offset = url_for(controller='feed', action='general') + app = self._get_test_app() + res = app.get(offset) + + assert '-2373790.000000 2937940.000000 -1681290.000000 3567770.000000' in res.body, res.body + + +class MockFeedPlugin(plugins.SingletonPlugin): + plugins.implements(plugins.IFeed) + + def get_feed_class(self): + return GeoAtom1Feed + + def get_item_additional_fields(self, dataset_dict): + extras = {e['key']: e['value'] for e in dataset_dict['extras']} + + box = tuple(float(extras.get(n)) + for n in ('ymin', 'xmin', 'ymax', 'xmax')) + return {'geometry': box} diff --git a/setup.py b/setup.py index c633d2ea217..3de8b932e4e 100644 --- a/setup.py +++ b/setup.py @@ -166,6 +166,7 @@ 'test_datapusher_plugin = ckanext.datapusher.tests.test_interfaces:FakeDataPusherPlugin', 'test_routing_plugin = ckan.tests.config.test_middleware:MockRoutingPlugin', 'test_helpers_plugin = ckan.tests.lib.test_helpers:TestHelpersPlugin', + 'test_feed_plugin = ckan.tests.controllers.test_feed:MockFeedPlugin', ], 'babel.extractors': [ 'ckan = ckan.lib.extract:extract_ckan', From f5f1d1c36ebbb93d83761ce1e46198f9b01a3300 Mon Sep 17 00:00:00 2001 From: Martin Burchell Date: Fri, 23 Sep 2016 12:33:01 +0100 Subject: [PATCH 4/5] Only load test plugin if not loaded --- ckan/tests/controllers/test_feed.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ckan/tests/controllers/test_feed.py b/ckan/tests/controllers/test_feed.py index 3f905472e29..c018d7ceb6a 100644 --- a/ckan/tests/controllers/test_feed.py +++ b/ckan/tests/controllers/test_feed.py @@ -90,7 +90,9 @@ class TestFeedInterface(helpers.FunctionalTestBase): @classmethod def setup_class(cls): super(TestFeedInterface, cls).setup_class() - plugins.load('test_feed_plugin') + + if not plugins.plugin_loaded('test_feed_plugin'): + plugins.load('test_feed_plugin') @classmethod def teardown_class(cls): From 28a3e2ff01f3bfb3a3433f96a721a5e302741100 Mon Sep 17 00:00:00 2001 From: Martin Burchell Date: Fri, 23 Sep 2016 12:33:17 +0100 Subject: [PATCH 5/5] Improve IFeed documentation --- ckan/plugins/interfaces.py | 39 +++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index aac254b2080..f91b26da572 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -211,20 +211,26 @@ def get_feed_class(self): """ Allows plugins to provide a custom class to generate feed items. - The feed item generator class should accept the following parameters - on the constructor method: - feed_title - feed_link - feed_description - language - author_name - author_link - feed_guid - feed_url - previous_page - next_page - first_page - last_page + :returns: feed class + :rtype: type + + The feed item generator's constructor is called as follows:: + + feed_class( + feed_title, # Mandatory + feed_link, # Mandatory + feed_description, # Mandatory + language, # Optional, always set to 'en' + author_name, # Optional + author_link, # Optional + feed_guid, # Optional + feed_url, # Optional + previous_page, # Optional, url of previous page of feed + next_page, # Optional, url of next page of feed + first_page, # Optional, url of first page of feed + last_page, # Optional, url of last page of feed + ) + """ pass @@ -233,7 +239,10 @@ def get_item_additional_fields(self, dataset_dict): """ Allows plugins to set additional fields on a feed item. - :param dataset_dict, a dict with the dataset metadata + :param dataset_dict: the dataset metadata + :type dataset_dict: dictionary + :returns: the fields to set + :rtype: dictionary """ pass