[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