[ckan-changes] [okfn/ckan] ccc0ff: Merge branch 'master' of https://github.com/okfn/c...

GitHub noreply at github.com
Thu Apr 19 10:10:12 UTC 2012


  Branch: refs/heads/master
  Home:   https://github.com/okfn/ckan
  Commit: ccc0ffdb2ef1d56f0a18d0c8d1ed67add24176f2
      https://github.com/okfn/ckan/commit/ccc0ffdb2ef1d56f0a18d0c8d1ed67add24176f2
  Author: Ross Jones <rossdjones 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' of https://github.com/okfn/ckan


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..6588ff8 100644
--- a/ckan/controllers/group.py
+++ b/ckan/controllers/group.py
@@ -226,6 +226,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..90ebca4 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"]),
@@ -100,15 +106,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 +156,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 = {}
@@ -428,6 +438,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 5fa12bf..30914ba 100644
--- a/ckan/lib/base.py
+++ b/ckan/lib/base.py
@@ -156,7 +156,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')
@@ -168,6 +170,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:
@@ -188,23 +191,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..9677187 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'
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 0824c15..b0fefe1 100644
--- a/ckan/lib/helpers.py
+++ b/ckan/lib/helpers.py
@@ -114,7 +114,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
@@ -497,11 +497,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


================================================================
  Commit: 5042cdd3ad07aa145d866b39a0cca86be25a247e
      https://github.com/okfn/ckan/commit/5042cdd3ad07aa145d866b39a0cca86be25a247e
  Author: Ross Jones <rossdjones at gmail.com>
  Date:   2012-04-19 (Thu, 19 Apr 2012)

  Changed paths:
    M ckan/config/deployment.ini_tmpl
    M ckan/config/environment.py
    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/cli.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/logic/schema.py
    A ckan/migration/versions/053_add_group_logo.py
    M ckan/model/__init__.py
    M ckan/model/group.py
    M ckan/plugins/interfaces.py
    M ckan/public/css/style.css
    M ckan/public/scripts/application.js
    M ckan/templates/group/new_group_form.html
    M ckan/templates/group/read.html
    M ckan/templates/layout_base.html
    M ckan/templates/package/search.html
    M ckan/templates/tag/read.html
    M ckan/tests/functional/api/model/test_package.py
    M ckan/tests/functional/test_group.py
    M ckan/tests/lib/test_dictization.py
    M ckan/tests/lib/test_dictization_schema.py
    M ckan_deb/usr/lib/ckan/common.sh
    M ckanext/organizations/templates/organization_form.html
    M ckanext/organizations/templates/organization_read.html
    M doc/configuration.rst
    M doc/forms.rst
    M requires/lucid_missing.txt
    M setup.py
    M test-core.ini

  Log Message:
  -----------
  Merge branch 'master' into feature-2255-organizations


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/environment.py b/ckan/config/environment.py
index a792b6e..fa91541 100644
--- a/ckan/config/environment.py
+++ b/ckan/config/environment.py
@@ -104,6 +104,20 @@ def find_controller(self, controller):
     else:
         config['pylons.h'] = h
 
+    # extend helper functions with ones supplied by plugins
+    from ckan.plugins import PluginImplementations
+    from ckan.plugins.interfaces import ITemplateHelpers
+
+    extra_helpers = []
+    for plugin in PluginImplementations(ITemplateHelpers):
+        helpers = plugin.get_helpers()
+        for helper in helpers:
+            if helper in extra_helpers:
+                raise Exception('overwritting extra helper %s' % helper)
+            extra_helpers.append(helper)
+            setattr(config['pylons.h'], helper, helpers[helper])
+
+
     ## redo template setup to use genshi.search_path (so remove std template setup)
     template_paths = [paths['templates'][0]]
     extra_template_paths = config.get('extra_template_paths', '')
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..6588ff8 100644
--- a/ckan/controllers/group.py
+++ b/ckan/controllers/group.py
@@ -226,6 +226,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..90ebca4 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"]),
@@ -100,15 +106,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 +156,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 = {}
@@ -428,6 +438,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 5fa12bf..30914ba 100644
--- a/ckan/lib/base.py
+++ b/ckan/lib/base.py
@@ -156,7 +156,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')
@@ -168,6 +170,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:
@@ -188,23 +191,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/cli.py b/ckan/lib/cli.py
index 008cf7d..5146aa9 100644
--- a/ckan/lib/cli.py
+++ b/ckan/lib/cli.py
@@ -375,6 +375,66 @@ def command(self):
         else:
             print 'Command %s not recognized' % cmd
 
