diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 34b50544daa..35599192526 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,11 @@ Minor changes: * For navl schemas, the 'default' validator no longer applies the default when the value is False, 0, [] or {} (#4448) + * If you've customized the schema for package_search, you'll need to add to it + the limiting of ``row``, as per default_package_search_schema now does (#4484) + * Several logic functions now have new limits to how many items can be + returned, notably ``group_list`` and ``organization_list`` when + ``all_fields=true``. These are all configurable. (#4484) Bugfixes: diff --git a/ckan/cli/__init__.py b/ckan/cli/__init__.py new file mode 100644 index 00000000000..281f52b6ac4 --- /dev/null +++ b/ckan/cli/__init__.py @@ -0,0 +1,52 @@ +# encoding: utf-8 + +import os + +import click +import logging +from logging.config import fileConfig as loggingFileConfig + +log = logging.getLogger(__name__) + + +def error_shout(exception): + click.secho(str(exception), fg=u'red', err=True) + + +click_config_option = click.option( + u'-c', + u'--config', + default=None, + metavar=u'CONFIG', + help=u'Config file to use (default: development.ini)' +) + + +def load_config(config=None): + from paste.deploy import appconfig + if config: + filename = os.path.abspath(config) + config_source = u'-c parameter' + elif os.environ.get(u'CKAN_INI'): + filename = os.environ.get(u'CKAN_INI') + config_source = u'$CKAN_INI' + else: + default_filename = u'development.ini' + filename = os.path.join(os.getcwd(), default_filename) + if not os.path.exists(filename): + # give really clear error message for this common situation + msg = u'ERROR: You need to specify the CKAN config (.ini) '\ + u'file path.'\ + u'\nUse the --config parameter or set environment ' \ + u'variable CKAN_INI or have {}\nin the current directory.' \ + .format(default_filename) + exit(msg) + + if not os.path.exists(filename): + msg = u'Config file not found: %s' % filename + msg += u'\n(Given by: %s)' % config_source + exit(msg) + + loggingFileConfig(filename) + log.info(u'Using configuration file {}'.format(filename)) + return appconfig(u'config:' + filename) diff --git a/ckan/cli/cli.py b/ckan/cli/cli.py new file mode 100644 index 00000000000..236196c88c6 --- /dev/null +++ b/ckan/cli/cli.py @@ -0,0 +1,30 @@ +# encoding: utf-8 + +import logging + +import click + +from ckan.cli import click_config_option, db, load_config, search_index, server +from ckan.config.middleware import make_app + +log = logging.getLogger(__name__) + + +class CkanCommand(object): + + def __init__(self, conf=None): + self.config = load_config(conf) + self.app = make_app(self.config.global_conf, **self.config.local_conf) + + +@click.group() +@click.help_option(u'-h', u'--help') +@click_config_option +@click.pass_context +def ckan(ctx, config, *args, **kwargs): + ctx.obj = CkanCommand(config) + + +ckan.add_command(server.run) +ckan.add_command(db.db) +ckan.add_command(search_index.search_index) diff --git a/ckan/cli/db.py b/ckan/cli/db.py new file mode 100644 index 00000000000..860668cfd64 --- /dev/null +++ b/ckan/cli/db.py @@ -0,0 +1,72 @@ +# encoding: utf-8 + +import logging + +import click + +from ckan.cli import error_shout + +log = logging.getLogger(__name__) + + +@click.group(name=u'db', short_help=u'Database commands') +def db(): + pass + + +@db.command(u'init', short_help=u'Initialize the database') +def initdb(): + u'''Initialising the database''' + log.info(u"Initialize the Database") + try: + import ckan.model as model + model.repo.init_db() + except Exception as e: + error_shout(e) + else: + click.secho(u'Initialising DB: SUCCESS', fg=u'green', bold=True) + + +PROMPT_MSG = u'This will delete all of your data!\nDo you want to continue?' + + +@db.command(u'clean', short_help=u'Clean the database') +@click.confirmation_option(prompt=PROMPT_MSG) +def cleandb(): + u'''Cleaning the database''' + try: + import ckan.model as model + model.repo.clean_db() + except Exception as e: + error_shout(e) + else: + click.secho(u'Cleaning DB: SUCCESS', fg=u'green', bold=True) + + +@db.command(u'upgrade', short_help=u'Upgrade the database') +@click.option(u'-v', u'--version', help=u'Migration version') +def updatedb(version=None): + u'''Upgrading the database''' + try: + import ckan.model as model + model.repo.upgrade_db(version) + except Exception as e: + error_shout(e) + else: + click.secho(u'Upgrading DB: SUCCESS', fg=u'green', bold=True) + + +@db.command(u'version', short_help=u'Returns current version of data schema') +def version(): + u'''Return current version''' + log.info(u"Returning current DB version") + try: + from ckan.model import Session + ver = Session.execute(u'select version from ' + u'migrate_version;').fetchall() + click.secho( + u"Latest data schema version: {0}".format(ver[0][0]), + bold=True + ) + except Exception as e: + error_shout(e) diff --git a/ckan/cli/search_index.py b/ckan/cli/search_index.py new file mode 100644 index 00000000000..c8e809cbd30 --- /dev/null +++ b/ckan/cli/search_index.py @@ -0,0 +1,112 @@ +# encoding: utf-8 + +import multiprocessing as mp + +import click +import sqlalchemy as sa + +from ckan.cli import error_shout + + +@click.group(name=u'search-index', short_help=u'Search index commands') +@click.help_option(u'-h', u'--help') +def search_index(): + pass + + +@search_index.command(name=u'rebuild', short_help=u'Rebuild search index') +@click.option(u'-v', u'--verbose', is_flag=True) +@click.option(u'-i', u'--force', is_flag=True, + help=u'Ignore exceptions when rebuilding the index') +@click.option(u'-r', u'--refresh', help=u'Refresh current index', is_flag=True) +@click.option(u'-o', u'--only-missing', + help=u'Index non indexed datasets only', is_flag=True) +@click.option(u'-q', u'--quiet', help=u'Do not output index rebuild progress', + is_flag=True) +@click.option(u'-e', u'--commit-each', is_flag=True, + help=u'Perform a commit after indexing each dataset. This' + u'ensures that changes are immediately available on the' + u'search, but slows significantly the process. Default' + u'is false.') +@click.pass_context +def rebuild(ctx, verbose, force, refresh, only_missing, quiet, commit_each): + u''' Rebuild search index ''' + flask_app = ctx.obj.app.apps['flask_app']._wsgi_app + from ckan.lib.search import rebuild, commit + try: + with flask_app.test_request_context(): + rebuild(only_missing=only_missing, + force=force, + refresh=refresh, + defer_commit=(not commit_each), + quiet=quiet) + except Exception as e: + error_shout(e) + if not commit_each: + commit() + + +@search_index.command(name=u'check', short_help=u'Check search index') +def check(): + from ckan.lib.search import check + check() + + +@search_index.command(name=u'show', short_help=u'Show index of a dataset') +@click.argument(u'dataset_name') +def show(dataset_name): + from ckan.lib.search import show + + index = show(dataset_name) + click.echo(index) + + +@search_index.command(name=u'clear', short_help=u'Clear the search index') +@click.argument(u'dataset_name', required=False) +def clear(dataset_name): + from ckan.lib.search import clear, clear_all + + if dataset_name: + clear(dataset_name) + else: + clear_all() + + +@search_index.command(name=u'rebuild-fast', + short_help=u'Reindex with multiprocessing') +@click.pass_context +def rebuild_fast(ctx): + conf = ctx.obj.config + flask_app = ctx.obj.app.apps['flask_app']._wsgi_app + db_url = conf['sqlalchemy.url'] + engine = sa.create_engine(db_url) + package_ids = [] + result = engine.execute(u"select id from package where state = 'active';") + for row in result: + package_ids.append(row[0]) + + def start(ids): + from ckan.lib.search import rebuild, commit + rebuild(package_ids=ids) + commit() + + def chunks(l, n): + u""" Yield n successive chunks from l.""" + newn = int(len(l) / n) + for i in range(0, n-1): + yield l[i*newn:i*newn+newn] + yield l[n*newn-newn:] + + processes = [] + with flask_app.test_request_context(): + try: + for chunk in chunks(package_ids, mp.cpu_count()): + process = mp.Process(target=start, args=(chunk,)) + processes.append(process) + process.daemon = True + process.start() + + for process in processes: + process.join() + except Exception as e: + click.echo(e.message) diff --git a/ckan/cli/server.py b/ckan/cli/server.py new file mode 100644 index 00000000000..ef38c75fa07 --- /dev/null +++ b/ckan/cli/server.py @@ -0,0 +1,19 @@ +# encoding: utf-8 + +import logging + +import click +from werkzeug.serving import run_simple + +log = logging.getLogger(__name__) + + +@click.command(u'run', short_help=u'Start development server') +@click.option(u'-H', u'--host', default=u'localhost', help=u'Set host') +@click.option(u'-p', u'--port', default=5000, help=u'Set port') +@click.option(u'-r', u'--reloader', default=True, help=u'Use reloader') +@click.pass_context +def run(ctx, host, port, reloader): + u'''Runs development server''' + log.info(u"Running server {0} on port {1}".format(host, port)) + run_simple(host, port, ctx.obj.app, use_reloader=reloader, use_evalex=True) diff --git a/ckan/config/middleware/flask_app.py b/ckan/config/middleware/flask_app.py index 6bfbde423ae..3593ff1c937 100644 --- a/ckan/config/middleware/flask_app.py +++ b/ckan/config/middleware/flask_app.py @@ -2,6 +2,7 @@ import os import re +import time import inspect import itertools import pkgutil @@ -107,6 +108,13 @@ def make_flask_stack(conf, **app_conf): app.config['DEBUG_TB_INTERCEPT_REDIRECTS'] = False DebugToolbarExtension(app) + from werkzeug.debug import DebuggedApplication + app = DebuggedApplication(app, True) + app = app.app + + log = logging.getLogger('werkzeug') + log.setLevel(logging.DEBUG) + # Use Beaker as the Flask session interface class BeakerSessionInterface(SessionInterface): def open_session(self, app, request): @@ -298,6 +306,8 @@ def ckan_before_request(): # with extensions set_controller_and_action() + g.__timer = time.time() + def ckan_after_request(response): u'''Common handler executed after all Flask requests''' @@ -311,6 +321,11 @@ def ckan_after_request(response): # Set CORS headers if necessary response = set_cors_headers_for_response(response) + r_time = time.time() - g.__timer + url = request.environ['CKAN_CURRENT_URL'].split('?')[0] + + log.info(' %s render time %.3f seconds' % (url, r_time)) + return response diff --git a/ckan/config/solr/schema.xml b/ckan/config/solr/schema.xml index 8e5018a2e2d..97559299a37 100644 --- a/ckan/config/solr/schema.xml +++ b/ckan/config/solr/schema.xml @@ -24,7 +24,7 @@ - + @@ -81,6 +81,18 @@ schema. In this case the version should be set to the next CKAN version number. + + + + + + + + + + + + @@ -89,10 +101,12 @@ schema. In this case the version should be set to the next CKAN version number. + + @@ -165,6 +179,8 @@ schema. In this case the version should be set to the next CKAN version number. + + diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index a831e075958..826a9b6d279 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -3,12 +3,8 @@ import os.path import logging import cgi -import datetime -import glob import urllib -from webob.multidict import UnicodeMultiDict -from paste.util.multidict import MultiDict from six import text_type import ckan.model as model diff --git a/ckan/lib/app_globals.py b/ckan/lib/app_globals.py index 0b7a5889be1..ce545dd288e 100644 --- a/ckan/lib/app_globals.py +++ b/ckan/lib/app_globals.py @@ -42,7 +42,7 @@ # has been setup in load_environment(): 'ckan.site_id': {}, 'ckan.recaptcha.publickey': {'name': 'recaptcha_publickey'}, - 'ckan.template_title_deliminater': {'default': '-'}, + 'ckan.template_title_delimiter': {'default': '-'}, 'ckan.template_head_end': {}, 'ckan.template_footer_end': {}, 'ckan.dumps_url': {}, diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py index 23b6f5ae524..ffb7dcb8aad 100644 --- a/ckan/lib/dictization/model_dictize.py +++ b/ckan/lib/dictization/model_dictize.py @@ -108,6 +108,7 @@ def resource_dictize(res, context): ## for_edit is only called at the times when the dataset is to be edited ## in the frontend. Without for_edit the whole qualified url is returned. if resource.get('url_type') == 'upload' and not context.get('for_edit'): + url = url.rsplit('/')[-1] cleaned_name = munge.munge_filename(url) resource['url'] = h.url_for('resource.download', id=resource['package_id'], @@ -388,11 +389,12 @@ def get_packages_for_this_group(group_, just_the_count=False): q['include_private'] = True if not just_the_count: - # Is there a packages limit in the context? + # package_search limits 'rows' anyway, so this is only if you + # want even fewer try: packages_limit = context['limits']['packages'] except KeyError: - q['rows'] = 1000 # Only the first 1000 datasets are returned + del q['rows'] # leave it to package_search to limit it else: q['rows'] = packages_limit diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py index b0abf1abcea..454159f41df 100644 --- a/ckan/lib/dictization/model_save.py +++ b/ckan/lib/dictization/model_save.py @@ -13,7 +13,9 @@ log = logging.getLogger(__name__) + def resource_dict_save(res_dict, context): + model = context["model"] session = context["session"] @@ -30,6 +32,10 @@ def resource_dict_save(res_dict, context): table = class_mapper(model.Resource).mapped_table fields = [field.name for field in table.c] + # Strip the full url for resources of type 'upload' + if res_dict.get('url') and res_dict.get('url_type') == u'upload': + res_dict['url'] = res_dict['url'].rsplit('/')[-1] + # Resource extras not submitted will be removed from the existing extras # dict new_extras = {} diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index 969374ca1b6..910b31fc827 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -48,7 +48,7 @@ import ckan.plugins as p import ckan -from ckan.common import _, ungettext, c, request, session, json +from ckan.common import _, ungettext, c, g, request, session, json from markupsafe import Markup, escape @@ -731,7 +731,7 @@ def _link_active_pylons(kwargs): def _link_active_flask(kwargs): - blueprint, endpoint = request.url_rule.endpoint.split('.') + blueprint, endpoint = p.toolkit.get_endpoint() return(kwargs.get('controller') == blueprint and kwargs.get('action') == endpoint) @@ -785,7 +785,7 @@ def nav_link(text, *args, **kwargs): def nav_link_flask(text, *args, **kwargs): if len(args) > 1: raise Exception('Too many unnamed parameters supplied') - blueprint, endpoint = request.url_rule.endpoint.split('.') + blueprint, endpoint = p.toolkit.get_endpoint() if args: kwargs['controller'] = blueprint or None kwargs['action'] = endpoint or None @@ -1154,8 +1154,10 @@ def sorted_extras(package_extras, auto_clean=False, subs=None, exclude=None): @core_helper def check_access(action, data_dict=None): + if not getattr(g, u'user', None): + g.user = '' context = {'model': model, - 'user': c.user} + 'user': g.user} if not data_dict: data_dict = {} try: @@ -1805,7 +1807,7 @@ def _create_url_with_params(params=None, controller=None, action=None, if not controller: controller = getattr(c, 'controller', False) or request.blueprint if not action: - action = getattr(c, 'action', False) or request.endpoint.split('.')[1] + action = getattr(c, 'action', False) or p.toolkit.get_endpoint()[1] if not extras: extras = {} diff --git a/ckan/lib/navl/validators.py b/ckan/lib/navl/validators.py index 172da15316d..a88146b1cb9 100644 --- a/ckan/lib/navl/validators.py +++ b/ckan/lib/navl/validators.py @@ -4,7 +4,7 @@ import ckan.lib.navl.dictization_functions as df -from ckan.common import _, json +from ckan.common import _, json, config missing = df.missing StopOnError = df.StopOnError @@ -85,6 +85,16 @@ def callable(key, data, errors, context): return callable +def configured_default(config_name, default_value_if_not_configured): + '''When key is missing or value is an empty string or None, replace it with + a default value from config, or if that isn't set from the + default_value_if_not_configured.''' + + default_value = config.get(config_name) + if default_value is None: + default_value = default_value_if_not_configured + return default(default_value) + def ignore_missing(key, data, errors, context): '''If the key is missing from the data, ignore the rest of the key's schema. @@ -163,3 +173,18 @@ def unicode_safe(value): return text_type(value) except Exception: return u'\N{REPLACEMENT CHARACTER}' + +def limit_to_configured_maximum(config_option, default_limit): + ''' + If the value is over a limit, it changes it to the limit. The limit is + defined by a configuration option, or if that is not set, a given int + default_limit. + ''' + def callable(key, data, errors, context): + + value = data.get(key) + limit = int(config.get(config_option, default_limit)) + if value > limit: + data[key] = limit + + return callable diff --git a/ckan/lib/search/__init__.py b/ckan/lib/search/__init__.py index dbc8ac12cc5..fe34325396a 100644 --- a/ckan/lib/search/__init__.py +++ b/ckan/lib/search/__init__.py @@ -31,7 +31,7 @@ def text_traceback(): return res -SUPPORTED_SCHEMA_VERSIONS = ['2.8'] +SUPPORTED_SCHEMA_VERSIONS = ['2.8', '2.9'] DEFAULT_OPTIONS = { 'limit': 20, diff --git a/ckan/lib/search/query.py b/ckan/lib/search/query.py index 599125bbe54..c469838c7df 100644 --- a/ckan/lib/search/query.py +++ b/ckan/lib/search/query.py @@ -2,17 +2,20 @@ import re import logging - -from ckan.common import config +import six import pysolr + from paste.deploy.converters import asbool -from paste.util.multidict import MultiDict -import six +from werkzeug.datastructures import MultiDict -from ckan.lib.search.common import make_connection, SearchError, SearchQueryError import ckan.logic as logic import ckan.model as model +from ckan.common import config +from ckan.lib.search.common import ( + make_connection, SearchError, SearchQueryError +) + log = logging.getLogger(__name__) _open_licenses = None @@ -211,7 +214,7 @@ def run(self, fields={}, options=None, **kwargs): options.update(kwargs) context = { - 'model':model, + 'model': model, 'session': model.Session, 'search_query': True, } @@ -219,6 +222,7 @@ def run(self, fields={}, options=None, **kwargs): # Transform fields into structure required by the resource_search # action. query = [] + for field, terms in fields.items(): if isinstance(terms, six.string_types): terms = terms.split() @@ -312,7 +316,9 @@ def run(self, query, permission_labels=None, **kwargs): query['q'] = "*:*" # number of results - rows_to_return = min(1000, int(query.get('rows', 10))) + rows_to_return = int(query.get('rows', 10)) + # query['rows'] should be a defaulted int, due to schema, but make + # certain, for legacy tests if rows_to_return > 0: # #1683 Work around problem of last result being out of order # in SOLR 1.4 diff --git a/ckan/lib/uploader.py b/ckan/lib/uploader.py index b8fdb5ea9f8..9d5fe84217f 100644 --- a/ckan/lib/uploader.py +++ b/ckan/lib/uploader.py @@ -221,7 +221,7 @@ def __init__(self, resource): upload_field_storage = resource.pop('upload', None) self.clear = resource.pop('clear_upload', None) - if config_mimetype_guess == 'file_ext': + if url and config_mimetype_guess == 'file_ext': self.mimetype = mimetypes.guess_type(url)[0] if isinstance(upload_field_storage, ALLOWED_UPLOAD_TYPES): diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 40d427ce781..302e6fa3951 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -342,6 +342,15 @@ def _group_or_org_list(context, data_dict, is_org=False): all_fields = asbool(data_dict.get('all_fields', None)) + if all_fields: + # all_fields is really computationally expensive, so need a tight limit + max_limit = config.get( + 'ckan.group_and_organization_list_all_fields_max', 25) + else: + max_limit = config.get('ckan.group_and_organization_list_max', 1000) + if limit is None or limit > max_limit: + limit = max_limit + # order_by deprecated in ckan 1.8 # if it is supplied and sort isn't use order_by and raise a warning order_by = data_dict.get('order_by', '') @@ -438,9 +447,11 @@ def group_list(context, data_dict): "name asc" string of field name and sort-order. The allowed fields are 'name', 'package_count' and 'title' :type sort: string - :param limit: if given, the list of groups will be broken into pages of - at most ``limit`` groups per page and only one page will be returned - at a time (optional) + :param limit: the maximum number of groups returned (optional) + Default: ``1000`` when all_fields=false unless set in site's + configuration ``ckan.group_and_organization_list_max`` + Default: ``25`` when all_fields=true unless set in site's + configuration ``ckan.group_and_organization_list_all_fields_max`` :type limit: int :param offset: when ``limit`` is given, the offset to start returning groups from @@ -487,9 +498,11 @@ def organization_list(context, data_dict): "name asc" string of field name and sort-order. The allowed fields are 'name', 'package_count' and 'title' :type sort: string - :param limit: if given, the list of organizations will be broken into pages - of at most ``limit`` organizations per page and only one page will be - returned at a time (optional) + :param limit: the maximum number of organizations returned (optional) + Default: ``1000`` when all_fields=false unless set in site's + configuration ``ckan.group_and_organization_list_max`` + Default: ``25`` when all_fields=true unless set in site's + configuration ``ckan.group_and_organization_list_all_fields_max`` :type limit: int :param offset: when ``limit`` is given, the offset to start returning organizations from @@ -1502,34 +1515,46 @@ def package_autocomplete(context, data_dict): :rtype: list of dictionaries ''' - model = context['model'] - _check_access('package_autocomplete', context, data_dict) + user = context.get('user') limit = data_dict.get('limit', 10) q = data_dict['q'] - like_q = u"%s%%" % q + # enforce permission filter based on user + if context.get('ignore_auth') or (user and authz.is_sysadmin(user)): + labels = None + else: + labels = lib_plugins.get_permission_labels().get_user_dataset_labels( + context['auth_user_obj'] + ) - query = model.Session.query(model.Package) - query = query.filter(model.Package.state == 'active') - query = query.filter(model.Package.private == False) - query = query.filter(_or_(model.Package.name.ilike(like_q), - model.Package.title.ilike(like_q))) - query = query.limit(limit) + data_dict = { + 'q': ' OR '.join([ + 'name_ngram:{0}', + 'title_ngram:{0}', + 'name:{0}', + 'title:{0}', + ]).format(search.query.solr_literal(q)), + 'fl': 'name,title', + 'rows': limit + } + query = search.query_for(model.Package) + + results = query.run(data_dict, permission_labels=labels)['results'] q_lower = q.lower() pkg_list = [] - for package in query: - if package.name.startswith(q_lower): + for package in results: + if q_lower in package['name']: match_field = 'name' - match_displayed = package.name + match_displayed = package['name'] else: match_field = 'title' - match_displayed = '%s (%s)' % (package.title, package.name) + match_displayed = '%s (%s)' % (package['title'], package['name']) result_dict = { - 'name': package.name, - 'title': package.title, + 'name': package['name'], + 'title': package['title'], 'match_field': match_field, 'match_displayed': match_displayed} pkg_list.append(result_dict) @@ -1696,8 +1721,9 @@ def package_search(context, data_dict): documentation, this is a comma-separated string of field names and sort-orderings. :type sort: string - :param rows: the number of matching rows to return. There is a hard limit - of 1000 datasets per query. + :param rows: the maximum number of matching rows (datasets) to return. + (optional, default: ``10``, upper limit: ``1000`` unless set in + site's configuration ``ckan.search.rows_max``) :type rows: int :param start: the offset in the complete result for where the set of returned datasets should begin. @@ -2463,8 +2489,9 @@ def user_activity_list(context, data_dict): (optional, default: ``0``) :type offset: int :param limit: the maximum number of activities to return - (optional, default: ``31``, the default value is configurable via the - ckan.activity_list_limit setting) + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) :type limit: int :rtype: list of dictionaries @@ -2482,8 +2509,7 @@ def user_activity_list(context, data_dict): raise logic.NotFound offset = data_dict.get('offset', 0) - limit = int( - data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) + limit = data_dict['limit'] # defaulted, limited & made an int by schema _activity_objects = model.activity.user_activity_list(user.id, limit=limit, offset=offset) @@ -2505,8 +2531,9 @@ def package_activity_list(context, data_dict): (optional, default: ``0``) :type offset: int :param limit: the maximum number of activities to return - (optional, default: ``31``, the default value is configurable via the - ckan.activity_list_limit setting) + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) :type limit: int :rtype: list of dictionaries @@ -2524,8 +2551,7 @@ def package_activity_list(context, data_dict): raise logic.NotFound offset = int(data_dict.get('offset', 0)) - limit = int( - data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) + limit = data_dict['limit'] # defaulted, limited & made an int by schema _activity_objects = model.activity.package_activity_list(package.id, limit=limit, offset=offset) @@ -2547,8 +2573,9 @@ def group_activity_list(context, data_dict): (optional, default: ``0``) :type offset: int :param limit: the maximum number of activities to return - (optional, default: ``31``, the default value is configurable via the - ckan.activity_list_limit setting) + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) :type limit: int :rtype: list of dictionaries @@ -2561,8 +2588,7 @@ def group_activity_list(context, data_dict): model = context['model'] group_id = data_dict.get('id') offset = data_dict.get('offset', 0) - limit = int( - data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) + limit = data_dict['limit'] # defaulted, limited & made an int by schema # Convert group_id (could be id or name) into id. group_show = logic.get_action('group_show') @@ -2582,6 +2608,14 @@ def organization_activity_list(context, data_dict): :param id: the id or name of the organization :type id: string + :param offset: where to start getting activity items from + (optional, default: ``0``) + :type offset: int + :param limit: the maximum number of activities to return + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) + :type limit: int :rtype: list of dictionaries @@ -2593,8 +2627,7 @@ def organization_activity_list(context, data_dict): model = context['model'] org_id = data_dict.get('id') offset = data_dict.get('offset', 0) - limit = int( - data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) + limit = data_dict['limit'] # defaulted, limited & made an int by schema # Convert org_id (could be id or name) into id. org_show = logic.get_action('organization_show') @@ -2608,7 +2641,7 @@ def organization_activity_list(context, data_dict): return model_dictize.activity_list_dictize(activity_objects, context) -@logic.validate(logic.schema.default_pagination_schema) +@logic.validate(logic.schema.default_dashboard_activity_list_schema) def recently_changed_packages_activity_list(context, data_dict): '''Return the activity stream of all recently added or changed packages. @@ -2616,8 +2649,9 @@ def recently_changed_packages_activity_list(context, data_dict): (optional, default: ``0``) :type offset: int :param limit: the maximum number of activities to return - (optional, default: ``31``, the default value is configurable via the - ckan.activity_list_limit setting) + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) :type limit: int :rtype: list of dictionaries @@ -2627,8 +2661,7 @@ def recently_changed_packages_activity_list(context, data_dict): # authorized to read. model = context['model'] offset = data_dict.get('offset', 0) - limit = int( - data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) + limit = data_dict['limit'] # defaulted, limited & made an int by schema _activity_objects = model.activity.recently_changed_packages_activity_list( limit=limit, offset=offset) @@ -2667,8 +2700,9 @@ def user_activity_list_html(context, data_dict): (optional, default: ``0``) :type offset: int :param limit: the maximum number of activities to return - (optional, default: ``31``, the default value is configurable via the - ckan.activity_list_limit setting) + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) :type limit: int :rtype: string @@ -2698,8 +2732,9 @@ def package_activity_list_html(context, data_dict): (optional, default: ``0``) :type offset: int :param limit: the maximum number of activities to return - (optional, default: ``31``, the default value is configurable via the - ckan.activity_list_limit setting) + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) :type limit: int :rtype: string @@ -2729,8 +2764,9 @@ def group_activity_list_html(context, data_dict): (optional, default: ``0``) :type offset: int :param limit: the maximum number of activities to return - (optional, default: ``31``, the default value is configurable via the - ckan.activity_list_limit setting) + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) :type limit: int :rtype: string @@ -2756,6 +2792,14 @@ def organization_activity_list_html(context, data_dict): :param id: the id or name of the organization :type id: string + :param offset: where to start getting activity items from + (optional, default: ``0``) + :type offset: int + :param limit: the maximum number of activities to return + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) + :type limit: int :rtype: string @@ -2784,8 +2828,9 @@ def recently_changed_packages_activity_list_html(context, data_dict): (optional, default: ``0``) :type offset: int :param limit: the maximum number of activities to return - (optional, default: ``31``, the default value is configurable via the - ckan.activity_list_limit setting) + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) :type limit: int :rtype: string @@ -3276,7 +3321,7 @@ def _group_or_org_followee_list(context, data_dict, is_org=False): return [model_dictize.group_dictize(group, context) for group in groups] -@logic.validate(logic.schema.default_pagination_schema) +@logic.validate(logic.schema.default_dashboard_activity_list_schema) def dashboard_activity_list(context, data_dict): '''Return the authorized (via login or API key) user's dashboard activity stream. @@ -3292,8 +3337,9 @@ def dashboard_activity_list(context, data_dict): (optional, default: ``0``) :type offset: int :param limit: the maximum number of activities to return - (optional, default: ``31``, the default value is configurable via the - :ref:`ckan.activity_list_limit` setting) + (optional, default: ``31`` unless set in site's configuration + ``ckan.activity_list_limit``, upper limit: ``100`` unless set in + site's configuration ``ckan.activity_list_limit_max``) :type limit: int :rtype: list of activity dictionaries @@ -3304,8 +3350,7 @@ def dashboard_activity_list(context, data_dict): model = context['model'] user_id = model.User.get(context['user']).id offset = data_dict.get('offset', 0) - limit = int( - data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) + limit = data_dict['limit'] # defaulted, limited & made an int by schema # FIXME: Filter out activities whose subject or object the user is not # authorized to read. @@ -3332,7 +3377,7 @@ def dashboard_activity_list(context, data_dict): return activity_dicts -@logic.validate(ckan.logic.schema.default_pagination_schema) +@logic.validate(ckan.logic.schema.default_dashboard_activity_list_schema) def dashboard_activity_list_html(context, data_dict): '''Return the authorized (via login or API key) user's dashboard activity stream as HTML. diff --git a/ckan/logic/action/patch.py b/ckan/logic/action/patch.py index db377f81ad6..7e1fecb892f 100644 --- a/ckan/logic/action/patch.py +++ b/ckan/logic/action/patch.py @@ -19,7 +19,14 @@ def package_patch(context, data_dict): The difference between the update and patch methods is that the patch will perform an update of the provided parameters, while leaving all other parameters unchanged, whereas the update methods deletes all parameters - not explicitly provided in the data_dict + not explicitly provided in the data_dict. + + You are able to partially update and/or create resources with + package_patch. If you are updating existing resources be sure to provide + the resource id. Existing resources excluded from the package_patch + data_dict will be removed. Resources in the package data_dict without + an id will be treated as new resources and will be added. New resources + added with the patch method do not create the default views. You must be authorized to edit the dataset and the groups that it belongs to. diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index 7e6fdd0d203..bcbec5f994e 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -525,8 +525,12 @@ def _group_or_org_update(context, data_dict, is_org=False): else: rev.message = _(u'REST API: Update object %s') % data.get("name") - group = model_save.group_dict_save(data, context, - prevent_packages_update=is_org) + contains_packages = 'packages' in data_dict + + group = model_save.group_dict_save( + data, context, + prevent_packages_update=is_org or not contains_packages + ) if is_org: plugin_type = plugins.IOrganizationController diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index d01836131c2..7aa77fdd21d 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -601,16 +601,27 @@ def default_pagination_schema(ignore_missing, natural_number_validator): @validator_args -def default_dashboard_activity_list_schema(unicode_safe): +def default_dashboard_activity_list_schema( + configured_default, natural_number_validator, + limit_to_configured_maximum): schema = default_pagination_schema() - schema['id'] = [unicode_safe] + schema['limit'] = [ + configured_default('ckan.activity_list_limit', 31), + natural_number_validator, + limit_to_configured_maximum('ckan.activity_list_limit_max', 100)] return schema @validator_args -def default_activity_list_schema(not_missing, unicode_safe): +def default_activity_list_schema( + not_missing, unicode_safe, configured_default, + natural_number_validator, limit_to_configured_maximum): schema = default_pagination_schema() schema['id'] = [not_missing, unicode_safe] + schema['limit'] = [ + configured_default('ckan.activity_list_limit', 31), + natural_number_validator, + limit_to_configured_maximum('ckan.activity_list_limit_max', 100)] return schema @@ -627,12 +638,13 @@ def default_autocomplete_schema( def default_package_search_schema( ignore_missing, unicode_safe, list_of_strings, natural_number_validator, int_validator, convert_to_json_if_string, - convert_to_list_if_string): + convert_to_list_if_string, limit_to_configured_maximum, default): return { 'q': [ignore_missing, unicode_safe], 'fl': [ignore_missing, convert_to_list_if_string], 'fq': [ignore_missing, unicode_safe], - 'rows': [ignore_missing, natural_number_validator], + 'rows': [default(10), natural_number_validator, + limit_to_configured_maximum('ckan.search.rows_max', 1000)], 'sort': [ignore_missing, unicode_safe], 'start': [ignore_missing, natural_number_validator], 'qf': [ignore_missing, unicode_safe], diff --git a/ckan/model/group.py b/ckan/model/group.py index 18fe6a16cdf..469d0669329 100644 --- a/ckan/model/group.py +++ b/ckan/model/group.py @@ -116,13 +116,15 @@ class Group(vdm.sqlalchemy.RevisionedObjectMixin, domain_object.DomainObject): def __init__(self, name=u'', title=u'', description=u'', image_url=u'', - type=u'group', approval_status=u'approved'): + type=u'group', approval_status=u'approved', + is_organization=False): self.name = name self.title = title self.description = description self.image_url = image_url self.type = type self.approval_status = approval_status + self.is_organization = is_organization @property def display_name(self): diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index 5ff9e01e889..6fc92435aec 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -1291,7 +1291,7 @@ def validate(self, context, data_dict, schema, action): This is an adavanced interface. Most changes to validation should be accomplished by customizing the schemas returned from ``show_package_schema()``, ``create_package_schema()`` - and ``update_package_schama()``. If you need to have a different + and ``update_package_schema()``. If you need to have a different schema depending on the user or value of any field stored in the dataset, or if you wish to use a different method for validation, then this method may be used. @@ -1301,7 +1301,7 @@ def validate(self, context, data_dict, schema, action): :param data_dict: the dataset to be validated :type data_dict: dictionary :param schema: a schema, typically from ``show_package_schema()``, - ``create_package_schema()`` or ``update_package_schama()`` + ``create_package_schema()`` or ``update_package_schema()`` :type schema: dictionary :param action: ``'package_show'``, ``'package_create'`` or ``'package_update'`` @@ -1464,7 +1464,7 @@ def validate(self, context, data_dict, schema, action): :param data_dict: the group to be validated :type data_dict: dictionary :param schema: a schema, typically from ``form_to_db_schema()``, - or ``db_to_form_schama()`` + or ``db_to_form_schema()`` :type schema: dictionary :param action: ``'group_show'``, ``'group_create'``, ``'group_update'``, ``'organization_show'``, diff --git a/ckan/templates-bs2/base.html b/ckan/templates-bs2/base.html index 2d13f4eb520..966fea2fa9e 100644 --- a/ckan/templates-bs2/base.html +++ b/ckan/templates-bs2/base.html @@ -42,7 +42,7 @@ {%- block title -%} {%- block subtitle %}{% endblock -%} - {%- if self.subtitle()|trim %} {{ g.template_title_deliminater }} {% endif -%} + {%- if self.subtitle()|trim %} {{ g.template_title_delimiter }} {% endif -%} {{ g.site_title }} {%- endblock -%} diff --git a/ckan/templates-bs2/dataviewer/base.html b/ckan/templates-bs2/dataviewer/base.html index 49ee2c1636f..1958a44ac64 100644 --- a/ckan/templates-bs2/dataviewer/base.html +++ b/ckan/templates-bs2/dataviewer/base.html @@ -1,7 +1,7 @@ {% extends "base.html" %} -{% block subtitle %}{{ h.dataset_display_name(package) }} - {{h.resource_display_name(resource) }}{% endblock %} +{% block subtitle %}{{ h.dataset_display_name(package) }} {{ g.template_title_delimiter }} {{h.resource_display_name(resource) }}{% endblock %} {# remove any scripts #} {% block scripts %} diff --git a/ckan/templates-bs2/group/about.html b/ckan/templates-bs2/group/about.html index 413027c2c75..905a922a24f 100644 --- a/ckan/templates-bs2/group/about.html +++ b/ckan/templates-bs2/group/about.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('About') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('About') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %}

diff --git a/ckan/templates-bs2/group/activity_stream.html b/ckan/templates-bs2/group/activity_stream.html index 3977256a716..c7c42dfc902 100644 --- a/ckan/templates-bs2/group/activity_stream.html +++ b/ckan/templates-bs2/group/activity_stream.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %}

{% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

diff --git a/ckan/templates-bs2/group/admins.html b/ckan/templates-bs2/group/admins.html index ba6d8704d40..ee98a8a5f55 100644 --- a/ckan/templates-bs2/group/admins.html +++ b/ckan/templates-bs2/group/admins.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('Administrators') }} - {{ group_dict.title or group_dict.name }}{% endblock %} +{% block subtitle %}{{ _('Administrators') }} {{ g.template_title_delimiter }} {{ group_dict.title or group_dict.name }}{% endblock %} {% block primary_content_inner %}

{% block page_heading %}{{ _('Administrators') }}{% endblock %}

diff --git a/ckan/templates-bs2/group/followers.html b/ckan/templates-bs2/group/followers.html index d5db5683e8b..c51255c2550 100644 --- a/ckan/templates-bs2/group/followers.html +++ b/ckan/templates-bs2/group/followers.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('Followers') }} - {{ group_dict.title or group_dict.name }}{% endblock %} +{% block subtitle %}{{ _('Followers') }} {{ g.template_title_delimiter }} {{ group_dict.title or group_dict.name }}{% endblock %} {% block primary_content_inner %}

{% block page_heading %}{{ _('Followers') }}{% endblock %}

diff --git a/ckan/templates-bs2/group/history.html b/ckan/templates-bs2/group/history.html index dd59b7cba82..3481770ef85 100644 --- a/ckan/templates-bs2/group/history.html +++ b/ckan/templates-bs2/group/history.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('History') }} - {{ group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ _('History') }} {{ g.template_title_delimiter }} {{ group_dict.display_name }}{% endblock %} {% block primary_content_inner %}

