[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