[ckan-changes] [okfn/ckan] ed6ec0: plugins.toolkit plugin helper functions added
GitHub
noreply at github.com
Thu Apr 19 10:53:24 UTC 2012
Branch: refs/heads/master
Home: https://github.com/okfn/ckan
Commit: ed6ec0db6551cc05bc137155b323a04aa0f67609
https://github.com/okfn/ckan/commit/ed6ec0db6551cc05bc137155b323a04aa0f67609
Author: Toby <toby.junk at gmail.com>
Date: 2012-04-19 (Thu, 19 Apr 2012)
Changed paths:
M ckan/plugins/__init__.py
A ckan/plugins/toolkit.py
Log Message:
-----------
plugins.toolkit plugin helper functions added
diff --git a/ckan/plugins/__init__.py b/ckan/plugins/__init__.py
index 3784b59..fcb6055 100644
--- a/ckan/plugins/__init__.py
+++ b/ckan/plugins/__init__.py
@@ -1,2 +1,3 @@
from ckan.plugins.core import *
from ckan.plugins.interfaces import *
+import toolkit
diff --git a/ckan/plugins/toolkit.py b/ckan/plugins/toolkit.py
new file mode 100644
index 0000000..64a80d7
--- /dev/null
+++ b/ckan/plugins/toolkit.py
@@ -0,0 +1,75 @@
+## This file is intended to make functions consistently available to
+## plugins whilst giving developers the ability move code around or
+## change underlying frameworks etc. It should not be used internaly
+## within ckan only by extensions. Functions should only be removed from
+## this file after reasonable depreciation notice has been given.
+
+import inspect
+import os
+
+import pylons
+import paste.deploy.converters as converters
+import webhelpers.html.tags
+
+import lib.base as base
+
+
+__all__ = [
+ ## Imported functions ##
+ 'c', # template context
+ 'request', # http request object
+ 'render', # template render function
+ 'render_text', # Genshi NewTextTemplate render function
+ 'render_snippet', # snippet render function
+ 'asbool', # converts an object to a boolean
+ 'asint', # converts an object to an integer
+ 'aslist', # converts an object to a list
+ 'literal', # stop tags in a string being escaped
+
+ ## Functions fully defined here ##
+ 'add_template_directory',
+ 'add_public_directory',
+]
+
+c = pylons.c
+request = pylons.request
+render = base.render
+render_text = base.render_text
+asbool = converters.asbool
+asint = converters.asint
+aslist = converters.aslist
+literal = webhelpers.html.tags.literal
+
+
+# wrappers
+def render_snippet(template, data=None):
+ data = data or {}
+ return base.render_snippet(template, **data)
+
+
+# new functions
+def add_template_directory(config, relative_path):
+ ''' Function to aid adding extra template paths to the config.
+ The path is relative to the file calling this function. '''
+ _add_served_directory(config, relative_path, 'extra_template_paths')
+
+def add_public_directory(config, relative_path):
+ ''' Function to aid adding extra public paths to the config.
+ The path is relative to the file calling this function. '''
+ _add_served_directory(config, relative_path, 'extra_public_paths')
+
+def _add_served_directory(config, relative_path, config_var):
+ ''' Add extra public/template directories to config. '''
+ assert config_var in ('extra_template_paths', 'extra_public_paths')
+ # we want the filename that of the function caller but they will
+ # have used one of the available helper functions
+ frame, filename, line_number, function_name, lines, index =\
+ inspect.getouterframes(inspect.currentframe())[2]
+
+ this_dir = os.path.dirname(filename)
+ absolute_path = os.path.join(this_dir, relative_path)
+ if absolute_path not in config.get(config_var, ''):
+ if config.get(config_var):
+ config[config_var] += ',' + absolute_path
+ else:
+ config[config_var] = absolute_path
================================================================
Commit: c729973503b70e27e5b8cc36dc7a7dc8acace145
https://github.com/okfn/ckan/commit/c729973503b70e27e5b8cc36dc7a7dc8acace145
Author: Toby <toby.junk at gmail.com>
Date: 2012-04-19 (Thu, 19 Apr 2012)
Changed paths:
M ckan/lib/base.py
M ckan/lib/helpers.py
Log Message:
-----------
render snippet to use main render function
diff --git a/ckan/lib/base.py b/ckan/lib/base.py
index 5fa12bf..7f07e86 100644
--- a/ckan/lib/base.py
+++ b/ckan/lib/base.py
@@ -47,6 +47,17 @@ def abort(status_code=None, detail='', headers=None, comment=None):
headers=headers,
comment=comment)
+
+def render_snippet(template_name, **kw):
+ ''' Helper function for rendering snippets. Rendered html has
+ comment tags added to show the template used. NOTE: unlike other
+ render functions this takes a list of keywords instead of a dict for
+ the extra template variables. '''
+ output = render(template_name, extra_vars=kw)
+ output = '\n<!-- Snippet %s start -->\n%s\n<!-- Snippet %s end -->\n' % (
+ template_name, output, template_name)
+ return literal(output)
+
def render(template_name, extra_vars=None, cache_key=None, cache_type=None,
cache_expire=None, method='xhtml', loader_class=MarkupTemplate,
cache_force = None):
diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py
index 0824c15..a46496d 100644
--- a/ckan/lib/helpers.py
+++ b/ckan/lib/helpers.py
@@ -31,9 +31,6 @@
from pylons import session
from pylons import c
from pylons.i18n import _
-from pylons.templating import pylons_globals
-from genshi.template import MarkupTemplate
-from ckan.plugins import PluginImplementations, IGenshiStreamFilter
get_available_locales = i18n.get_available_locales
get_locales_dict = i18n.get_locales_dict
@@ -653,20 +650,8 @@ def activity_div(template, activity, actor, object=None, target=None):
def snippet(template_name, **kw):
''' This function is used to load html snippets into pages. keywords
can be used to pass parameters into the snippet rendering '''
- pylons_globs = pylons_globals()
- genshi_loader = pylons_globs['app_globals'].genshi_loader
- template = genshi_loader.load(template_name, cls=MarkupTemplate)
- globs = kw
- globs['h'] = pylons_globs['h']
- globs['c'] = pylons_globs['c']
- globs['config'] = pylons_globs['config']
- stream = template.generate(**globs)
- for item in PluginImplementations(IGenshiStreamFilter):
- stream = item.filter(stream)
- output = stream.render(method='xhtml', encoding=None, strip_whitespace=True)
- output = '\n<!-- Snippet %s start -->\n%s\n<!-- Snippet %s end -->\n' % (
- template_name, output, template_name)
- return literal(output)
+ import ckan.lib.base as base
+ return base.render_snippet(template_name, **kw)
def convert_to_dict(object_type, objs):
================================================================
Commit: 00f547627a7ce72f7f37c6988fba54c684e43c06
https://github.com/okfn/ckan/commit/00f547627a7ce72f7f37c6988fba54c684e43c06
Author: Toby <toby.junk at gmail.com>
Date: 2012-04-19 (Thu, 19 Apr 2012)
Changed paths:
M ckan/lib/base.py
Log Message:
-----------
add new render_text() helper function
diff --git a/ckan/lib/base.py b/ckan/lib/base.py
index 7f07e86..97d88f1 100644
--- a/ckan/lib/base.py
+++ b/ckan/lib/base.py
@@ -58,6 +58,14 @@ def render_snippet(template_name, **kw):
template_name, output, template_name)
return literal(output)
+def render_text(template_name, extra_vars=None):
+ ''' Helper function to render a genshi NewTextTemplate without
+ having to pass the loader_class or method. '''
+ return render(template_name,
+ extra_vars=extra_vars,
+ method='text',
+ loader_class=NewTextTemplate)
+
def render(template_name, extra_vars=None, cache_key=None, cache_type=None,
cache_expire=None, method='xhtml', loader_class=MarkupTemplate,
cache_force = None):
================================================================
Commit: cd5f0de3c052bfbb391614602a8a00b6c0b108fa
https://github.com/okfn/ckan/commit/cd5f0de3c052bfbb391614602a8a00b6c0b108fa
Author: Toby <toby.junk at gmail.com>
Date: 2012-04-19 (Thu, 19 Apr 2012)
Changed paths:
M ckan/lib/base.py
Log Message:
-----------
add docstring to render()
diff --git a/ckan/lib/base.py b/ckan/lib/base.py
index 97d88f1..3e6ec70 100644
--- a/ckan/lib/base.py
+++ b/ckan/lib/base.py
@@ -69,6 +69,7 @@ def render_text(template_name, extra_vars=None):
def render(template_name, extra_vars=None, cache_key=None, cache_type=None,
cache_expire=None, method='xhtml', loader_class=MarkupTemplate,
cache_force = None):
+ ''' Main genshi template rendering function. '''
def render_template():
globs = extra_vars or {}
================================================================
Commit: f7834dbc3d699b5d08bc2cfe818aead4c1747a57
https://github.com/okfn/ckan/commit/f7834dbc3d699b5d08bc2cfe818aead4c1747a57
Author: Toby <toby.junk at gmail.com>
Date: 2012-04-19 (Thu, 19 Apr 2012)
Changed paths:
M ckan/config/deployment.ini_tmpl
M ckan/config/routing.py
A ckan/controllers/feed.py
M ckan/controllers/group.py
M ckan/controllers/package.py
M ckan/controllers/user.py
M ckan/lib/app_globals.py
M ckan/lib/base.py
M ckan/lib/dictization/model_save.py
M ckan/lib/field_types.py
M ckan/lib/helpers.py
M ckan/lib/navl/dictization_functions.py
M ckan/lib/plugins.py
M ckan/logic/action/get.py
M ckan/logic/action/update.py
M ckan/logic/auth/publisher/update.py
M ckan/model/__init__.py
M ckan/plugins/interfaces.py
M ckan/templates/group/read.html
M ckan/templates/package/search.html
M ckan/templates/tag/read.html
M ckan/tests/functional/api/model/test_package.py
M ckan_deb/usr/lib/ckan/common.sh
M doc/configuration.rst
M doc/forms.rst
M requires/lucid_missing.txt
M test-core.ini
Log Message:
-----------
Merge branch 'master' into template-cleanup
diff --git a/ckan/config/deployment.ini_tmpl b/ckan/config/deployment.ini_tmpl
index 5a96f43..5d16c87 100644
--- a/ckan/config/deployment.ini_tmpl
+++ b/ckan/config/deployment.ini_tmpl
@@ -165,6 +165,39 @@ ckan.locale_default = en
ckan.locale_order = en de fr it es pl ru nl sv no cs_CZ hu pt_BR fi bg ca sq sr sr_Latn
ckan.locales_filtered_out = el ro lt sl
+## Atom Feeds
+#
+# Settings for customising the metadata provided in
+# atom feeds.
+#
+# These settings are used to generate the <id> tags for both feeds
+# and entries. The unique <id>s are created following the method
+# outlined in http://www.taguri.org/ ie - they generate tagURIs, as specified
+# in http://tools.ietf.org/html/rfc4151#section-2.1 :
+#
+# <id>tag:thedatahub.org,2012:/feeds/group/933f3857-79fd-4beb-a835-c0349e31ce76</id>
+#
+# Each component has the corresponding settings:
+#
+# "thedatahub.org" is ckan.feeds.authority_name
+# "2012" is ckan.feeds.date
+#
+
+# Leave blank to use the ckan.site_url config value, otherwise set to a
+# domain or email address that you own. e.g. thedatahub.org or
+# admin at thedatahub.org
+ckan.feeds.authority_name =
+
+# Pick a date of the form "yyyy[-mm[-dd]]" during which the above domain was
+# owned by you.
+ckan.feeds.date = 2012
+
+# If not set, then the value in `ckan.site_id` is used.
+ckan.feeds.author_name =
+
+# If not set, then the value in `ckan.site_url` is used.
+ckan.feeds.author_link =
+
## Webstore
## Uncommment to enable datastore
# ckan.datastore.enabled = 1
diff --git a/ckan/config/routing.py b/ckan/config/routing.py
index 5dc72e7..f3ae5b8 100644
--- a/ckan/config/routing.py
+++ b/ckan/config/routing.py
@@ -256,6 +256,13 @@ def make_map():
m.connect('/revision/list', action='list')
m.connect('/revision/{id}', action='read')
+ # feeds
+ with SubMapper(map, controller='feed') as m:
+ m.connect('/feeds/group/{id}.atom', action='group')
+ m.connect('/feeds/tag/{id}.atom', action='tag')
+ m.connect('/feeds/dataset.atom', action='general')
+ m.connect('/feeds/custom.atom', action='custom')
+
map.connect('ckanadmin_index', '/ckan-admin', controller='admin', action='index')
map.connect('ckanadmin', '/ckan-admin/{action}', controller='admin')
diff --git a/ckan/controllers/feed.py b/ckan/controllers/feed.py
new file mode 100644
index 0000000..92bd4fe
--- /dev/null
+++ b/ckan/controllers/feed.py
@@ -0,0 +1,496 @@
+"""
+The feed controller produces Atom feeds of datasets.
+
+ * datasets belonging to a particular group.
+ * datasets tagged with a particular tag.
+ * datasets that match an arbitrary search.
+
+TODO: document paged feeds
+
+Other feeds are available elsewhere in the code, but these provide feeds
+of the revision history, rather than a feed of datasets.
+
+ * ``ckan/controllers/group.py`` provides an atom feed of a group's
+ revision history.
+ * ``ckan/controllers/package.py`` provides an atom feed of a dataset's
+ revision history.
+ * ``ckan/controllers/revision.py`` provides an atom feed of the repository's
+ revision history.
+
+"""
+# TODO fix imports
+import logging
+import urlparse
+
+import webhelpers.feedgenerator
+from pylons import config
+from urllib import urlencode
+
+from ckan import model
+from ckan.lib.base import BaseController, c, request, response, json, abort, g
+from ckan.lib.helpers import date_str_to_datetime, url_for
+from ckan.logic import get_action, NotFound
+
+# TODO make the item list configurable
+ITEMS_LIMIT = 20
+
+log = logging.getLogger(__name__)
+
+def _package_search(data_dict):
+ """
+ Helper method that wraps the package_search action.
+
+ * unless overridden, sorts results by metadata_modified date
+ * unless overridden, sets a default item limit
+ """
+ context = {'model': model, 'session': model.Session,
+ 'user': c.user or c.author}
+
+ if 'sort' not in data_dict or not data_dict['sort']:
+ data_dict['sort'] = 'metadata_modified desc'
+
+ if 'rows' not in data_dict or not data_dict['rows']:
+ data_dict['rows'] = ITEMS_LIMIT
+
+ # package_search action modifies the data_dict, so keep our copy intact.
+ query = get_action('package_search')(context,data_dict.copy())
+
+ return query['count'], query['results']
+
+def _create_atom_id(resource_path, authority_name=None, date_string=None):
+ """
+ Helper method that creates an atom id for a feed or entry.
+
+ An id must be unique, and must not change over time. ie - once published,
+ it represents an atom feed or entry uniquely, and forever. See [4]:
+
+ When an Atom Document is relocated, migrated, syndicated,
+ republished, exported, or imported, the content of its atom:id
+ element MUST NOT change. Put another way, an atom:id element
+ pertains to all instantiations of a particular Atom entry or feed;
+ revisions retain the same content in their atom:id elements. It is
+ suggested that the atom:id element be stored along with the
+ associated resource.
+
+ resource_path
+ The resource path that uniquely identifies the feed or element. This
+ mustn't be something that changes over time for a given entry or feed.
+ And does not necessarily need to be resolvable.
+
+ e.g. ``"/group/933f3857-79fd-4beb-a835-c0349e31ce76"`` could represent
+ the feed of datasets belonging to the identified group.
+
+ authority_name
+ The domain name or email address of the publisher of the feed. See [3]
+ for more details. If ``None`` then the domain name is taken from the
+ config file. First trying ``ckan.feeds.authority_name``, and failing
+ that, it uses ``ckan.site_url``. Again, this should not change over time.
+
+ date_string
+ A string representing a date on which the authority_name is owned by the
+ publisher of the feed.
+
+ e.g. ``"2012-03-22"``
+
+ Again, this should not change over time.
+
+ If date_string is None, then an attempt is made to read the config
+ option ``ckan.feeds.date``. If that's not available,
+ then the date_string is not used in the generation of the atom id.
+
+ Following the methods outlined in [1], [2] and [3], this function produces
+ tagURIs like: ``"tag:thedatahub.org,2012:/group/933f3857-79fd-4beb-a835-c0349e31ce76"``.
+
+ If not enough information is provide to produce a valid tagURI, then only
+ the resource_path is used, e.g.: ::
+
+ "http://thedatahub.org/group/933f3857-79fd-4beb-a835-c0349e31ce76"
+
+ or
+
+ "/group/933f3857-79fd-4beb-a835-c0349e31ce76"
+
+ The latter of which is only used if no site_url is available. And it should
+ be noted will result in an invalid feed.
+
+ [1] http://web.archive.org/web/20110514113830/http://diveintomark.org/archives/2004/05/28/howto-atom-id
+ [2] http://www.taguri.org/
+ [3] http://tools.ietf.org/html/rfc4151#section-2.1
+ [4] http://www.ietf.org/rfc/rfc4287
+ """
+ if authority_name is None:
+ authority_name = config.get('ckan.feeds.authority_name', '').strip()
+ if not authority_name:
+ site_url = config.get('ckan.site_url', '').strip()
+ authority_name = urlparse.urlparse(site_url).netloc
+
+ if not authority_name:
+ log.warning('No authority_name available for feed generation. '
+ 'Generated feed will be invalid.')
+
+ if date_string is None:
+ date_string = config.get('ckan.feeds.date', '')
+
+ if not date_string:
+ log.warning('No date_string available for feed generation. '
+ 'Please set the "ckan.feeds.date" config value.')
+
+ # Don't generate a tagURI without a date as it wouldn't be valid.
+ # This is best we can do, and if the site_url is not set, then
+ # this still results in an invalid feed.
+ site_url = config.get('ckan.site_url', '')
+ return '/'.join([site_url, resource_path])
+
+ tagging_entity = ','.join([authority_name, date_string])
+ return ':'.join(['tag', tagging_entity, resource_path])
+
+class FeedController(BaseController):
+
+ base_url = config.get('ckan.site_url')
+
+ def _alternate_url(self, params, **kwargs):
+ search_params = params.copy()
+ search_params.update(kwargs)
+
+ # Can't count on the page sizes being the same on the search results
+ # view. So provide an alternate link to the first page, regardless
+ # of the page we're looking at in the feed.
+ search_params.pop('page', None)
+ return self._feed_url(search_params,
+ controller='package',
+ action='search')
+
+ def group(self,id):
+
+ try:
+ context = {'model': model, 'session': model.Session,
+ 'user': c.user or c.author}
+ group_dict = get_action('group_show')(context,{'id':id})
+ except NotFound:
+ abort(404,'Group not found')
+
+ data_dict, params = self._parse_url_params()
+ data_dict['fq'] = 'groups:"%s"' % id
+
+ item_count, results = _package_search(data_dict)
+
+ navigation_urls = self._navigation_urls(params,
+ item_count=item_count,
+ limit=data_dict['rows'],
+ controller='feed',
+ action='group',
+ id=id)
+
+ feed_url = self._feed_url(params,
+ controller='feed',
+ action='group',
+ id=id)
+
+ alternate_url = self._alternate_url(params, groups=id)
+
+ return self.output_feed(results,
+ feed_title = u'%s - Group: "%s"' % (g.site_title, group_dict['title']),
+ feed_description = u'Recently created or updated datasets on %s by group: "%s"' % \
+ (g.site_title,group_dict['title']),
+ feed_link = alternate_url,
+ feed_guid = _create_atom_id(u'/feeds/groups/%s.atom' % id),
+ feed_url = feed_url,
+ navigation_urls = navigation_urls,
+ )
+
+ def tag(self,id):
+
+ data_dict, params = self._parse_url_params()
+ data_dict['fq'] = 'tags:"%s"' % id
+
+ item_count, results = _package_search(data_dict)
+
+ navigation_urls = self._navigation_urls(params,
+ item_count=item_count,
+ limit=data_dict['rows'],
+ controller='feed',
+ action='tag',
+ id=id)
+
+ feed_url = self._feed_url(params,
+ controller='feed',
+ action='tag',
+ id=id)
+
+ alternate_url = self._alternate_url(params, tags=id)
+
+ return self.output_feed(results,
+ feed_title = u'%s - Tag: "%s"' % (g.site_title, id),
+ feed_description = u'Recently created or updated datasets on %s by tag: "%s"' % \
+ (g.site_title, id),
+ feed_link = alternate_url,
+ feed_guid = _create_atom_id(u'/feeds/tag/%s.atom' % id),
+ feed_url = feed_url,
+ navigation_urls = navigation_urls,
+ )
+
+ def general(self):
+ data_dict, params = self._parse_url_params()
+ data_dict['q'] = '*:*'
+
+ item_count, results = _package_search(data_dict)
+
+ navigation_urls = self._navigation_urls(params,
+ item_count=item_count,
+ limit=data_dict['rows'],
+ controller='feed',
+ action='general')
+
+ feed_url = self._feed_url(params,
+ controller='feed',
+ action='general')
+
+ alternate_url = self._alternate_url(params)
+
+ return self.output_feed(results,
+ feed_title = g.site_title,
+ feed_description = u'Recently created or updated datasets on %s' % g.site_title,
+ feed_link = alternate_url,
+ feed_guid = _create_atom_id(u'/feeds/dataset.atom'),
+ feed_url = feed_url,
+ navigation_urls = navigation_urls,
+ )
+
+ # TODO check search params
+ def custom(self):
+ q = request.params.get('q', u'')
+ fq = ''
+ search_params = {}
+ for (param, value) in request.params.items():
+ if param not in ['q', 'page', 'sort'] \
+ and len(value) and not param.startswith('_'):
+ search_params[param] = value
+ fq += ' %s:"%s"' % (param, value)
+
+ search_url_params = urlencode(search_params)
+
+ try:
+ page = int(request.params.get('page', 1))
+ except ValueError:
+ abort(400, ('"page" parameter must be an integer'))
+
+ limit = ITEMS_LIMIT
+ data_dict = {
+ 'q': q,
+ 'fq': fq,
+ 'start': (page-1) * limit,
+ 'rows': limit,
+ 'sort': request.params.get('sort', None),
+ }
+
+ item_count, results = _package_search(data_dict)
+
+ navigation_urls = self._navigation_urls(request.params,
+ item_count=item_count,
+ limit=data_dict['rows'],
+ controller='feed',
+ action='custom')
+
+ feed_url = self._feed_url(request.params,
+ controller='feed',
+ action='custom')
+
+ alternate_url = self._alternate_url(request.params)
+
+ return self.output_feed(results,
+ feed_title = u'%s - Custom query' % g.site_title,
+ feed_description = u'Recently created or updated datasets on %s. Custom query: \'%s\'' % (g.site_title, q),
+ feed_link = alternate_url,
+ feed_guid = _create_atom_id(u'/feeds/custom.atom?%s' % search_url_params),
+ feed_url = feed_url,
+ navigation_urls = navigation_urls,
+ )
+
+ def output_feed(self, results,
+ feed_title,
+ feed_description,
+ feed_link,
+ feed_url,
+ navigation_urls,
+ feed_guid):
+
+ author_name = config.get('ckan.feeds.author_name', '').strip() or \
+ config.get('ckan.site_id', '').strip()
+ author_link = config.get('ckan.feeds.author_link', '').strip() or \
+ config.get('ckan.site_url', '').strip()
+
+ # TODO language
+ feed = _FixedAtom1Feed(
+ title=feed_title,
+ link=feed_link,
+ description=feed_description,
+ language=u'en',
+ author_name=author_name,
+ author_link=author_link,
+ feed_guid=feed_guid,
+ feed_url=feed_url,
+ previous_page=navigation_urls['previous'],
+ next_page=navigation_urls['next'],
+ first_page=navigation_urls['first'],
+ last_page=navigation_urls['last'],
+ )
+
+ for pkg in results:
+ feed.add_item(
+ title = pkg.get('title', ''),
+ link = self.base_url + url_for(controller='package', action='read', id=pkg['id']),
+ description = pkg.get('notes', ''),
+ updated = date_str_to_datetime(pkg.get('metadata_modified')),
+ published = date_str_to_datetime(pkg.get('metadata_created')),
+ unique_id = _create_atom_id(u'/dataset/%s' % pkg['id']),
+ author_name = pkg.get('author', ''),
+ author_email = pkg.get('author_email', ''),
+ categories = [t['name'] for t in pkg.get('tags', [])],
+ enclosure=webhelpers.feedgenerator.Enclosure(
+ self.base_url + url_for(controller='api', register='package', action='show', id=pkg['name'], ver='2'),
+ unicode(len(json.dumps(pkg))), # TODO fix this
+ u'application/json'
+ )
+ )
+ response.content_type = feed.mime_type
+ return feed.writeString('utf-8')
+
+ #### CLASS PRIVATE METHODS ####
+
+ def _feed_url(self, query, controller, action, **kwargs):
+ """
+ Constructs the url for the given action. Encoding the query parameters.
+ """
+ path = url_for(controller=controller, action=action, **kwargs)
+ query = [(k, v.encode('utf-8') if isinstance(v, basestring) else str(v)) \
+ for k, v in query.items()]
+
+ return self.base_url + path + u'?' + urlencode(query) # a trailing '?' is valid.
+
+ def _navigation_urls(self, query, controller, action, item_count, limit, **kwargs):
+ """
+ Constructs and returns first, last, prev and next links for paging
+ """
+ urls = dict( (rel, None) for rel in 'previous next first last'.split() )
+
+ page = int(query.get('page', 1))
+
+ # first: remove any page parameter
+ first_query = query.copy()
+ first_query.pop('page', None)
+ urls['first'] = self._feed_url(first_query, controller, action, **kwargs)
+
+ # last: add last page parameter
+ last_page = (item_count / limit) + min(1, item_count % limit)
+ last_query = query.copy()
+ last_query['page'] = last_page
+ urls['last'] = self._feed_url(last_query, controller, action, **kwargs)
+
+ # previous
+ if page > 1:
+ previous_query = query.copy()
+ previous_query['page'] = page-1
+ urls['previous'] = self._feed_url(previous_query, controller, action, **kwargs)
+ else:
+ urls['previous'] = None
+
+ # next
+ if page < last_page:
+ next_query = query.copy()
+ next_query['page'] = page+1
+ urls['next'] = self._feed_url(next_query, controller, action, **kwargs)
+ else:
+ urls['next'] = None
+
+ return urls
+
+ def _parse_url_params(self):
+ """
+ Constructs a search-query dict from the URL query parameters.
+
+ Returns the constructed search-query dict, and the valid URL query parameters.
+ """
+
+ try:
+ page = int(request.params.get('page', 1))
+ except ValueError:
+ abort(400, ('"page" parameter must be an integer'))
+
+ limit = ITEMS_LIMIT
+ data_dict = {
+ 'start': (page-1)*limit,
+ 'rows': limit
+ }
+
+ # Filter ignored query parameters
+ valid_params = ['page']
+ params = dict( (p,request.params.get(p)) for p in valid_params \
+ if p in request.params )
+ return data_dict, params
+
+# TODO paginated feed
+class _FixedAtom1Feed(webhelpers.feedgenerator.Atom1Feed):
+ """
+ The Atom1Feed defined in webhelpers doesn't provide all the fields we
+ might want to publish.
+
+ * In Atom1Feed, each <entry> is created with identical <updated> and
+ <published> fields. See [1] (webhelpers 1.2) for details.
+
+ So, this class fixes that by allow an item to set both an <updated> and
+ <published> field.
+
+ * In Atom1Feed, the feed description is not used. So this class uses the
+ <subtitle> field to publish that.
+
+ [1] https://bitbucket.org/bbangert/webhelpers/src/f5867a319abf/webhelpers/feedgenerator.py#cl-373
+ """
+
+ def add_item(self, *args, **kwargs):
+ """
+ Drop the pubdate field from the new item.
+ """
+ if 'pubdate' in kwargs:
+ kwargs.pop('pubdate')
+ defaults = {'updated': None, 'published': None}
+ defaults.update(kwargs)
+ super(_FixedAtom1Feed, self).add_item(*args, **defaults)
+
+ def latest_post_date(self):
+ """
+ Calculates the latest post date from the 'updated' fields,
+ rather than the 'pubdate' fields.
+ """
+ updates = [ item['updated'] for item in self.items if item['updated'] is not None ]
+ if not len(updates): # delegate to parent for default behaviour
+ return super(_FixedAtom1Feed, self).latest_post_date()
+ return max(updates)
+
+ def add_item_elements(self, handler, item):
+ """
+ Add the <updated> and <published> fields to each entry that's written to the handler.
+ """
+ super(_FixedAtom1Feed, self).add_item_elements(handler, item)
+
+ if(item['updated']):
+ handler.addQuickElement(u'updated', webhelpers.feedgenerator.rfc3339_date(item['updated']).decode('utf-8'))
+
+ if(item['published']):
+ handler.addQuickElement(u'published', webhelpers.feedgenerator.rfc3339_date(item['published']).decode('utf-8'))
+
+ def add_root_elements(self, handler):
+ """
+ Add additional feed fields.
+
+ * Add the <subtitle> field from the feed description
+ * Add links other pages of the logical feed.
+ """
+ super(_FixedAtom1Feed, self).add_root_elements(handler)
+
+ handler.addQuickElement(u'subtitle', self.feed['description'])
+
+ for page in ['previous', 'next', 'first', 'last']:
+ if self.feed.get(page+'_page', None):
+ handler.addQuickElement(u'link', u'',
+ {'rel': page, 'href': self.feed.get(page+'_page')})
+
diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py
index aa59175..4e7cda3 100644
--- a/ckan/controllers/group.py
+++ b/ckan/controllers/group.py
@@ -50,10 +50,26 @@ def _history_template(self, group_type):
## end hooks
+ def _guess_group_type(self, expecting_name=False):
+ """
+ Guess the type of group from the URL handling the case
+ where there is a prefix on the URL (such as /data/organization)
+ """
+ parts = [x for x in request.path.split('/') if x]
+
+ idx = -1
+ if expecting_name:
+ idx = -2
+
+ gt = parts[idx]
+ if gt == 'group':
+ gt = None
+
+ return gt
+
+
def index(self):
- group_type = request.path.strip('/').split('/')[0]
- if group_type == 'group':
- group_type = None
+ group_type = self._guess_group_type()
context = {'model': model, 'session': model.Session,
'user': c.user or c.author}
@@ -194,11 +210,9 @@ def pager_url(q=None, page=None):
return render( self._read_template(c.group_dict['type']) )
def new(self, data=None, errors=None, error_summary=None):
- group_type = request.path.strip('/').split('/')[0]
- if group_type == 'group':
- group_type = None
- if data:
- data['type'] = group_type
+ group_type = self._guess_group_type(True)
+ if data:
+ data['type'] = group_type
context = {'model': model, 'session': model.Session,
'user': c.user or c.author, 'extras_as_string': True,
@@ -226,6 +240,7 @@ def edit(self, id, data=None, errors=None, error_summary=None):
context = {'model': model, 'session': model.Session,
'user': c.user or c.author, 'extras_as_string': True,
'save': 'save' in request.params,
+ 'for_edit': True,
'parent': request.params.get('parent', None)
}
data_dict = {'id': id}
diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py
index 26ee8a6..e90d658 100644
--- a/ckan/controllers/package.py
+++ b/ckan/controllers/package.py
@@ -27,11 +27,17 @@
log = logging.getLogger(__name__)
+def _encode_params(params):
+ return [(k, v.encode('utf-8') if isinstance(v, basestring) else str(v)) \
+ for k, v in params]
+
+def url_with_params(url, params):
+ params = _encode_params(params)
+ return url + u'?' + urlencode(params)
+
def search_url(params):
url = h.url_for(controller='package', action='search')
- params = [(k, v.encode('utf-8') if isinstance(v, basestring) else str(v)) \
- for k, v in params]
- return url + u'?' + urlencode(params)
+ return url_with_params(url, params)
autoneg_cfg = [
("application", "xhtml+xml", ["html"]),
@@ -78,15 +84,30 @@ def _read_template(self, package_type):
def _history_template(self, package_type):
return lookup_package_plugin(package_type).history_template()
+ def _guess_package_type(self, expecting_name=False):
+ """
+ Guess the type of package from the URL handling the case
+ where there is a prefix on the URL (such as /data/package)
+ """
+ parts = [x for x in request.path.split('/') if x]
+
+ idx = -1
+ if expecting_name:
+ idx = -2
+
+ pt = parts[idx]
+ if pt == 'package':
+ pt = 'dataset'
+
+ return pt
+
authorizer = ckan.authz.Authorizer()
def search(self):
from ckan.lib.search import SearchError
- package_type = request.path.strip('/').split('/')[0]
- if package_type == 'package':
- package_type = 'dataset'
+ package_type = self._guess_package_type()
try:
context = {'model':model,'user': c.user or c.author}
@@ -100,15 +121,17 @@ def search(self):
page = int(request.params.get('page', 1))
except ValueError, e:
abort(400, ('"page" parameter must be an integer'))
- limit = 20
+ limit = g.datasets_per_page
# most search operations should reset the page counter:
params_nopage = [(k, v) for k,v in request.params.items() if k != 'page']
- def drill_down_url(**by):
- params = list(params_nopage)
- params.extend(by.items())
- return search_url(set(params))
+ def drill_down_url(alternative_url=None, **by):
+ params = set(params_nopage)
+ params |= set(by.items())
+ if alternative_url:
+ return url_with_params(alternative_url, params)
+ return search_url(params)
c.drill_down_url = drill_down_url
@@ -148,6 +171,8 @@ def pager_url(q=None, page=None):
params.append(('page', page))
return search_url(params)
+ c.search_url_params = urlencode(_encode_params(params_nopage))
+
try:
c.fields = []
search_extras = {}
@@ -309,9 +334,7 @@ def comments(self, id):
def history(self, id):
- package_type = request.path.strip('/').split('/')[0]
- if package_type == 'package':
- package_type = 'dataset'
+ package_type = self._get_package_type(id.split('@')[0])
if 'diff' in request.params or 'selected1' in request.params:
try:
@@ -383,10 +406,7 @@ def history(self, id):
return render( self._history_template(c.pkg_dict.get('type',package_type)))
def new(self, data=None, errors=None, error_summary=None):
-
- package_type = request.path.strip('/').split('/')[0]
- if package_type == 'group':
- package_type = None
+ package_type = self._guess_package_type(True)
context = {'model': model, 'session': model.Session,
'user': c.user or c.author, 'extras_as_string': True,
@@ -428,6 +448,7 @@ def edit(self, id, data=None, errors=None, error_summary=None):
'user': c.user or c.author, 'extras_as_string': True,
'save': 'save' in request.params,
'moderated': config.get('moderated'),
+ 'for_edit': True,
'pending': True,}
if context['save'] and not data:
diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py
index df0a4e0..0a26d59 100644
--- a/ckan/controllers/user.py
+++ b/ckan/controllers/user.py
@@ -276,8 +276,10 @@ def logged_in(self):
h.flash_success(_("%s is now logged in") % user_dict['display_name'])
return self.me(locale=lang)
else:
- h.flash_error(_('Login failed. Bad username or password.' + \
- ' (Or if using OpenID, it hasn\'t been associated with a user account.)'))
+ err = _('Login failed. Bad username or password.')
+ if g.openid_enabled:
+ err += _(' (Or if using OpenID, it hasn\'t been associated with a user account.)')
+ h.flash_error(err)
h.redirect_to(locale=lang, controller='user', action='login')
def logout(self):
diff --git a/ckan/lib/app_globals.py b/ckan/lib/app_globals.py
index 221cb0e..b508353 100644
--- a/ckan/lib/app_globals.py
+++ b/ckan/lib/app_globals.py
@@ -41,3 +41,4 @@ def __init__(self):
self.recaptcha_publickey = config.get('ckan.recaptcha.publickey', '')
self.recaptcha_privatekey = config.get('ckan.recaptcha.privatekey', '')
+ self.datasets_per_page = int(config.get('ckan.datasets_per_page', '20'))
diff --git a/ckan/lib/base.py b/ckan/lib/base.py
index 3e6ec70..da9d69f 100644
--- a/ckan/lib/base.py
+++ b/ckan/lib/base.py
@@ -176,7 +176,9 @@ def _identify_user(self):
if not c.remote_addr:
c.remote_addr = request.environ.get('REMOTE_ADDR', 'Unknown IP Address')
- # what is different between session['user'] and environ['REMOTE_USER']
+ # environ['REMOTE_USER'] is set by repoze.who if it authenticates a user's
+ # cookie or OpenID. (But it doesn't check the user (still) exists in our
+ # database - we need to do that here.
c.user = request.environ.get('REMOTE_USER', '')
if c.user:
c.user = c.user.decode('utf8')
@@ -188,6 +190,7 @@ def _identify_user(self):
# and your cookie has ckan_display_name, we need to force user
# to logout and login again to get the User object.
c.user = None
+ self.log.warn('Logout to login')
else:
c.userobj = self._get_user_for_apikey()
if c.userobj is not None:
@@ -208,23 +211,30 @@ def __call__(self, environ, start_response):
# This also improves the cachability of our pages as cookies
# prevent proxy servers from caching content unless they have
# been configured to ignore them.
-
- # we need to be careful with the /user/set_lang/ URL as this
- # creates a cookie.
- if not environ.get('HTTP_PATH', '').startswith('/user/set_lang/'):
+ # we do not want to clear cookies when setting the user lang
+ if not environ.get('PATH_INFO').startswith('/user/set_lang'):
for cookie in request.cookies:
if cookie.startswith('ckan') and cookie not in ['ckan']:
response.delete_cookie(cookie)
# Remove the ckan session cookie if not used e.g. logged out
- elif cookie == 'ckan' and not c.user and not h.are_there_flash_messages():
- if session.id:
- if not session.get('lang'):
- session.delete()
- else:
- response.delete_cookie(cookie)
+ elif cookie == 'ckan' and not c.user:
+ # Check session for valid data (including flash messages)
+ # (DGU also uses session for a shopping basket-type behaviour)
+ is_valid_cookie_data = False
+ for key, value in session.items():
+ if not key.startswith('_') and value:
+ is_valid_cookie_data = True
+ break
+ if not is_valid_cookie_data:
+ if session.id:
+ if not session.get('lang'):
+ session.delete()
+ else:
+ response.delete_cookie(cookie)
# Remove auth_tkt repoze.who cookie if user not logged in.
elif cookie == 'auth_tkt' and not session.id:
response.delete_cookie(cookie)
+
try:
return WSGIController.__call__(self, environ, start_response)
finally:
diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py
index e8b2658..aa9f1a5 100644
--- a/ckan/lib/dictization/model_save.py
+++ b/ckan/lib/dictization/model_save.py
@@ -69,7 +69,7 @@ def package_resource_list_save(res_dicts, package, context):
resource_list[:] = obj_list
for resource in set(old_list) - set(obj_list):
- if pending and resource.state <> 'deleted':
+ if pending and resource.state != 'deleted':
resource.state = 'pending-deleted'
else:
resource.state = 'deleted'
@@ -223,13 +223,14 @@ def package_membership_list_save(group_dicts, package, context):
## need to flush so we can get out the package id
model.Session.flush()
for group in groups - set(group_member.keys()):
- member_obj = model.Member(table_id = package.id,
- table_name = 'package',
- group = group,
- capacity = capacity,
- group_id=group.id,
- state = 'active')
- session.add(member_obj)
+ if group:
+ member_obj = model.Member(table_id = package.id,
+ table_name = 'package',
+ group = group,
+ capacity = capacity,
+ group_id=group.id,
+ state = 'active')
+ session.add(member_obj)
for group in set(group_member.keys()) - groups:
diff --git a/ckan/lib/field_types.py b/ckan/lib/field_types.py
index 6821040..9c47724 100644
--- a/ckan/lib/field_types.py
+++ b/ckan/lib/field_types.py
@@ -1,8 +1,11 @@
import re
import time
import datetime
+import warnings
-import formalchemy
+with warnings.catch_warnings():
+ warnings.filterwarnings('ignore', '.*compile_mappers.*')
+ import formalchemy
from ckan.lib.helpers import OrderedDict
months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py
index a46496d..b4dd06e 100644
--- a/ckan/lib/helpers.py
+++ b/ckan/lib/helpers.py
@@ -111,7 +111,7 @@ def _add_i18n_to_url(url_to_amend, **kw):
root = ''
# ckan.root_path is defined when we have none standard language
# position in the url
- root_path = config.get('ckan.root_path')
+ root_path = config.get('ckan.root_path', None)
if root_path:
# FIXME this can be written better once the merge
# into the ecportal core is done - Toby
@@ -494,11 +494,33 @@ class Page(paginate.Page):
# our custom layout set as default.
def pager(self, *args, **kwargs):
kwargs.update(
- format=u"<div class='pager'>$link_previous ~2~ $link_next</div>",
- symbol_previous=u'« Prev', symbol_next=u'Next »'
+ format=u"<div class='pagination'><ul>$link_previous ~2~ $link_next</ul></div>",
+ symbol_previous=u'« Prev', symbol_next=u'Next »',
+ curpage_attr={'class':'active'}, link_attr={}
)
return super(Page, self).pager(*args, **kwargs)
+ # Put each page link into a <li> (for Bootstrap to style it)
+ def _pagerlink(self, page, text, extra_attributes=None):
+ anchor = super(Page, self)._pagerlink(page, text)
+ extra_attributes = extra_attributes or {}
+ return HTML.li(anchor, **extra_attributes)
+
+ # Change 'current page' link from <span> to <li><a>
+ # and '..' into '<li><a>..'
+ # (for Bootstrap to style them properly)
+ def _range(self, regexp_match):
+ html = super(Page, self)._range(regexp_match)
+ # Convert ..
+ dotdot = '\.\.'
+ dotdot_link = HTML.li(HTML.a('...', href='#'), class_='disabled')
+ html = re.sub(dotdot, dotdot_link, html)
+ # Convert current page
+ text = '%s' % self.page
+ current_page_span = str(HTML.span(c=text, **self.curpage_attr))
+ current_page_link = self._pagerlink(self.page, text, extra_attributes=self.curpage_attr)
+ return re.sub(current_page_span, current_page_link, html)
+
def render_datetime(datetime_, date_format=None, with_hours=False):
'''Render a datetime object or timestamp string as a pretty string
(Y-m-d H:m).
diff --git a/ckan/lib/navl/dictization_functions.py b/ckan/lib/navl/dictization_functions.py
index d47bd62..aa77d91 100644
--- a/ckan/lib/navl/dictization_functions.py
+++ b/ckan/lib/navl/dictization_functions.py
@@ -28,15 +28,21 @@ def __nonzero__(self):
class State(object):
pass
-class Invalid(Exception):
+class DictizationError(Exception):
+ def __str__(self):
+ if hasattr(self, 'error') and self.error:
+ return repr(self.error)
+ return ''
+
+class Invalid(DictizationError):
def __init__(self, error, key=None):
self.error = error
-class DataError(Exception):
+class DataError(DictizationError):
def __init__(self, error):
self.error = error
-class StopOnError(Exception):
+class StopOnError(DictizationError):
'''error to stop validations for a particualar key'''
pass
diff --git a/ckan/lib/plugins.py b/ckan/lib/plugins.py
index 509a299..c7fe228 100644
--- a/ckan/lib/plugins.py
+++ b/ckan/lib/plugins.py
@@ -231,6 +231,17 @@ def db_to_form_schema(self):
'''This is an interface to manipulate data from the database
into a format suitable for the form (optional)'''
+ def db_to_form_schema_options(self, options):
+ '''This allows the selectino of different schemas for different
+ purposes. It is optional and if not available, ``db_to_form_schema``
+ should be used.
+ If a context is provided, and it contains a schema, it will be
+ returned.
+ '''
+ schema = options.get('context',{}).get('schema',None)
+ if schema:
+ return schema
+ return self.db_to_form_schema()
def check_data_dict(self, data_dict, schema=None):
'''Check if the return data is correct, mostly for checking out
@@ -351,6 +362,18 @@ def db_to_form_schema(self):
'''This is an interface to manipulate data from the database
into a format suitable for the form (optional)'''
+ def db_to_form_schema_options(self, options):
+ '''This allows the selectino of different schemas for different
+ purposes. It is optional and if not available, ``db_to_form_schema``
+ should be used.
+ If a context is provided, and it contains a schema, it will be
+ returned.
+ '''
+ schema = options.get('context',{}).get('schema',None)
+ if schema:
+ return schema
+ return self.db_to_form_schema()
+
def check_data_dict(self, data_dict):
'''Check if the return data is correct, mostly for checking out
if spammers are submitting only part of the form
diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py
index b205f6c..21429d3 100644
--- a/ckan/logic/action/get.py
+++ b/ckan/logic/action/get.py
@@ -392,7 +392,14 @@ def package_show(context, data_dict):
for item in plugins.PluginImplementations(plugins.IPackageController):
item.read(pkg)
- schema = lib_plugins.lookup_package_plugin(package_dict['type']).db_to_form_schema()
+ package_plugin = lib_plugins.lookup_package_plugin(package_dict['type'])
+ try:
+ schema = package_plugin.db_to_form_schema_options({
+ 'type':'show',
+ 'api': 'api_version' in context,
+ 'context': context })
+ except AttributeError:
+ schema = package_plugin.db_to_form_schema()
if schema and context.get('validate', True):
package_dict, errors = validate(package_dict, schema, context=context)
@@ -444,7 +451,14 @@ def group_show(context, data_dict):
for item in plugins.PluginImplementations(plugins.IGroupController):
item.read(group)
- schema = lib_plugins.lookup_group_plugin(group_dict['type']).db_to_form_schema()
+ group_plugin = lib_plugins.lookup_group_plugin(group_dict['type'])
+ try:
+ schema = group_plugin.db_to_form_schema_options({
+ 'type':'show',
+ 'api': 'api_version' in context,
+ 'context': context })
+ except AttributeError:
+ schema = group_plugin.db_to_form_schema()
if schema:
package_dict, errors = validate(group_dict, schema, context=context)
@@ -695,7 +709,8 @@ def package_search(context, data_dict):
for item in plugins.PluginImplementations(plugins.IPackageController):
data_dict = item.before_search(data_dict)
- # the extension may have decided that it's no necessary to perform the query
+ # the extension may have decided that it is not necessary to perform
+ # the query
abort = data_dict.get('abort_search',False)
results = []
diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py
index ead5dae..2509c92 100644
--- a/ckan/logic/action/update.py
+++ b/ckan/logic/action/update.py
@@ -525,7 +525,7 @@ def package_update_rest(context, data_dict):
raise ValidationError(error_dict)
context["package"] = pkg
- context["allow_partial_update"] = True
+ context["allow_partial_update"] = False
dictized_package = model_save.package_api_to_dict(data_dict, context)
check_access('package_update_rest', context, dictized_package)
diff --git a/ckan/logic/auth/publisher/update.py b/ckan/logic/auth/publisher/update.py
index 13ee8e6..dbbf194 100644
--- a/ckan/logic/auth/publisher/update.py
+++ b/ckan/logic/auth/publisher/update.py
@@ -38,6 +38,9 @@ def resource_update(context, data_dict):
resource = get_resource_object(context, data_dict)
userobj = model.User.get( user )
+ if Authorizer().is_sysadmin(unicode(user)):
+ return { 'success': True }
+
if not userobj:
return {'success': False, 'msg': _('User %s not authorized to edit resources in this package') % str(user)}
diff --git a/ckan/model/__init__.py b/ckan/model/__init__.py
index 8e191fd..df0d2f5 100644
--- a/ckan/model/__init__.py
+++ b/ckan/model/__init__.py
@@ -2,7 +2,9 @@
import warnings
import logging
-from pylons import config
+with warnings.catch_warnings():
+ warnings.filterwarnings('ignore', '.*Unbuilt egg.*')
+ from pylons import config
from sqlalchemy import MetaData, __version__ as sqav
from sqlalchemy.schema import Index
from paste.deploy.converters import asbool
@@ -186,7 +188,9 @@ def upgrade_db(self, version=None):
def are_tables_created(self):
metadata = MetaData(self.metadata.bind)
- metadata.reflect()
+ with warnings.catch_warnings():
+ warnings.filterwarnings('ignore', '.*(reflection|geometry).*')
+ metadata.reflect()
return bool(metadata.tables)
def purge_revision(self, revision, leave_record=False):
diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py
index 826e41d..ee092d7 100644
--- a/ckan/plugins/interfaces.py
+++ b/ckan/plugins/interfaces.py
@@ -271,7 +271,7 @@ def after_search(self, search_results, search_params):
def before_index(self, pkg_dict):
'''
- Extensions will recieve what will be given to the solr for indexing.
+ Extensions will receive what will be given to the solr for indexing.
This is essentially a flattened dict (except for multlivlaued fields such as tags
of all the terms sent to the indexer. The extension can modify this by returning
an altered version.
diff --git a/ckan/templates/group/read.html b/ckan/templates/group/read.html
index 7b01fe5..f3c6b9f 100644
--- a/ckan/templates/group/read.html
+++ b/ckan/templates/group/read.html
@@ -88,6 +88,13 @@ <h3 py:if="c.group['state'] != 'active'">State: ${c.group['state']}</h3>
</div>
</py:match>
+ <py:def function="optional_feed">
+ <link rel="alternate" type="application/atom+xml" title="${g.site_title} - Datasets in group '${c.group['title']}'"
+ href="${h.url(controller='feed', action='group', id=c.group['name'])}" />
+ <link rel="alternate" type="application/atom+xml" title="${g.site_title} - Recent Revision History"
+ href="${h.url_for(controller='revision', action='list', format='atom', days=1)}" />
+ </py:def>
+
<xi:include href="layout.html" />
</html>
diff --git a/ckan/templates/package/search.html b/ckan/templates/package/search.html
index 055e587..a73bfa2 100644
--- a/ckan/templates/package/search.html
+++ b/ckan/templates/package/search.html
@@ -61,6 +61,14 @@ <h4 i18n:msg="item_count"><strong>${c.page.item_count}</strong> datasets found</
${c.page.pager(q=c.q)}
</div>
+
+ <py:def function="optional_feed">
+ <link rel="alternate" type="application/atom+xml" title="${g.site_title} - Datasets found with custom search: '${c.search_url_params}'"
+ href="${h.url(controller='feed', action='custom')}?${c.search_url_params}" />
+ <link rel="alternate" type="application/atom+xml" title="${g.site_title} - Recent Revision History"
+ href="${h.url_for(controller='revision', action='list', format='atom', days=1)}" />
+ </py:def>
+
<xi:include href="layout.html" />
</html>
diff --git a/ckan/templates/tag/read.html b/ckan/templates/tag/read.html
index 7bb53ec..24997ac 100644
--- a/ckan/templates/tag/read.html
+++ b/ckan/templates/tag/read.html
@@ -11,6 +11,14 @@
${package_list_from_dict(c.tag['packages'])}
</div>
+ <py:def function="optional_feed">
+ <link rel="alternate" type="application/atom+xml" title="${g.site_title} - Datasets tagged with '${c.tag['name']}'"
+ href="${h.url(controller='feed', action='tag', id=c.tag['name'])}" />
+ <link rel="alternate" type="application/atom+xml" title="${g.site_title} - Recent Revision History"
+ href="${h.url_for(controller='revision', action='list', format='atom', days=1)}" />
+ </py:def>
+
+
<xi:include href="layout.html" />
</html>
diff --git a/ckan/tests/functional/api/model/test_package.py b/ckan/tests/functional/api/model/test_package.py
index 7ae8d71..b094ac7 100644
--- a/ckan/tests/functional/api/model/test_package.py
+++ b/ckan/tests/functional/api/model/test_package.py
@@ -380,7 +380,9 @@ def test_09_update_package_entity_not_found(self):
status=self.STATUS_404_NOT_FOUND,
extra_environ=self.extra_environ)
- def create_package_roles_revision(self, package_data):
+ def create_package_with_admin_user(self, package_data):
+ '''Creates a package with self.user as admin and provided package_data.
+ '''
self.create_package(admins=[self.user], data=package_data)
def assert_package_update_ok(self, package_ref_attribute,
@@ -420,7 +422,7 @@ def assert_package_update_ok(self, package_ref_attribute,
},
'tags': [u'tag 1.1', u'tag2', u'tag 4', u'tag5.'],
}
- self.create_package_roles_revision(old_fixture_data)
+ self.create_package_with_admin_user(old_fixture_data)
pkg = self.get_package_by_name(old_fixture_data['name'])
# This is the one occasion where we reference package explicitly
# by name or ID, rather than use the value from self.ref_package_by
@@ -517,7 +519,7 @@ def test_package_update_invalid(self):
u'last_modified':u'123', # INVALID
}],
}
- self.create_package_roles_revision(old_fixture_data)
+ self.create_package_with_admin_user(old_fixture_data)
pkg = self.get_package_by_name(old_fixture_data['name'])
offset = self.offset('/rest/dataset/%s' % pkg.name)
params = '%s=1' % self.dumps(new_fixture_data)
@@ -542,7 +544,7 @@ def test_package_update_delete_last_extra(self):
u'key1': None,
},
}
- self.create_package_roles_revision(old_fixture_data)
+ self.create_package_with_admin_user(old_fixture_data)
offset = self.package_offset(old_fixture_data['name'])
params = '%s=1' % self.dumps(new_fixture_data)
res = self.app.post(offset, params=params, status=self.STATUS_200_OK,
@@ -576,7 +578,7 @@ def test_package_update_do_not_delete_last_extra(self):
'extras': {}, # no extras specified, but existing
# ones should be left alone
}
- self.create_package_roles_revision(old_fixture_data)
+ self.create_package_with_admin_user(old_fixture_data)
offset = self.package_offset(old_fixture_data['name'])
params = '%s=1' % self.dumps(new_fixture_data)
res = self.app.post(offset, params=params, status=self.STATUS_200_OK,
@@ -607,7 +609,7 @@ def test_entity_update_readd_tag(self):
'name': name,
'tags': ['tag 1.']
}
- self.create_package_roles_revision(old_fixture_data)
+ self.create_package_with_admin_user(old_fixture_data)
offset = self.package_offset(name)
params = '%s=1' % self.dumps(new_fixture_data)
res = self.app.post(offset, params=params, status=self.STATUS_200_OK,
@@ -631,10 +633,10 @@ def test_entity_update_readd_tag(self):
def test_entity_update_conflict(self):
package1_name = self.package_fixture_data['name']
package1_data = {'name': package1_name}
- package1 = self.create_package_roles_revision(package1_data)
+ package1 = self.create_package_with_admin_user(package1_data)
package2_name = u'somethingnew'
package2_data = {'name': package2_name}
- package2 = self.create_package_roles_revision(package2_data)
+ package2 = self.create_package_with_admin_user(package2_data)
try:
package1_offset = self.package_offset(package1_name)
# trying to rename package 1 to package 2's name
@@ -645,7 +647,7 @@ def test_entity_update_conflict(self):
def test_entity_update_empty(self):
package1_name = self.package_fixture_data['name']
package1_data = {'name': package1_name}
- package1 = self.create_package_roles_revision(package1_data)
+ package1 = self.create_package_with_admin_user(package1_data)
package2_data = '' # this is the error
package1_offset = self.package_offset(package1_name)
self.app.put(package1_offset, package2_data,
@@ -668,6 +670,46 @@ def test_entity_update_indexerror(self):
plugins.unload('synchronous_search')
SolrSettings.init(original_settings)
+ def test_package_update_delete_resource(self):
+ old_fixture_data = {
+ 'name': self.package_fixture_data['name'],
+ 'resources': [{
+ u'url':u'http://blah.com/file2.xml',
+ u'format':u'xml',
+ u'description':u'Appendix 1',
+ u'hash':u'def123',
+ u'alt_url':u'alt123',
+ },{
+ u'url':u'http://blah.com/file3.xml',
+ u'format':u'xml',
+ u'description':u'Appenddic 2',
+ u'hash':u'ghi123',
+ u'alt_url':u'alt123',
+ }],
+ }
+ new_fixture_data = {
+ 'name':u'somethingnew',
+ 'resources': [],
+ }
+ self.create_package_with_admin_user(old_fixture_data)
+ offset = self.package_offset(old_fixture_data['name'])
+ params = '%s=1' % self.dumps(new_fixture_data)
+ res = self.app.post(offset, params=params, status=self.STATUS_200_OK,
+ extra_environ=self.extra_environ)
+
+ try:
+ # Check the returned package is as expected
+ pkg = self.loads(res.body)
+ assert_equal(pkg['name'], new_fixture_data['name'])
+ assert_equal(pkg['resources'], [])
+
+ # Check resources were deleted
+ model.Session.remove()
+ package = self.get_package_by_name(new_fixture_data['name'])
+ self.assert_equal(len(package.resources), 0)
+ finally:
+ self.purge_package_by_name(new_fixture_data['name'])
+
def test_entity_delete_ok(self):
# create a package with package_fixture_data
if not self.get_package_by_name(self.package_fixture_data['name']):
diff --git a/ckan_deb/usr/lib/ckan/common.sh b/ckan_deb/usr/lib/ckan/common.sh
index f967c31..0c4a333 100644
--- a/ckan_deb/usr/lib/ckan/common.sh
+++ b/ckan_deb/usr/lib/ckan/common.sh
@@ -220,11 +220,11 @@ ckan_overwrite_apache_config () {
#rm /etc/apache2/sites-available/${INSTANCE}.common
cat <<EOF > /etc/apache2/sites-available/${INSTANCE}.common
- # WARNING: Do not manually edit this file, it is desgined to be
+ # WARNING: Do not manually edit this file, it is designed to be
# overwritten at any time by the postinst script of
# dependent packages
- # These are common settings used for both the normal and maintence modes
+ # These are common settings used for both the normal and maintenance modes
DocumentRoot /var/lib/ckan/${INSTANCE}/static
ServerName ${ServerName}
@@ -266,7 +266,7 @@ EOF
#rm /etc/apache2/sites-available/${INSTANCE}
cat <<EOF > /etc/apache2/sites-available/${INSTANCE}
<VirtualHost *:80>
- # WARNING: Do not manually edit this file, it is desgined to be
+ # WARNING: Do not manually edit this file, it is designed to be
# overwritten at any time by the postinst script of
# dependent packages
Include /etc/apache2/sites-available/${INSTANCE}.common
@@ -275,7 +275,7 @@ EOF
#rm /etc/apache2/sites-available/${INSTANCE}.maint
cat <<EOF > /etc/apache2/sites-available/${INSTANCE}.maint
<VirtualHost *:80>
- # WARNING: Do not manually edit this file, it is desgined to be
+ # WARNING: Do not manually edit this file, it is designed to be
# overwritten at any time by the postinst script of
# dependent packages
Include /etc/apache2/sites-available/${INSTANCE}.common
diff --git a/doc/configuration.rst b/doc/configuration.rst
index 4cb3ee2..0ac49fb 100644
--- a/doc/configuration.rst
+++ b/doc/configuration.rst
@@ -168,6 +168,17 @@ And there is an option for the default expiry time if not specified::
ckan.cache.default_expires = 600
+datasets_per_page
+^^^^^^^^^^^^^^^^^
+
+Example::
+
+ ckan.datasets_per_page = 10
+
+Default value: ``20``
+
+This controls the pagination of the dataset search results page. This is the maximum number of datasets viewed per page of results.
+
Authentication Settings
-----------------------
diff --git a/doc/forms.rst b/doc/forms.rst
index 2c38c21..22180fe 100644
--- a/doc/forms.rst
+++ b/doc/forms.rst
@@ -158,6 +158,13 @@ This defines a navl schema to customize validation and conversion to the databas
This defines a navl schema to customize conversion from the database to the form.
+::
+
+ _db_to_form_schema_options(self, options)
+
+Like ``_form_to_db_schema_options()``, this allows different schemas to be
+used for different purposes.
+It is optional, and if it is not available then ``form_to_db_schema`` is used.
Example: Geospatial Tags
------------------------
diff --git a/requires/lucid_missing.txt b/requires/lucid_missing.txt
index cb49ca0..8b5789e 100644
--- a/requires/lucid_missing.txt
+++ b/requires/lucid_missing.txt
@@ -4,8 +4,8 @@
# Packages we install from source (could perhaps use release versions)
# pyutilib.component.core>=4.1,<4.1.99
-e svn+https://software.sandia.gov/svn/public/pyutilib/pyutilib.component.core/trunk@1972#egg=pyutilib.component.core
-# vdm>=0.10,<0.10.99
--e git+https://github.com/okfn/vdm.git@vdm-0.10#egg=vdm
+# vdm>=0.10,<0.11.99
+-e git+https://github.com/okfn/vdm.git@vdm-0.11#egg=vdm
# autoneg>=0.5
-e git+https://github.com/wwaites/autoneg.git@b4c727b164f411cc9d60#egg=autoneg
# flup>=0.5
diff --git a/test-core.ini b/test-core.ini
index 2d0ebde..1d59fe9 100644
--- a/test-core.ini
+++ b/test-core.ini
@@ -65,6 +65,8 @@ ckanext.stats.cache_enabled = 0
openid_enabled = True
+ckan.datasets_per_page = 20
+
# Logging configuration
[loggers]
keys = root, ckan, sqlalchemy
================================================================
Compare: https://github.com/okfn/ckan/compare/4a32b1d...f7834db
More information about the ckan-changes
mailing list