{{ _('History') }}

diff --git a/ckan/templates-bs2/group/members.html b/ckan/templates-bs2/group/members.html index 54dab5a818b..12bc60487aa 100644 --- a/ckan/templates-bs2/group/members.html +++ b/ckan/templates-bs2/group/members.html @@ -1,6 +1,6 @@ {% extends "group/edit_base.html" %} -{% block subtitle %}{{ _('Members') }} - {{ group_dict.display_name }} - {{ _('Groups') }}{% endblock %} +{% block subtitle %}{{ _('Members') }} {{ g.template_title_delimiter }} {{ group_dict.display_name }} {{ g.template_title_delimiter }} {{ _('Groups') }}{% endblock %} {% block page_primary_action %} {% link_for _('Add Member'), named_route=group_type+'.member_new', id=group_dict.id, class_='btn btn-primary', icon='plus-square' %} diff --git a/ckan/templates-bs2/group/read_base.html b/ckan/templates-bs2/group/read_base.html index e0c98de02f2..368b7b0d291 100644 --- a/ckan/templates-bs2/group/read_base.html +++ b/ckan/templates-bs2/group/read_base.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ h.get_translated(group_dict, 'title') or group_dict.display_name }} - {{ _('Groups') }}{% endblock %} +{% block subtitle %}{{ h.get_translated(group_dict, 'title') or group_dict.display_name }} {{ g.template_title_delimiter }} {{ _('Groups') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Groups'), named_route=group_type+'.index' %}
  • diff --git a/ckan/templates-bs2/group/snippets/group_item.html b/ckan/templates-bs2/group/snippets/group_item.html index 654ea809d63..cc6021201ce 100644 --- a/ckan/templates-bs2/group/snippets/group_item.html +++ b/ckan/templates-bs2/group/snippets/group_item.html @@ -12,7 +12,7 @@ #} {% set type = group.type or 'group' %} -{% set url = h.url_for(type ~ '_read', action='read', id=group.name) %} +{% set url = h.url_for(type ~ '.read', id=group.name) %} {% block item %}
  • {% block item_inner %} diff --git a/ckan/templates-bs2/organization/about.html b/ckan/templates-bs2/organization/about.html index ed0e7b718ae..f3c4089328c 100644 --- a/ckan/templates-bs2/organization/about.html +++ b/ckan/templates-bs2/organization/about.html @@ -1,6 +1,6 @@ {% extends "organization/read_base.html" %} -{% block subtitle %}{{ _('About') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('About') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ c.group_dict.display_name }}{% endblock %}

    diff --git a/ckan/templates-bs2/organization/activity_stream.html b/ckan/templates-bs2/organization/activity_stream.html index ffa029d951b..f502cc51669 100644 --- a/ckan/templates-bs2/organization/activity_stream.html +++ b/ckan/templates-bs2/organization/activity_stream.html @@ -1,6 +1,6 @@ {% extends "organization/read_base.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

    diff --git a/ckan/templates-bs2/organization/admins.html b/ckan/templates-bs2/organization/admins.html index 034fdb68ddb..e66219d9df7 100644 --- a/ckan/templates-bs2/organization/admins.html +++ b/ckan/templates-bs2/organization/admins.html @@ -1,6 +1,6 @@ {% extends "organization/read_base.html" %} -{% block subtitle %}{{ _('Administrators') }} - {{ c.group_dict.title or c.group_dict.name }}{% endblock %} +{% block subtitle %}{{ _('Administrators') }} {{ g.template_title_delimiter }} {{ c.group_dict.title or c.group_dict.name }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Administrators') }}{% endblock %}

    diff --git a/ckan/templates-bs2/organization/bulk_process.html b/ckan/templates-bs2/organization/bulk_process.html index 0dce6acfcd2..89849f11e2e 100644 --- a/ckan/templates-bs2/organization/bulk_process.html +++ b/ckan/templates-bs2/organization/bulk_process.html @@ -1,6 +1,6 @@ {% extends "organization/edit_base.html" %} -{% block subtitle %}{{ _('Edit datasets') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Edit datasets') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block page_primary_action %} {% link_for _('Add dataset'), named_route='dataset.new', group=c.group_dict.id, class_='btn btn-primary', icon='plus-square' %} diff --git a/ckan/templates-bs2/organization/edit.html b/ckan/templates-bs2/organization/edit.html index 3fde80a2ab0..a6ca1f261fd 100644 --- a/ckan/templates-bs2/organization/edit.html +++ b/ckan/templates-bs2/organization/edit.html @@ -1,6 +1,6 @@ {% extends "organization/base_form_page.html" %} -{% block subtitle %}{{ _('Edit') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Edit') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block page_heading_class %}hide-heading{% endblock %} {% block page_heading %}{{ _('Edit Organization') }}{% endblock %} diff --git a/ckan/templates-bs2/organization/edit_base.html b/ckan/templates-bs2/organization/edit_base.html index d76741ccc4f..2ee3fcfc1f1 100644 --- a/ckan/templates-bs2/organization/edit_base.html +++ b/ckan/templates-bs2/organization/edit_base.html @@ -2,7 +2,7 @@ {% set organization = c.group_dict %} -{% block subtitle %}{{ c.group_dict.display_name }} - {{ _('Organizations') }}{% endblock %} +{% block subtitle %}{{ c.group_dict.display_name }} {{ g.template_title_delimiter }} {{ _('Organizations') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Organizations'), named_route=group_type+'.index' %}
  • diff --git a/ckan/templates-bs2/organization/member_new.html b/ckan/templates-bs2/organization/member_new.html index aacef9238f3..015d55858c2 100644 --- a/ckan/templates-bs2/organization/member_new.html +++ b/ckan/templates-bs2/organization/member_new.html @@ -4,7 +4,7 @@ {% set user = c.user_dict %} -{% block subtitle %}{{ _('Edit Member') if user else _('Add Member') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Edit Member') if user else _('Add Member') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %} {% link_for _('Back to all members'), named_route=group_type+'.members', id=organization.name, class_='btn pull-right', icon='arrow-left' %} diff --git a/ckan/templates-bs2/organization/members.html b/ckan/templates-bs2/organization/members.html index e3b262b1423..2bec0c172e5 100644 --- a/ckan/templates-bs2/organization/members.html +++ b/ckan/templates-bs2/organization/members.html @@ -1,6 +1,6 @@ {% extends "organization/edit_base.html" %} -{% block subtitle %}{{ _('Members') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Members') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block page_primary_action %} {% if h.check_access('organization_update', {'id': organization.id}) %} diff --git a/ckan/templates-bs2/organization/read_base.html b/ckan/templates-bs2/organization/read_base.html index 1fd76976fe4..be0f50eaaf7 100644 --- a/ckan/templates-bs2/organization/read_base.html +++ b/ckan/templates-bs2/organization/read_base.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ c.group_dict.display_name }} - {{ _('Organizations') }}{% endblock %} +{% block subtitle %}{{ c.group_dict.display_name }} {{ g.template_title_delimiter }} {{ _('Organizations') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Organizations'), named_route=group_type+'.index' %}
  • diff --git a/ckan/templates-bs2/package/activity.html b/ckan/templates-bs2/package/activity.html index fa2f6b08c57..8767a887e16 100644 --- a/ckan/templates-bs2/package/activity.html +++ b/ckan/templates-bs2/package/activity.html @@ -1,6 +1,6 @@ {% extends "package/read_base.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

    diff --git a/ckan/templates-bs2/package/edit_view.html b/ckan/templates-bs2/package/edit_view.html index 682bad1047c..7cbfa72146b 100644 --- a/ckan/templates-bs2/package/edit_view.html +++ b/ckan/templates-bs2/package/edit_view.html @@ -1,6 +1,6 @@ {% extends "package/view_edit_base.html" %} -{% block subtitle %}{{ _('Edit view') }} - {{ h.resource_display_name(resource) }}{% endblock %} +{% block subtitle %}{{ _('Edit view') }} {{ g.template_title_delimiter }} {{ h.resource_display_name(resource) }}{% endblock %} {% block form_title %}{{ _('Edit view') }}{% endblock %} {% block breadcrumb_content %} diff --git a/ckan/templates-bs2/package/followers.html b/ckan/templates-bs2/package/followers.html index bce4280da0e..5e6cfbbc5de 100644 --- a/ckan/templates-bs2/package/followers.html +++ b/ckan/templates-bs2/package/followers.html @@ -1,6 +1,6 @@ {% extends "package/read_base.html" %} -{% block subtitle %}{{ _('Followers') }} - {{ h.dataset_display_name(pkg_dict) }}{% endblock %} +{% block subtitle %}{{ _('Followers') }} {{ g.template_title_delimiter }} {{ h.dataset_display_name(pkg_dict) }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Followers') }}{% endblock %}

    diff --git a/ckan/templates-bs2/package/history.html b/ckan/templates-bs2/package/history.html index 49fbf36927c..a4e81d4821b 100644 --- a/ckan/templates-bs2/package/history.html +++ b/ckan/templates-bs2/package/history.html @@ -1,6 +1,6 @@ {% extends "package/read_base.html" %} -{% block subtitle %}{{ _('History') }} - {{ h.dataset_display_name(c.pkg_dict) }}{% endblock %} +{% block subtitle %}{{ _('History') }} {{ g.template_title_delimiter }} {{ h.dataset_display_name(c.pkg_dict) }}{% endblock %} {% block primary_content_inner %}

    {{ _('History') }}

    diff --git a/ckan/templates-bs2/package/new_resource_not_draft.html b/ckan/templates-bs2/package/new_resource_not_draft.html index 06cdbab3679..e2ef4cb2e24 100644 --- a/ckan/templates-bs2/package/new_resource_not_draft.html +++ b/ckan/templates-bs2/package/new_resource_not_draft.html @@ -1,6 +1,6 @@ {% extends "package/resource_edit_base.html" %} -{% block subtitle %}{{ _('Add resource') }} - {{ h.dataset_display_name(pkg) }}{% endblock %} +{% block subtitle %}{{ _('Add resource') }} {{ g.template_title_delimiter }} {{ h.dataset_display_name(pkg) }}{% endblock %} {% block form_title %}{{ _('Add resource') }}{% endblock %} {% block breadcrumb_content %} diff --git a/ckan/templates-bs2/package/new_view.html b/ckan/templates-bs2/package/new_view.html index c7782310ac4..87eca869f2f 100644 --- a/ckan/templates-bs2/package/new_view.html +++ b/ckan/templates-bs2/package/new_view.html @@ -1,6 +1,6 @@ {% extends "package/view_edit_base.html" %} -{% block subtitle %}{{ _('Add view') }} - {{ h.resource_display_name(resource) }}{% endblock %} +{% block subtitle %}{{ _('Add view') }} {{ g.template_title_delimiter }} {{ h.resource_display_name(resource) }}{% endblock %} {% block form_title %}{{ _('Add view') }}{% endblock %} {% block breadcrumb_content %} diff --git a/ckan/templates-bs2/package/read_base.html b/ckan/templates-bs2/package/read_base.html index b18322707b0..3d6b466dc36 100644 --- a/ckan/templates-bs2/package/read_base.html +++ b/ckan/templates-bs2/package/read_base.html @@ -1,6 +1,6 @@ {% extends "package/base.html" %} -{% block subtitle %}{{ h.dataset_display_name(pkg) }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ h.dataset_display_name(pkg) }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block head_extras -%} {{ super() }} diff --git a/ckan/templates-bs2/package/resource_edit.html b/ckan/templates-bs2/package/resource_edit.html index 5d0a65c30aa..6acb7929467 100644 --- a/ckan/templates-bs2/package/resource_edit.html +++ b/ckan/templates-bs2/package/resource_edit.html @@ -1,6 +1,6 @@ {% extends "package/resource_edit_base.html" %} -{% block subtitle %}{{ _('Edit') }} - {{ h.resource_display_name(res) }} - {{ h.dataset_display_name(pkg) }}{% endblock %} +{% block subtitle %}{{ _('Edit') }} {{ g.template_title_delimiter }} {{ h.resource_display_name(res) }} {{ g.template_title_delimiter }} {{ h.dataset_display_name(pkg) }}{% endblock %} {% block form %} {% snippet 'package/snippets/resource_edit_form.html', diff --git a/ckan/templates-bs2/package/resource_read.html b/ckan/templates-bs2/package/resource_read.html index c57cda08e48..3ab83c4cafb 100644 --- a/ckan/templates-bs2/package/resource_read.html +++ b/ckan/templates-bs2/package/resource_read.html @@ -9,7 +9,7 @@ {% endblock -%} -{% block subtitle %}{{ h.dataset_display_name(package) }} - {{ h.resource_display_name(res) }}{% endblock %} +{% block subtitle %}{{ h.dataset_display_name(package) }} {{ g.template_title_delimiter }} {{ h.resource_display_name(res) }}{% endblock %} {% block breadcrumb_content_selected %}{% endblock %} diff --git a/ckan/templates-bs2/package/resource_views.html b/ckan/templates-bs2/package/resource_views.html index f167d92a2c9..5679c07f9aa 100644 --- a/ckan/templates-bs2/package/resource_views.html +++ b/ckan/templates-bs2/package/resource_views.html @@ -1,7 +1,7 @@ {% import 'macros/form.html' as form %} {% extends "package/resource_edit_base.html" %} -{% block subtitle %}{{ _('View') }} - {{ h.resource_display_name(res) }}{% endblock %} +{% block subtitle %}{{ _('View') }} {{ g.template_title_delimiter }} {{ h.resource_display_name(res) }}{% endblock %} {% block page_primary_action %}
    diff --git a/ckan/templates-bs2/package/resources.html b/ckan/templates-bs2/package/resources.html index 991b16db48b..8de6b1a2ec0 100644 --- a/ckan/templates-bs2/package/resources.html +++ b/ckan/templates-bs2/package/resources.html @@ -2,7 +2,7 @@ {% set has_reorder = pkg_dict and pkg_dict.resources and pkg_dict.resources|length > 0 %} -{% block subtitle %}{{ _('Resources') }} - {{ h.dataset_display_name(pkg) }}{% endblock %} +{% block subtitle %}{{ _('Resources') }} {{ g.template_title_delimiter }} {{ h.dataset_display_name(pkg) }}{% endblock %} {% block page_primary_action %} {% link_for _('Add new resource'), named_route='resource.new', id=pkg_dict.name, class_='btn btn-primary', icon='plus' %} diff --git a/ckan/templates-bs2/snippets/organization.html b/ckan/templates-bs2/snippets/organization.html index 765c8aea77c..43e18a8da58 100644 --- a/ckan/templates-bs2/snippets/organization.html +++ b/ckan/templates-bs2/snippets/organization.html @@ -15,7 +15,7 @@ #} {% set truncate = truncate or 0 %} -{% set url = h.url_for(organization.type + '_read', id=organization.name, ) %} +{% set url = h.url_for(organization.type + '.read', id=organization.name, ) %} {% block info %}
    diff --git a/ckan/templates-bs2/user/activity_stream.html b/ckan/templates-bs2/user/activity_stream.html index 729c7a430c8..12fe507c417 100644 --- a/ckan/templates-bs2/user/activity_stream.html +++ b/ckan/templates-bs2/user/activity_stream.html @@ -1,6 +1,6 @@ {% extends "user/read.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

    diff --git a/ckan/templates-bs2/user/edit_base.html b/ckan/templates-bs2/user/edit_base.html index 56128037010..76c6f45f165 100644 --- a/ckan/templates-bs2/user/edit_base.html +++ b/ckan/templates-bs2/user/edit_base.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ _('Manage') }} - {{ user_dict.display_name }} - {{ _('Users') }}{% endblock %} +{% block subtitle %}{{ _('Manage') }} {{ g.template_title_delimiter }} {{ user_dict.display_name }} {{ g.template_title_delimiter }} {{ _('Users') }}{% endblock %} {% block primary_content %}
    diff --git a/ckan/templates-bs2/user/read_base.html b/ckan/templates-bs2/user/read_base.html index 4d88037696a..1eb10247705 100644 --- a/ckan/templates-bs2/user/read_base.html +++ b/ckan/templates-bs2/user/read_base.html @@ -2,7 +2,7 @@ {% set user = user_dict %} -{% block subtitle %}{{ user.display_name }} - {{ _('Users') }}{% endblock %} +{% block subtitle %}{{ user.display_name }} {{ g.template_title_delimiter }} {{ _('Users') }}{% endblock %} {% block breadcrumb_content %} {{ h.build_nav('user.index', _('Users')) }} diff --git a/ckan/templates/base.html b/ckan/templates/base.html index db8104205ee..58e36311803 100644 --- a/ckan/templates/base.html +++ b/ckan/templates/base.html @@ -40,7 +40,7 @@ {%- block title -%} {%- block subtitle %}{% endblock -%} - {%- if self.subtitle()|trim %} {{ g.template_title_deliminater }} {% endif -%} + {%- if self.subtitle()|trim %} {{ g.template_title_delimiter }} {% endif -%} {{ g.site_title }} {%- endblock -%} diff --git a/ckan/templates/dataviewer/base.html b/ckan/templates/dataviewer/base.html index 8130d76a49b..9f3d7147776 100644 --- a/ckan/templates/dataviewer/base.html +++ b/ckan/templates/dataviewer/base.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block subtitle %}{{ h.dataset_display_name(package) }} - {{h.resource_display_name(resource) }}{% endblock %} +{% block subtitle %}{{ h.dataset_display_name(package) }} {{ g.template_title_delimiter }} {{h.resource_display_name(resource) }}{% endblock %} {# remove any scripts #} {% block scripts %} diff --git a/ckan/templates/group/about.html b/ckan/templates/group/about.html index d2f205b6cf2..07461cf7d19 100644 --- a/ckan/templates/group/about.html +++ b/ckan/templates/group/about.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('About') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('About') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ group_dict.display_name }}{% endblock %}

    diff --git a/ckan/templates/group/activity_stream.html b/ckan/templates/group/activity_stream.html index 3977256a716..c7c42dfc902 100644 --- a/ckan/templates/group/activity_stream.html +++ b/ckan/templates/group/activity_stream.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

    diff --git a/ckan/templates/group/admins.html b/ckan/templates/group/admins.html index ba6d8704d40..ee98a8a5f55 100644 --- a/ckan/templates/group/admins.html +++ b/ckan/templates/group/admins.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('Administrators') }} - {{ group_dict.title or group_dict.name }}{% endblock %} +{% block subtitle %}{{ _('Administrators') }} {{ g.template_title_delimiter }} {{ group_dict.title or group_dict.name }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Administrators') }}{% endblock %}

    diff --git a/ckan/templates/group/edit_base.html b/ckan/templates/group/edit_base.html index 2f965322be8..2c2e5ed3e75 100644 --- a/ckan/templates/group/edit_base.html +++ b/ckan/templates/group/edit_base.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ _('Manage') }} - {{ group_dict.display_name }} - {{ _('Groups') }}{% endblock %} +{% block subtitle %}{{ _('Manage') }} {{ g.template_title_delimiter }} {{ group_dict.display_name }} {{ g.template_title_delimiter }} {{ _('Groups') }}{% endblock %} {% set group = group_dict %} diff --git a/ckan/templates/group/followers.html b/ckan/templates/group/followers.html index d5db5683e8b..c51255c2550 100644 --- a/ckan/templates/group/followers.html +++ b/ckan/templates/group/followers.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('Followers') }} - {{ group_dict.title or group_dict.name }}{% endblock %} +{% block subtitle %}{{ _('Followers') }} {{ g.template_title_delimiter }} {{ group_dict.title or group_dict.name }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Followers') }}{% endblock %}

    diff --git a/ckan/templates/group/history.html b/ckan/templates/group/history.html index dd59b7cba82..3481770ef85 100644 --- a/ckan/templates/group/history.html +++ b/ckan/templates/group/history.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('History') }} - {{ group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ _('History') }} {{ g.template_title_delimiter }} {{ group_dict.display_name }}{% endblock %} {% block primary_content_inner %}

    {{ _('History') }}

    diff --git a/ckan/templates/group/members.html b/ckan/templates/group/members.html index 587fda19e0f..011c57291b1 100644 --- a/ckan/templates/group/members.html +++ b/ckan/templates/group/members.html @@ -1,6 +1,6 @@ {% extends "group/edit_base.html" %} -{% block subtitle %}{{ _('Members') }} - {{ group_dict.display_name }} - {{ _('Groups') }}{% endblock %} +{% block subtitle %}{{ _('Members') }} {{ g.template_title_delimiter }} {{ group_dict.display_name }} {{ g.template_title_delimiter }} {{ _('Groups') }}{% endblock %} {% block page_primary_action %} {% link_for _('Add Member'), named_route=group_type+'.member_new', id=group_dict.id, class_='btn btn-primary', icon='plus-square' %} diff --git a/ckan/templates/group/read_base.html b/ckan/templates/group/read_base.html index 22e811fba73..f98b135175b 100644 --- a/ckan/templates/group/read_base.html +++ b/ckan/templates/group/read_base.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ group_dict.display_name }} - {{ _('Groups') }}{% endblock %} +{% block subtitle %}{{ group_dict.display_name }} {{ g.template_title_delimiter }} {{ _('Groups') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Groups'), named_route=group_type+'.index' %}
  • diff --git a/ckan/templates/group/snippets/group_item.html b/ckan/templates/group/snippets/group_item.html index 8cc8be9290d..e37df05b171 100644 --- a/ckan/templates/group/snippets/group_item.html +++ b/ckan/templates/group/snippets/group_item.html @@ -12,7 +12,7 @@ #} {% set type = group.type or 'group' %} -{% set url = h.url_for(type ~ '_read', action='read', id=group.name) %} +{% set url = h.url_for(type ~ '.read', id=group.name) %} {% block item %}
  • {% block item_inner %} diff --git a/ckan/templates/organization/about.html b/ckan/templates/organization/about.html index 49f33b518b1..806225a2d46 100644 --- a/ckan/templates/organization/about.html +++ b/ckan/templates/organization/about.html @@ -1,6 +1,6 @@ {% extends "organization/read_base.html" %} -{% block subtitle %}{{ _('About') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('About') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ group_dict.display_name }}{% endblock %}

    diff --git a/ckan/templates/organization/activity_stream.html b/ckan/templates/organization/activity_stream.html index 185d5dbf2af..9f909c4c424 100644 --- a/ckan/templates/organization/activity_stream.html +++ b/ckan/templates/organization/activity_stream.html @@ -1,6 +1,6 @@ {% extends "organization/read_base.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

    diff --git a/ckan/templates/organization/admins.html b/ckan/templates/organization/admins.html index 9df9c8c4468..30e505c905e 100644 --- a/ckan/templates/organization/admins.html +++ b/ckan/templates/organization/admins.html @@ -1,6 +1,6 @@ {% extends "organization/read_base.html" %} -{% block subtitle %}{{ _('Administrators') }} - {{ group_dict.title or group_dict.name }}{% endblock %} +{% block subtitle %}{{ _('Administrators') }} {{ g.template_title_delimiter }} {{ group_dict.title or group_dict.name }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Administrators') }}{% endblock %}

    diff --git a/ckan/templates/organization/bulk_process.html b/ckan/templates/organization/bulk_process.html index 714d61038ea..0d79302b4ff 100644 --- a/ckan/templates/organization/bulk_process.html +++ b/ckan/templates/organization/bulk_process.html @@ -1,6 +1,6 @@ {% extends "organization/edit_base.html" %} -{% block subtitle %}{{ _('Edit datasets') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Edit datasets') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block page_primary_action %} {% snippet 'snippets/add_dataset.html', group=group_dict.id %} diff --git a/ckan/templates/organization/edit.html b/ckan/templates/organization/edit.html index 3fde80a2ab0..a6ca1f261fd 100644 --- a/ckan/templates/organization/edit.html +++ b/ckan/templates/organization/edit.html @@ -1,6 +1,6 @@ {% extends "organization/base_form_page.html" %} -{% block subtitle %}{{ _('Edit') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Edit') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block page_heading_class %}hide-heading{% endblock %} {% block page_heading %}{{ _('Edit Organization') }}{% endblock %} diff --git a/ckan/templates/organization/edit_base.html b/ckan/templates/organization/edit_base.html index 2f6266ce908..eac8ab38724 100644 --- a/ckan/templates/organization/edit_base.html +++ b/ckan/templates/organization/edit_base.html @@ -2,7 +2,7 @@ {% set organization = group_dict %} -{% block subtitle %}{{ group_dict.display_name }} - {{ _('Organizations') }}{% endblock %} +{% block subtitle %}{{ group_dict.display_name }} {{ g.template_title_delimiter }} {{ _('Organizations') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Organizations'), named_route=group_type+'.index' %}
  • diff --git a/ckan/templates/organization/member_new.html b/ckan/templates/organization/member_new.html index 4a089f33331..44e455da574 100644 --- a/ckan/templates/organization/member_new.html +++ b/ckan/templates/organization/member_new.html @@ -4,7 +4,7 @@ {% set user = user_dict %} -{% block subtitle %}{{ _('Edit Member') if user else _('Add Member') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Edit Member') if user else _('Add Member') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %} {% link_for _('Back to all members'), named_route=group_type+'.members', id=organization.name, class_='btn btn-default pull-right', icon='arrow-left' %} diff --git a/ckan/templates/organization/members.html b/ckan/templates/organization/members.html index a93ca7792d9..04380945ab8 100644 --- a/ckan/templates/organization/members.html +++ b/ckan/templates/organization/members.html @@ -1,6 +1,6 @@ {% extends "organization/edit_base.html" %} -{% block subtitle %}{{ _('Members') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Members') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block page_primary_action %} {% if h.check_access('organization_update', {'id': organization.id}) %} diff --git a/ckan/templates/organization/read_base.html b/ckan/templates/organization/read_base.html index eeffcae84b6..8f70ef91fdc 100644 --- a/ckan/templates/organization/read_base.html +++ b/ckan/templates/organization/read_base.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ group_dict.display_name }} - {{ _('Organizations') }}{% endblock %} +{% block subtitle %}{{ group_dict.display_name }} {{ g.template_title_delimiter }} {{ _('Organizations') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Organizations'), named_route=group_type+'.index' %}
  • diff --git a/ckan/templates/package/activity.html b/ckan/templates/package/activity.html index fa2f6b08c57..8767a887e16 100644 --- a/ckan/templates/package/activity.html +++ b/ckan/templates/package/activity.html @@ -1,6 +1,6 @@ {% extends "package/read_base.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

    diff --git a/ckan/templates/package/edit_view.html b/ckan/templates/package/edit_view.html index 65ffa4f7e50..e470c20e8f3 100644 --- a/ckan/templates/package/edit_view.html +++ b/ckan/templates/package/edit_view.html @@ -1,6 +1,6 @@ {% extends "package/view_edit_base.html" %} -{% block subtitle %}{{ _('Edit view') }} - {{ h.resource_display_name(resource) }}{% endblock %} +{% block subtitle %}{{ _('Edit view') }} {{ g.template_title_delimiter }} {{ h.resource_display_name(resource) }}{% endblock %} {% block form_title %}{{ _('Edit view') }}{% endblock %} {% block breadcrumb_content %} diff --git a/ckan/templates/package/followers.html b/ckan/templates/package/followers.html index bce4280da0e..5e6cfbbc5de 100644 --- a/ckan/templates/package/followers.html +++ b/ckan/templates/package/followers.html @@ -1,6 +1,6 @@ {% extends "package/read_base.html" %} -{% block subtitle %}{{ _('Followers') }} - {{ h.dataset_display_name(pkg_dict) }}{% endblock %} +{% block subtitle %}{{ _('Followers') }} {{ g.template_title_delimiter }} {{ h.dataset_display_name(pkg_dict) }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Followers') }}{% endblock %}

    diff --git a/ckan/templates/package/history.html b/ckan/templates/package/history.html index 49fbf36927c..a4e81d4821b 100644 --- a/ckan/templates/package/history.html +++ b/ckan/templates/package/history.html @@ -1,6 +1,6 @@ {% extends "package/read_base.html" %} -{% block subtitle %}{{ _('History') }} - {{ h.dataset_display_name(c.pkg_dict) }}{% endblock %} +{% block subtitle %}{{ _('History') }} {{ g.template_title_delimiter }} {{ h.dataset_display_name(c.pkg_dict) }}{% endblock %} {% block primary_content_inner %}

    {{ _('History') }}

    diff --git a/ckan/templates/package/new_resource_not_draft.html b/ckan/templates/package/new_resource_not_draft.html index 06cdbab3679..e2ef4cb2e24 100644 --- a/ckan/templates/package/new_resource_not_draft.html +++ b/ckan/templates/package/new_resource_not_draft.html @@ -1,6 +1,6 @@ {% extends "package/resource_edit_base.html" %} -{% block subtitle %}{{ _('Add resource') }} - {{ h.dataset_display_name(pkg) }}{% endblock %} +{% block subtitle %}{{ _('Add resource') }} {{ g.template_title_delimiter }} {{ h.dataset_display_name(pkg) }}{% endblock %} {% block form_title %}{{ _('Add resource') }}{% endblock %} {% block breadcrumb_content %} diff --git a/ckan/templates/package/new_view.html b/ckan/templates/package/new_view.html index 5c9fdd66861..173771f31e1 100644 --- a/ckan/templates/package/new_view.html +++ b/ckan/templates/package/new_view.html @@ -1,6 +1,6 @@ {% extends "package/view_edit_base.html" %} -{% block subtitle %}{{ _('Add view') }} - {{ h.resource_display_name(resource) }}{% endblock %} +{% block subtitle %}{{ _('Add view') }} {{ g.template_title_delimiter }} {{ h.resource_display_name(resource) }}{% endblock %} {% block form_title %}{{ _('Add view') }}{% endblock %} {% block breadcrumb_content %} diff --git a/ckan/templates/package/read_base.html b/ckan/templates/package/read_base.html index 4d94cbdd9ce..0e47a02b5d7 100644 --- a/ckan/templates/package/read_base.html +++ b/ckan/templates/package/read_base.html @@ -1,6 +1,6 @@ {% extends "package/base.html" %} -{% block subtitle %}{{ h.dataset_display_name(pkg) }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ h.dataset_display_name(pkg) }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block head_extras -%} {{ super() }} diff --git a/ckan/templates/package/resource_edit.html b/ckan/templates/package/resource_edit.html index 5d0a65c30aa..6acb7929467 100644 --- a/ckan/templates/package/resource_edit.html +++ b/ckan/templates/package/resource_edit.html @@ -1,6 +1,6 @@ {% extends "package/resource_edit_base.html" %} -{% block subtitle %}{{ _('Edit') }} - {{ h.resource_display_name(res) }} - {{ h.dataset_display_name(pkg) }}{% endblock %} +{% block subtitle %}{{ _('Edit') }} {{ g.template_title_delimiter }} {{ h.resource_display_name(res) }} {{ g.template_title_delimiter }} {{ h.dataset_display_name(pkg) }}{% endblock %} {% block form %} {% snippet 'package/snippets/resource_edit_form.html', diff --git a/ckan/templates/package/resource_read.html b/ckan/templates/package/resource_read.html index 3f9ef2ab0b4..daed76adc5e 100644 --- a/ckan/templates/package/resource_read.html +++ b/ckan/templates/package/resource_read.html @@ -9,7 +9,7 @@ {% endblock -%} -{% block subtitle %}{{ h.dataset_display_name(package) }} - {{ h.resource_display_name(res) }}{% endblock %} +{% block subtitle %}{{ h.dataset_display_name(package) }} {{ g.template_title_delimiter }} {{ h.resource_display_name(res) }}{% endblock %} {% block breadcrumb_content_selected %}{% endblock %} diff --git a/ckan/templates/package/resource_views.html b/ckan/templates/package/resource_views.html index 234d4e84fac..8fd3b3ee9c6 100644 --- a/ckan/templates/package/resource_views.html +++ b/ckan/templates/package/resource_views.html @@ -1,7 +1,7 @@ {% import 'macros/form.html' as form %} {% extends "package/resource_edit_base.html" %} -{% block subtitle %}{{ _('View') }} - {{ h.resource_display_name(res) }}{% endblock %} +{% block subtitle %}{{ _('View') }} {{ g.template_title_delimiter }} {{ h.resource_display_name(res) }}{% endblock %} {% block page_primary_action %}
    diff --git a/ckan/templates/package/resources.html b/ckan/templates/package/resources.html index 991b16db48b..8de6b1a2ec0 100644 --- a/ckan/templates/package/resources.html +++ b/ckan/templates/package/resources.html @@ -2,7 +2,7 @@ {% set has_reorder = pkg_dict and pkg_dict.resources and pkg_dict.resources|length > 0 %} -{% block subtitle %}{{ _('Resources') }} - {{ h.dataset_display_name(pkg) }}{% endblock %} +{% block subtitle %}{{ _('Resources') }} {{ g.template_title_delimiter }} {{ h.dataset_display_name(pkg) }}{% endblock %} {% block page_primary_action %} {% link_for _('Add new resource'), named_route='resource.new', id=pkg_dict.name, class_='btn btn-primary', icon='plus' %} diff --git a/ckan/templates/snippets/organization.html b/ckan/templates/snippets/organization.html index 765c8aea77c..43e18a8da58 100644 --- a/ckan/templates/snippets/organization.html +++ b/ckan/templates/snippets/organization.html @@ -15,7 +15,7 @@ #} {% set truncate = truncate or 0 %} -{% set url = h.url_for(organization.type + '_read', id=organization.name, ) %} +{% set url = h.url_for(organization.type + '.read', id=organization.name, ) %} {% block info %}
    diff --git a/ckan/templates/user/activity_stream.html b/ckan/templates/user/activity_stream.html index 729c7a430c8..12fe507c417 100644 --- a/ckan/templates/user/activity_stream.html +++ b/ckan/templates/user/activity_stream.html @@ -1,6 +1,6 @@ {% extends "user/read.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} {{ g.template_title_delimiter }} {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

    diff --git a/ckan/templates/user/edit_base.html b/ckan/templates/user/edit_base.html index 56128037010..76c6f45f165 100644 --- a/ckan/templates/user/edit_base.html +++ b/ckan/templates/user/edit_base.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ _('Manage') }} - {{ user_dict.display_name }} - {{ _('Users') }}{% endblock %} +{% block subtitle %}{{ _('Manage') }} {{ g.template_title_delimiter }} {{ user_dict.display_name }} {{ g.template_title_delimiter }} {{ _('Users') }}{% endblock %} {% block primary_content %}
    diff --git a/ckan/templates/user/read_base.html b/ckan/templates/user/read_base.html index f503c7c04cb..cf652b78def 100644 --- a/ckan/templates/user/read_base.html +++ b/ckan/templates/user/read_base.html @@ -2,7 +2,7 @@ {% set user = user_dict %} -{% block subtitle %}{{ user.display_name }} - {{ _('Users') }}{% endblock %} +{% block subtitle %}{{ user.display_name }} {{ g.template_title_delimiter }} {{ _('Users') }}{% endblock %} {% block breadcrumb_content %} {{ h.build_nav('user.index', _('Users')) }} diff --git a/ckan/tests/legacy/functional/api/test_activity.py b/ckan/tests/legacy/functional/api/test_activity.py index 36f0aa99842..b4d8fe51411 100644 --- a/ckan/tests/legacy/functional/api/test_activity.py +++ b/ckan/tests/legacy/functional/api/test_activity.py @@ -235,7 +235,7 @@ def group_activity_stream(self, group_id, apikey=None): extra_environ = {'Authorization': str(apikey)} else: extra_environ = None - params = {'id': group_id} + params = {'id': group_id, 'limit': 100} response = self.app.get("/api/action/group_activity_list", params=params, extra_environ=extra_environ) assert response.json['success'] is True @@ -981,7 +981,10 @@ def _update_package(self, package, user): user_id = 'not logged in' apikey = None - before = self.record_details(user_id, package['id'], apikey=apikey) + group_ids = [group['name'] for group in package['groups']] + before = self.record_details( + user_id, package['id'], apikey=apikey, group_ids=group_ids + ) # Update the package. if package['title'] != 'edited': @@ -991,7 +994,9 @@ def _update_package(self, package, user): package['title'] = 'edited again' package_update(self.app, package, user) - after = self.record_details(user_id, package['id'], apikey=apikey) + after = self.record_details( + user_id, package['id'], apikey=apikey, group_ids=group_ids + ) # Find the new activity in the user's activity stream. user_new_activities = (find_new_activities( @@ -1132,9 +1137,11 @@ def _delete_package(self, package): item and detail are emitted. """ - before = self.record_details(self.sysadmin_user['id'], package['id'], - apikey=self.sysadmin_user['apikey']) - + group_ids = [group['name'] for group in package['groups']] + before = self.record_details( + self.sysadmin_user['id'], package['id'], + apikey=self.sysadmin_user['apikey'], group_ids=group_ids + ) # Delete the package. package_dict = {'id': package['id']} response = self.app.post('/api/action/package_delete', @@ -1143,8 +1150,10 @@ def _delete_package(self, package): response_dict = json.loads(response.body) assert response_dict['success'] is True - after = self.record_details(self.sysadmin_user['id'], package['id'], - apikey=self.sysadmin_user['apikey']) + after = self.record_details( + self.sysadmin_user['id'], package['id'], + apikey=self.sysadmin_user['apikey'], group_ids=group_ids + ) # Find the new activity in the user's activity stream. user_new_activities = (find_new_activities( @@ -1167,13 +1176,14 @@ def _delete_package(self, package): after['recently changed datasets stream']) \ == user_new_activities - # If the package has any groups, the same new activity should appear - # in the activity stream of each group. + # If the package has any groups, there should be no new activities + # because package has been deleted == removed from group lifecycle + for group_dict in package['groups']: grp_new_activities = find_new_activities( before['group activity streams'][group_dict['name']], after['group activity streams'][group_dict['name']]) - assert grp_new_activities == [activity] + assert grp_new_activities == [] # Check that the new activity has the right attributes. assert activity['object_id'] == package['id'], ( @@ -1522,15 +1532,21 @@ def test_add_tag(self): pkg_dict = package_show(self.app, {'id': pkg_name}) # Add one new tag to the package. - before = self.record_details(user['id'], pkg_dict['id'], - apikey=user['apikey']) + group_ids = [group['name'] for group in pkg_dict['groups']] + + before = self.record_details( + user['id'], pkg_dict['id'], + apikey=user['apikey'], group_ids=group_ids + ) new_tag_name = 'test tag' assert new_tag_name not in [tag['name'] for tag in pkg_dict['tags']] pkg_dict['tags'].append({'name': new_tag_name}) package_update(self.app, pkg_dict, user) - after = self.record_details(user['id'], pkg_dict['id'], - apikey=user['apikey']) + after = self.record_details( + user['id'], pkg_dict['id'], + apikey=user['apikey'], group_ids=group_ids + ) # Find the new activity in the user's activity stream. user_new_activities = (find_new_activities( diff --git a/ckan/tests/legacy/functional/api/test_dashboard.py b/ckan/tests/legacy/functional/api/test_dashboard.py index d1fa99dcf69..11e7d33eae7 100644 --- a/ckan/tests/legacy/functional/api/test_dashboard.py +++ b/ckan/tests/legacy/functional/api/test_dashboard.py @@ -311,6 +311,7 @@ def test_07_mark_new_activities_as_read(self): assert self.dashboard_new_activities_count(self.new_user) == 0 assert len(self.dashboard_new_activities(self.new_user)) == 0 + @helpers.change_config('ckan.activity_list_limit', '15') def test_08_maximum_number_of_new_activities(self): '''Test that the new activities count does not go higher than 15, even if there are more than 15 new activities from the user's followers.''' diff --git a/ckan/tests/legacy/functional/test_activity.py b/ckan/tests/legacy/functional/test_activity.py index 5a5d9aabc12..f7db62f3dcd 100644 --- a/ckan/tests/legacy/functional/test_activity.py +++ b/ckan/tests/legacy/functional/test_activity.py @@ -37,7 +37,7 @@ def setup(cls): def teardown(cls): ckan.model.repo.rebuild_db() - + @helpers.change_config('ckan.activity_list_limit', '15') def test_user_activity(self): """Test user activity streams HTML rendering.""" diff --git a/ckan/tests/legacy/lib/test_resource_search.py b/ckan/tests/legacy/lib/test_resource_search.py index 92862ac33a5..4e60c4ae4ce 100644 --- a/ckan/tests/legacy/lib/test_resource_search.py +++ b/ckan/tests/legacy/lib/test_resource_search.py @@ -1,7 +1,6 @@ # encoding: utf-8 -from webob.multidict import UnicodeMultiDict, MultiDict -from nose.tools import assert_raises, assert_equal +from nose.tools import assert_raises, assert_equal, assert_set_equal from ckan.tests.legacy import * from ckan.tests.legacy import is_search_supported @@ -76,10 +75,9 @@ def test_02_search_url_2(self): assert set([self.ab]) == urls, urls def test_03_search_url_multiple_words(self): - fields = UnicodeMultiDict(MultiDict(url='e')) - fields.add('url', 'f') + fields = dict([['url', 'e f']]) urls = self.res_search(fields=fields) - assert set([self.ef]) == urls, urls + assert_set_equal({self.ef}, urls) def test_04_search_url_none(self): urls = self.res_search(fields={'url':'nothing'}) diff --git a/ckan/tests/legacy/logic/test_action.py b/ckan/tests/legacy/logic/test_action.py index 6a7d418cc95..9714da797ac 100644 --- a/ckan/tests/legacy/logic/test_action.py +++ b/ckan/tests/legacy/logic/test_action.py @@ -130,7 +130,7 @@ def test_02_package_autocomplete_match_name(self): assert_equal(res_obj['result'][0]['match_displayed'], 'warandpeace') def test_02_package_autocomplete_match_title(self): - postparams = '%s=1' % json.dumps({'q':'a%20w', 'limit': 5}) + postparams = '%s=1' % json.dumps({'q': 'won', 'limit': 5}) res = self.app.post('/api/action/package_autocomplete', params=postparams) res_obj = json.loads(res.body) assert_equal(res_obj['success'], True) diff --git a/ckan/tests/lib/dictization/test_model_dictize.py b/ckan/tests/lib/dictization/test_model_dictize.py index 43de44788c1..c26c0590990 100644 --- a/ckan/tests/lib/dictization/test_model_dictize.py +++ b/ckan/tests/lib/dictization/test_model_dictize.py @@ -5,7 +5,7 @@ from nose.tools import assert_equal -from ckan.lib.dictization import model_dictize +from ckan.lib.dictization import model_dictize, model_save from ckan import model from ckan.lib import search @@ -240,6 +240,19 @@ def test_group_dictize_with_package_list_limited_over(self): assert_equal(len(group['packages']), 3) + @helpers.change_config('ckan.search.rows_max', '4') + def test_group_dictize_with_package_list_limited_by_config(self): + group_ = factories.Group() + for _ in range(5): + factories.Dataset(groups=[{'name': group_['name']}]) + group_obj = model.Session.query(model.Group).filter_by().first() + context = {'model': model, 'session': model.Session} + + group = model_dictize.group_dictize(group_obj, context) + + assert_equal(len(group['packages']), 4) + # limited by ckan.search.rows_max + def test_group_dictize_with_package_count(self): # group_list_dictize calls it like this by default group_ = factories.Group() @@ -401,8 +414,7 @@ def test_package_dictize_resource(self): result = model_dictize.package_dictize(dataset_obj, context) - assert_equal_for_keys(result['resources'][0], resource, - 'name', 'url') + assert_equal_for_keys(result['resources'][0], resource, 'name', 'url') expected_dict = { u'cache_last_updated': None, u'cache_url': None, @@ -422,6 +434,40 @@ def test_package_dictize_resource(self): } self.assert_equals_expected(expected_dict, result['resources'][0]) + def test_package_dictize_resource_upload_and_striped(self): + dataset = factories.Dataset() + resource = factories.Resource(package=dataset['id'], + name='test_pkg_dictize', + url_type='upload', + url='some_filename.csv') + + context = {'model': model, 'session': model.Session} + + result = model_save.resource_dict_save(resource, context) + + expected_dict = { + u'url': u'some_filename.csv', + u'url_type': u'upload' + } + assert expected_dict['url'] == result.url + + def test_package_dictize_resource_upload_with_url_and_striped(self): + dataset = factories.Dataset() + resource = factories.Resource(package=dataset['id'], + name='test_pkg_dictize', + url_type='upload', + url='http://some_filename.csv') + + context = {'model': model, 'session': model.Session} + + result = model_save.resource_dict_save(resource, context) + + expected_dict = { + u'url': u'some_filename.csv', + u'url_type': u'upload' + } + assert expected_dict['url'] == result.url + def test_package_dictize_tags(self): dataset = factories.Dataset(tags=[{'name': 'fish'}]) dataset_obj = model.Package.get(dataset['id']) diff --git a/ckan/tests/logic/action/test_get.py b/ckan/tests/logic/action/test_get.py index 70a85384a8e..344f2026e4d 100644 --- a/ckan/tests/logic/action/test_get.py +++ b/ckan/tests/logic/action/test_get.py @@ -163,6 +163,36 @@ def test_group_list_all_fields(self): assert 'users' not in group_list[0] assert 'datasets' not in group_list[0] + def _create_bulk_groups(self, name, count): + from ckan import model + model.repo.new_revision() + groups = [model.Group(name='{}_{}'.format(name, i)) + for i in range(count)] + model.Session.add_all(groups) + model.repo.commit_and_remove() + + def test_limit_default(self): + self._create_bulk_groups('group_default', 1010) + results = helpers.call_action('group_list') + eq(len(results), 1000) # i.e. default value + + @helpers.change_config('ckan.group_and_organization_list_max', '5') + def test_limit_configured(self): + self._create_bulk_groups('group_default', 7) + results = helpers.call_action('group_list') + eq(len(results), 5) # i.e. configured limit + + def test_all_fields_limit_default(self): + self._create_bulk_groups('org_all_fields_default', 30) + results = helpers.call_action('group_list', all_fields=True) + eq(len(results), 25) # i.e. default value + + @helpers.change_config('ckan.group_and_organization_list_all_fields_max', '5') + def test_all_fields_limit_configured(self): + self._create_bulk_groups('org_all_fields_default', 30) + results = helpers.call_action('group_list', all_fields=True) + eq(len(results), 5) # i.e. configured limit + def test_group_list_extras_returned(self): group = factories.Group(extras=[{'key': 'key1', 'value': 'val1'}]) @@ -393,6 +423,17 @@ def test_group_show_does_not_show_private_datasets(self): in group['packages']], ( "group_show() should never show private datasets") + @helpers.change_config('ckan.search.rows_max', '5') + def test_package_limit_configured(self): + group = factories.Group() + for i in range(7): + factories.Dataset(groups=[{'id': group['id']}]) + id = group['id'] + + results = helpers.call_action('group_show', id=id, + include_datasets=1) + eq(len(results['packages']), 5) # i.e. ckan.search.rows_max + class TestOrganizationList(helpers.FunctionalTestBase): @@ -451,6 +492,37 @@ def test_organization_list_return_custom_organization_type(self): assert (sorted(org_list) == sorted([g['name'] for g in [org2]])), '{}'.format(org_list) + def _create_bulk_orgs(self, name, count): + from ckan import model + model.repo.new_revision() + orgs = [model.Group(name='{}_{}'.format(name, i), is_organization=True, + type='organization') + for i in range(count)] + model.Session.add_all(orgs) + model.repo.commit_and_remove() + + def test_limit_default(self): + self._create_bulk_orgs('org_default', 1010) + results = helpers.call_action('organization_list') + eq(len(results), 1000) # i.e. default value + + @helpers.change_config('ckan.group_and_organization_list_max', '5') + def test_limit_configured(self): + self._create_bulk_orgs('org_default', 7) + results = helpers.call_action('organization_list') + eq(len(results), 5) # i.e. configured limit + + def test_all_fields_limit_default(self): + self._create_bulk_orgs('org_all_fields_default', 30) + results = helpers.call_action('organization_list', all_fields=True) + eq(len(results), 25) # i.e. default value + + @helpers.change_config('ckan.group_and_organization_list_all_fields_max', '5') + def test_all_fields_limit_configured(self): + self._create_bulk_orgs('org_all_fields_default', 30) + results = helpers.call_action('organization_list', all_fields=True) + eq(len(results), 5) # i.e. configured limit + class TestOrganizationShow(helpers.FunctionalTestBase): @@ -522,6 +594,17 @@ def test_organization_show_private_packages_not_returned(self): assert org_dict['packages'][0]['name'] == 'dataset_1' assert org_dict['package_count'] == 1 + @helpers.change_config('ckan.search.rows_max', '5') + def test_package_limit_configured(self): + org = factories.Organization() + for i in range(7): + factories.Dataset(owner_org=org['id']) + id = org['id'] + + results = helpers.call_action('organization_show', id=id, + include_datasets=1) + eq(len(results['packages']), 5) # i.e. ckan.search.rows_max + class TestUserList(helpers.FunctionalTestBase): @@ -840,10 +923,39 @@ def test_package_autocomplete_does_not_return_private_datasets(self): dataset2 = factories.Dataset(user=user, owner_org=org['name'], private=True, title='Some private stuff') - package_list = helpers.call_action('package_autocomplete', - q='some') + package_list = helpers.call_action( + 'package_autocomplete', context={'ignore_auth': False}, q='some' + ) eq(len(package_list), 1) + def test_package_autocomplete_does_return_private_datasets_from_my_org(self): + user = factories.User() + org = factories.Organization( + users=[{'name': user['name'], 'capacity': 'member'}] + ) + factories.Dataset( + user=user, owner_org=org['id'], title='Some public stuff' + ) + factories.Dataset( + user=user, owner_org=org['id'], private=True, + title='Some private stuff' + ) + package_list = helpers.call_action( + 'package_autocomplete', + context={'user': user['name'], 'ignore_auth': False}, + q='some' + ) + eq(len(package_list), 2) + + def test_package_autocomplete_works_for_the_middle_part_of_title(self): + factories.Dataset(title='Some public stuff') + factories.Dataset(title='Some random stuff') + + package_list = helpers.call_action('package_autocomplete', q='bli') + eq(len(package_list), 1) + package_list = helpers.call_action('package_autocomplete', q='tuf') + eq(len(package_list), 2) + class TestPackageSearch(helpers.FunctionalTestBase): @@ -892,6 +1004,25 @@ def test_bad_solr_parameter(self): # SOLR error is 'Missing sort order' or 'Missing_sort_order', # depending on the solr version. + def _create_bulk_datasets(self, name, count): + from ckan import model + model.repo.new_revision() + pkgs = [model.Package(name='{}_{}'.format(name, i)) + for i in range(count)] + model.Session.add_all(pkgs) + model.repo.commit_and_remove() + + def test_rows_returned_default(self): + self._create_bulk_datasets('rows_default', 11) + results = logic.get_action('package_search')({}, {}) + eq(len(results['results']), 10) # i.e. 'rows' default value + + @helpers.change_config('ckan.search.rows_max', '12') + def test_rows_returned_limited(self): + self._create_bulk_datasets('rows_limited', 14) + results = logic.get_action('package_search')({}, {'rows': '15'}) + eq(len(results['results']), 12) # i.e. ckan.search.rows_max + def test_facets(self): org = factories.Organization(name='test-org-facet', title='Test Org') factories.Dataset(owner_org=org['id']) @@ -2228,3 +2359,196 @@ def test_not_existing_job(self): Test showing a not existing job. ''' helpers.call_action(u'job_show', id=u'does-not-exist') + + +class TestPackageActivityList(helpers.FunctionalTestBase): + def _create_bulk_package_activities(self, count): + dataset = factories.Dataset() + from ckan import model + objs = [ + model.Activity( + user_id=None, object_id=dataset['id'], revision_id=None, + activity_type=None, data=None) + for i in range(count)] + model.Session.add_all(objs) + model.repo.commit_and_remove() + return dataset['id'] + + def test_limit_default(self): + id = self._create_bulk_package_activities(35) + results = helpers.call_action('package_activity_list', id=id) + eq(len(results), 31) # i.e. default value + + @helpers.change_config('ckan.activity_list_limit', '5') + def test_limit_configured(self): + id = self._create_bulk_package_activities(7) + results = helpers.call_action('package_activity_list', id=id) + eq(len(results), 5) # i.e. ckan.activity_list_limit + + @helpers.change_config('ckan.activity_list_limit', '5') + @helpers.change_config('ckan.activity_list_limit_max', '7') + def test_limit_hits_max(self): + id = self._create_bulk_package_activities(9) + results = helpers.call_action('package_activity_list', id=id, limit='9') + eq(len(results), 7) # i.e. ckan.activity_list_limit_max + + +class TestUserActivityList(helpers.FunctionalTestBase): + def _create_bulk_user_activities(self, count): + user = factories.User() + from ckan import model + objs = [ + model.Activity( + user_id=user['id'], object_id=None, revision_id=None, + activity_type=None, data=None) + for i in range(count)] + model.Session.add_all(objs) + model.repo.commit_and_remove() + return user['id'] + + def test_limit_default(self): + id = self._create_bulk_user_activities(35) + results = helpers.call_action('user_activity_list', id=id) + eq(len(results), 31) # i.e. default value + + @helpers.change_config('ckan.activity_list_limit', '5') + def test_limit_configured(self): + id = self._create_bulk_user_activities(7) + results = helpers.call_action('user_activity_list', id=id) + eq(len(results), 5) # i.e. ckan.activity_list_limit + + @helpers.change_config('ckan.activity_list_limit', '5') + @helpers.change_config('ckan.activity_list_limit_max', '7') + def test_limit_hits_max(self): + id = self._create_bulk_user_activities(9) + results = helpers.call_action('user_activity_list', id=id, limit='9') + eq(len(results), 7) # i.e. ckan.activity_list_limit_max + + +class TestGroupActivityList(helpers.FunctionalTestBase): + def _create_bulk_group_activities(self, count): + group = factories.Group() + from ckan import model + objs = [ + model.Activity( + user_id=None, object_id=group['id'], revision_id=None, + activity_type=None, data=None) + for i in range(count)] + model.Session.add_all(objs) + model.repo.commit_and_remove() + return group['id'] + + def test_limit_default(self): + id = self._create_bulk_group_activities(35) + results = helpers.call_action('group_activity_list', id=id) + eq(len(results), 31) # i.e. default value + + @helpers.change_config('ckan.activity_list_limit', '5') + def test_limit_configured(self): + id = self._create_bulk_group_activities(7) + results = helpers.call_action('group_activity_list', id=id) + eq(len(results), 5) # i.e. ckan.activity_list_limit + + @helpers.change_config('ckan.activity_list_limit', '5') + @helpers.change_config('ckan.activity_list_limit_max', '7') + def test_limit_hits_max(self): + id = self._create_bulk_group_activities(9) + results = helpers.call_action('group_activity_list', id=id, limit='9') + eq(len(results), 7) # i.e. ckan.activity_list_limit_max + + +class TestOrganizationActivityList(helpers.FunctionalTestBase): + def _create_bulk_org_activities(self, count): + org = factories.Organization() + from ckan import model + objs = [ + model.Activity( + user_id=None, object_id=org['id'], revision_id=None, + activity_type=None, data=None) + for i in range(count)] + model.Session.add_all(objs) + model.repo.commit_and_remove() + return org['id'] + + def test_limit_default(self): + id = self._create_bulk_org_activities(35) + results = helpers.call_action('organization_activity_list', id=id) + eq(len(results), 31) # i.e. default value + + @helpers.change_config('ckan.activity_list_limit', '5') + def test_limit_configured(self): + id = self._create_bulk_org_activities(7) + results = helpers.call_action('organization_activity_list', id=id) + eq(len(results), 5) # i.e. ckan.activity_list_limit + + @helpers.change_config('ckan.activity_list_limit', '5') + @helpers.change_config('ckan.activity_list_limit_max', '7') + def test_limit_hits_max(self): + id = self._create_bulk_org_activities(9) + results = helpers.call_action('organization_activity_list', id=id, limit='9') + eq(len(results), 7) # i.e. ckan.activity_list_limit_max + + +class TestRecentlyChangedPackagesActivityList(helpers.FunctionalTestBase): + def _create_bulk_package_activities(self, count): + from ckan import model + objs = [ + model.Activity( + user_id=None, object_id=None, revision_id=None, + activity_type='new_package', data=None) + for i in range(count)] + model.Session.add_all(objs) + model.repo.commit_and_remove() + + def test_limit_default(self): + self._create_bulk_package_activities(35) + results = helpers.call_action('recently_changed_packages_activity_list') + eq(len(results), 31) # i.e. default value + + @helpers.change_config('ckan.activity_list_limit', '5') + def test_limit_configured(self): + self._create_bulk_package_activities(7) + results = helpers.call_action('recently_changed_packages_activity_list') + eq(len(results), 5) # i.e. ckan.activity_list_limit + + @helpers.change_config('ckan.activity_list_limit', '5') + @helpers.change_config('ckan.activity_list_limit_max', '7') + def test_limit_hits_max(self): + self._create_bulk_package_activities(9) + results = helpers.call_action('recently_changed_packages_activity_list', limit='9') + eq(len(results), 7) # i.e. ckan.activity_list_limit_max + + +class TestDashboardActivityList(helpers.FunctionalTestBase): + def _create_bulk_package_activities(self, count): + user = factories.User() + from ckan import model + objs = [ + model.Activity( + user_id=user['id'], object_id=None, revision_id=None, + activity_type=None, data=None) + for i in range(count)] + model.Session.add_all(objs) + model.repo.commit_and_remove() + return user['id'] + + def test_limit_default(self): + id = self._create_bulk_package_activities(35) + results = helpers.call_action('dashboard_activity_list', + context={'user': id}) + eq(len(results), 31) # i.e. default value + + @helpers.change_config('ckan.activity_list_limit', '5') + def test_limit_configured(self): + id = self._create_bulk_package_activities(7) + results = helpers.call_action('dashboard_activity_list', + context={'user': id}) + eq(len(results), 5) # i.e. ckan.activity_list_limit + + @helpers.change_config('ckan.activity_list_limit', '5') + @helpers.change_config('ckan.activity_list_limit_max', '7') + def test_limit_hits_max(self): + id = self._create_bulk_package_activities(9) + results = helpers.call_action('dashboard_activity_list', limit='9', + context={'user': id}) + eq(len(results), 7) # i.e. ckan.activity_list_limit_max diff --git a/ckan/tests/logic/action/test_patch.py b/ckan/tests/logic/action/test_patch.py index 4de8a8765c5..4f597988171 100644 --- a/ckan/tests/logic/action/test_patch.py +++ b/ckan/tests/logic/action/test_patch.py @@ -75,6 +75,36 @@ def test_group_patch_updating_single_field(self): assert_equals(group2['name'], 'economy') assert_equals(group2['description'], 'somethingnew') + def test_group_patch_preserve_datasets(self): + user = factories.User() + group = factories.Group( + name='economy', + description='some test now', + user=user) + factories.Dataset(groups=[{'name': group['name']}]) + + group2 = helpers.call_action('group_show', id=group['id']) + assert_equals(1, group2['package_count']) + + group = helpers.call_action( + 'group_patch', + id=group['id'], + context={'user': user['name']}) + + group3 = helpers.call_action('group_show', id=group['id']) + assert_equals(1, group3['package_count']) + + group = helpers.call_action( + 'group_patch', + id=group['id'], + packages=[], + context={'user': user['name']}) + + group4 = helpers.call_action( + 'group_show', id=group['id'], include_datasets=True + ) + assert_equals(0, group4['package_count']) + def test_organization_patch_updating_single_field(self): user = factories.User() organization = factories.Organization( diff --git a/ckan/views/__init__.py b/ckan/views/__init__.py index d0be1468aec..58cc0e1536b 100644 --- a/ckan/views/__init__.py +++ b/ckan/views/__init__.py @@ -204,11 +204,4 @@ def _get_user_for_apikey(): def set_controller_and_action(): - try: - controller, action = request.endpoint.split(u'.') - except ValueError: - log.debug( - u'Endpoint does not contain dot: {}'.format(request.endpoint) - ) - controller = action = request.endpoint - g.controller, g.action = controller, action + g.controller, g.action = p.toolkit.get_endpoint() diff --git a/ckan/views/user.py b/ckan/views/user.py index 00efe36b76e..d1fc9eabddc 100644 --- a/ckan/views/user.py +++ b/ckan/views/user.py @@ -78,7 +78,7 @@ def before_request(): context = dict(model=model, user=g.user, auth_user_obj=g.userobj) logic.check_access(u'site_read', context) except logic.NotAuthorized: - _, action = request.url_rule.endpoint.split(u'.') + _, action = plugins.toolkit.get_endpoint() if action not in ( u'login', u'request_reset', diff --git a/ckanext/datastore/backend/postgres.py b/ckanext/datastore/backend/postgres.py index 4b46aaadb15..b6c6460ef5c 100644 --- a/ckanext/datastore/backend/postgres.py +++ b/ckanext/datastore/backend/postgres.py @@ -1054,11 +1054,10 @@ def upsert_data(context, data_dict): try: context['connection'].execute(sql_string, rows) - except sqlalchemy.exc.DataError: + except sqlalchemy.exc.DataError as err: raise InvalidDataError( - toolkit._("The data was invalid (for example: a numeric value " - "is out of range or was inserted into a text field)." - )) + toolkit._("The data was invalid: {}" + ).format(_programming_error_summary(err))) except sqlalchemy.exc.DatabaseError as err: raise ValidationError( {u'records': [_programming_error_summary(err)]}) @@ -1189,7 +1188,9 @@ def validate(context, data_dict): data_dict_copy.pop('id', None) data_dict_copy.pop('include_total', None) + data_dict_copy.pop('total_estimation_threshold', None) data_dict_copy.pop('records_format', None) + data_dict_copy.pop('calculate_record_count', None) for key, values in data_dict_copy.iteritems(): if not values: @@ -1312,17 +1313,53 @@ def search_data(context, data_dict): _insert_links(data_dict, limit, offset) if data_dict.get('include_total', True): - count_sql_string = u'''SELECT count(*) FROM ( - SELECT {distinct} {select} - FROM "{resource}" {ts_query} {where}) as t;'''.format( - distinct=distinct, - select=select_columns, - resource=resource_id, - ts_query=ts_query, - where=where_clause) - count_result = _execute_single_statement( - context, count_sql_string, where_values) - data_dict['total'] = count_result.fetchall()[0][0] + total_estimation_threshold = \ + data_dict.get('total_estimation_threshold') + estimated_total = None + if total_estimation_threshold is not None and \ + not (where_clause or distinct): + # there are no filters, so we can try to use the estimated table + # row count from pg stats + # See: https://wiki.postgresql.org/wiki/Count_estimate + # (We also tried using the EXPLAIN to estimate filtered queries but + # it didn't estimate well in tests) + analyze_count_sql = sqlalchemy.text(''' + SELECT reltuples::BIGINT AS approximate_row_count + FROM pg_class + WHERE relname=:resource; + ''') + count_result = context['connection'].execute(analyze_count_sql, + resource=resource_id) + try: + estimated_total = count_result.fetchall()[0][0] + except ValueError: + # the table doesn't have the stats calculated yet. (This should + # be done by xloader/datapusher at the end of loading.) + # We could provoke their creation with an ANALYZE, but that + # takes 10x the time to run, compared to SELECT COUNT(*) so + # we'll just revert to the latter. At some point the autovacuum + # will run and create the stats so we can use an estimate in + # future. + pass + + if estimated_total is not None \ + and estimated_total >= total_estimation_threshold: + data_dict['total'] = estimated_total + data_dict['total_was_estimated'] = True + else: + # this is slow for large results + count_sql_string = u'''SELECT count(*) FROM ( + SELECT {distinct} {select} + FROM "{resource}" {ts_query} {where}) as t;'''.format( + distinct=distinct, + select=select_columns, + resource=resource_id, + ts_query=ts_query, + where=where_clause) + count_result = _execute_single_statement( + context, count_sql_string, where_values) + data_dict['total'] = count_result.fetchall()[0][0] + data_dict['total_was_estimated'] = False return data_dict @@ -1948,6 +1985,19 @@ def before_fork(self): # to avoid sharing them between parent and child processes. _dispose_engines() + def calculate_record_count(self, resource_id): + ''' + Calculate an estimate of the record/row count and store it in + Postgresql's pg_stat_user_tables. This number will be used when + specifying `total_estimation_threshold` + ''' + connection = get_write_engine().connect() + sql = 'ANALYZE "{}"'.format(resource_id) + try: + connection.execute(sql) + except sqlalchemy.exc.DatabaseError as err: + raise DatastoreException(err) + def create_function(name, arguments, rettype, definition, or_replace): sql = u''' diff --git a/ckanext/datastore/logic/action.py b/ckanext/datastore/logic/action.py index 0c91b0f1057..dcef7f2812a 100644 --- a/ckanext/datastore/logic/action.py +++ b/ckanext/datastore/logic/action.py @@ -70,6 +70,12 @@ def datastore_create(context, data_dict): {"function": "trigger_clean_reference"}, {"function": "trigger_check_codes"}] :type triggers: list of dictionaries + :param calculate_record_count: updates the stored count of records, used to + optimize datastore_search in combination with the + `total_estimation_threshold` parameter. If doing a series of requests + to change a resource, you only need to set this to True on the last + request. + :type calculate_record_count: bool (optional, default: False) Please note that setting the ``aliases``, ``indexes`` or ``primary_key`` replaces the exising aliases or constraints. Setting ``records`` appends @@ -152,6 +158,9 @@ def datastore_create(context, data_dict): except InvalidDataError as err: raise p.toolkit.ValidationError(text_type(err)) + if data_dict.get('calculate_record_count', False): + backend.calculate_record_count(data_dict['resource_id']) + # Set the datastore_active flag on the resource if necessary model = _get_or_bust(context, 'model') resobj = model.Resource.get(data_dict['resource_id']) @@ -229,6 +238,12 @@ def datastore_upsert(context, data_dict): Possible options are: upsert, insert, update (optional, default: upsert) :type method: string + :param calculate_record_count: updates the stored count of records, used to + optimize datastore_search in combination with the + `total_estimation_threshold` parameter. If doing a series of requests + to change a resource, you only need to set this to True on the last + request. + :type calculate_record_count: bool (optional, default: False) :param dry_run: set to True to abort transaction instead of committing, e.g. to check for validation or type errors. :type dry_run: bool (optional, default: False) @@ -264,6 +279,10 @@ def datastore_upsert(context, data_dict): result = backend.upsert(context, data_dict) result.pop('id', None) result.pop('connection_url', None) + + if data_dict.get('calculate_record_count', False): + backend.calculate_record_count(data_dict['resource_id']) + return result @@ -306,6 +325,12 @@ def datastore_delete(context, data_dict): If missing delete whole table and all dependent views. (optional) :type filters: dictionary + :param calculate_record_count: updates the stored count of records, used to + optimize datastore_search in combination with the + `total_estimation_threshold` parameter. If doing a series of requests + to change a resource, you only need to set this to True on the last + request. + :type calculate_record_count: bool (optional, default: False) **Results:** @@ -313,7 +338,7 @@ def datastore_delete(context, data_dict): :rtype: dictionary ''' - schema = context.get('schema', dsschema.datastore_upsert_schema()) + schema = context.get('schema', dsschema.datastore_delete_schema()) backend = DatastoreBackend.get_active_backend() # Remove any applied filters before running validation. @@ -349,6 +374,9 @@ def datastore_delete(context, data_dict): result = backend.delete(context, data_dict) + if data_dict.get('calculate_record_count', False): + backend.calculate_record_count(data_dict['resource_id']) + # Set the datastore_active flag on the resource if necessary model = _get_or_bust(context, 'model') resource = model.Resource.get(data_dict['resource_id']) @@ -405,6 +433,17 @@ def datastore_search(context, data_dict): :param include_total: True to return total matching record count (optional, default: true) :type include_total: bool + :param total_estimation_threshold: If "include_total" is True and + "total_estimation_threshold" is not None and the estimated total + (matching record count) is above the "total_estimation_threshold" then + this datastore_search will return an *estimate* of the total, rather + than a precise one. This is often good enough, and saves + computationally expensive row counting for larger results (e.g. >100000 + rows). The estimated total comes from the PostgreSQL table statistics, + generated when Express Loader or DataPusher finishes a load, or by + autovacuum. NB Currently estimation can't be done if the user specifies + 'filters' or 'distinct' options. (optional, default: None) + :type total_estimation_threshold: int or None :param records_format: the format for the records return value: 'objects' (default) list of {fieldname1: value1, ...} dicts, 'lists' list of [value1, value2, ...] lists, @@ -438,6 +477,8 @@ def datastore_search(context, data_dict): :type filters: list of dictionaries :param total: number of total matching records :type total: int + :param total_was_estimated: whether or not the total was estimated + :type total_was_estimated: bool :param records: list of matching results :type records: depends on records_format value passed diff --git a/ckanext/datastore/logic/auth.py b/ckanext/datastore/logic/auth.py index 06aad380f9b..5a20c20a728 100644 --- a/ckanext/datastore/logic/auth.py +++ b/ckanext/datastore/logic/auth.py @@ -83,3 +83,7 @@ def datastore_function_delete(context, data_dict): def datastore_run_triggers(context, data_dict): '''sysadmin-only: functions can be used to skip access checks''' return {'success': False} + + +def datastore_analyze(context, data_dict): + return {'success': False} diff --git a/ckanext/datastore/logic/schema.py b/ckanext/datastore/logic/schema.py index c9b221d9f42..36c1a22c2ea 100644 --- a/ckanext/datastore/logic/schema.py +++ b/ckanext/datastore/logic/schema.py @@ -122,6 +122,8 @@ def datastore_create_schema(): OneOf([u'row'])], 'function': [not_empty, unicode_only], }, + 'calculate_record_count': [ignore_missing, default(False), + boolean_validator], '__junk': [empty], '__before': [rename('id', 'resource_id')] } @@ -135,6 +137,8 @@ def datastore_upsert_schema(): 'id': [ignore_missing], 'method': [ignore_missing, text_type, OneOf( ['upsert', 'insert', 'update'])], + 'calculate_record_count': [ignore_missing, default(False), + boolean_validator], 'dry_run': [ignore_missing, boolean_validator], '__junk': [empty], '__before': [rename('id', 'resource_id')] @@ -147,6 +151,8 @@ def datastore_delete_schema(): 'resource_id': [not_missing, not_empty, text_type], 'force': [ignore_missing, boolean_validator], 'id': [ignore_missing], + 'calculate_record_count': [ignore_missing, default(False), + boolean_validator], '__junk': [empty], '__before': [rename('id', 'resource_id')] } @@ -167,6 +173,7 @@ def datastore_search_schema(): 'sort': [ignore_missing, list_of_strings_or_string], 'distinct': [ignore_missing, boolean_validator], 'include_total': [default(True), boolean_validator], + 'total_estimation_threshold': [default(None), int_validator], 'records_format': [ default(u'objects'), OneOf([u'objects', u'lists', u'csv', u'tsv'])], @@ -195,3 +202,9 @@ def datastore_function_delete_schema(): 'name': [unicode_only, not_empty], 'if_exists': [default(False), boolean_validator], } + + +def datastore_analyze_schema(): + return { + 'resource_id': [text_type, resource_id_exists], + } diff --git a/ckanext/datastore/tests/helpers.py b/ckanext/datastore/tests/helpers.py index c94aacd3a65..2d6e5728736 100644 --- a/ckanext/datastore/tests/helpers.py +++ b/ckanext/datastore/tests/helpers.py @@ -60,6 +60,21 @@ def set_url_type(resources, user): p.toolkit.get_action('resource_update')(context, resource) +def execute_sql(sql, *args): + engine = db.get_write_engine() + session = orm.scoped_session(orm.sessionmaker(bind=engine)) + return session.connection().execute(sql, *args) + + +def when_was_last_analyze(resource_id): + results = execute_sql( + '''SELECT last_analyze + FROM pg_stat_user_tables + WHERE relname=%s; + ''', resource_id).fetchall() + return results[0][0] + + class DatastoreFunctionalTestBase(FunctionalTestBase): _load_plugins = (u'datastore', ) diff --git a/ckanext/datastore/tests/test_create.py b/ckanext/datastore/tests/test_create.py index 9533769b326..27c0650e1b2 100644 --- a/ckanext/datastore/tests/test_create.py +++ b/ckanext/datastore/tests/test_create.py @@ -1,8 +1,7 @@ # encoding: utf-8 import json -import nose -from nose.tools import assert_equal, raises +from nose.tools import assert_equal, assert_not_equal, raises import sqlalchemy.orm as orm from ckan.tests.helpers import _get_test_app @@ -11,13 +10,13 @@ import ckan.plugins as p import ckan.lib.create_test_data as ctd import ckan.model as model -import ckan.tests.legacy as tests import ckan.tests.helpers as helpers import ckan.tests.factories as factories import ckanext.datastore.backend.postgres as db from ckanext.datastore.tests.helpers import ( - set_url_type, DatastoreFunctionalTestBase, DatastoreLegacyTestBase) + set_url_type, DatastoreFunctionalTestBase, DatastoreLegacyTestBase, + execute_sql, when_was_last_analyze) from ckan.plugins.toolkit import ValidationError @@ -163,7 +162,7 @@ def _has_index_on_field(self, resource_id, field): pg_class.relname = %s """ index_name = db._generate_index_name(resource_id, field) - results = self._execute_sql(sql, index_name).fetchone() + results = execute_sql(sql, index_name).fetchone() return bool(results) def _get_index_names(self, resource_id): @@ -180,14 +179,9 @@ def _get_index_names(self, resource_id): AND t.relkind = 'r' AND t.relname = %s """ - results = self._execute_sql(sql, resource_id).fetchall() + results = execute_sql(sql, resource_id).fetchall() return [result[0] for result in results] - def _execute_sql(self, sql, *args): - engine = db.get_write_engine() - session = orm.scoped_session(orm.sessionmaker(bind=engine)) - return session.connection().execute(sql, *args) - def test_sets_datastore_active_on_resource_on_create(self): resource = factories.Resource() @@ -244,6 +238,36 @@ def test_create_exceeds_column_name_limit(self): } result = helpers.call_action('datastore_create', **data) + def test_calculate_record_count_is_false(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'fields': [{'id': 'name', 'type': 'text'}, + {'id': 'age', 'type': 'text'}], + 'records': [{"name": "Sunita", "age": "51"}, + {"name": "Bowan", "age": "68"}], + 'force': True, + } + helpers.call_action('datastore_create', **data) + last_analyze = when_was_last_analyze(resource['id']) + assert_equal(last_analyze, None) + + def test_calculate_record_count(self): + # how datapusher loads data (send_resource_to_datastore) + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'fields': [{'id': 'name', 'type': 'text'}, + {'id': 'age', 'type': 'text'}], + 'records': [{"name": "Sunita", "age": "51"}, + {"name": "Bowan", "age": "68"}], + 'calculate_record_count': True, + 'force': True, + } + helpers.call_action('datastore_create', **data) + last_analyze = when_was_last_analyze(resource['id']) + assert_not_equal(last_analyze, None) + class TestDatastoreCreate(DatastoreLegacyTestBase): sysadmin_user = None diff --git a/ckanext/datastore/tests/test_delete.py b/ckanext/datastore/tests/test_delete.py index 50dfcb3e4ef..2ce59dd630c 100644 --- a/ckanext/datastore/tests/test_delete.py +++ b/ckanext/datastore/tests/test_delete.py @@ -1,29 +1,98 @@ # encoding: utf-8 import json -import nose -from nose.tools import assert_equal +from nose.tools import assert_equal, assert_not_equal, assert_raises -import sqlalchemy import sqlalchemy.orm as orm -import ckan.plugins as p import ckan.lib.create_test_data as ctd import ckan.model as model -import ckan.tests.legacy as tests from ckan.tests import helpers from ckan.plugins.toolkit import ValidationError import ckan.tests.factories as factories from ckan.logic import NotFound import ckanext.datastore.backend.postgres as db from ckanext.datastore.tests.helpers import ( - rebuild_all_dbs, set_url_type, + set_url_type, when_was_last_analyze, execute_sql, DatastoreFunctionalTestBase, DatastoreLegacyTestBase) -assert_raises = nose.tools.assert_raises +class TestDatastoreDelete(DatastoreFunctionalTestBase): + def test_delete_basic(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'aliases': u'b\xfck2', + 'fields': [{'id': 'book', 'type': 'text'}, + {'id': 'author', 'type': 'text'}, + {'id': 'rating with %', 'type': 'text'}], + 'records': [{'book': 'annakarenina', 'author': 'tolstoy', + 'rating with %': '90%'}, + {'book': 'warandpeace', 'author': 'tolstoy', + 'rating with %': '42%'}] + } + helpers.call_action('datastore_create', **data) + data = { + 'resource_id': resource['id'], + 'force': True, + } + helpers.call_action('datastore_delete', **data) + + results = execute_sql(u'select 1 from pg_views where viewname = %s', u'b\xfck2') + assert results.rowcount == 0 + + # check the table is gone + results = execute_sql( + u'''SELECT table_name + FROM information_schema.tables + WHERE table_name=%s;''', + resource['id']) + assert results.rowcount == 0 -class TestDatastoreDelete(DatastoreLegacyTestBase): + def test_calculate_record_count_is_false(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'fields': [{'id': 'name', 'type': 'text'}, + {'id': 'age', 'type': 'text'}], + 'records': [{"name": "Sunita", "age": "51"}, + {"name": "Bowan", "age": "68"}], + } + helpers.call_action('datastore_create', **data) + data = { + 'resource_id': resource['id'], + 'filters': {'name': 'Bowan'}, + 'force': True, + } + helpers.call_action('datastore_delete', **data) + last_analyze = when_was_last_analyze(resource['id']) + assert_equal(last_analyze, None) + + def test_calculate_record_count(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'fields': [{'id': 'name', 'type': 'text'}, + {'id': 'age', 'type': 'text'}], + 'records': [{"name": "Sunita", "age": "51"}, + {"name": "Bowan", "age": "68"}], + } + helpers.call_action('datastore_create', **data) + data = { + 'resource_id': resource['id'], + 'filters': {'name': 'Bowan'}, + 'calculate_record_count': True, + 'force': True, + } + helpers.call_action('datastore_delete', **data) + last_analyze = when_was_last_analyze(resource['id']) + assert_not_equal(last_analyze, None) + + +class TestDatastoreDeleteLegacy(DatastoreLegacyTestBase): sysadmin_user = None normal_user = None Session = None @@ -31,7 +100,7 @@ class TestDatastoreDelete(DatastoreLegacyTestBase): @classmethod def setup_class(cls): cls.app = helpers._get_test_app() - super(TestDatastoreDelete, cls).setup_class() + super(TestDatastoreDeleteLegacy, cls).setup_class() ctd.CreateTestData.create() cls.sysadmin_user = model.User.get('testsysadmin') cls.normal_user = model.User.get('annafan') @@ -74,32 +143,6 @@ def _delete(self): assert res_dict['result'] == data return res_dict - def test_delete_basic(self): - self._create() - self._delete() - resource_id = self.data['resource_id'] - c = self.Session.connection() - - # It's dangerous to build queries as someone could inject sql. - # It's okay here as it is a test but don't use it anyhwere else! - results = c.execute( - u"select 1 from pg_views where viewname = '{0}'".format( - self.data['aliases'] - ) - ) - assert results.rowcount == 0 - - try: - # check that data was actually deleted: this should raise a - # ProgrammingError as the table should not exist any more - c.execute(u'select * from "{0}";'.format(resource_id)) - raise Exception("Data not deleted") - except sqlalchemy.exc.ProgrammingError as e: - expected_msg = 'relation "{0}" does not exist'.format(resource_id) - assert expected_msg in str(e) - - self.Session.remove() - def test_datastore_deleted_during_resource_deletion(self): package = factories.Dataset() data = { @@ -320,7 +363,7 @@ def test_delete_nonexistant(self): else: assert 0, u'no validation error' - def test_delete_if_exitst(self): + def test_delete_if_exists(self): helpers.call_action( u'datastore_function_delete', name=u'test_not_there_either', diff --git a/ckanext/datastore/tests/test_search.py b/ckanext/datastore/tests/test_search.py index 3f4fa6b12e3..31b5dc159e0 100644 --- a/ckanext/datastore/tests/test_search.py +++ b/ckanext/datastore/tests/test_search.py @@ -100,6 +100,25 @@ def test_all_params_work_with_fields_with_whitespaces(self): result_years = [r['the year'] for r in result['records']] assert_equals(result_years, [2013]) + def test_search_total(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'records': [ + {'the year': 2014}, + {'the year': 2013}, + ], + } + result = helpers.call_action('datastore_create', **data) + search_data = { + 'resource_id': resource['id'], + 'include_total': True, + } + result = helpers.call_action('datastore_search', **search_data) + assert_equals(result['total'], 2) + assert_equals(result.get('total_was_estimated'), False) + def test_search_without_total(self): resource = factories.Resource() data = { @@ -117,6 +136,151 @@ def test_search_without_total(self): } result = helpers.call_action('datastore_search', **search_data) assert 'total' not in result + assert 'total_was_estimated' not in result + + def test_estimate_total(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'records': [{'the year': 1900 + i} for i in range(100)], + } + result = helpers.call_action('datastore_create', **data) + analyze_sql = ''' + ANALYZE "{resource}"; + '''.format(resource=resource['id']) + db.get_write_engine().execute(analyze_sql) + search_data = { + 'resource_id': resource['id'], + 'total_estimation_threshold': 50, + } + result = helpers.call_action('datastore_search', **search_data) + assert_equals(result.get('total_was_estimated'), True) + assert 95 < result['total'] < 105, result['total'] + + def test_estimate_total_with_filters(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'records': [{'the year': 1900 + i} for i in range(3)] * 10, + } + result = helpers.call_action('datastore_create', **data) + analyze_sql = ''' + ANALYZE "{resource}"; + '''.format(resource=resource['id']) + db.get_write_engine().execute(analyze_sql) + search_data = { + 'resource_id': resource['id'], + 'filters': {u'the year': 1901}, + 'total_estimation_threshold': 5, + } + result = helpers.call_action('datastore_search', **search_data) + assert_equals(result['total'], 10) + # estimation is not compatible with filters + assert_equals(result.get('total_was_estimated'), False) + + def test_estimate_total_with_distinct(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'records': [{'the year': 1900 + i} for i in range(3)] * 10, + } + result = helpers.call_action('datastore_create', **data) + analyze_sql = ''' + ANALYZE "{resource}"; + '''.format(resource=resource['id']) + db.get_write_engine().execute(analyze_sql) + search_data = { + 'resource_id': resource['id'], + 'fields': ['the year'], + 'distinct': True, + 'total_estimation_threshold': 1, + } + result = helpers.call_action('datastore_search', **search_data) + assert_equals(result['total'], 3) + # estimation is not compatible with distinct + assert_equals(result.get('total_was_estimated'), False) + + def test_estimate_total_where_analyze_is_not_already_done(self): + # ANALYSE is done by latest datapusher/xloader, but need to cope in + # if tables created in other ways which may not have had an ANALYSE + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'records': [{'the year': 1900 + i} for i in range(100)], + } + result = helpers.call_action('datastore_create', **data) + search_data = { + 'resource_id': resource['id'], + 'total_estimation_threshold': 50, + } + result = helpers.call_action('datastore_search', **search_data) + assert_equals(result.get('total_was_estimated'), True) + assert 95 < result['total'] < 105, result['total'] + + def test_estimate_total_with_zero_threshold(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'records': [{'the year': 1900 + i} for i in range(100)], + } + result = helpers.call_action('datastore_create', **data) + analyze_sql = ''' + ANALYZE "{resource}"; + '''.format(resource=resource['id']) + db.get_write_engine().execute(analyze_sql) + search_data = { + 'resource_id': resource['id'], + 'total_estimation_threshold': 0, + } + result = helpers.call_action('datastore_search', **search_data) + # threshold of 0 means always estimate + assert_equals(result.get('total_was_estimated'), True) + assert 95 < result['total'] < 105, result['total'] + + def test_estimate_total_off(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'records': [{'the year': 1900 + i} for i in range(100)], + } + result = helpers.call_action('datastore_create', **data) + analyze_sql = ''' + ANALYZE "{resource}"; + '''.format(resource=resource['id']) + db.get_write_engine().execute(analyze_sql) + search_data = { + 'resource_id': resource['id'], + 'total_estimation_threshold': None, + } + result = helpers.call_action('datastore_search', **search_data) + # threshold of None means don't estimate + assert_equals(result.get('total_was_estimated'), False) + + def test_estimate_total_default_off(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'records': [{'the year': 1900 + i} for i in range(100)], + } + result = helpers.call_action('datastore_create', **data) + analyze_sql = ''' + ANALYZE "{resource}"; + '''.format(resource=resource['id']) + db.get_write_engine().execute(analyze_sql) + search_data = { + 'resource_id': resource['id'], + # don't specify total_estimation_threshold + } + result = helpers.call_action('datastore_search', **search_data) + # default threshold is None, meaning don't estimate + assert_equals(result.get('total_was_estimated'), False) class TestDatastoreSearchLegacyTests(DatastoreLegacyTestBase): diff --git a/ckanext/datastore/tests/test_upsert.py b/ckanext/datastore/tests/test_upsert.py index 57e56160587..58d88e5166e 100644 --- a/ckanext/datastore/tests/test_upsert.py +++ b/ckanext/datastore/tests/test_upsert.py @@ -1,26 +1,21 @@ # encoding: utf-8 import json -import nose import datetime +from nose.tools import assert_equal, assert_not_equal import sqlalchemy.orm as orm -import ckan.plugins as p import ckan.lib.create_test_data as ctd import ckan.model as model -import ckan.tests.legacy as tests import ckan.tests.helpers as helpers import ckan.tests.factories as factories from ckan.plugins.toolkit import ValidationError -from ckan.common import config - import ckanext.datastore.backend.postgres as db from ckanext.datastore.tests.helpers import ( - set_url_type, DatastoreFunctionalTestBase, DatastoreLegacyTestBase) - -assert_equal = nose.tools.assert_equal + set_url_type, DatastoreFunctionalTestBase, DatastoreLegacyTestBase, + when_was_last_analyze) class TestDatastoreUpsert(DatastoreFunctionalTestBase): @@ -135,6 +130,47 @@ def test_dry_run_trigger_error(self): else: assert 0, 'error not raised' + def test_calculate_record_count_is_false(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'fields': [{'id': 'name', 'type': 'text'}, + {'id': 'age', 'type': 'text'}], + } + helpers.call_action('datastore_create', **data) + data = { + 'resource_id': resource['id'], + 'force': True, + 'method': 'insert', + 'records': [{"name": "Sunita", "age": "51"}, + {"name": "Bowan", "age": "68"}], + } + helpers.call_action('datastore_upsert', **data) + last_analyze = when_was_last_analyze(resource['id']) + assert_equal(last_analyze, None) + + def test_calculate_record_count(self): + resource = factories.Resource() + data = { + 'resource_id': resource['id'], + 'force': True, + 'fields': [{'id': 'name', 'type': 'text'}, + {'id': 'age', 'type': 'text'}], + } + helpers.call_action('datastore_create', **data) + data = { + 'resource_id': resource['id'], + 'force': True, + 'method': 'insert', + 'records': [{"name": "Sunita", "age": "51"}, + {"name": "Bowan", "age": "68"}], + 'calculate_record_count': True + } + helpers.call_action('datastore_upsert', **data) + last_analyze = when_was_last_analyze(resource['id']) + assert_not_equal(last_analyze, None) + class TestDatastoreUpsertLegacyTests(DatastoreLegacyTestBase): sysadmin_user = None diff --git a/ckanext/datatablesview/controller.py b/ckanext/datatablesview/controller.py index 1af6fc49ddf..06d7e40d721 100644 --- a/ckanext/datatablesview/controller.py +++ b/ckanext/datatablesview/controller.py @@ -1,10 +1,11 @@ # encoding: utf-8 -import json +from urllib import urlencode from six import text_type -from ckan.plugins.toolkit import BaseController, get_action, request +from ckan.plugins.toolkit import BaseController, get_action, request, h +from ckan.common import json class DataTablesController(BaseController): @@ -62,6 +63,50 @@ def ajax(self, resource_view_id): ], }) + def filtered_download(self, resource_view_id): + params = json.loads(request.params['params']) + resource_view = get_action(u'resource_view_show')( + None, {u'id': resource_view_id}) + + search_text = text_type(params['search']['value']) + view_filters = resource_view.get(u'filters', {}) + user_filters = text_type(params['filters']) + filters = merge_filters(view_filters, user_filters) + + datastore_search = get_action(u'datastore_search') + unfiltered_response = datastore_search(None, { + u"resource_id": resource_view[u'resource_id'], + u"limit": 0, + u"filters": view_filters, + }) + + cols = [f['id'] for f in unfiltered_response['fields']] + if u'show_fields' in resource_view: + cols = [c for c in cols if c in resource_view['show_fields']] + + sort_list = [] + for order in params['order']: + sort_by_num = int(order['column']) + sort_order = ( + u'desc' if order['dir'] == u'desc' + else u'asc') + sort_list.append(cols[sort_by_num] + u' ' + sort_order) + + cols = [c for (c, v) in zip(cols, params['visible']) if v] + + h.redirect_to( + h.url_for( + controller=u'ckanext.datastore.controller:DatastoreController', + action=u'dump', + resource_id=resource_view[u'resource_id']) + + u'?' + urlencode({ + u'q': search_text, + u'sort': u','.join(sort_list), + u'filters': json.dumps(filters), + u'format': request.params['format'], + u'fields': u','.join(cols), + })) + def merge_filters(view_filters, user_filters_str): u''' diff --git a/ckanext/datatablesview/plugin.py b/ckanext/datatablesview/plugin.py index f5cb7031401..ac267c6f0b4 100644 --- a/ckanext/datatablesview/plugin.py +++ b/ckanext/datatablesview/plugin.py @@ -58,4 +58,9 @@ def before_map(self, m): controller=u'ckanext.datatablesview.controller' u':DataTablesController', action=u'ajax') + m.connect( + u'/datatables/filtered-download/{resource_view_id}', + controller=u'ckanext.datatablesview.controller' + u':DataTablesController', + action=u'filtered_download') return m diff --git a/ckanext/datatablesview/public/datatablesview.js b/ckanext/datatablesview/public/datatablesview.js index a273bf32f15..eb843718c5a 100644 --- a/ckanext/datatablesview/public/datatablesview.js +++ b/ckanext/datatablesview/public/datatablesview.js @@ -1,7 +1,53 @@ +var run_query = function(params, format) { + var form = $('#filtered-datatables-download'); + var p = $(''); + p.attr("value", JSON.stringify(params)); + form.append(p); + var f = $(''); + f.attr("value", format); + form.append(f); + form.submit(); +} + this.ckan.module('datatables_view', function (jQuery) { return { initialize: function() { - jQuery('#dtprv').DataTable({}); + var datatable = jQuery('#dtprv').DataTable({}); + + // Adds download dropdown to buttons menu + datatable.button().add(2, { + text: 'Download', + extend: 'collection', + buttons: [{ + text: 'CSV', + action: function (e, dt, button, config) { + var params = datatable.ajax.params(); + params.visible = datatable.columns().visible().toArray(); + run_query(params, 'csv'); + } + }, { + text: 'TSV', + action: function (e, dt, button, config) { + var params = datatable.ajax.params(); + params.visible = datatable.columns().visible().toArray(); + run_query(params, 'tsv'); + } + }, { + text: 'JSON', + action: function (e, dt, button, config) { + var params = datatable.ajax.params(); + params.visible = datatable.columns().visible().toArray(); + run_query(params, 'json'); + } + }, { + text: 'XML', + action: function (e, dt, button, config) { + var params = datatable.ajax.params(); + params.visible = datatable.columns().visible().toArray(); + run_query(params, 'xml'); + } + }] + }); } } }); diff --git a/ckanext/datatablesview/templates/datatables/datatables_view.html b/ckanext/datatablesview/templates/datatables/datatables_view.html index 0d20f3f316f..1c1b487185e 100644 --- a/ckanext/datatablesview/templates/datatables/datatables_view.html +++ b/ckanext/datatablesview/templates/datatables/datatables_view.html @@ -8,9 +8,12 @@ data-server-side="true" data-processing="true" data-ajax='{ - "url": "/datatables/ajax/{{ resource_view.id }}", + "url": "{{ h.url_for( + controller='ckanext.datatablesview.controller:DataTablesController', + action='ajax', + resource_view_id=resource_view.id) }}", "type": "POST", - "data": { "filters": "{{ request.params.get('filters', '')|e }}" } + "data": { "filters": "{{ request.args.get('filters', '')|e }}" } }' {% if resource_view.get('responsive') %} data-responsive="true" @@ -26,7 +29,7 @@ { "extend": "colvis", "text": "{{ _('Hide/Unhide Columns') }}" - }{{ ', "copy", "excel", "print"' | safe + }{{ ', "copy", "print"' | safe if resource_view.get('export_buttons') else ''}} ]' data-keys='true'> @@ -43,6 +46,12 @@ +
    + +
    {% resource 'ckanext-datatablesview/main' %} {% endblock %} diff --git a/ckanext/reclineview/theme/public/vendor/ckan.js/ckan.js b/ckanext/reclineview/theme/public/vendor/ckan.js/ckan.js index 82a6472a5dc..184ec4b5248 100644 --- a/ckanext/reclineview/theme/public/vendor/ckan.js/ckan.js +++ b/ckanext/reclineview/theme/public/vendor/ckan.js/ckan.js @@ -10,7 +10,7 @@ if (isNodeModule) { } (function(my) { - my.Client = function(endpoint, apiKey) { + my.Client = function(endpoint, apiKey) { this.endpoint = _getEndpoint(endpoint); this.apiKey = apiKey; }; @@ -51,7 +51,8 @@ if (isNodeModule) { var out = { total: results.result.total, fields: fields, - hits: results.result.records + hits: results.result.records, + total_was_estimated: results.result.total_was_estimated }; cb(null, out); }); @@ -67,7 +68,7 @@ if (isNodeModule) { 'bool': 'boolean', }; - // + // my.jsonTableSchema2CkanTypes = { 'string': 'text', 'number': 'float', @@ -128,7 +129,7 @@ if (isNodeModule) { code: obj.status, message: obj.responseText } - cb(err); + cb(err); } if (options.headers) { options.beforeSend = function(req) { @@ -147,7 +148,8 @@ if (isNodeModule) { q: queryObj.q, filters: {}, limit: queryObj.size || 10, - offset: queryObj.from || 0 + offset: queryObj.from || 0, + total_estimation_threshold: 1000 }; if (queryObj.sort && queryObj.sort.length > 0) { @@ -188,7 +190,7 @@ if (isNodeModule) { // This provides connection to the CKAN DataStore (v2) // // General notes -// +// // We need 2 things to make most requests: // // 1. CKAN API endpoint @@ -196,13 +198,13 @@ if (isNodeModule) { // // There are 2 ways to specify this information. // -// EITHER (checked in order): +// EITHER (checked in order): // // * Every dataset must have an id equal to its resource id on the CKAN instance // * The dataset has an endpoint attribute pointing to the CKAN API endpoint // // OR: -// +// // Set the url attribute of the dataset to point to the Resource on the CKAN instance. The endpoint and id will then be automatically computed. var recline = recline || {}; recline.Backend = recline.Backend || {}; diff --git a/ckanext/reclineview/theme/public/vendor/recline/recline.js b/ckanext/reclineview/theme/public/vendor/recline/recline.js index 25caa0520c6..5f0954b411d 100755 --- a/ckanext/reclineview/theme/public/vendor/recline/recline.js +++ b/ckanext/reclineview/theme/public/vendor/recline/recline.js @@ -422,6 +422,7 @@ my.Dataset = Backbone.Model.extend({ }; this.facets = new my.FacetList(); this.recordCount = null; + this.recordCountWasEstimated = null; this.queryState = new my.Query(); this.queryState.bind('change facet:add', function () { self.query(); // We want to call query() without any arguments. @@ -602,6 +603,10 @@ my.Dataset = Backbone.Model.extend({ _handleQueryResult: function(queryResult) { var self = this; self.recordCount = queryResult.total; + self.recordCountWasEstimated = queryResult.total_was_estimated; + if (self.recordCountWasEstimated) { + self.recordCount = Math.floor((self.recordCount + 500)/1000) + '000'; + } var docs = _.map(queryResult.hits, function(hit) { var _doc = new my.Record(hit); _doc.fields = self.fields; @@ -626,6 +631,7 @@ my.Dataset = Backbone.Model.extend({ toTemplateJSON: function() { var data = this.toJSON(); data.recordCount = this.recordCount; + data.recordCountWasEstimated = this.recordCountWasEstimated; data.fields = this.fields.toJSON(); return data; }, @@ -2619,7 +2625,10 @@ my.MultiView = Backbone.View.extend({
    \
    \
    \ - {{recordCount}} records\ + {{#recordCountWasEstimated}} \ + about \ + {{/recordCountWasEstimated}} \ + {{recordCount}} records \
    \