+
+class RDFExport(CkanCommand):
+    '''
+    This command dumps out all currently active datasets as RDF into the
+    specified folder.
+
+    Usage:
+      paster rdf-export /path/to/store/output
+    '''
+    summary = __doc__.split('\n')[0]
+    usage = __doc__
+
+    def command(self):
+        self._load_config()
+
+        if not self.args:
+            # default to run
+            print RDFExport.__doc__
+        else:
+            self.export_datasets( self.args[0] )
+
+    def export_datasets(self, out_folder):
+        '''
+        Export datasets as RDF to an output folder.
+        '''
+        import urlparse
+        import urllib2
+        import pylons.config as config
+        import ckan.model as model
+        import ckan.logic as logic
+        import ckan.lib.helpers as h
+
+        # Create output folder if not exists
+        if not os.path.isdir( out_folder ):
+            os.makedirs( out_folder )
+
+        fetch_url = config['ckan.site_url']
+        user = logic.get_action('get_site_user')({'model': model, 'ignore_auth': True}, {})
+        context = {'model': model, 'session': model.Session, 'user': user['name']}
+        dataset_names = logic.get_action('package_list')(context, {})
+        for dataset_name in dataset_names:
+            dd = logic.get_action('package_show')(context, {'id':dataset_name })
+            if not dd['state'] == 'active':
+                continue
+
+            url = h.url_for( controller='package',action='read',
+                                                  id=dd['name'])
+
+            url = urlparse.urljoin(fetch_url, url[1:]) + '.rdf'
+            try:
+                fname = os.path.join( out_folder, dd['name'] ) + ".rdf"
+                r = urllib2.urlopen(url).read()
+                with open(fname, 'wb') as f:
+                    f.write(r)
+            except IOError, ioe:
+                sys.stderr.write( str(ioe) + "\n" )
+
+
+
+
 class Sysadmin(CkanCommand):
     '''Gives sysadmin rights to a named user
 
@@ -720,7 +780,7 @@ class Celery(CkanCommand):
     summary = __doc__.split('\n')[0]
     usage = __doc__
 
-    def command(self):        
+    def command(self):
         if not self.args:
             self.run_()
         else:
diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py
index e8b2658..9677187 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'
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 7996e7f..b0fefe1 100644
--- a/ckan/lib/helpers.py
+++ b/ckan/lib/helpers.py
@@ -114,7 +114,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
@@ -497,11 +497,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).
@@ -659,6 +681,7 @@ def snippet(template_name, **kw):
     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)
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/logic/schema.py b/ckan/logic/schema.py
index e77b894..b07d2bb 100644
--- a/ckan/logic/schema.py
+++ b/ckan/logic/schema.py
@@ -179,6 +179,7 @@ def default_group_schema():
         'name': [not_empty, unicode, name_validator, group_name_validator],
         'title': [ignore_missing, unicode],
         'description': [ignore_missing, unicode],
+        'image_url': [ignore_missing, unicode],
         'type': [ignore_missing, unicode],
         'state': [ignore_not_group_admin, ignore_missing],
         'created': [ignore],
diff --git a/ckan/migration/versions/053_add_group_logo.py b/ckan/migration/versions/053_add_group_logo.py
new file mode 100644
index 0000000..7a31fb6
--- /dev/null
+++ b/ckan/migration/versions/053_add_group_logo.py
@@ -0,0 +1,12 @@
+from sqlalchemy import *
+from migrate import *
+
+def upgrade(migrate_engine):
+    migrate_engine.execute('''
+        ALTER TABLE "group"
+            ADD COLUMN image_url text;
+
+        ALTER TABLE group_revision
+            ADD COLUMN image_url text;
+    '''
+    )
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/model/group.py b/ckan/model/group.py
index b43ee10..f622a7a 100644
--- a/ckan/model/group.py
+++ b/ckan/model/group.py
@@ -31,6 +31,7 @@
     Column('title', UnicodeText),
     Column('type', UnicodeText, nullable=False),
     Column('description', UnicodeText),
+    Column('image_url', UnicodeText),
     Column('created', DateTime, default=datetime.datetime.now),
     Column('approval_status', UnicodeText, default=u"approved"),
     )
@@ -78,11 +79,12 @@ class Group(vdm.sqlalchemy.RevisionedObjectMixin,
             vdm.sqlalchemy.StatefulObjectMixin,
             DomainObject):
 
-    def __init__(self, name=u'', title=u'', description=u'',
-                 type=u'group', approval_status=u'approved' ):
+    def __init__(self, name=u'', title=u'', description=u'', image_url=u'',
+                 type=u'group', approval_status=u'approved'):
         self.name = name
         self.title = title
         self.description = description
+        self.image_url = image_url
         self.type = type
         self.approval_status= approval_status
 
diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py
index a04d8fd..ee092d7 100644
--- a/ckan/plugins/interfaces.py
+++ b/ckan/plugins/interfaces.py
@@ -13,7 +13,7 @@
     'IPackageController', 'IPluginObserver',
     'IConfigurable', 'IConfigurer', 'IAuthorizer',
     'IActions', 'IResourceUrlChange', 'IDatasetForm',
-    'IGroupForm',
+    'IGroupForm', 'ITemplateHelpers',
 ]
 
 from inspect import isclass
@@ -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.
@@ -385,6 +385,16 @@ def get_auth_functions(self):
         implementation overrides
         """
 
+class ITemplateHelpers(Interface):
+    """
+    Allow adding extra template functions available via h variable
+    """
+    def get_helpers(self):
+        """
+        Should return a dict, the keys being the name of the helper
+        function and the values being the functions themselves.
+        """
+
 class IDatasetForm(Interface):
     """
     Allows customisation of the package controller as a plugin.
diff --git a/ckan/public/css/style.css b/ckan/public/css/style.css
index 354734e..981156e 100644
--- a/ckan/public/css/style.css
+++ b/ckan/public/css/style.css
@@ -196,6 +196,11 @@ tbody tr:nth-child(odd) td, tbody tr.odd td {
   font-size: 2.2em;
   font-weight: normal;
 }
+#page-logo {
+  max-width: 36px;
+  max-height: 36px;
+  margin-right: 5px;
+}
 .hover-for-help {
   position: relative;
 }
diff --git a/ckan/public/scripts/application.js b/ckan/public/scripts/application.js
index 40513fd..995e703 100644
--- a/ckan/public/scripts/application.js
+++ b/ckan/public/scripts/application.js
@@ -537,7 +537,7 @@ CKAN.View.Resource = Backbone.View.extend({
     }
     self.updateIconTimer = setTimeout(function() {
         // AJAX to server API
-        $.getJSON('/api/2/util/resource/format_icon?format='+encodeURIComponent(self.formatBox.val()), function(data) {
+        $.getJSON(CKAN.SITE_URL + '/api/2/util/resource/format_icon?format='+encodeURIComponent(self.formatBox.val()), function(data) {
           if (data && data.icon && data.format==self.formatBox.val()) {
             self.li.find('.js-resource-icon').attr('src',data.icon);
             self.table.find('.js-resource-icon').attr('src',data.icon);
diff --git a/ckan/templates/group/new_group_form.html b/ckan/templates/group/new_group_form.html
index ad24ea3..9abc6c1 100644
--- a/ckan/templates/group/new_group_form.html
+++ b/ckan/templates/group/new_group_form.html
@@ -1,8 +1,8 @@
-<form 
-  class="form-horizontal ${'has-errors' if errors else ''}" 
-  id="group-edit" 
-  action="" 
-  method="post" 
+<form
+  class="form-horizontal ${'has-errors' if errors else ''}"
+  id="group-edit"
+  action=""
+  method="post"
   xmlns:i18n="http://genshi.edgewall.org/i18n"
   xmlns:py="http://genshi.edgewall.org/"
   xmlns:xi="http://www.w3.org/2001/XInclude">
@@ -43,6 +43,13 @@
       ${markdown_editor('description', data.get('description'), 'notes', _('Start with a summary sentence ...'))}
     </div>
   </div>
+  <div class="control-group">
+    <label for="name" class="control-label">Image URL:</label>
+    <div class="controls">
+      <input id="image_url" name="image_url" type="text" value="${data.get('image_url', '')}"/>
+      <p>The URL for the image that is associated with this group.</p>
+    </div>
+  </div>
   <div class="state-field control-group" py:if="c.is_sysadmin or c.auth_for_change_state">
     <label for="" class="control-label">State</label>
     <div class="controls">
@@ -53,7 +60,7 @@
     </div>
   </div>
 </fieldset>
-  
+
 <fieldset id="extras">
   <h3>Extras</h3>
   <dl>
diff --git a/ckan/templates/group/read.html b/ckan/templates/group/read.html
index 69314b4..f3c6b9f 100644
--- a/ckan/templates/group/read.html
+++ b/ckan/templates/group/read.html
@@ -6,6 +6,9 @@
   <xi:include href="../facets.html" />
   <py:def function="page_title">${c.group.display_name}</py:def>
   <py:def function="page_heading">${c.group.display_name}</py:def>
+  <py:if test="c.group.image_url">
+    <py:def function="page_logo">${c.group.image_url}</py:def>
+  </py:if>
 
   <py:match path="primarysidebar">
   
@@ -85,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/layout_base.html b/ckan/templates/layout_base.html
index 3b090f1..1518d72 100644
--- a/ckan/templates/layout_base.html
+++ b/ckan/templates/layout_base.html
@@ -90,7 +90,10 @@
     </py:with>
 
     <div id="main" class="container" role="main">
-      <h1 py:if="defined('page_heading')" class="page_heading">${page_heading()}</h1>
+      <h1 py:if="defined('page_heading')" class="page_heading">
+        <img py:if="defined('page_logo')" id="page-logo" src="${page_logo()}" alt="Page Logo" />
+        ${page_heading()}
+      </h1>
       <div class="row">
         <div class="span12">
           <div id="minornavigation">
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/tests/functional/test_group.py b/ckan/tests/functional/test_group.py
index 8b452a6..e71cd28 100644
--- a/ckan/tests/functional/test_group.py
+++ b/ckan/tests/functional/test_group.py
@@ -72,7 +72,7 @@ def test_mainmenu(self):
     def test_index(self):
         offset = url_for(controller='group', action='index')
         res = self.app.get(offset)
-        assert '<h1 class="page_heading">Groups' in res, res
+        assert re.search('<h1(.*)>\s*Groups', res.body)
         groupname = 'david'
         group = model.Group.by_name(unicode(groupname))
         group_title = group.title
@@ -259,6 +259,20 @@ def test_edit_plugin_hook(self):
         assert plugin.calls['edit'] == 1, plugin.calls
         plugins.unload(plugin)
 
+    def test_edit_image_url(self):
+        group = model.Group.by_name(self.groupname)
+        offset = url_for(controller='group', action='edit', id=self.groupname)
+        res = self.app.get(offset, status=200, extra_environ={'REMOTE_USER': 'russianfan'})
+
+        form = res.forms['group-edit']
+        image_url = u'http://url.to/image_url'
+        form['image_url'] = image_url
+        res = form.submit('save', status=302, extra_environ={'REMOTE_USER': 'russianfan'})
+
+        model.Session.remove()
+        group = model.Group.by_name(self.groupname)
+        assert group.image_url == image_url, group
+
     def test_edit_non_existent(self):
         name = u'group_does_not_exist'
         offset = url_for(controller='group', action='edit', id=name)
diff --git a/ckan/tests/lib/test_dictization.py b/ckan/tests/lib/test_dictization.py
index ba8ec69..c6ceb0b 100644
--- a/ckan/tests/lib/test_dictization.py
+++ b/ckan/tests/lib/test_dictization.py
@@ -42,19 +42,19 @@ def setup_class(cls):
             'groups': [{'description': u'These are books that David likes.',
                         'name': u'david',
                         'capacity': 'public',
+                        'image_url': u'',
                         'type': u'group',
                         'state': u'active',
                         'title': u"Dave's books",
-                        "approval_status": u"approved",
-                        'capacity': u'public'},
+                        "approval_status": u"approved"},
                        {'description': u'Roger likes these books.',
                         'name': u'roger',
                         'capacity': 'public',
+                        'image_url': u'',
                         'type': u'group',
                         'state': u'active',
                         'title': u"Roger's books",
-                        "approval_status": u"approved",
-                        'capacity': u'public'}],
+                        "approval_status": u"approved"}],
             'isopen': True,
             'license_id': u'other-open',
             'license_title': u'Other (Open)',
@@ -212,7 +212,7 @@ def test_01_dictize_main_objects_simple(self):
     def test_02_package_dictize(self):
 
         context = {"model": model,
-                 "session": model.Session}
+                   "session": model.Session}
 
         model.Session.remove()
         pkg = model.Session.query(model.Package).filter_by(name='annakarenina').first()
@@ -864,13 +864,14 @@ def test_16_group_dictized(self):
 
         group_dictized = group_dictize(group, context)
 
-        expected =  {'description': u'',
+        expected = {'description': u'',
                     'extras': [{'key': u'genre', 'state': u'active', 'value': u'"horror"'},
                                {'key': u'media', 'state': u'active', 'value': u'"dvd"'}],
                     'tags': [{'capacity': 'public', 'name': u'russian'}],
                     'groups': [{'description': u'',
                                'capacity' : 'public',
                                'display_name': u'simple',
+                               'image_url': u'',
                                'name': u'simple',
                                'packages': 0,
                                'state': u'active',
@@ -889,6 +890,7 @@ def test_16_group_dictized(self):
                               'reset_key': None}],
                     'name': u'help',
                     'display_name': u'help',
+                    'image_url': u'',
                     'packages': [{'author': None,
                                   'author_email': None,
                                   'license_id': u'other-open',
diff --git a/ckan/tests/lib/test_dictization_schema.py b/ckan/tests/lib/test_dictization_schema.py
index 13f14e1..a004b1e 100644
--- a/ckan/tests/lib/test_dictization_schema.py
+++ b/ckan/tests/lib/test_dictization_schema.py
@@ -149,6 +149,7 @@ def test_2_group_schema(self):
                                  'id': group.id,
                                  'name': u'david',
                                  'type': u'group',
+                                 'image_url': u'',
                                  'packages': sorted([{'id': group_pack[0].id,
                                                     'name': group_pack[0].name,
                                                     'title': group_pack[0].title},
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/ckanext/organizations/templates/organization_form.html b/ckanext/organizations/templates/organization_form.html
index 76b28c7..5a1dc6a 100644
--- a/ckanext/organizations/templates/organization_form.html
+++ b/ckanext/organizations/templates/organization_form.html
@@ -1,113 +1,145 @@
+<form
+  class="form-horizontal ${'has-errors' if errors else ''}"
+  id="group-edit"
+  action=""
+  method="post"
+  xmlns:i18n="http://genshi.edgewall.org/i18n"
+  xmlns:py="http://genshi.edgewall.org/"
+  xmlns:xi="http://www.w3.org/2001/XInclude">
 
-<form id="organization-edit" action="" method="post"
-    py:attrs="{'class':'has-errors'} if errors else {}"
-    xmlns:i18n="http://genshi.edgewall.org/i18n"
-    xmlns:py="http://genshi.edgewall.org/"
-    xmlns:xi="http://www.w3.org/2001/XInclude">
+<xi:include href="_util.html" />
 
 <div class="error-explanation" py:if="error_summary">
 <h2>Errors in form</h2>
 <p>The form contains invalid entries:</p>
 <ul>
-  <li py:for="key, error in error_summary.items()">${"%s: %s" % (key, error)}</li>
+  <li py:for="key, error in error_summary.items()">${"%s: %s" % (key if not key=='Name' else 'URL', error)}</li>
 </ul>
 </div>
 
-<input type="hidden" id="type" name="type" value="organization" />
-<input type="hidden" id="approval_status" name="approval_status" value="pending" />
-
 <fieldset id="basic-information">
-  <dl>
-    <dt><label class="field_opt" for="name">Organization name</label></dt>
-    <dd><input class="js-title" id="title" name="title" type="text" value="${data.get('title', '')}"/></dd>
-
-
-    <dt><label class="field_opt" for="title">Url</label></dt>
-    <dd class="name-field">
-      <span class="js-url-text url-text">${g.site_url+h.url_for('organization_index')+'/'}<span class="js-url-viewmode js-url-suffix"> </span><a style="display: none;" href="#" class="url-edit js-url-editlink js-url-viewmode">(edit)</a></span>
-      <input style="display: none;" id="name" maxlength="100" name="name" type="text" class="url-input js-url-editmode js-url-input" value="${data.get('name', '')}" />
+  <div class="control-group">
+    <label for="name" class="control-label">Title</label>
+    <div class="controls">
+      <input class="js-title" id="title" name="title" type="text" value="${data.get('title', '')}"/>
+    </div>
+  </div>
+  <div class="control-group">
+    <label for="title" class="control-label">Url</label>
+    <div class="controls">
+      <div class="input-prepend">
+        <span class="add-on">${h.url(controller='group', action='index')+'/'}</span>
+        <input maxlength="100" name="name" type="text" class="js-url-input" value="${data.get('name', '')}" />
+      </div>
       <p class="js-url-is-valid"> </p>
-    </dd>
-    <dd style="display: none;" class="js-url-editmode instructions basic">2+ chars, lowercase, using only 'a-z0-9' and '-_'</dd>
-    <dd class="field_error" py:if="errors.get('name', '')">${errors.get('name', '')}</dd>
-
-    <dt class="description-label"><label class="field_opt" for="title">Organization Description</label></dt>
-    <dd class="description-field"><div class="markdown-editor">
-      <ul class="button-row">
-        <li><button class="pretty-button js-markdown-edit depressed">Edit</button></li>
-        <li><button class="pretty-button js-markdown-preview">Preview</button></li>
-      </ul>
-      <textarea class="markdown-input" name="description" id="notes" placeholder="${_('Start with a summary sentence ...')}">${data.get('description','')}</textarea>
-      <div class="markdown-preview" style="display: none;"></div>
-      <span class="hints">You can use <a href="http://daringfireball.net/projects/markdown/syntax" target="_blank">Markdown formatting</a> here.</span>
-    </div></dd>
-
-    <dt class="parent-label" py:if="c.is_superuser_or_groupadmin">
-		<label class="field_opt" for="parent">Parent Organization</label>
-	</dt>
+      <p class="url-is-long">Warning: URL is very long. Consider changing it to something shorter.</p>
+      <p>2+ characters, lowercase, using only 'a-z0-9' and '-_'</p>
+      <p class="field_error" py:if="errors.get('name', '')">${errors.get('name', '')}</p>
+    </div>
+  </div>
+  <div class="control-group">
+    <label for="" class="control-label">Description</label>
+    <div class="controls">
+      ${markdown_editor('description', data.get('description'), 'notes', _('Start with a summary sentence ...'))}
+    </div>
+  </div>
+  <div class="control-group">
+    <label for="name" class="control-label">Image URL:</label>
+    <div class="controls">
+      <input id="image_url" name="image_url" type="text" value="${data.get('image_url', '')}"/>
+      <p>The URL for the image that is associated with this organization.</p>
+    </div>
+  </div>
+  <div class="state-field control-group" py:if="c.is_sysadmin or c.auth_for_change_state">
+    <label for="" class="control-label">State</label>
+    <div class="controls">
+      <select id="state" name="state" >
+        <option py:attrs="{'selected': 'selected' if data.get('state') == 'active' else None}" value="active">active</option>
+        <option py:attrs="{'selected': 'selected' if data.get('state') == 'deleted' else None}" value="deleted">deleted</option>
+      </select>
+    </div>
+  </div>
+</fieldset>
 
-	<dd py:if="c.group and not c.is_superuser_or_groupadmin">
+  <div class="control-group" py:if="c.is_superuser_or_groupadmin">
+    <label class="control-label" for="parent">Parent Organization</label>
+    <div class="controls" py:if="c.group and not c.is_superuser_or_groupadmin">
 		<span py:if="c.parent is not None" class="js-title">
 			${ c.parent.title }
 		</span>
 		<span py:if="c.parent is None" class="js-title">
 			No parent organization
 		</span>
-	</dd>
-
-    <dd py:if="c.is_superuser_or_groupadmin" class="parent-field">
+	</div>
+    <div class="controls" py:if="c.is_superuser_or_groupadmin">
 		<select id="parent" name="parent" class="chzn-select" data-placeholder="Please choose a organization">
 			<option value=""></option>
     		<py:for each="pg in c.possible_parents">
 				<option py:attrs="{'selected': 'selected' if c.parent and pg.id == c.parent.id else None}" value="${pg.id}">${pg.title}</option>
 			</py:for>
 		</select>
-	</dd>
-
+	</div>
+  </div>
 
-    <dt class="state-label" py:if="c.is_sysadmin or c.auth_for_change_state"><label class="field_opt" for="state">State</label></dt>
-    <dd class="state-field" py:if="c.is_sysadmin or c.auth_for_change_state">
-    <select id="state" name="state" >
-      <option py:attrs="{'selected': 'selected' if data.get('state') == 'active' else None}" value="active">active</option>
-      <option py:attrs="{'selected': 'selected' if data.get('state') == 'deleted' else None}" value="deleted">deleted</option>
-    </select>
-    </dd>
-  </dl>
-</fieldset>
 
 <fieldset id="extras">
   <h3>Extras</h3>
   <dl>
     <py:with vars="extras = data.get('extras', [])">
     <py:for each="num, extra in enumerate(data.get('extras', []))">
-    <dt><label for="extras__${num}__value">${extra.get('key')}</label></dt>
-    <dd>
-      <input id="extras__${num}__key" name="extras__${num}__key" type="hidden" value="${extra.get('key')}" />
-      <input id="extras__${num}__value" name="extras__${num}__value" type="text" value="${extra.get('value')}" />
-      <input type="checkbox" name="extras__${num}__deleted" checked="${extra.get('deleted')}">Delete</input>
-    </dd>
+    <div class="control-group">
+      <label class="control-label" for="extras__${num}__value">${extra.get('key')}</label>
+      <div class="controls">
+        <input id="extras__${num}__key" name="extras__${num}__key" type="hidden" value="${extra.get('key')}" />
+        <input id="extras__${num}__value" name="extras__${num}__value" type="text" value="${extra.get('value')}" />
+        <label class="checkbox" style="display: inline-block;">
+          <input type="checkbox" name="extras__${num}__deleted" checked="${extra.get('deleted')}" />Delete
+        </label>
+      </div>
+    </div>
     </py:for>
-
+    <hr py:if="len(extras)" class="extras-divider" />
     <py:for each="num in range(len(extras), len(extras) + 4)">
-    <dt><label for="extras__${num}__key">New key</label></dt>
-    <dd>
-      <input class="medium-width" id="extras__${num}__key" name="extras__${num}__key" type="text" />
-      with value
-      <input class="medium-width" id="extras__${num}__value" name="extras__${num}__value" type="text" />
-    </dd>
+      <div class="control-group">
+        <label class="control-label" for="extras__${num}__key">Add...</label>
+        <div class="controls">
+          <label>
+            <span class="extras-label">Key =</span>
+            <input class="medium-width" id="extras__${num}__key" name="extras__${num}__key" type="text" />
+          </label>
+          <label>
+            <span class="extras-label">Value =</span>
+            <input class="medium-width" id="extras__${num}__value" name="extras__${num}__value" type="text" />
+          </label>
+        </div>
+      </div>
     </py:for>
     </py:with>
   </dl>
 </fieldset>
 
+<?python
+    import ckan.model as model
+    users = []
+    if c.group:
+        users.extend( { "name": user.name,
+                            "capacity": "admin" }
+                            for user in c.group.members_of_type( model.User, "admin"  ).all() )
+        users.extend( { "name": user.name,
+                            "capacity": "editor" }
+                            for user in c.group.members_of_type( model.User, 'editor' ).all() )
+?>
 <fieldset id="users">
   <h3>Users <span py:if="c.users">(${len(c.users.all())})</span></h3>
 <a py:if="c.group" href="${h.url_for(controller='ckanext.organizations.controllers:OrganizationController', action='users', id=c.group.name)}">Manage users</a>
 
-  <dl py:if="c.users">
-    <py:for each="user in c.users">
+  <dl py:if="users">
+    <py:for each="num, user in enumerate(users)">
 		<dd>
 			<label>${user['name']}</label>
+			<input type="hidden" name="users__${num}__name" value="${user['name']}"/>
+			<input type="hidden" name="users__${num}__capacity" value="${user['capacity']}"/>
+
 		</dd>
     </py:for>
   </dl>
@@ -115,10 +147,10 @@
 </fieldset>
 
 
-<div class="form-submit">
-  <input id="save" class="pretty-button primary" name="save" type="submit" value="${_('Save Changes')}" />
+<div class="form-actions">
+  <input id="save" class="btn btn-primary" name="save" type="submit" value="${_('Save Changes')}" />
   <py:if test="c.group">
-    <input id="cancel" class="pretty-button href-action" name="cancel" type="reset" value="${_('Cancel')}" action="${h.url_for(controller='group', action='read', id=c.group.name)}" />
+    <input id="cancel" class="btn href-action" name="cancel" type="reset" value="${_('Cancel')}" action="${h.url_for(controller='group', action='read', id=c.group.name)}" />
   </py:if>
 </div>
 </form>
diff --git a/ckanext/organizations/templates/organization_read.html b/ckanext/organizations/templates/organization_read.html
index 0176952..d946d31 100644
--- a/ckanext/organizations/templates/organization_read.html
+++ b/ckanext/organizations/templates/organization_read.html
@@ -6,6 +6,11 @@
   <xi:include href="facets.html" />
   <py:def function="page_title">${c.group.display_name}</py:def>
   <py:def function="page_heading">${c.group.display_name}</py:def>
+  <py:if test="c.group.image_url">
+    <py:def function="page_logo">${c.group.image_url}</py:def>
+  </py:if>
+
+<input type="hidden" id="type" name="type" value="organization" />
 
   <?python
     from pylons import config
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/setup.py b/setup.py
index a8f3389..ee1f06d 100644
--- a/setup.py
+++ b/setup.py
@@ -70,6 +70,7 @@
     rights = ckan.lib.authztool:RightsCommand
     roles = ckan.lib.authztool:RolesCommand
     celeryd = ckan.lib.cli:Celery
+    rdf-export = ckan.lib.cli:RDFExport
 
     [console_scripts]
     ckan-admin = bin.ckan_admin:Command
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


================================================================
  Commit: 664cdfe96ef7931ed3628ab10fd8e143d2211be0
      https://github.com/okfn/ckan/commit/664cdfe96ef7931ed3628ab10fd8e143d2211be0
  Author: Ross Jones <rossdjones at gmail.com>
  Date:   2012-04-19 (Thu, 19 Apr 2012)

  Changed paths:
    M ckan/controllers/group.py
    M ckan/controllers/package.py
    M ckan/lib/dictization/model_save.py

  Log Message:
  -----------
  [2255] Improved guessing of group+package type from urls that have prefixed an extra path such as /open-data/data/organization/


diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py
index 6588ff8..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,
diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py
index 90ebca4..e90d658 100644
--- a/ckan/controllers/package.py
+++ b/ckan/controllers/package.py
@@ -84,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}
@@ -319,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:
@@ -393,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,
diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py
index 9677187..aa9f1a5 100644
--- a/ckan/lib/dictization/model_save.py
+++ b/ckan/lib/dictization/model_save.py
@@ -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:


================================================================
  Commit: 4a32b1d399d67ff4dec050b3d4fb17ef69536c87
      https://github.com/okfn/ckan/commit/4a32b1d399d67ff4dec050b3d4fb17ef69536c87
  Author: Ross Jones <rossdjones at gmail.com>
  Date:   2012-04-19 (Thu, 19 Apr 2012)

  Changed paths:
    M ckan/controllers/group.py
    M ckan/controllers/package.py
    M ckan/lib/dictization/model_save.py

  Log Message:
  -----------
  Merge branch 'feature-2255-organizations'


diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py
index 6588ff8..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,
diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py
index 90ebca4..e90d658 100644
--- a/ckan/controllers/package.py
+++ b/ckan/controllers/package.py
@@ -84,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}
@@ -319,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:
@@ -393,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,
diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py
index 9677187..aa9f1a5 100644
--- a/ckan/lib/dictization/model_save.py
+++ b/ckan/lib/dictization/model_save.py
@@ -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:


================================================================
Compare: https://github.com/okfn/ckan/compare/e1d0534...4a32b1d


More information about the ckan-changes mailing list