diff --git a/ckan/__init__.py b/ckan/__init__.py index 8e06d49b45a..588713ab455 100644 --- a/ckan/__init__.py +++ b/ckan/__init__.py @@ -1,4 +1,4 @@ -__version__ = '1.9a' +__version__ = '2.0a' __description__ = 'Comprehensive Knowledge Archive Network (CKAN) Software' __long_description__ = \ '''CKAN software provides a hub for datasets. The flagship site running CKAN diff --git a/ckan/config/environment.py b/ckan/config/environment.py index c77cb5ba147..db452ece5c9 100644 --- a/ckan/config/environment.py +++ b/ckan/config/environment.py @@ -175,6 +175,8 @@ def find_controller(self, controller): config['routes.map'] = routing.make_map() config['pylons.app_globals'] = app_globals.app_globals + # initialise the globals + config['pylons.app_globals']._init() # add helper functions restrict_helpers = asbool( @@ -296,8 +298,7 @@ def genshi_lookup_attr(cls, obj, key): # Create Jinja2 environment env = lib.jinja_extensions.Environment( - loader=lib.jinja_extensions.CkanFileSystemLoader(template_paths, - ckan_base_path=paths['templates'][0]), + loader=lib.jinja_extensions.CkanFileSystemLoader(template_paths), autoescape=True, extensions=['jinja2.ext.i18n', 'jinja2.ext.do', 'jinja2.ext.with_', lib.jinja_extensions.SnippetExtension, diff --git a/ckan/config/middleware.py b/ckan/config/middleware.py index a3fb74e9839..749b8896c21 100644 --- a/ckan/config/middleware.py +++ b/ckan/config/middleware.py @@ -201,10 +201,10 @@ def __call__(self, environ, start_response): path_info = '/'.join(urllib.quote(pce,'') for pce in path_info.split('/')) qs = environ.get('QUERY_STRING') - # sort out weird encodings - qs = urllib.quote(qs, '') if qs: + # sort out weird encodings + #qs = urllib.quote(qs, '') environ['CKAN_CURRENT_URL'] = '%s?%s' % (path_info, qs) else: environ['CKAN_CURRENT_URL'] = path_info diff --git a/ckan/config/routing.py b/ckan/config/routing.py index 8b299ebb855..d4c692279a6 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -330,8 +330,8 @@ def make_map(): with SubMapper(map, controller='util') as m: m.connect('/i18n/strings_{lang}.js', action='i18n_js_strings') m.connect('/util/redirect', action='redirect') - m.connect('/test/primer', action='primer') - m.connect('/test/markup', action='markup') + m.connect('/testing/primer', action='primer') + m.connect('/testing/markup', action='markup') for plugin in routing_plugins: map = plugin.after_map(map) diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index 4d96ab873df..9705390398c 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -34,7 +34,7 @@ def _form_to_db_schema(self, group_type=None): def _db_to_form_schema(self, group_type=None): '''This is an interface to manipulate data from the database into a format suitable for the form (optional)''' - return lookup_group_plugin(group_type).form_to_db_schema() + return lookup_group_plugin(group_type).db_to_form_schema() def _setup_template_variables(self, context, data_dict, group_type=None): return lookup_group_plugin(group_type).\ @@ -100,7 +100,7 @@ def read(self, id): group_type = self._get_group_type(id.split('@')[0]) context = {'model': model, 'session': model.Session, 'user': c.user or c.author, - 'schema': self._form_to_db_schema(group_type=group_type), + 'schema': self._db_to_form_schema(group_type=group_type), 'for_view': True, 'extras_as_string': True} data_dict = {'id': id} # unicode format (decoded from utf8) @@ -139,6 +139,7 @@ def read(self, id): # most search operations should reset the page counter: params_nopage = [(k, v) for k, v in request.params.items() if k != 'page'] + sort_by = request.params.get('sort', 'name asc') def search_url(params): url = h.url_for(controller='group', action='read', @@ -171,7 +172,7 @@ def pager_url(q=None, page=None): c.fields = [] search_extras = {} for (param, value) in request.params.items(): - if not param in ['q', 'page'] \ + if not param in ['q', 'page', 'sort'] \ and len(value) and not param.startswith('_'): if not param.startswith('ext_'): c.fields.append((param, value)) @@ -189,6 +190,7 @@ def pager_url(q=None, page=None): 'fq': fq, 'facet.field': g.facets, 'rows': limit, + 'sort': sort_by, 'start': (page - 1) * limit, 'extras': search_extras } @@ -218,6 +220,9 @@ def pager_url(q=None, page=None): limit = int(request.params.get('_%s_limit' % facet, 10)) c.search_facets_limits[facet] = limit c.page.items = query['results'] + + c.sort_by_selected = sort_by + except SearchError, se: log.error('Group search error: %r', se.args) c.query_error = True diff --git a/ckan/controllers/home.py b/ckan/controllers/home.py index e2eea51a79d..0cefc7fbe94 100644 --- a/ckan/controllers/home.py +++ b/ckan/controllers/home.py @@ -121,37 +121,59 @@ def _get_group_type(id): return None return group.type - def _form_to_db_schema(group_type=None): + def db_to_form_schema(group_type=None): from ckan.lib.plugins import lookup_group_plugin - return lookup_group_plugin(group_type).form_to_db_schema() + return lookup_group_plugin(group_type).db_to_form_schema() group_type = _get_group_type(id.split('@')[0]) context = {'model': model, 'session': model.Session, 'ignore_auth': True, 'user': c.user or c.author, - 'schema': _form_to_db_schema(group_type=group_type), + 'schema': db_to_form_schema(group_type=group_type), 'for_view': True} data_dict = {'id': id} try: group_dict = ckan.logic.get_action('group_show')(context, data_dict) except ckan.logic.NotFound: - return {'group_dict' :{}} + return None - # We get all the packages or at least too many so - # limit it to just 2 - group_dict['packages'] = group_dict['packages'][:2] - return {'group_dict': group_dict} + return {'group_dict' :group_dict} global dirty_cached_group_stuff if not dirty_cached_group_stuff: - # ARON - # uncomment the first for testing - # the second for demo - different data - #dirty_cached_group_stuff = [get_group('access-to-medicines'), get_group('archaeology')] - dirty_cached_group_stuff = [get_group('data-explorer'), get_group('geo-examples')] + groups_data = [] + groups = config.get('demo.featured_groups', '').split() + + for group_name in groups: + group = get_group(group_name) + if group: + groups_data.append(group) + if len(groups_data) == 2: + break + + # c.groups is from the solr query above + if len(groups_data) < 2 and len(c.groups) > 0: + group = get_group(c.groups[0]['name']) + if group: + groups_data.append(group) + if len(groups_data) < 2 and len(c.groups) > 1: + group = get_group(c.groups[1]['name']) + if group: + groups_data.append(group) + # We get all the packages or at least too many so + # limit it to just 2 + for group in groups_data: + group['group_dict']['packages'] = group['group_dict']['packages'][:2] + #now add blanks so we have two + while len(groups_data) < 2: + groups_data.append({'group_dict' :{}}) + # cache for later use + dirty_cached_group_stuff = groups_data + c.group_package_stuff = dirty_cached_group_stuff + # END OF DIRTYNESS return render('home/index.html', cache_force=True) diff --git a/ckan/controllers/storage.py b/ckan/controllers/storage.py index c60eb6148d6..19dd64554a1 100644 --- a/ckan/controllers/storage.py +++ b/ckan/controllers/storage.py @@ -270,7 +270,7 @@ def get_metadata(self, label): qualified=False ) if url.startswith('/'): - url = config.get('ckan.site_url','') + url + url = config.get('ckan.site_url','').rstrip('/') + '/' + url if not self.ofs.exists(bucket, label): abort(404) diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py index 2e0ff052751..9f9a69ee8d1 100644 --- a/ckan/controllers/user.py +++ b/ckan/controllers/user.py @@ -399,12 +399,18 @@ def request_reset(self): def perform_reset(self, id): context = {'model': model, 'session': model.Session, - 'user': c.user} + 'user': c.user, + 'keep_sensitive_data': True} data_dict = {'id': id} try: user_dict = get_action('user_show')(context, data_dict) + + # Be a little paranoid, and get rid of sensitive data that's + # not needed. + user_dict.pop('apikey', None) + user_dict.pop('reset_key', None) user_obj = context['user_obj'] except NotFound, e: abort(404, _('User not found')) diff --git a/ckan/lib/activity_streams.py b/ckan/lib/activity_streams.py new file mode 100644 index 00000000000..ffa5b3b012d --- /dev/null +++ b/ckan/lib/activity_streams.py @@ -0,0 +1,191 @@ +import re + +from pylons.i18n import _ +from webhelpers.html import literal + +import ckan.lib.helpers as h +import ckan.lib.base as base +import ckan.logic as logic + +# get_snippet_*() functions replace placeholders like {user}, {dataset}, etc. +# in activity strings with HTML representations of particular users, datasets, +# etc. + +def get_snippet_actor(activity, detail): + return h.linked_user(activity['user_id']) + +def get_snippet_user(activity, detail): + return h.linked_user(activity['data']['user']['name']) + +def get_snippet_dataset(activity, detail): + data = activity['data'] + return h.dataset_link(data.get('package') or data.get('dataset')) + +def get_snippet_tag(activity, detail): + return h.tag_link(detail['data']['tag']) + +def get_snippet_group(activity, detail): + return h.group_link(activity['data']['group']) + +def get_snippet_extra(activity, detail): + return '"%s"' % detail['data']['package_extra']['key'] + +def get_snippet_resource(activity, detail): + return h.resource_link(detail['data']['resource'], + activity['data']['package']['id']) + +def get_snippet_related_item(activity, detail): + return h.related_item_link(activity['data']['related']) + +def get_snippet_related_type(activity, detail): + # FIXME this needs to be translated + return activity['data']['related']['type'] + +# activity_stream_string_*() functions return translatable string +# representations of activity types, the strings contain placeholders like +# {user}, {dataset} etc. to be replaced with snippets from the get_snippet_*() +# functions above. + +def activity_stream_string_added_tag(): + return _("{actor} added the tag {tag} to the dataset {dataset}") + +def activity_stream_string_changed_group(): + return _("{actor} updated the group {group}") + +def activity_stream_string_changed_package(): + return _("{actor} updated the dataset {dataset}") + +def activity_stream_string_changed_package_extra(): + return _("{actor} changed the extra {extra} of the dataset {dataset}") + +def activity_stream_string_changed_resource(): + return _("{actor} updated the resource {resource} in the dataset {dataset}") + +def activity_stream_string_changed_user(): + return _("{actor} updated their profile") + +def activity_stream_string_deleted_group(): + return _("{actor} deleted the group {group}") + +def activity_stream_string_deleted_package(): + return _("{actor} deleted the dataset {dataset}") + +def activity_stream_string_deleted_package_extra(): + return _("{actor} deleted the extra {extra} from the dataset {dataset}") + +def activity_stream_string_deleted_resource(): + return _("{actor} deleted the resource {resource} from the dataset {dataset}") + +def activity_stream_string_new_group(): + return _("{actor} created the group {group}") + +def activity_stream_string_new_package(): + return _("{actor} created the dataset {dataset}") + +def activity_stream_string_new_package_extra(): + return _("{actor} added the extra {extra} to the dataset {dataset}") + +def activity_stream_string_new_resource(): + return _("{actor} added the resource {resource} to the dataset {dataset}") + +def activity_stream_string_new_user(): + return _("{actor} signed up") + +def activity_stream_string_removed_tag(): + return _("{actor} removed the tag {tag} from the dataset {dataset}") + +def activity_stream_string_deleted_related_item(): + return _("{actor} deleted the related item {related_item}") + +def activity_stream_string_follow_dataset(): + return _("{actor} started following {dataset}") + +def activity_stream_string_follow_user(): + return _("{actor} started following {user}") + +def activity_stream_string_new_related_item(): + return _("{actor} created the link to related {related_type} {related_item}") + +# A dictionary mapping activity snippets to functions that expand the snippets. +activity_snippet_functions = { + 'actor': get_snippet_actor, + 'user': get_snippet_user, + 'dataset': get_snippet_dataset, + 'tag': get_snippet_tag, + 'group': get_snippet_group, + 'extra': get_snippet_extra, + 'resource': get_snippet_resource, + 'related_item': get_snippet_related_item, + 'related_type': get_snippet_related_type, +} + +# A dictionary mapping activity types to functions that return translatable +# string descriptions of the activity types. +activity_stream_string_functions = { + 'added tag': activity_stream_string_added_tag, + 'changed group': activity_stream_string_changed_group, + 'changed package': activity_stream_string_changed_package, + 'changed package_extra': activity_stream_string_changed_package_extra, + 'changed resource': activity_stream_string_changed_resource, + 'changed user': activity_stream_string_changed_user, + 'deleted group': activity_stream_string_deleted_group, + 'deleted package': activity_stream_string_deleted_package, + 'deleted package_extra': activity_stream_string_deleted_package_extra, + 'deleted resource': activity_stream_string_deleted_resource, + 'new group': activity_stream_string_new_group, + 'new package': activity_stream_string_new_package, + 'new package_extra': activity_stream_string_new_package_extra, + 'new resource': activity_stream_string_new_resource, + 'new user': activity_stream_string_new_user, + 'removed tag': activity_stream_string_removed_tag, + 'deleted related item': activity_stream_string_deleted_related_item, + 'follow dataset': activity_stream_string_follow_dataset, + 'follow user': activity_stream_string_follow_user, + 'new related item': activity_stream_string_new_related_item, +} + +# A list of activity types that may have details +activity_stream_actions_with_detail = ['changed package'] + +def activity_list_to_html(context, activity_stream): + '''Return the given activity stream as a snippet of HTML.''' + + activity_list = [] # These are the activity stream messages. + for activity in activity_stream: + detail = None + activity_type = activity['activity_type'] + # Some activity types may have details. + if activity_type in activity_stream_actions_with_detail: + details = logic.get_action('activity_detail_list')(context=context, + data_dict={'id': activity['id']}) + # If an activity has just one activity detail then render the + # detail instead of the activity. + if len(details) == 1: + detail = details[0] + object_type = detail['object_type'] + + if object_type == 'PackageExtra': + object_type = 'package_extra' + + new_activity_type = '%s %s' % (detail['activity_type'], + object_type.lower()) + if new_activity_type in activity_stream_string_functions: + activity_type = new_activity_type + + if not activity_type in activity_stream_string_functions: + raise NotImplementedError("No activity renderer for activity " + "type '%s'" % str(activity_type)) + + activity_msg = activity_stream_string_functions[activity_type]() + + # Get the data needed to render the message. + matches = re.findall('\{([^}]*)\}', activity_msg) + data = {} + for match in matches: + snippet = activity_snippet_functions[match](activity, detail) + data[str(match)] = snippet + activity_list.append({'msg': activity_msg, + 'data': data, + 'timestamp': activity['timestamp']}) + return literal(base.render('activity_streams/activity_stream_items.html', + extra_vars={'activities': activity_list})) diff --git a/ckan/lib/activity.py b/ckan/lib/activity_streams_session_extension.py similarity index 100% rename from ckan/lib/activity.py rename to ckan/lib/activity_streams_session_extension.py diff --git a/ckan/lib/app_globals.py b/ckan/lib/app_globals.py index 2f222997975..400ceb0c546 100644 --- a/ckan/lib/app_globals.py +++ b/ckan/lib/app_globals.py @@ -74,11 +74,11 @@ def get_config_value(key, default=''): if key not in _CONFIG_CACHE: _CONFIG_CACHE[key] = config_value if value: - log.info('config `%s` set to `%s` from db' % (key, value)) + log.debug('config `%s` set to `%s` from db' % (key, value)) else: value = _CONFIG_CACHE[key] if value: - log.info('config `%s` set to `%s` from config' % (key, value)) + log.debug('config `%s` set to `%s` from config' % (key, value)) else: value = default setattr(app_globals, get_globals_key(key), value) @@ -116,7 +116,9 @@ def __init__(self): initialization and is available during requests via the 'app_globals' variable ''' + self._init() + def _init(self): self.favicon = config.get('ckan.favicon', '/images/icons/ckan.ico') facets = config.get('search.facets', 'groups tags res_format license') self.facets = facets.split() diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py index 309cb44ee3b..97869588391 100644 --- a/ckan/lib/dictization/model_dictize.py +++ b/ckan/lib/dictization/model_dictize.py @@ -2,6 +2,7 @@ from pylons import config from sqlalchemy.sql import select import datetime +import ckan.authz import ckan.model import ckan.misc import ckan.logic as logic @@ -377,7 +378,6 @@ def user_list_dictize(obj_list, context, for obj in obj_list: user_dict = user_dictize(obj, context) - user_dict.pop('apikey') result_list.append(user_dict) return sorted(result_list, key=sort_key, reverse=reverse) @@ -399,6 +399,16 @@ def user_dictize(user, context): result_dict['number_of_edits'] = user.number_of_edits() result_dict['number_administered_packages'] = user.number_administered_packages() + requester = context['user'] + + if not (ckan.authz.Authorizer().is_sysadmin(unicode(requester)) or + requester == user.name or + context.get('keep_sensitive_data', False)): + # If not sysadmin or the same user, strip sensible info + result_dict.pop('apikey', None) + result_dict.pop('reset_key', None) + result_dict.pop('email', None) + model = context['model'] session = model.Session diff --git a/ckan/lib/formatters.py b/ckan/lib/formatters.py new file mode 100644 index 00000000000..e030a990e53 --- /dev/null +++ b/ckan/lib/formatters.py @@ -0,0 +1,122 @@ +import datetime + +from pylons.i18n import _, ungettext +from babel import numbers + +import ckan.lib.i18n as i18n + + +################################################## +# # +# Month translations # +# # +################################################## + +def _month_jan(): + return _('January') + + +def _month_feb(): + return _('February') + + +def _month_mar(): + return _('March') + + +def _month_apr(): + return _('April') + + +def _month_may(): + return _('May') + + +def _month_june(): + return _('June') + + +def _month_july(): + return _('July') + + +def _month_aug(): + return _('August') + + +def _month_sept(): + return _('September') + + +def _month_oct(): + return _('October') + + +def _month_nov(): + return _('November') + + +def _month_dec(): + return _('December') + + +# _MONTH_FUNCTIONS provides an easy way to get a localised month via +# _MONTH_FUNCTIONS[month]() where months are zero based ie jan = 0, dec = 11 +_MONTH_FUNCTIONS = [_month_jan, _month_feb, _month_mar, _month_apr, + _month_may, _month_june, _month_july, _month_aug, + _month_sept, _month_oct, _month_nov, _month_dec] + + +def localised_nice_date(datetime_): + ''' Returns a friendly localised unicode representation of a datetime. ''' + now = datetime.datetime.now() + date_diff = now - datetime_ + days = date_diff.days + if days < 1 and now > datetime_: + # less than one day + seconds = date_diff.seconds + if seconds < 3600: + # less than one hour + if seconds < 60: + return _('Just now') + else: + return ungettext('{mins} minute ago', '{mins} minutes ago', + seconds / 60).format(mins=seconds / 60) + else: + return ungettext('{hours} hour ago', '{hours} hours ago', + seconds / 3600).format(hours=seconds / 3600) + # more than one day + if days < 31: + return ungettext('{days} day ago', '{days} days ago', + days).format(days=days) + # actual date + month = datetime_.month + day = datetime_.day + year = datetime_.year + month_name = _MONTH_FUNCTIONS[month - 1]() + return _('{month} {day}, {year}').format(month=month_name, day=day, + year=year) + + +def localised_number(number): + ''' Returns a localised unicode representation of number ''' + return numbers.format_number(number, locale=i18n.get_lang()) + + +def localised_filesize(number): + ''' Returns a localised unicode representation of a number in bytes, MiB + etc ''' + def rnd(number, divisor): + # round to 1 decimal place + return localised_number(float(number * 10 / divisor) / 10) + + if number < 1024: + return _('{bytes} bytes').format(bytes=number) + elif number < 1024 ** 2: + return _('{kibibytes} KiB').format(kibibytes=rnd(number, 1024)) + elif number < 1024 ** 3: + return _('{mebibytes} KiB').format(mebibytes=rnd(number, 1024 ** 2)) + elif number < 1024 ** 4: + return _('{gibibytes} KiB').format(gibibytes=rnd(number, 1024 ** 3)) + else: + return _('{tebibytes} KiB').format(tebibytes=rnd(number, 1024 ** 4)) diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index 56b19e49628..f3b4b71f309 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -39,6 +39,7 @@ import html_resources from lib.maintain import deprecated import ckan.model as model +import ckan.lib.formatters as formatters get_available_locales = i18n.get_available_locales get_locales_dict = i18n.get_locales_dict @@ -522,11 +523,17 @@ def facet_title(name): return config.get('search.facets.%s.title' % name, name.capitalize()) def get_facet_title(name): + + # if this is set in the config use this + config_title = config.get('search.facets.%s.title' % name) + if config_title: + return config_title + facet_titles = {'groups' : _('Groups'), 'tags' : _('Tags'), 'res_format' : _('Formats'), 'license' : _('Licence'), } - return facet_titles.get(name, name) + return facet_titles.get(name, name.capitalize()) def get_param_int(name, default=10): return int(request.params.get(name, default)) @@ -592,7 +599,7 @@ def linked_user(user, maxlength=0): displayname = user.display_name if maxlength and len(user.display_name) > maxlength: displayname = displayname[:maxlength] + '...' - return _icon + link_to(displayname, + return _icon + u' ' + link_to(displayname, url_for(controller='user', action='read', id=_name)) def linked_authorization_group(authgroup, maxlength=0): @@ -618,7 +625,7 @@ def markdown_extract(text, extract_length=190): if (text is None) or (text.strip() == ''): return '' plain = re.sub(r'<.*?>', '', markdown(text)) - return unicode(truncate(plain, length=extract_length, indicator='...', whole_word=True)) + return literal(unicode(truncate(plain, length=extract_length, indicator='...', whole_word=True))) def icon_url(name): return url_for_static('/images/icons/%s.png' % name) @@ -905,6 +912,13 @@ def resource_link(resource_dict, package_id): resource_id=resource_dict['id']) return link_to(text, url) +def related_item_link(related_item_dict): + text = related_item_dict.get('title', '') + url = url_for(controller='related', + action='read', + id=related_item_dict['id']) + return link_to(text, url) + def tag_link(tag): url = url_for(controller='tag', action='read', id=tag['name']) return link_to(tag['name'], url) @@ -1213,20 +1227,34 @@ def render_markdown(data): return '' return literal(ckan.misc.MarkdownFormat().to_html(data)) + def format_resource_items(items): + ''' Take a resource item list and format nicely with blacklisting etc. ''' blacklist = ['name', 'description', 'url', 'tracking_summary'] output = [] - reg_ex = '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}$' + # regular expressions for detecting types in strings + reg_ex_datetime = '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{6})?$' + reg_ex_number = '^-?\d{1,}\.?\d*$' # int/float for key, value in items: if not value or key in blacklist: continue - if re.search(reg_ex, value): - value = render_datetime(date_str_to_datetime(value), - with_hours=True) + # size is treated specially as we want to show in MiB etc + if key == 'size': + value = formatters.localised_filesize(int(value)) + elif isinstance(value, basestring): + # check if strings are actually datetime/number etc + if re.search(reg_ex_datetime, value): + datetime_ = date_str_to_datetime(value) + value = formatters.localised_nice_date(datetime_) + elif re.search(reg_ex_number, value): + value = formatters.localised_number(float(value)) + elif isinstance(value, int) or isinstance(value, float): + value = formatters.localised_number(value) key = key.replace('_', ' ') output.append((key, value)) return sorted(output, key=lambda x:x[0]) + # these are the functions that will end up in `h` template helpers # if config option restrict_template_vars is true __allowed_functions__ = [ @@ -1270,6 +1298,7 @@ def format_resource_items(items): 'dataset_link', 'resource_display_name', 'resource_link', + 'related_item_link', 'tag_link', 'group_link', 'dump_json', diff --git a/ckan/lib/jinja_extensions.py b/ckan/lib/jinja_extensions.py index 08de400069e..19d5400970f 100644 --- a/ckan/lib/jinja_extensions.py +++ b/ckan/lib/jinja_extensions.py @@ -1,4 +1,5 @@ from os import path +import logging from jinja2 import nodes from jinja2 import loaders @@ -11,6 +12,8 @@ import ckan.lib.base as base import ckan.lib.helpers as h + +log = logging.getLogger(__name__) ### Filters def empty_and_escape(value): @@ -36,16 +39,44 @@ def truncate(value, length=255, killwords=None, end='...'): class CkanExtend(Extension): ''' Custom {% ckan_extends