diff --git a/ckan/__init__.py b/ckan/__init__.py index e12d19e7d41..2cdfbbacca4 100644 --- a/ckan/__init__.py +++ b/ckan/__init__.py @@ -1,4 +1,4 @@ -__version__ = '2.4a' +__version__ = '2.5.0a' __description__ = 'CKAN Software' __long_description__ = \ diff --git a/ckan/config/environment.py b/ckan/config/environment.py index acadbb39413..0bd9416d4df 100644 --- a/ckan/config/environment.py +++ b/ckan/config/environment.py @@ -233,23 +233,31 @@ def genshi_lookup_attr(cls, obj, key): # A mapping of config settings that can be overridden by env vars. +# Note: Do not remove the following lines, they are used in the docs +# Start CONFIG_FROM_ENV_VARS CONFIG_FROM_ENV_VARS = { 'sqlalchemy.url': 'CKAN_SQLALCHEMY_URL', 'ckan.datastore.write_url': 'CKAN_DATASTORE_WRITE_URL', 'ckan.datastore.read_url': 'CKAN_DATASTORE_READ_URL', 'solr_url': 'CKAN_SOLR_URL', 'ckan.site_id': 'CKAN_SITE_ID', + 'ckan.site_url': 'CKAN_SITE_URL', + 'ckan.storage_path': 'CKAN_STORAGE_PATH', + 'ckan.datapusher.url': 'CKAN_DATAPUSHER_URL', 'smtp.server': 'CKAN_SMTP_SERVER', 'smtp.starttls': 'CKAN_SMTP_STARTTLS', 'smtp.user': 'CKAN_SMTP_USER', 'smtp.password': 'CKAN_SMTP_PASSWORD', 'smtp.mail_from': 'CKAN_SMTP_MAIL_FROM' } +# End CONFIG_FROM_ENV_VARS def update_config(): ''' This code needs to be run when the config is changed to take those - changes into account. ''' + changes into account. It is called whenever a plugin is loaded as the + plugin might have changed the config values (for instance it might + change ckan.site_url) ''' for plugin in p.PluginImplementations(p.IConfigurer): # must do update in place as this does not work: @@ -274,6 +282,18 @@ def update_config(): root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) site_url = config.get('ckan.site_url', '') + if not site_url: + raise RuntimeError( + 'ckan.site_url is not configured and it must have a value.' + ' Please amend your .ini file.') + if not site_url.lower().startswith('http'): + raise RuntimeError( + 'ckan.site_url should be a full URL, including the schema ' + '(http or https)') + + # Remove backslash from site_url if present + config['ckan.site_url'] = config['ckan.site_url'].rstrip('/') + ckan_host = config['ckan.host'] = urlparse(site_url).netloc if config.get('ckan.site_id') is None: if ':' in ckan_host: diff --git a/ckan/config/routing.py b/ckan/config/routing.py index 9402211eda5..4f92b715991 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -174,6 +174,8 @@ def make_map(): m.connect('/util/resource/format_icon', action='format_icon', conditions=GET) m.connect('/util/group/autocomplete', action='group_autocomplete') + m.connect('/util/organization/autocomplete', action='organization_autocomplete', + conditions=GET) m.connect('/util/markdown', action='markdown') m.connect('/util/dataset/munge_name', action='munge_package_name') m.connect('/util/dataset/munge_title_to_name', diff --git a/ckan/controllers/admin.py b/ckan/controllers/admin.py index 3ff7eca70ad..5d103a1e054 100644 --- a/ckan/controllers/admin.py +++ b/ckan/controllers/admin.py @@ -3,13 +3,18 @@ import ckan.lib.base as base import ckan.lib.helpers as h import ckan.lib.app_globals as app_globals +import ckan.lib.navl.dictization_functions as dict_fns import ckan.model as model import ckan.logic as logic +import ckan.plugins as plugins +from home import CACHE_PARAMETERS + c = base.c request = base.request _ = base._ + def get_sysadmins(): q = model.Session.query(model.User).filter(model.User.sysadmin==True) return q.all() @@ -51,6 +56,15 @@ def _get_config_form_items(self): return items def reset_config(self): + '''FIXME: This method is probably not doing what people would expect. + It will reset the configuration to values cached when CKAN started. + If these were coming from the database during startup, that's the + ones that will get applied on reset, not the ones in the ini file. + Only after restarting the server and having CKAN reset the values + from the ini file (as the db ones are not there anymore) will these + be used. + ''' + if 'cancel' in request.params: h.redirect_to(controller='admin', action='config') @@ -58,7 +72,7 @@ def reset_config(self): # remove sys info items for item in self._get_config_form_items(): name = item['name'] - app_globals.delete_global(name) + model.delete_system_info(name) # reset to values in config app_globals.reset() h.redirect_to(controller='admin', action='config') @@ -70,22 +84,35 @@ def config(self): items = self._get_config_form_items() data = request.POST if 'save' in data: - # update config from form - for item in items: - name = item['name'] - if name in data: - app_globals.set_global(name, data[name]) - app_globals.reset() + try: + # really? + data_dict = logic.clean_dict( + dict_fns.unflatten( + logic.tuplize_dict( + logic.parse_params( + request.POST, ignore_keys=CACHE_PARAMETERS)))) + + del data_dict['save'] + + data = logic.get_action('config_option_update')( + {'user': c.user}, data_dict) + except logic.ValidationError, e: + errors = e.error_dict + error_summary = e.error_summary + vars = {'data': data, 'errors': errors, + 'error_summary': error_summary, 'form_items': items} + return base.render('admin/config.html', extra_vars=vars) + h.redirect_to(controller='admin', action='config') + schema = logic.schema.update_configuration_schema() data = {} - for item in items: - name = item['name'] - data[name] = config.get(name) + for key in schema: + data[key] = config.get(key) vars = {'data': data, 'errors': {}, 'form_items': items} return base.render('admin/config.html', - extra_vars = vars) + extra_vars=vars) def index(self): #now pass the list of sysadmins diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index ddd18f688e2..e114253f919 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -673,6 +673,18 @@ def convert_to_dict(user): out = map(convert_to_dict, query.all()) return out + @jsonp.jsonpify + def organization_autocomplete(self): + q = request.params.get('q', '') + limit = request.params.get('limit', 20) + organization_list = [] + + if q: + context = {'user': c.user, 'model': model} + data_dict = {'q': q, 'limit': limit} + organization_list = get_action('organization_autocomplete')(context, data_dict) + return organization_list + def is_slug_valid(self): def package_exists(val): diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index abe149db307..3377f7bb98b 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -625,9 +625,9 @@ def delete(self, id): try: if request.method == 'POST': self._action('group_delete')(context, {'id': id}) - if self.group_type == 'organization': + if group_type == 'organization': h.flash_notice(_('Organization has been deleted.')) - elif self.group_type == 'group': + elif group_type == 'group': h.flash_notice(_('Group has been deleted.')) else: h.flash_notice(_('%s has been deleted.') diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py index 506df9f319f..d1470f1e377 100644 --- a/ckan/controllers/user.py +++ b/ckan/controllers/user.py @@ -12,6 +12,7 @@ import ckan.lib.captcha as captcha import ckan.lib.mailer as mailer import ckan.lib.navl.dictization_functions as dictization_functions +import ckan.lib.authenticator as authenticator import ckan.plugins as p from ckan.common import _, c, g, request, response @@ -27,6 +28,7 @@ NotFound = logic.NotFound NotAuthorized = logic.NotAuthorized ValidationError = logic.ValidationError +UsernamePasswordError = logic.UsernamePasswordError DataError = dictization_functions.DataError unflatten = dictization_functions.unflatten @@ -136,8 +138,7 @@ def me(self, locale=None): h.redirect_to(locale=locale, controller='user', action='login', id=None) user_ref = c.userobj.get_reference_preferred_for_uri() - h.redirect_to(locale=locale, controller='user', action='dashboard', - id=user_ref) + h.redirect_to(locale=locale, controller='user', action='dashboard') def register(self, data=None, errors=None, error_summary=None): context = {'model': model, 'session': model.Session, 'user': c.user, @@ -324,6 +325,14 @@ def _save_edit(self, id, context): context['message'] = data_dict.get('log_message', '') data_dict['id'] = id + if data_dict['password1'] and data_dict['password2']: + identity = {'login': c.user, + 'password': data_dict['old_password']} + auth = authenticator.UsernamePasswordAuthenticator() + + if auth.authenticate(request.environ, identity) != c.user: + raise UsernamePasswordError + # MOAN: Do I really have to do this here? if 'activity_streams_email_notifications' not in data_dict: data_dict['activity_streams_email_notifications'] = False @@ -341,6 +350,10 @@ def _save_edit(self, id, context): errors = e.error_dict error_summary = e.error_summary return self.edit(id, data_dict, errors, error_summary) + except UsernamePasswordError: + errors = {'oldpassword': [_('Password entered was incorrect')]} + error_summary = {_('Old Password'): _('incorrect password')} + return self.edit(id, data_dict, errors, error_summary) def login(self, error=None): # Do any plugin login stuff diff --git a/ckan/lib/app_globals.py b/ckan/lib/app_globals.py index 8e5036ff3d8..d7b0a67dbce 100644 --- a/ckan/lib/app_globals.py +++ b/ckan/lib/app_globals.py @@ -10,6 +10,8 @@ import ckan import ckan.model as model +import ckan.logic as logic + log = logging.getLogger(__name__) @@ -20,19 +22,17 @@ # 'config_key': 'globals_key', } -# these config settings will get updated from system_info -auto_update = [ - 'ckan.site_title', - 'ckan.site_logo', - 'ckan.site_url', - 'ckan.site_description', - 'ckan.site_about', - 'ckan.site_intro_text', - 'ckan.site_custom_css', - 'ckan.homepage_style', -] - -config_details = { + +# This mapping is only used to define the configuration options (from the +# `config` object) that should be copied to the `app_globals` (`g`) object. +app_globals_from_config_details = { + 'ckan.site_title': {}, + 'ckan.site_logo': {}, + 'ckan.site_url': {}, + 'ckan.site_description': {}, + 'ckan.site_about': {}, + 'ckan.site_intro_text': {}, + 'ckan.site_custom_css': {}, 'ckan.favicon': {}, # default gets set in config.environment.py 'ckan.template_head_end': {}, 'ckan.template_footer_end': {}, @@ -84,18 +84,40 @@ def set_main_css(css_file): app_globals.main_css = str(new_css) -def set_global(key, value): - ''' helper function for getting value from database or config file ''' - model.set_system_info(key, value) - setattr(app_globals, get_globals_key(key), value) - model.set_system_info('ckan.config_update', str(time.time())) - # update the config - config[key] = value - log.info('config `%s` set to `%s`' % (key, value)) +def set_app_global(key, value): + ''' + Set a new key on the app_globals (g) object + + It will process the value according to the options on + app_globals_from_config_details (if any) + ''' + key, value = process_app_global(key, value) + setattr(app_globals, key, value) + + +def process_app_global(key, value): + ''' + Tweak a key, value pair meant to be set on the app_globals (g) object + + According to the options on app_globals_from_config_details (if any) + ''' + options = app_globals_from_config_details.get(key) + key = get_globals_key(key) + if options: + if 'name' in options: + key = options['name'] + value = value or options.get('default', '') + + data_type = options.get('type') + if data_type == 'bool': + value = asbool(value) + elif data_type == 'int': + value = int(value) + elif data_type == 'split': + value = value.split() + + return key, value -def delete_global(key): - model.delete_system_info(key) - log.info('config `%s` deleted' % (key)) def get_globals_key(key): # create our globals key @@ -106,6 +128,9 @@ def get_globals_key(key): return mappings[key] elif key.startswith('ckan.'): return key[5:] + else: + return key + def reset(): ''' set updatable values from config ''' @@ -133,13 +158,16 @@ def get_config_value(key, default=''): log.debug('config `%s` set to `%s` from config' % (key, value)) else: value = default - setattr(app_globals, get_globals_key(key), value) + + set_app_global(key, value) + # update the config config[key] = value return value # update the config settings in auto update - for key in auto_update: + schema = logic.schema.update_configuration_schema() + for key in schema.keys(): get_config_value(key) # cusom styling @@ -158,8 +186,6 @@ def get_config_value(key, default=''): app_globals.header_class = 'header-text-logo-tagline' - - class _Globals(object): ''' Globals acts as a container for objects available throughout the @@ -193,25 +219,10 @@ def _init(self): else: self.ckan_doc_version = 'latest' - # process the config_details to set globals - for name, options in config_details.items(): - if 'name' in options: - key = options['name'] - elif name.startswith('ckan.'): - key = name[5:] - else: - key = name - value = config.get(name, options.get('default', '')) - - data_type = options.get('type') - if data_type == 'bool': - value = asbool(value) - elif data_type == 'int': - value = int(value) - elif data_type == 'split': - value = value.split() - - setattr(self, key, value) + # process the config details to set globals + for key in app_globals_from_config_details.keys(): + new_key, value = process_app_global(key, config.get(key) or '') + setattr(self, new_key, value) app_globals = _Globals() diff --git a/ckan/lib/create_test_data.py b/ckan/lib/create_test_data.py index 42b5a937e92..5704db10b3a 100644 --- a/ckan/lib/create_test_data.py +++ b/ckan/lib/create_test_data.py @@ -492,6 +492,7 @@ def create(cls, auth_profile="", package_type=None): title=u'Roger\'s books', description=u'Roger likes these books.', type=auth_profile or 'group') + for obj in [david, roger]: model.Session.add(obj) diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py index fe0a0f14fc9..141fc958525 100644 --- a/ckan/lib/dictization/model_dictize.py +++ b/ckan/lib/dictization/model_dictize.py @@ -361,14 +361,10 @@ def group_dictize(group, context, like tags are included unless you specify it in the params. :param packages_field: determines the format of the `packages` field - can - be `datasets`, `dataset_count`, `none_but_include_package_count` or None. - If set to `dataset_count` or `none_but_include_package_count` then you - can precalculate dataset counts in advance by supplying: - context['dataset_counts'] = get_group_dataset_counts() + be `datasets` or None. ''' - assert packages_field in ('datasets', 'dataset_count', - 'none_but_include_package_count', None) - if packages_field in ('dataset_count', 'none_but_include_package_count'): + assert packages_field in ('datasets', 'dataset_count', None) + if packages_field == 'dataset_count': dataset_counts = context.get('dataset_counts', None) result_dict = d.table_dictize(group, context) @@ -417,12 +413,11 @@ def get_packages_for_this_group(group_, just_the_count=False): search_results = logic.get_action('package_search')(search_context, q) return search_results['count'], search_results['results'] + if packages_field == 'datasets': package_count, packages = get_packages_for_this_group(group) result_dict['packages'] = packages else: - # i.e. packages_field is 'dataset_count' or - # 'none_but_include_package_count' if dataset_counts is None: package_count, packages = get_packages_for_this_group( group, just_the_count=True) @@ -433,8 +428,6 @@ def get_packages_for_this_group(group_, just_the_count=False): package_count = facets['owner_org'].get(group.id, 0) else: package_count = facets['groups'].get(group.name, 0) - if packages_field != 'none_but_include_package_count': - result_dict['packages'] = package_count result_dict['package_count'] = package_count diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index f604da3ab7d..b0db46aaf31 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -2032,6 +2032,22 @@ def get_organization(org=None, include_datasets=False): except (NotFound, ValidationError, NotAuthorized): return {} + +def license_options(existing_license_id=None): + '''Returns [(l.title, l.id), ...] for the licenses configured to be + offered. Always includes the existing_license_id, if supplied. + ''' + register = model.Package.get_license_register() + sorted_licenses = sorted(register.values(), key=lambda x: x.title) + license_ids = [license.id for license in sorted_licenses] + if existing_license_id and existing_license_id not in license_ids: + license_ids.insert(0, existing_license_id) + return [ + (license_id, + register[license_id].title if license_id in register else license_id) + for license_id in license_ids] + + # these are the functions that will end up in `h` template helpers __allowed_functions__ = [ # functions defined in ckan.lib.helpers @@ -2150,4 +2166,5 @@ def get_organization(org=None, include_datasets=False): 'urlencode', 'check_config_permission', 'view_resource_url', + 'license_options', ] diff --git a/ckan/logic/__init__.py b/ckan/logic/__init__.py index 177e5c29d88..883b15c121c 100644 --- a/ckan/logic/__init__.py +++ b/ckan/logic/__init__.py @@ -20,6 +20,10 @@ class NameConflict(Exception): pass +class UsernamePasswordError(Exception): + pass + + class AttributeDict(dict): def __getattr__(self, name): try: diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index 28c9103203d..0d7a5c220c2 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -213,7 +213,9 @@ def package_create(context, data_dict): # Create default views for resources if necessary if data.get('resources'): logic.get_action('package_create_default_resource_views')( - context, {'package': data}) + {'model': context['model'], 'user': context['user'], + 'ignore_auth': True}, + {'package': data}) if not context.get('defer_commit'): model.repo.commit() @@ -786,7 +788,14 @@ def _group_or_org_create(context, data_dict, is_org=False): logic.get_action('member_create')(member_create_context, member_dict) log.debug('Created object %s' % group.name) - return model_dictize.group_dictize(group, context) + + return_id_only = context.get('return_id_only', False) + action = 'organization_show' if is_org else 'group_show' + + output = context['id'] if return_id_only \ + else _get_action(action)(context, {'id': group.id}) + + return output def group_create(context, data_dict): @@ -846,7 +855,9 @@ def group_create(context, data_dict): a member of the group) :type users: list of dictionaries - :returns: the newly created group + :returns: the newly created group (unless 'return_id_only' is set to True + in the context, in which case just the group id will + be returned) :rtype: dictionary ''' @@ -905,7 +916,9 @@ def organization_create(context, data_dict): in which the user is a member of the organization) :type users: list of dictionaries - :returns: the newly created organization + :returns: the newly created organization (unless 'return_id_only' is set + to True in the context, in which case just the organization id + will be returned) :rtype: dictionary ''' diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 29afc0c0e8d..1596563bb7c 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -378,6 +378,7 @@ def _group_or_org_list(context, data_dict, is_org=False): log.warn('`order_by` deprecated please use `sort`') if not data_dict.get('sort'): sort = order_by + # if the sort is packages and no sort direction is supplied we want to do a # reverse sort to maintain compatibility. if sort.strip() in ('packages', 'package_count'): @@ -413,22 +414,16 @@ def _group_or_org_list(context, data_dict, is_org=False): query = query.filter(model.Group.type == group_type) groups = query.all() - if all_fields: - include_tags = asbool(data_dict.get('include_tags', False)) - else: - include_tags = False - # even if we are not going to return all_fields, we need to dictize all the - # groups so that we can sort by any field. - group_list = model_dictize.group_list_dictize( - groups, context, - sort_key=lambda x: x[sort_info[0][0]], - reverse=sort_info[0][1] == 'desc', - with_package_counts=all_fields or - sort_info[0][0] in ('packages', 'package_count'), - include_groups=asbool(data_dict.get('include_groups', False)), - include_tags=include_tags, - include_extras=include_extras, - ) + + action = 'organization_show' if is_org else 'group_show' + + group_list = [] + for group in groups: + data_dict['id'] = group.id + group_list.append(logic.get_action(action)(context, data_dict)) + + group_list = sorted(group_list, key=lambda x: x[sort_info[0][0]], + reverse=sort_info[0][1] == 'desc') if not all_fields: group_list = [group[ref_group_by] for group in group_list] @@ -463,7 +458,7 @@ def group_list(context, data_dict): (optional, default: ``False``) :type include_tags: boolean :param include_groups: if all_fields, include the groups the groups are in - (optional, default: ``False``) + (optional, default: ``False``). :type include_groups: boolean :rtype: list of strings @@ -1158,8 +1153,7 @@ def _group_or_org_show(context, data_dict, is_org=False): context['group'] = group include_datasets = asbool(data_dict.get('include_datasets', False)) - packages_field = 'datasets' if include_datasets \ - else 'none_but_include_package_count' + packages_field = 'datasets' if include_datasets else 'dataset_count' if group is None: raise NotFound @@ -1558,6 +1552,38 @@ def user_autocomplete(context, data_dict): return user_list +def organization_autocomplete(context, data_dict): + ''' + Return a list of organization names that contain a string. + + :param q: the string to search for + :type q: string + :param limit: the maximum number of organizations to return (optional, + default: 20) + :type limit: int + + :rtype: a list of organization dictionaries each with keys ``'name'``, + ``'title'``, and ``'id'`` + ''' + + _check_access('organization_autocomplete', context, data_dict) + + q = data_dict['q'] + limit = data_dict.get('limit', 20) + model = context['model'] + + query = model.Group.search_by_name_or_title(q, group_type=None, is_org=True) + + organization_list = [] + for organization in query.all(): + result_dict = {} + for k in ['id', 'name', 'title']: + result_dict[k] = getattr(organization, k) + organization_list.append(result_dict) + + return organization_list + + def package_search(context, data_dict): ''' Searches for packages satisfying a given search criteria. @@ -3376,3 +3402,51 @@ def help_show(context, data_dict): raise NotFound('Action function not found') return function.__doc__ + + +def config_option_show(context, data_dict): + '''Show the current value of a particular configuration option. + + Only returns runtime-editable config options (the ones returned by + :py:func:`~ckan.logic.action.get.config_option_list`), which can be updated with the + :py:func:`~ckan.logic.action.update.config_option_update` action. + + :param id: The configuration option key + :type id: string + + :returns: The value of the config option from either the system_info table + or ini file. + :rtype: string + + :raises: :class:`ckan.logic.ValidationError`: if config option is not in + the schema (whitelisted as editable). + ''' + + _check_access('config_option_show', context, data_dict) + + key = _get_or_bust(data_dict, 'key') + + schema = ckan.logic.schema.update_configuration_schema() + + # Only return whitelisted keys + if key not in schema: + raise ValidationError( + 'Configuration option \'{0}\' can not be shown'.format(key)) + + # return the value from config + return config.get(key, None) + + +def config_option_list(context, data_dict): + '''Return a list of runtime-editable config options keys that can be + updated with :py:func:`~ckan.logic.action.update.config_option_update`. + + :returns: A list of config option keys. + :rtype: list + ''' + + _check_access('config_option_list', context, data_dict) + + schema = ckan.logic.schema.update_configuration_schema() + + return schema.keys() diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index 9288f507899..f87b81ad078 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -2,6 +2,7 @@ import logging import datetime +import time import json from pylons import config @@ -20,6 +21,8 @@ import ckan.lib.search as search import ckan.lib.uploader as uploader import ckan.lib.datapreview +import ckan.lib.app_globals as app_globals + from ckan.common import _, request @@ -363,7 +366,9 @@ def package_update(context, data_dict): # Create default views for resources if necessary if data.get('resources'): logic.get_action('package_create_default_resource_views')( - context, {'package': data}) + {'model': context['model'], 'user': context['user'], + 'ignore_auth': True}, + {'package': data}) if not context.get('defer_commit'): model.repo.commit() @@ -1308,3 +1313,93 @@ def bulk_update_delete(context, data_dict): _check_access('bulk_update_delete', context, data_dict) _bulk_update_dataset(context, data_dict, {'state': 'deleted'}) + + +def config_option_update(context, data_dict): + ''' + + .. versionadded:: 2.4 + + Allows to modify some CKAN runtime-editable config options + + It takes arbitrary key, value pairs and checks the keys against the + config options update schema. If some of the provided keys are not present + in the schema a :py:class:`~ckan.plugins.logic.ValidationError` is raised. + The values are then validated against the schema, and if validation is + passed, for each key, value config option: + + * It is stored on the ``system_info`` database table + * The Pylons ``config`` object is updated. + * The ``app_globals`` (``g``) object is updated (this only happens for + options explicitly defined in the ``app_globals`` module. + + The following lists a ``key`` parameter, but this should be replaced by + whichever config options want to be updated, eg:: + + get_action('config_option_update)({}, { + 'ckan.site_title': 'My Open Data site', + 'ckan.homepage_layout': 2, + }) + + :param key: a configuration option key (eg ``ckan.site_title``). It must + be present on the ``update_configuration_schema`` + :type key: string + + :returns: a dictionary with the options set + :rtype: dictionary + + .. note:: You can see all available runtime-editable configuration options + calling + the :py:func:`~ckan.logic.action.get.config_option_list` action + + .. note:: Extensions can modify which configuration options are + runtime-editable. + For details, check :doc:`/extensions/remote-config-update`. + + .. warning:: You should only add config options that you are comfortable + they can be edited during runtime, such as ones you've added in your + own extension, or have reviewed the use of in core CKAN. + + ''' + model = context['model'] + + _check_access('config_option_update', context, data_dict) + + schema = schema_.update_configuration_schema() + + available_options = schema.keys() + + provided_options = data_dict.keys() + + unsupported_options = set(provided_options) - set(available_options) + if unsupported_options: + msg = 'Configuration option(s) \'{0}\' can not be updated'.format( + ' '.join(list(unsupported_options))) + + raise ValidationError(msg, error_summary={'message': msg}) + + data, errors = _validate(data_dict, schema, context) + if errors: + model.Session.rollback() + raise ValidationError(errors) + + for key, value in data.iteritems(): + + # Save value in database + model.set_system_info(key, value) + + # Update the pylons `config` object + config[key] = value + + # Only add it to the app_globals (`g`) object if explicitly defined + # there + globals_keys = app_globals.app_globals_from_config_details.keys() + if key in globals_keys: + app_globals.set_app_global(key, value) + + # Update the config update timestamp + model.set_system_info('ckan.config_update', str(time.time())) + + log.info('Updated config options: {0}'.format(data)) + + return data diff --git a/ckan/logic/auth/get.py b/ckan/logic/auth/get.py index 4018c683180..ef3748fc046 100644 --- a/ckan/logic/auth/get.py +++ b/ckan/logic/auth/get.py @@ -187,6 +187,9 @@ def package_autocomplete(context, data_dict): def group_autocomplete(context, data_dict): return group_list(context, data_dict) +def organization_autocomplete(context, data_dict): + return organization_list(context, data_dict) + def tag_autocomplete(context, data_dict): return tag_list(context, data_dict) @@ -309,3 +312,13 @@ def request_reset(context, data_dict): def help_show(context, data_dict): return {'success': True} + + +def config_option_show(context, data_dict): + '''Show runtime-editable configuration option. Only sysadmins.''' + return {'success': False} + + +def config_option_list(context, data_dict): + '''List runtime-editable configuration options. Only sysadmins.''' + return {'success': False} diff --git a/ckan/logic/auth/update.py b/ckan/logic/auth/update.py index 383ca8abaef..ff527da0f92 100644 --- a/ckan/logic/auth/update.py +++ b/ckan/logic/auth/update.py @@ -326,3 +326,11 @@ def bulk_update_delete(context, data_dict): if not authorized: return {'success': False} return {'success': True} + + +def config_option_update(context, data_dict): + '''Update the runtime-editable configuration options + + Only sysdmins can do it + ''' + return {'success': False} diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index 40b56efa792..4bf3a6ba6a2 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -70,7 +70,8 @@ ) from formencode.validators import OneOf import ckan.model -import ckan.lib.maintain as maintain +import ckan.plugins as plugins + def default_resource_schema(): @@ -659,3 +660,52 @@ def default_update_resource_view_schema(resource_view): 'package_id': [ignore] }) return schema + + +def default_update_configuration_schema(): + + schema = { + 'ckan.site_title': [unicode], + 'ckan.site_logo': [unicode], + 'ckan.site_url': [unicode], + 'ckan.site_description': [unicode], + 'ckan.site_about': [unicode], + 'ckan.site_intro_text': [unicode], + 'ckan.site_custom_css': [unicode], + 'ckan.main_css': [unicode], + 'ckan.homepage_style': [is_positive_integer], + } + + # Add ignore_missing to all fields, otherwise you need to provide them all + for key, validators in schema.iteritems(): + validators.insert(0, ignore_missing) + + return schema + + +def update_configuration_schema(): + ''' + Returns the schema for the config options that can be edited during runtime + + By default these are the keys of the + :py:func:`ckan.logic.schema.default_update_configuration_schema`. + Extensions can add or remove keys from this schema using the + :py:meth:`ckan.plugins.interfaces.IConfigurer.update_config_schema` + method. + + These configuration options can be edited during runtime via the web + interface or using + the :py:func:`ckan.logic.action.update.config_option_update` API call. + + :returns: a dictionary mapping runtime-editable configuration option keys + to lists of validator and converter functions to be applied to those + keys + :rtype: dictionary + ''' + + schema = default_update_configuration_schema() + for plugin in plugins.PluginImplementations(plugins.IConfigurer): + if hasattr(plugin, 'update_config_schema'): + schema = plugin.update_config_schema(schema) + + return schema diff --git a/ckan/migration/versions/077_add_revisions_to_system_info.py b/ckan/migration/versions/077_add_revisions_to_system_info.py new file mode 100644 index 00000000000..8711be6ba66 --- /dev/null +++ b/ckan/migration/versions/077_add_revisions_to_system_info.py @@ -0,0 +1,23 @@ +import vdm.sqlalchemy + + +def upgrade(migrate_engine): + migrate_engine.execute( + ''' + ALTER TABLE "system_info" + ADD COLUMN "state" text NOT NULL DEFAULT '{state}'; + + ALTER TABLE "system_info_revision" + ADD COLUMN "state" text NOT NULL DEFAULT '{state}'; + + ALTER TABLE system_info_revision + ADD COLUMN expired_id text, + ADD COLUMN revision_timestamp timestamp without time zone, + ADD COLUMN expired_timestamp timestamp without time zone, + ADD COLUMN current boolean; + + ALTER TABLE system_info_revision + DROP CONSTRAINT "system_info_revision_key_key"; + + '''.format(state=vdm.sqlalchemy.State.ACTIVE) + ) diff --git a/ckan/model/__init__.py b/ckan/model/__init__.py index 41b52729469..b0147316098 100644 --- a/ckan/model/__init__.py +++ b/ckan/model/__init__.py @@ -144,7 +144,9 @@ ) from system_info import ( system_info_table, + system_info_revision_table, SystemInfo, + SystemInfoRevision, get_system_info, set_system_info, delete_system_info, @@ -411,7 +413,7 @@ def purge_revision(self, revision, leave_record=False): repo = Repository(meta.metadata, meta.Session, versioned_objects=[Package, PackageTag, Resource, PackageExtra, Member, - Group] + Group, SystemInfo] ) diff --git a/ckan/model/system_info.py b/ckan/model/system_info.py index 63e974abc24..94bff0b602d 100644 --- a/ckan/model/system_info.py +++ b/ckan/model/system_info.py @@ -1,29 +1,52 @@ +''' +The system_info table and SystemInfo mapped class store runtime-editable +configuration options. + +For more details, check :doc:`maintaining/configuration`. +''' + from sqlalchemy import types, Column, Table +import vdm.sqlalchemy import meta import core import domain_object __all__ = ['system_info_revision_table', 'system_info_table', 'SystemInfo', - 'get_system_info', 'set_system_info'] + 'SystemInfoRevision', 'get_system_info', 'set_system_info'] -system_info_table = Table('system_info', meta.metadata, - Column('id', types.Integer(), primary_key=True, nullable=False), - Column('key', types.Unicode(100), unique=True, nullable=False), - Column('value', types.UnicodeText), - ) +system_info_table = Table( + 'system_info', meta.metadata, + Column('id', types.Integer(), primary_key=True, nullable=False), + Column('key', types.Unicode(100), unique=True, nullable=False), + Column('value', types.UnicodeText), +) +vdm.sqlalchemy.make_table_stateful(system_info_table) system_info_revision_table = core.make_revisioned_table(system_info_table) -class SystemInfo(domain_object.DomainObject): +class SystemInfo(vdm.sqlalchemy.RevisionedObjectMixin, + vdm.sqlalchemy.StatefulObjectMixin, + domain_object.DomainObject): def __init__(self, key, value): + + super(SystemInfo, self).__init__() + self.key = key self.value = unicode(value) -meta.mapper(SystemInfo, system_info_table) +meta.mapper(SystemInfo, system_info_table, + extension=[ + vdm.sqlalchemy.Revisioner(system_info_revision_table), + ]) + +vdm.sqlalchemy.modify_base_object_mapper(SystemInfo, core.Revision, core.State) +SystemInfoRevision = vdm.sqlalchemy.create_object_version(meta.mapper, + SystemInfo, + system_info_revision_table) def get_system_info(key, default=None): @@ -45,7 +68,6 @@ def delete_system_info(key, default=None): def set_system_info(key, value): ''' save data in the system_info table ''' - obj = None obj = meta.Session.query(SystemInfo).filter_by(key=key).first() if obj and obj.value == unicode(value): @@ -54,5 +76,11 @@ def set_system_info(key, value): obj = SystemInfo(key, value) else: obj.value = unicode(value) + + from ckan.model import repo + rev = repo.new_revision() + rev.message = 'Set {0} setting in system_info table'.format(key) meta.Session.add(obj) meta.Session.commit() + + return True diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index bd151dbb279..d9a6b0ec8c3 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -750,6 +750,32 @@ def update_config(self, config): :param config: ``pylons.config`` object """ + def update_config_schema(self, schema): + ''' + Return a schema with the runtime-editable config options + + CKAN will use the returned schema to decide which configuration options + can be edited during runtime (using + :py:func:`ckan.logic.action.update.config_option_update`) and to + validate them before storing them. + + Defaults to + :py:func:`ckan.logic.schema.default_update_configuration_schema`, which + will be passed to all extensions implementing this method, which can + add or remove runtime-editable config options to it. + + :param schema: a dictionary mapping runtime-editable configuration + option keys to lists + of validator and converter functions to be applied to those keys + :type schema: dictionary + + :returns: a dictionary mapping runtime-editable configuration option + keys to lists of + validator and converter functions to be applied to those keys + :rtype: dictionary + ''' + return schema + class IActions(Interface): """ diff --git a/ckan/templates/admin/config.html b/ckan/templates/admin/config.html index 641fb4459f8..29f2d101fb8 100644 --- a/ckan/templates/admin/config.html +++ b/ckan/templates/admin/config.html @@ -2,7 +2,12 @@ {% extends "admin/base.html" %} +{% import 'macros/form.html' as form %} + {% block primary_content_inner %} + + {{ form.errors(error_summary) }} +
{% block admin_form %} {{ autoform.generate(form_items, data, errors) }} diff --git a/ckan/templates/organization/confirm_delete.html b/ckan/templates/organization/confirm_delete.html index a8aca943f93..6d93e6802cf 100644 --- a/ckan/templates/organization/confirm_delete.html +++ b/ckan/templates/organization/confirm_delete.html @@ -10,7 +10,7 @@ {% block form %}

{{ _('Are you sure you want to delete organization - {name}?').format(name=c.group_dict.name) }}

- +

diff --git a/ckan/templates/package/snippets/package_basic_fields.html b/ckan/templates/package/snippets/package_basic_fields.html index 23ede701fa4..8d948e09c59 100644 --- a/ckan/templates/package/snippets/package_basic_fields.html +++ b/ckan/templates/package/snippets/package_basic_fields.html @@ -30,8 +30,9 @@
{% if error %}{{ error }}{% endif %} diff --git a/ckan/templates/user/edit_user_form.html b/ckan/templates/user/edit_user_form.html index 489ac1cc7b6..9643b35b0ea 100644 --- a/ckan/templates/user/edit_user_form.html +++ b/ckan/templates/user/edit_user_form.html @@ -1,11 +1,10 @@ {% import 'macros/form.html' as form %} -
+ {{ form.errors(error_summary) }}
{{ _('Change details') }} - {{ form.input('name', label=_('Username'), id='field-username', value=data.name, error=errors.name, classes=['control-medium'], is_required=true) }} {{ form.input('fullname', label=_('Full name'), id='field-fullname', value=data.fullname, error=errors.fullname, placeholder=_('eg. Joe Bloggs'), classes=['control-medium']) }} @@ -25,6 +24,7 @@
{{ _('Change password') }} + {{ form.input('old_password', type='password', label=_('Old Password'), id='field-password', value=data.oldpassword, error=errors.oldpassword, classes=['control-medium'], attrs={'autocomplete': 'off'} ) }} {{ form.input('password1', type='password', label=_('Password'), id='field-password', value=data.password1, error=errors.password1, classes=['control-medium'], attrs={'autocomplete': 'off'} ) }} diff --git a/ckan/tests/config/test_environment.py b/ckan/tests/config/test_environment.py index 31b6f0bde53..ed9f8daabb9 100644 --- a/ckan/tests/config/test_environment.py +++ b/ckan/tests/config/test_environment.py @@ -5,6 +5,9 @@ import ckan.tests.helpers as h import ckan.plugins as p +from ckan.config import environment + +from ckan.tests import helpers class TestUpdateConfig(h.FunctionalTestBase): @@ -71,3 +74,24 @@ def test_update_config_db_url_precedence(self): nosetools.assert_equal(config['sqlalchemy.url'], 'postgresql://mynewsqlurl/') + + +class TestSiteUrlMandatory(object): + + @helpers.change_config('ckan.site_url', '') + def test_missing_siteurl(self): + nosetools.assert_raises(RuntimeError, environment.update_config) + + @helpers.change_config('ckan.site_url', 'demo.ckan.org') + def test_siteurl_missing_schema(self): + nosetools.assert_raises(RuntimeError, environment.update_config) + + @helpers.change_config('ckan.site_url', 'ftp://demo.ckan.org') + def test_siteurl_wrong_schema(self): + nosetools.assert_raises(RuntimeError, environment.update_config) + + @helpers.change_config('ckan.site_url', 'http://demo.ckan.org/') + def test_siteurl_removes_backslash(self): + environment.update_config() + nosetools.assert_equals(config['ckan.site_url'], + 'http://demo.ckan.org') diff --git a/ckan/tests/controllers/test_admin.py b/ckan/tests/controllers/test_admin.py index b65464bf5b2..32264acdd03 100644 --- a/ckan/tests/controllers/test_admin.py +++ b/ckan/tests/controllers/test_admin.py @@ -2,10 +2,12 @@ from bs4 import BeautifulSoup from routes import url_for +from pylons import config import ckan.model as model import ckan.tests.helpers as helpers import ckan.tests.factories as factories +from ckan.model.system_info import get_system_info submit_and_follow = helpers.submit_and_follow @@ -348,3 +350,68 @@ def test_trash_purge_deleted_datasets(self): # how many datasets after purge pkgs_before_purge = model.Session.query(model.Package).count() assert_equal(pkgs_before_purge, 1) + + +class TestAdminConfigUpdate(helpers.FunctionalTestBase): + + def teardown(self): + '''Reset the database and clear the search indexes.''' + helpers.reset_db() + + def _update_config_option(self): + sysadmin = factories.Sysadmin() + env = {'REMOTE_USER': sysadmin['name'].encode('ascii')} + app = self._get_test_app() + url = url_for(controller='admin', action='config') + + response = app.get(url=url, extra_environ=env) + form = response.forms[1] + form['ckan.site_title'] = 'My Updated Site Title' + + webtest_submit(form, 'save', status=302, extra_environ=env) + + def test_admin_config_update(self): + '''Changing a config option using the admin interface appropriately + updates value returned by config_option_show, + system_info.get_system_info and in the title tag in templates.''' + + # test value before update + # config_option_show returns default value + before_update = helpers.call_action('config_option_show', + key='ckan.site_title') + assert_equal(before_update, 'CKAN') + + # system_info.get_system_info returns None, or default + # test value before update + before_update = get_system_info('ckan.site_title') + assert_equal(before_update, None) + # test value before update with default + before_update_default = get_system_info('ckan.site_title', + config['ckan.site_title']) + assert_equal(before_update_default, 'CKAN') + + # title tag contains default value + app = self._get_test_app() + home_page_before = app.get('/', status=200) + assert_true('Welcome - CKAN' in home_page_before) + + # update the option + self._update_config_option() + + # test config_option_show returns new value after update + after_update = helpers.call_action('config_option_show', + key='ckan.site_title') + assert_equal(after_update, 'My Updated Site Title') + + # system_info.get_system_info returns new value + after_update = get_system_info('ckan.site_title') + assert_equal(after_update, 'My Updated Site Title') + # test value after update with default + after_update_default = get_system_info('ckan.site_title', + config['ckan.site_title']) + assert_equal(after_update_default, 'My Updated Site Title') + + # title tag contains new value + home_page_after = app.get('/', status=200) + assert_true('Welcome - My Updated Site Title' + in home_page_after) diff --git a/ckan/tests/controllers/test_api.py b/ckan/tests/controllers/test_api.py index 6d285a624aa..2292006c890 100644 --- a/ckan/tests/controllers/test_api.py +++ b/ckan/tests/controllers/test_api.py @@ -4,7 +4,11 @@ ''' import json +from routes import url_for +from nose.tools import assert_equal + import ckan.tests.helpers as helpers +from ckan.tests import factories class TestApiController(helpers.FunctionalTestBase): @@ -19,3 +23,146 @@ def test_unicode_in_error_message_works_ok(self): # The unicode is backslash encoded (because that is the default when # you do str(exception) ) assert 'Delta symbol: \\u0394' in response.body + + def test_dataset_autocomplete_name(self): + dataset = factories.Dataset(name='rivers') + url = url_for(controller='api', action='dataset_autocomplete', ver='/2') + assert_equal(url, '/api/2/util/dataset/autocomplete') + app = self._get_test_app() + + response = app.get( + url=url, + params={ + 'incomplete': u'rive', + }, + status=200, + ) + + results = json.loads(response.body) + assert_equal(results, {"ResultSet": {"Result": [{ + 'match_field': 'name', + "name": "rivers", + 'match_displayed': 'rivers', + 'title': dataset['title'], + }]}}) + assert_equal(response.headers['Content-Type'], + 'application/json;charset=utf-8') + + def test_dataset_autocomplete_title(self): + dataset = factories.Dataset(name='test_ri', title='Rivers') + url = url_for(controller='api', action='dataset_autocomplete', ver='/2') + assert_equal(url, '/api/2/util/dataset/autocomplete') + app = self._get_test_app() + + response = app.get( + url=url, + params={ + 'incomplete': u'riv', + }, + status=200, + ) + + results = json.loads(response.body) + assert_equal(results, {"ResultSet": {"Result": [{ + 'match_field': 'title', + "name": dataset['name'], + 'match_displayed': 'Rivers (test_ri)', + 'title': 'Rivers', + }]}}) + assert_equal(response.headers['Content-Type'], + 'application/json;charset=utf-8') + + def test_tag_autocomplete(self): + factories.Dataset(tags=[{'name': 'rivers'}]) + url = url_for(controller='api', action='tag_autocomplete', ver='/2') + assert_equal(url, '/api/2/util/tag/autocomplete') + app = self._get_test_app() + + response = app.get( + url=url, + params={ + 'incomplete': u'rive', + }, + status=200, + ) + + results = json.loads(response.body) + assert_equal(results, {"ResultSet": {"Result": [{"Name": "rivers"}]}}) + assert_equal(response.headers['Content-Type'], + 'application/json;charset=utf-8') + + def test_group_autocomplete_by_name(self): + org = factories.Group(name='rivers', title='Bridges') + url = url_for(controller='api', action='group_autocomplete', ver='/2') + assert_equal(url, '/api/2/util/group/autocomplete') + app = self._get_test_app() + + response = app.get( + url=url, + params={ + 'q': u'rive', + }, + status=200, + ) + + results = json.loads(response.body) + assert_equal(len(results), 1) + assert_equal(results[0]['name'], 'rivers') + assert_equal(results[0]['title'], 'Bridges') + assert_equal(response.headers['Content-Type'], + 'application/json;charset=utf-8') + + def test_group_autocomplete_by_title(self): + org = factories.Group(name='frogs', title='Bugs') + url = url_for(controller='api', action='group_autocomplete', ver='/2') + app = self._get_test_app() + + response = app.get( + url=url, + params={ + 'q': u'bug', + }, + status=200, + ) + + results = json.loads(response.body) + assert_equal(len(results), 1) + assert_equal(results[0]['name'], 'frogs') + + def test_organization_autocomplete_by_name(self): + org = factories.Organization(name='simple-dummy-org') + url = url_for(controller='api', action='organization_autocomplete', ver='/2') + assert_equal(url, '/api/2/util/organization/autocomplete') + app = self._get_test_app() + + response = app.get( + url=url, + params={ + 'q': u'simple', + }, + status=200, + ) + + results = json.loads(response.body) + assert_equal(len(results), 1) + assert_equal(results[0]['name'], 'simple-dummy-org') + assert_equal(results[0]['title'], org['title']) + assert_equal(response.headers['Content-Type'], + 'application/json;charset=utf-8') + + def test_organization_autocomplete_by_title(self): + org = factories.Organization(title='Simple dummy org') + url = url_for(controller='api', action='organization_autocomplete', ver='/2') + app = self._get_test_app() + + response = app.get( + url=url, + params={ + 'q': u'simple dum', + }, + status=200, + ) + + results = json.loads(response.body) + assert_equal(len(results), 1) + assert_equal(results[0]['title'], 'Simple dummy org') diff --git a/ckan/tests/controllers/test_organization.py b/ckan/tests/controllers/test_organization.py new file mode 100644 index 00000000000..b83c063dc22 --- /dev/null +++ b/ckan/tests/controllers/test_organization.py @@ -0,0 +1,67 @@ +from nose.tools import assert_equal +from routes import url_for + +from ckan.tests import factories, helpers +from ckan.tests.helpers import submit_and_follow + + +class TestOrganizationDelete(helpers.FunctionalTestBase): + def setup(self): + super(TestOrganizationDelete, self).setup() + self.app = helpers._get_test_app() + self.user = factories.User() + self.user_env = {'REMOTE_USER': self.user['name'].encode('ascii')} + self.organization = factories.Organization(user=self.user) + + def test_owner_delete(self): + response = self.app.get(url=url_for(controller='organization', + action='delete', + id=self.organization['id']), + status=200, + extra_environ=self.user_env) + + form = response.forms['organization-confirm-delete-form'] + response = submit_and_follow(self.app, form, name='delete', + extra_environ=self.user_env) + organization = helpers.call_action('organization_show', + id=self.organization['id']) + assert_equal(organization['state'], 'deleted') + + def test_sysadmin_delete(self): + sysadmin = factories.Sysadmin() + extra_environ = {'REMOTE_USER': sysadmin['name'].encode('ascii')} + response = self.app.get(url=url_for(controller='organization', + action='delete', + id=self.organization['id']), + status=200, + extra_environ=extra_environ) + + form = response.forms['organization-confirm-delete-form'] + response = submit_and_follow(self.app, form, name='delete', + extra_environ=self.user_env) + organization = helpers.call_action('organization_show', + id=self.organization['id']) + assert_equal(organization['state'], 'deleted') + + def test_non_authorized_user_trying_to_delete_fails(self): + user = factories.User() + extra_environ = {'REMOTE_USER': user['name'].encode('ascii')} + self.app.get(url=url_for(controller='organization', + action='delete', + id=self.organization['id']), + status=401, + extra_environ=extra_environ) + + organization = helpers.call_action('organization_show', + id=self.organization['id']) + assert_equal(organization['state'], 'active') + + def test_anon_user_trying_to_delete_fails(self): + self.app.get(url=url_for(controller='organization', + action='delete', + id=self.organization['id']), + status=302) # redirects to login form + + organization = helpers.call_action('organization_show', + id=self.organization['id']) + assert_equal(organization['state'], 'active') diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py index ba4ca411dd2..3c9cc961727 100644 --- a/ckan/tests/controllers/test_user.py +++ b/ckan/tests/controllers/test_user.py @@ -8,9 +8,20 @@ from ckan.lib.mailer import create_reset_key +webtest_submit = helpers.webtest_submit submit_and_follow = helpers.submit_and_follow +def _get_user_edit_page(app): + user = factories.User() + env = {'REMOTE_USER': user['name'].encode('ascii')} + response = app.get( + url=url_for(controller='user', action='edit'), + extra_environ=env, + ) + return env, response, user + + class TestUser(helpers.FunctionalTestBase): def test_own_datasets_show_up_on_user_dashboard(self): @@ -47,7 +58,7 @@ def test_other_datasets_dont_show_up_on_user_dashboard(self): assert_false(dataset_title in response) def test_edit_user(self): - user = factories.User() + user = factories.User(password='pass') app = self._get_test_app() env = {'REMOTE_USER': user['name'].encode('ascii')} response = app.get( @@ -55,7 +66,7 @@ def test_edit_user(self): extra_environ=env, ) # existing values in the form - form = response.forms['user-edit'] + form = response.forms['user-edit-form'] assert_equal(form['name'].value, user['name']) assert_equal(form['fullname'].value, user['fullname']) assert_equal(form['email'].value, user['email']) @@ -70,6 +81,7 @@ def test_edit_user(self): form['email'] = 'new@example.com' form['about'] = 'new about' form['activity_streams_email_notifications'] = True + form['old_password'] = 'pass' form['password1'] = 'newpass' form['password2'] = 'newpass' response = submit_and_follow(app, form, env, 'save') @@ -98,3 +110,38 @@ def test_perform_reset_for_key_change(self): user_obj = helpers.model.User.by_name(user['name']) # Update user_obj assert_true(key != user_obj.reset_key) + + def test_password_reset_correct_password(self): + """ + user password reset attempted with correct old password + """ + app = self._get_test_app() + env, response, user = _get_user_edit_page(app) + + form = response.forms['user-edit-form'] + + # factory returns user with password 'pass' + form.fields['old_password'][0].value = 'pass' + form.fields['password1'][0].value = 'newpass' + form.fields['password2'][0].value = 'newpass' + + response = submit_and_follow(app, form, env, 'save') + assert_true('Profile updated' in response) + + def test_password_reset_incorrect_password(self): + """ + user password reset attempted with invalid old password + """ + + app = self._get_test_app() + env, response, user = _get_user_edit_page(app) + + form = response.forms['user-edit-form'] + + # factory returns user with password 'pass' + form.fields['old_password'][0].value = 'wrong-pass' + form.fields['password1'][0].value = 'newpass' + form.fields['password2'][0].value = 'newpass' + + response = webtest_submit(form, 'save', status=200, extra_environ=env) + assert_true('Old Password: incorrect password' in response) diff --git a/ckan/tests/factories.py b/ckan/tests/factories.py index a74406eb88e..ff2c4fe8abf 100644 --- a/ckan/tests/factories.py +++ b/ckan/tests/factories.py @@ -36,6 +36,8 @@ user = factories.User(**user_attributes_dict) ''' +import random +import string import factory import mock @@ -88,6 +90,12 @@ def _generate_group_title(group): return group.name.replace('_', ' ').title() +def _generate_random_string(length=6): + '''Return a random string of the defined length.''' + + return ''.join(random.sample(string.ascii_lowercase, length)) + + class User(factory.Factory): '''A factory class for creating CKAN users.''' @@ -379,6 +387,32 @@ def _create(cls, target_class, *args, **kwargs): return mock_user +class SystemInfo(factory.Factory): + '''A factory class for creating SystemInfo objects (config objects + stored in the DB).''' + + FACTORY_FOR = ckan.model.SystemInfo + + key = factory.Sequence(lambda n: 'test_config_{n}'.format(n=n)) + value = _generate_random_string() + + @classmethod + def _build(cls, target_class, *args, **kwargs): + raise NotImplementedError(".build() isn't supported in CKAN") + + @classmethod + def _create(cls, target_class, *args, **kwargs): + if args: + assert False, "Positional args aren't supported, use keyword args." + + ckan.model.system_info.set_system_info(kwargs['key'], + kwargs['value']) + obj = ckan.model.Session.query(ckan.model.system_info.SystemInfo) \ + .filter_by(key=kwargs['key']).first() + + return obj + + def validator_data_dict(): '''Return a data dict with some arbitrary data in it, suitable to be passed to validator functions for testing. diff --git a/ckan/tests/legacy/functional/api/test_util.py b/ckan/tests/legacy/functional/api/test_util.py index 6f5b4ef9f32..9bb236a47e4 100644 --- a/ckan/tests/legacy/functional/api/test_util.py +++ b/ckan/tests/legacy/functional/api/test_util.py @@ -61,61 +61,6 @@ def test_package_slug_valid(self): assert_equal(response.body, '{"valid": false}') assert_equal(response.header('Content-Type'), 'application/json;charset=utf-8') - def test_dataset_autocomplete_match_name(self): - url = url_for(controller='api', action='dataset_autocomplete', ver=2) - assert_equal(url, '/api/2/util/dataset/autocomplete') - response = self.app.get( - url=url, - params={ - 'incomplete': u'an', - }, - status=200, - ) - assert_equal(response.body, '{"ResultSet": {"Result": [{"match_field": "name", "match_displayed": "annakarenina", "name": "annakarenina", "title": "A Novel By Tolstoy"}]}}') - assert_equal(response.header('Content-Type'), 'application/json;charset=utf-8') - - def test_dataset_autocomplete_match_title(self): - url = url_for(controller='api', action='dataset_autocomplete', ver=2) - assert_equal(url, '/api/2/util/dataset/autocomplete') - response = self.app.get( - url=url, - params={ - 'incomplete': u'a n', - }, - status=200, - ) - assert_equal(response.body, '{"ResultSet": {"Result": [{"match_field": "title", "match_displayed": "A Novel By Tolstoy (annakarenina)", "name": "annakarenina", "title": "A Novel By Tolstoy"}]}}') - assert_equal(response.header('Content-Type'), 'application/json;charset=utf-8') - - def test_tag_autocomplete(self): - url = url_for(controller='api', action='tag_autocomplete', ver=2) - assert_equal(url, '/api/2/util/tag/autocomplete') - response = self.app.get( - url=url, - params={ - 'incomplete': u'ru', - }, - status=200, - ) - assert_equal(response.body, '{"ResultSet": {"Result": [{"Name": "russian"}]}}') - assert_equal(response.header('Content-Type'), 'application/json;charset=utf-8') - - def test_group_autocomplete(self): - url = url_for(controller='api', action='group_autocomplete', ver=2) - assert_equal(url, '/api/2/util/group/autocomplete') - response = self.app.get( - url=url, - params={ - 'q': u'dave', - }, - status=200, - ) - results = json.loads(response.body) - assert_equal(len(results), 1) - assert_equal(results[0]['name'], 'david') - assert_equal(results[0]['title'], 'Dave\'s books') - assert_equal(response.header('Content-Type'), 'application/json;charset=utf-8') - def test_markdown(self): markdown = '''##Title''' response = self.app.get( diff --git a/ckan/tests/legacy/functional/test_group.py b/ckan/tests/legacy/functional/test_group.py index b9c0df7aedd..d8f0e3b4f36 100644 --- a/ckan/tests/legacy/functional/test_group.py +++ b/ckan/tests/legacy/functional/test_group.py @@ -78,19 +78,19 @@ def test_sorting(self): assert results[-1]['name'] == u'alpha', results[-1]['name'] # Test packages reversed - data_dict = {'all_fields': True, 'sort': 'packages desc'} + data_dict = {'all_fields': True, 'sort': 'package_count desc'} results = get_action('group_list')(context, data_dict) assert results[0]['name'] == u'beta', results[0]['name'] assert results[1]['name'] == u'delta', results[1]['name'] # Test packages forward - data_dict = {'all_fields': True, 'sort': 'packages asc'} + data_dict = {'all_fields': True, 'sort': 'package_count asc'} results = get_action('group_list')(context, data_dict) assert results[-2]['name'] == u'delta', results[-2]['name'] assert results[-1]['name'] == u'beta', results[-1]['name'] # Default ordering for packages - data_dict = {'all_fields': True, 'sort': 'packages'} + data_dict = {'all_fields': True, 'sort': 'package_count'} results = get_action('group_list')(context, data_dict) assert results[0]['name'] == u'beta', results[0]['name'] assert results[1]['name'] == u'delta', results[1]['name'] diff --git a/ckan/tests/legacy/models/test_group.py b/ckan/tests/legacy/models/test_group.py index 56eda5241e3..ab4e5a5c9ac 100644 --- a/ckan/tests/legacy/models/test_group.py +++ b/ckan/tests/legacy/models/test_group.py @@ -55,9 +55,9 @@ def test_2_add_packages(self): def test_3_search(self): model.repo.new_revision() model.Session.add(model.Group(name=u'test_org', - title=u'Test org', - type=u'organization' - )) + title=u'Test org', + type=u'organization' + )) model.repo.commit_and_remove() diff --git a/ckan/tests/lib/dictization/test_model_dictize.py b/ckan/tests/lib/dictization/test_model_dictize.py index f1e4a63324f..493104980cb 100644 --- a/ckan/tests/lib/dictization/test_model_dictize.py +++ b/ckan/tests/lib/dictization/test_model_dictize.py @@ -25,7 +25,7 @@ def test_group_list_dictize(self): assert_equal(len(group_dicts), 1) assert_equal(group_dicts[0]['name'], group['name']) - assert_equal(group_dicts[0]['packages'], 0) + assert_equal(group_dicts[0]['package_count'], 0) assert 'extras' not in group_dicts[0] assert 'tags' not in group_dicts[0] assert 'groups' not in group_dicts[0] @@ -181,7 +181,6 @@ def test_group_dictize_group_with_parent_group(self): assert_equal(len(group['groups']), 1) assert_equal(group['groups'][0]['name'], 'parent') - assert_equal(group['groups'][0]['packages'], 0) # deprecated assert_equal(group['groups'][0]['package_count'], 0) def test_group_dictize_without_packages(self): @@ -250,8 +249,6 @@ def test_group_dictize_with_package_count(self): group = model_dictize.group_dictize(group_obj, context, packages_field='dataset_count') - - assert_equal(group['packages'], 1) assert_equal(group['package_count'], 1) def test_group_dictize_with_no_packages_field_but_still_package_count(self): @@ -263,8 +260,7 @@ def test_group_dictize_with_no_packages_field_but_still_package_count(self): # not supplying dataset_counts in this case either group = model_dictize.group_dictize(group_obj, context, - packages_field= - 'none_but_include_package_count') + packages_field='dataset_count') assert 'packages' not in group assert_equal(group['package_count'], 1) @@ -293,7 +289,7 @@ def test_group_dictize_for_org_with_package_count(self): org = model_dictize.group_dictize(org_obj, context, packages_field='dataset_count') - assert_equal(org['packages'], 1) + assert_equal(org['package_count'], 1) class TestPackageDictize: diff --git a/ckan/tests/lib/test_app_globals.py b/ckan/tests/lib/test_app_globals.py new file mode 100644 index 00000000000..6ad6c0bf3c5 --- /dev/null +++ b/ckan/tests/lib/test_app_globals.py @@ -0,0 +1,17 @@ +from ckan.lib.app_globals import app_globals as g + + +class TestGlobals(object): + def test_config_not_set(self): + # ckan.site_about has not been configured. + # Behaviour has always been to return an empty string. + assert g.site_about == '' + + def test_config_set_to_blank(self): + # ckan.site_description is configured but with no value. + # Behaviour has always been to return an empty string. + assert g.site_description == '' + + def test_set_from_ini(self): + # ckan.template_head_end is configured in test-core.ini + assert g.template_head_end == '' diff --git a/ckan/tests/lib/test_helpers.py b/ckan/tests/lib/test_helpers.py index feded9d1a59..a1175dddc20 100644 --- a/ckan/tests/lib/test_helpers.py +++ b/ckan/tests/lib/test_helpers.py @@ -101,3 +101,11 @@ class UnicodeLike(unicode): strType = u''.__class__ assert result.__class__ == strType,\ '"remove_linebreaks" casts into unicode()' + + +class TestLicenseOptions(object): + def test_includes_existing_license(self): + licenses = h.license_options('some-old-license') + eq_(dict(licenses)['some-old-license'], 'some-old-license') + # and it is first on the list + eq_(licenses[0][0], 'some-old-license') diff --git a/ckan/tests/logic/action/test_create.py b/ckan/tests/logic/action/test_create.py index 1b1b2611a44..78fbf287f67 100644 --- a/ckan/tests/logic/action/test_create.py +++ b/ckan/tests/logic/action/test_create.py @@ -474,3 +474,159 @@ def test_id_cant_already_exist(self): id=dataset['id'], name='test-dataset', ) + + +class TestGroupCreate(helpers.FunctionalTestBase): + + def test_create_group(self): + user = factories.User() + context = { + 'user': user['name'], + 'ignore_auth': True, + } + + group = helpers.call_action( + 'group_create', + context=context, + name='test-group', + ) + + assert len(group['users']) == 1 + assert group['display_name'] == u'test-group' + assert group['package_count'] == 0 + assert not group['is_organization'] + assert group['type'] == 'group' + + @nose.tools.raises(logic.ValidationError) + def test_create_group_validation_fail(self): + user = factories.User() + context = { + 'user': user['name'], + 'ignore_auth': True, + } + + group = helpers.call_action( + 'group_create', + context=context, + name='', + ) + + def test_create_group_return_id(self): + import re + + user = factories.User() + context = { + 'user': user['name'], + 'ignore_auth': True, + 'return_id_only': True + } + + group = helpers.call_action( + 'group_create', + context=context, + name='test-group', + ) + + assert isinstance(group, str) + assert re.match('([a-f\d]{8}(-[a-f\d]{4}){3}-[a-f\d]{12}?)', group) + + def test_create_matches_show(self): + user = factories.User() + context = { + 'user': user['name'], + 'ignore_auth': True, + } + + created = helpers.call_action( + 'organization_create', + context=context, + name='test-organization', + ) + + shown = helpers.call_action( + 'organization_show', + context=context, + id='test-organization', + ) + + assert sorted(created.keys()) == sorted(shown.keys()) + for k in created.keys(): + assert created[k] == shown[k], k + + +class TestOrganizationCreate(helpers.FunctionalTestBase): + + def test_create_organization(self): + user = factories.User() + context = { + 'user': user['name'], + 'ignore_auth': True, + } + + org = helpers.call_action( + 'organization_create', + context=context, + name='test-organization', + ) + + assert len(org['users']) == 1 + assert org['display_name'] == u'test-organization' + assert org['package_count'] == 0 + assert org['is_organization'] + assert org['type'] == 'organization' + + @nose.tools.raises(logic.ValidationError) + def test_create_organization_validation_fail(self): + user = factories.User() + context = { + 'user': user['name'], + 'ignore_auth': True, + } + + org = helpers.call_action( + 'organization_create', + context=context, + name='', + ) + + def test_create_organization_return_id(self): + import re + + user = factories.User() + context = { + 'user': user['name'], + 'ignore_auth': True, + 'return_id_only': True + } + + org = helpers.call_action( + 'organization_create', + context=context, + name='test-organization', + ) + + assert isinstance(org, str) + assert re.match('([a-f\d]{8}(-[a-f\d]{4}){3}-[a-f\d]{12}?)', org) + + def test_create_matches_show(self): + user = factories.User() + context = { + 'user': user['name'], + 'ignore_auth': True, + } + + created = helpers.call_action( + 'organization_create', + context=context, + name='test-organization', + ) + + shown = helpers.call_action( + 'organization_show', + context=context, + id='test-organization', + ) + + assert sorted(created.keys()) == sorted(shown.keys()) + for k in created.keys(): + assert created[k] == shown[k], k diff --git a/ckan/tests/logic/action/test_get.py b/ckan/tests/logic/action/test_get.py index f72b5f725f6..38702a53949 100644 --- a/ckan/tests/logic/action/test_get.py +++ b/ckan/tests/logic/action/test_get.py @@ -2,9 +2,9 @@ import ckan.logic as logic import ckan.plugins as p -import ckan.lib.search as search import ckan.tests.helpers as helpers import ckan.tests.factories as factories +import ckan.logic.schema as schema eq = nose.tools.eq_ @@ -97,9 +97,7 @@ def test_group_list_sort_by_package_count(self): factories.Dataset(groups=[{'name': 'bb'}]) group_list = helpers.call_action('group_list', sort='package_count') - # default is descending order - - eq(group_list, ['bb', 'aa']) + eq(sorted(group_list), sorted(['bb', 'aa'])) def test_group_list_sort_by_package_count_ascending(self): @@ -113,6 +111,15 @@ def test_group_list_sort_by_package_count_ascending(self): eq(group_list, ['bb', 'aa']) + def assert_equals_expected(self, expected_dict, result_dict): + superfluous_keys = set(result_dict) - set(expected_dict) + assert not superfluous_keys, 'Did not expect key: %s' % \ + ' '.join(('%s=%s' % (k, result_dict[k]) for k in superfluous_keys)) + for key in expected_dict: + assert expected_dict[key] == result_dict[key], \ + '%s=%s should be %s' % \ + (key, result_dict[key], expected_dict[key]) + def test_group_list_all_fields(self): group = factories.Group() @@ -121,8 +128,10 @@ def test_group_list_all_fields(self): expected_group = dict(group.items()[:]) for field in ('users', 'tags', 'extras', 'groups'): + if field in group_list[0]: + del group_list[0][field] del expected_group[field] - expected_group['packages'] = 0 + assert group_list[0] == expected_group assert 'extras' not in group_list[0] assert 'tags' not in group_list[0] @@ -158,25 +167,19 @@ def test_group_list_groups_returned(self): else: child_group_returned, parent_group_returned = group_list[::-1] expected_parent_group = dict(parent_group.items()[:]) - for field in ('users', 'tags', 'extras'): - del expected_parent_group[field] - expected_parent_group['capacity'] = u'public' - expected_parent_group['packages'] = 0 - expected_parent_group['package_count'] = 0 - eq(child_group_returned['groups'], [expected_parent_group]) + + eq([g['name'] for g in child_group_returned['groups']], [expected_parent_group['name']]) class TestGroupShow(helpers.FunctionalTestBase): def test_group_show(self): - group = factories.Group(user=factories.User()) group_dict = helpers.call_action('group_show', id=group['id'], include_datasets=True) - # FIXME: Should this be returned by group_create? - group_dict.pop('num_followers', None) + group_dict.pop('packages', None) eq(group_dict, group) def test_group_show_error_not_found(self): @@ -359,14 +362,12 @@ def test_organization_list_in_presence_of_custom_group_types(self): class TestOrganizationShow(helpers.FunctionalTestBase): def test_organization_show(self): - org = factories.Organization() org_dict = helpers.call_action('organization_show', id=org['id'], include_datasets=True) - # FIXME: Should this be returned by organization_create? - org_dict.pop('num_followers', None) + org_dict.pop('packages', None) eq(org_dict, org) def test_organization_show_error_not_found(self): @@ -1493,6 +1494,49 @@ def test_help_show_not_found(self): helpers.call_action, 'help_show', name=function_name) +class TestConfigOptionShow(helpers.FunctionalTestBase): + + @helpers.change_config('ckan.site_title', 'My Test CKAN') + def test_config_option_show_in_config_not_in_db(self): + '''config_option_show returns value from config when value on in + system_info table.''' + + title = helpers.call_action('config_option_show', + key='ckan.site_title') + nose.tools.assert_equal(title, 'My Test CKAN') + + @helpers.change_config('ckan.site_title', 'My Test CKAN') + def test_config_option_show_in_config_and_in_db(self): + '''config_option_show returns value from db when value is in both + config and system_info table.''' + + params = {'ckan.site_title': 'Test site title'} + helpers.call_action('config_option_update', **params) + + title = helpers.call_action('config_option_show', + key='ckan.site_title') + nose.tools.assert_equal(title, 'Test site title') + + @helpers.change_config('ckan.not.editable', 'My non editable option') + def test_config_option_show_not_whitelisted_key(self): + '''config_option_show raises exception if key is not a whitelisted + config option.''' + + nose.tools.assert_raises(logic.ValidationError, helpers.call_action, + 'config_option_show', key='ckan.not.editable') + + +class TestConfigOptionList(object): + + def test_config_option_list(self): + '''config_option_list returns whitelisted config option keys''' + + keys = helpers.call_action('config_option_list') + schema_keys = schema.update_configuration_schema().keys() + + nose.tools.assert_equal(keys, schema_keys) + + def remove_pseudo_users(user_list): pseudo_users = set(('logged_in', 'visitor')) user_list[:] = [user for user in user_list diff --git a/ckan/tests/logic/action/test_update.py b/ckan/tests/logic/action/test_update.py index 433bed864bc..621d16b935d 100644 --- a/ckan/tests/logic/action/test_update.py +++ b/ckan/tests/logic/action/test_update.py @@ -6,6 +6,7 @@ import pylons.config as config import ckan.logic as logic +import ckan.lib.app_globals as app_globals import ckan.plugins as p import ckan.tests.helpers as helpers import ckan.tests.factories as factories @@ -14,9 +15,6 @@ assert_raises = nose.tools.assert_raises -assert_raises = nose.tools.assert_raises - - def datetime_from_string(s): '''Return a standard datetime.datetime object initialised from a string in the same format used for timestamps in dictized activities (the format @@ -723,3 +721,30 @@ def test_extra_gets_deleted_on_extra_only_update(self): assert_equals(res_returned['url'], 'http://first') assert_equals(res_returned['anotherfield'], 'second') assert 'newfield' not in res_returned + + +class TestConfigOptionUpdate(object): + + @classmethod + def teardown_class(cls): + helpers.reset_db() + + def setup(self): + helpers.reset_db() + + # NOTE: the opposite is tested in + # ckan/ckanext/example_iconfigurer/tests/test_iconfigurer_update_config.py + # as we need to enable an external config option from an extension + def test_app_globals_set_if_defined(self): + + key = 'ckan.site_title' + value = 'Test site title' + + params = {key: value} + + helpers.call_action('config_option_update', **params) + + globals_key = app_globals.get_globals_key(key) + assert hasattr(app_globals.app_globals, globals_key) + + assert_equals(getattr(app_globals.app_globals, globals_key), value) diff --git a/ckan/tests/logic/auth/test_get.py b/ckan/tests/logic/auth/test_get.py index 715a30f67ae..cbea2217a5a 100644 --- a/ckan/tests/logic/auth/test_get.py +++ b/ckan/tests/logic/auth/test_get.py @@ -10,7 +10,7 @@ from ckan import model -class TestGet(object): +class TestPackageShowAuth(object): def setup(self): helpers.reset_db() @@ -37,6 +37,12 @@ def test_package_show__deleted_dataset_is_visible_to_editor(self): id=dataset['name']) assert ret + +class TestGroupShowAuth(object): + + def setup(self): + helpers.reset_db() + def test_group_show__deleted_group_is_hidden_to_public(self): group = factories.Group(state='deleted') context = {'model': model} @@ -68,3 +74,55 @@ def test_group_show__deleted_org_is_visible_to_its_member(self): ret = helpers.call_auth('group_show', context=context, id=org['name']) assert ret + + +class TestConfigOptionShowAuth(object): + + def setup(self): + helpers.reset_db() + + def test_config_option_show_anon_user(self): + '''An anon user is not authorized to use config_option_show action.''' + context = {'user': None, 'model': None} + assert_raises(logic.NotAuthorized, helpers.call_auth, + 'config_option_show', context=context) + + def test_config_option_show_normal_user(self): + '''A normal logged in user is not authorized to use config_option_show + action.''' + factories.User(name='fred') + context = {'user': 'fred', 'model': None} + assert_raises(logic.NotAuthorized, helpers.call_auth, + 'config_option_show', context=context) + + def test_config_option_show_sysadmin(self): + '''A sysadmin is authorized to use config_option_show action.''' + factories.Sysadmin(name='fred') + context = {'user': 'fred', 'model': None} + assert helpers.call_auth('config_option_show', context=context) + + +class TestConfigOptionListAuth(object): + + def setup(self): + helpers.reset_db() + + def test_config_option_list_anon_user(self): + '''An anon user is not authorized to use config_option_list action.''' + context = {'user': None, 'model': None} + assert_raises(logic.NotAuthorized, helpers.call_auth, + 'config_option_list', context=context) + + def test_config_option_list_normal_user(self): + '''A normal logged in user is not authorized to use config_option_list + action.''' + factories.User(name='fred') + context = {'user': 'fred', 'model': None} + assert_raises(logic.NotAuthorized, helpers.call_auth, + 'config_option_list', context=context) + + def test_config_option_list_sysadmin(self): + '''A sysadmin is authorized to use config_option_list action.''' + factories.Sysadmin(name='fred') + context = {'user': 'fred', 'model': None} + assert helpers.call_auth('config_option_list', context=context) diff --git a/ckan/tests/logic/auth/test_update.py b/ckan/tests/logic/auth/test_update.py index 33c62e47844..4b933f2fd12 100644 --- a/ckan/tests/logic/auth/test_update.py +++ b/ckan/tests/logic/auth/test_update.py @@ -13,6 +13,7 @@ assert_equals = nose.tools.assert_equals +assert_raises = nose.tools.assert_raises class TestUpdate(object): @@ -263,3 +264,30 @@ def test_not_authorized_if_user_has_no_permissions_on_dataset(self): nose.tools.assert_raises(logic.NotAuthorized, helpers.call_auth, 'resource_view_update', context=context, **params) + + +class TestConfigOptionUpdateAuth(object): + + def setup(self): + helpers.reset_db() + + def test_config_option_update_anon_user(self): + '''An anon user is not authorized to use config_option_update + action.''' + context = {'user': None, 'model': None} + assert_raises(logic.NotAuthorized, helpers.call_auth, + 'config_option_update', context=context) + + def test_config_option_update_normal_user(self): + '''A normal logged in user is not authorized to use config_option_update + action.''' + factories.User(name='fred') + context = {'user': 'fred', 'model': None} + assert_raises(logic.NotAuthorized, helpers.call_auth, + 'config_option_update', context=context) + + def test_config_option_update_sysadmin(self): + '''A sysadmin is authorized to use config_option_update action.''' + factories.Sysadmin(name='fred') + context = {'user': 'fred', 'model': None} + assert helpers.call_auth('config_option_update', context=context) diff --git a/ckan/tests/model/test_system_info.py b/ckan/tests/model/test_system_info.py new file mode 100644 index 00000000000..63a4b11088f --- /dev/null +++ b/ckan/tests/model/test_system_info.py @@ -0,0 +1,70 @@ +import nose.tools + +import ckan.tests.helpers as helpers +import ckan.tests.factories as factories + +from ckan import model +from ckan.model.system_info import (SystemInfo, + get_system_info, + set_system_info, + delete_system_info, + system_info_revision_table, + ) + + +assert_equals = nose.tools.assert_equals +assert_not_equals = nose.tools.assert_not_equals + + +class TestSystemInfo(object): + + @classmethod + def setup_class(cls): + helpers.reset_db() + + @classmethod + def teardown_class(cls): + helpers.reset_db() + + def test_set_value(self): + + key = 'config_option_1' + value = 'test_value' + + set_system_info(key, value) + + results = model.Session.query(SystemInfo).filter_by(key=key).all() + + assert_equals(len(results), 1) + + obj = results[0] + + assert_equals(obj.key, key) + assert_equals(obj.value, value) + + def test_sets_new_value_for_same_key(self): + + config = factories.SystemInfo() + first_revision = config.revision_id + + set_system_info(config.key, 'new_value') + + new_config = model.Session.query(SystemInfo) \ + .filter_by(key=config.key).first() + + assert_equals(config.id, new_config.id) + assert_not_equals(first_revision, new_config.revision_id) + + assert_equals(new_config.value, 'new_value') + + def test_does_not_set_same_value_for_same_key(self): + + config = factories.SystemInfo() + + set_system_info(config.key, config.value) + + new_config = model.Session.query(SystemInfo) \ + .filter_by(key=config.key).first() + + assert_equals(config.id, new_config.id) + assert_equals(config.revision_id, new_config.revision_id) diff --git a/ckanext/example_iconfigurer/plugin.py b/ckanext/example_iconfigurer/plugin.py index b5850379d37..0974f3eace0 100644 --- a/ckanext/example_iconfigurer/plugin.py +++ b/ckanext/example_iconfigurer/plugin.py @@ -7,13 +7,21 @@ class ExampleIConfigurerPlugin(plugins.SingletonPlugin): ''' - An example IConfigurer plugin implementing toolkit.add_ckan_admin_tab() in - the update_config method. + An example IConfigurer plugin that shows: + + 1. How to implement ``toolkit.add_ckan_admin_tab()`` in the + ``update_config`` method to add a custom config tab in the admin pages. + + 2. How to make CKAN configuration options runtime-editable via + the web frontend or the API + ''' plugins.implements(plugins.IConfigurer) plugins.implements(plugins.IRoutes, inherit=True) + # IConfigurer + def update_config(self, config): # Add extension templates directory toolkit.add_template_directory(config, 'templates') @@ -23,6 +31,24 @@ def update_config(self, config): toolkit.add_ckan_admin_tab(config, 'ckanext_myext_config_two', 'My Second Custom Config Tab') + def update_config_schema(self, schema): + + ignore_missing = toolkit.get_validator('ignore_missing') + is_positive_integer = toolkit.get_validator('is_positive_integer') + + schema.update({ + # This is an existing CKAN core configuration option, we are just + # making it available to be editable at runtime + 'ckan.datasets_per_page': [ignore_missing, is_positive_integer], + + # This is a custom configuration option + 'ckanext.example_iconfigurer.test_conf': [ignore_missing, unicode], + }) + + return schema + + # IRoutes + def before_map(self, map): controller = 'ckanext.example_iconfigurer.controller:MyExtController' with SubMapper(map, controller=controller) as m: diff --git a/ckanext/example_iconfigurer/plugin_v1.py b/ckanext/example_iconfigurer/plugin_v1.py new file mode 100644 index 00000000000..3f454b6d6c3 --- /dev/null +++ b/ckanext/example_iconfigurer/plugin_v1.py @@ -0,0 +1,25 @@ +import ckan.plugins as plugins +import ckan.plugins.toolkit as toolkit + + +class ExampleIConfigurerPlugin(plugins.SingletonPlugin): + + plugins.implements(plugins.IConfigurer) + + # IConfigurer + + def update_config_schema(self, schema): + + ignore_missing = toolkit.get_validator('ignore_missing') + is_positive_integer = toolkit.get_validator('is_positive_integer') + + schema.update({ + # This is an existing CKAN core configuration option, we are just + # making it available to be editable at runtime + 'ckan.datasets_per_page': [ignore_missing, is_positive_integer], + + # This is a custom configuration option + 'ckanext.example_iconfigurer.test_conf': [ignore_missing, unicode], + }) + + return schema diff --git a/ckanext/example_iconfigurer/plugin_v2.py b/ckanext/example_iconfigurer/plugin_v2.py new file mode 100644 index 00000000000..7fb839fa076 --- /dev/null +++ b/ckanext/example_iconfigurer/plugin_v2.py @@ -0,0 +1,29 @@ +import ckan.plugins as plugins +import ckan.plugins.toolkit as toolkit + + +class ExampleIConfigurerPlugin(plugins.SingletonPlugin): + + plugins.implements(plugins.IConfigurer) + + # IConfigurer + + def update_config(self, config): + # Add extension templates directory + toolkit.add_template_directory(config, 'templates') + + def update_config_schema(self, schema): + + ignore_missing = toolkit.get_validator('ignore_missing') + is_positive_integer = toolkit.get_validator('is_positive_integer') + + schema.update({ + # This is an existing CKAN core configuration option, we are just + # making it available to be editable at runtime + 'ckan.datasets_per_page': [ignore_missing, is_positive_integer], + + # This is a custom configuration option + 'ckanext.example_iconfigurer.test_conf': [ignore_missing, unicode], + }) + + return schema diff --git a/ckanext/example_iconfigurer/templates/admin/config.html b/ckanext/example_iconfigurer/templates/admin/config.html new file mode 100644 index 00000000000..fa8940f3f22 --- /dev/null +++ b/ckanext/example_iconfigurer/templates/admin/config.html @@ -0,0 +1,27 @@ +{% ckan_extends %} + +{% import 'macros/form.html' as form %} + +{% block admin_form %} + + {{ super() }} + +

Custom configuration options

+ + {{ form.input('ckan.datasets_per_page', id='field-ckan.datasets_per_page', label=_('Datasets per page'), value=data['ckan.datasets_per_page'], error=errors['ckan.datasets_per_page']) }} + + {{ form.input('ckanext.example_iconfigurer.test_conf', id='field-ckanext.example_iconfigurer.test_conf', label=_('Test conf'), value=data['ckanext.example_iconfigurer.test_conf'], error=errors['ckanext.example_iconfigurer.test_conf']) }} + + +{% endblock %} + + +{% block admin_form_help %} + + {{ super() }} + +

Datasets per page: Number of datasets displayed in dataset listings (eg search page).

+ +

Test conf: An example configuration option, set from an extension.

+ +{% endblock %} diff --git a/ckanext/example_iconfigurer/tests/test_iconfigurer_update_config.py b/ckanext/example_iconfigurer/tests/test_iconfigurer_update_config.py new file mode 100644 index 00000000000..49ba5546c64 --- /dev/null +++ b/ckanext/example_iconfigurer/tests/test_iconfigurer_update_config.py @@ -0,0 +1,170 @@ +import nose.tools + +from pylons import config + +import ckan.lib.app_globals as app_globals + +import ckan.model as model +import ckan.logic as logic +import ckan.plugins as plugins +import ckan.tests.helpers as helpers + + +assert_equals = nose.tools.assert_equals + + +class TestConfigOptionUpdatePluginNotEnabled(object): + + def test_updating_unregistered_core_setting_not_allowed(self): + key = 'ckan.datasets_per_page' + value = 5 + + params = {key: value} + + nose.tools.assert_raises(logic.ValidationError, helpers.call_action, + 'config_option_update', + **params) + + def test_updating_unregistered_new_setting_not_allowed(self): + key = 'ckanext.example_iconfigurer.test_conf' + value = 'Test value' + + params = {key: value} + + nose.tools.assert_raises(logic.ValidationError, helpers.call_action, + 'config_option_update', + **params) + + +class TestConfigOptionUpdatePluginEnabled(object): + + @classmethod + def setup_class(cls): + if not plugins.plugin_loaded('example_iconfigurer'): + plugins.load('example_iconfigurer') + + cls._datasets_per_page_original_value = \ + config.get('ckan.datasets_per_page') + + @classmethod + def teardown_class(cls): + plugins.unload('example_iconfigurer') + config['ckan.datasets_per_page'] = \ + cls._datasets_per_page_original_value + helpers.reset_db() + + def setup(self): + helpers.reset_db() + + def test_update_registered_core_value(self): + + key = 'ckan.datasets_per_page' + value = 5 + + params = {key: value} + + assert_equals(config[key], self._datasets_per_page_original_value) + + new_config = helpers.call_action('config_option_update', **params) + + # output + assert_equals(new_config[key], value) + + # config + assert_equals(config[key], value) + + # app_globals + globals_key = app_globals.get_globals_key(key) + assert hasattr(app_globals.app_globals, globals_key) + + # db + obj = model.Session.query(model.SystemInfo).filter_by(key=key).first() + assert_equals(obj.value, unicode(value)) # all values stored as string + + def test_update_registered_external_value(self): + + key = 'ckanext.example_iconfigurer.test_conf' + value = 'Test value' + + params = {key: value} + + assert key not in config + + new_config = helpers.call_action('config_option_update', **params) + + # output + assert_equals(new_config[key], value) + + # config + assert_equals(config[key], value) + + # db + obj = model.Session.query(model.SystemInfo).filter_by(key=key).first() + assert_equals(obj.value, value) + + # not set in globals + globals_key = app_globals.get_globals_key(key) + assert not hasattr(app_globals.app_globals, globals_key) + + def test_update_registered_core_value_in_list(self): + '''Registering a core key/value will allow it to be included in the + list returned by config_option_list action.''' + + key = 'ckan.datasets_per_page' + value = 5 + params = {key: value} + + # add registered core value + helpers.call_action('config_option_update', **params) + + option_list = helpers.call_action('config_option_list') + + assert key in option_list + + def test_update_registered_core_value_in_show(self): + '''Registering a core key/value will allow it to be shown by the + config_option_show action.''' + + key = 'ckan.datasets_per_page' + value = 5 + params = {key: value} + + # add registered core value + helpers.call_action('config_option_update', **params) + + show_value = helpers.call_action('config_option_show', + key='ckan.datasets_per_page') + + assert show_value == value + + def test_update_registered_external_value_in_list(self): + '''Registering an external key/value will allow it to be included in + the list returned by config_option_list action.''' + + key = 'ckanext.example_iconfigurer.test_conf' + value = 'Test value' + params = {key: value} + + # add registered external value + helpers.call_action('config_option_update', **params) + + option_list = helpers.call_action('config_option_list') + + assert key in option_list + + def test_update_registered_external_value_in_show(self): + '''Registering an external key/value will allow it to be shown by the + config_option_show action.''' + + key = 'ckanext.example_iconfigurer.test_conf' + value = 'Test value' + params = {key: value} + + # add registered external value + helpers.call_action('config_option_update', **params) + + show_value = helpers.call_action( + 'config_option_show', + key='ckanext.example_iconfigurer.test_conf') + + assert show_value == value diff --git a/ckanext/example_theme/v18_snippet_api/templates/ajax_snippets/example_theme_popover.html b/ckanext/example_theme/v18_snippet_api/templates/ajax_snippets/example_theme_popover.html index 032077f6616..b4198bd2b2d 100644 --- a/ckanext/example_theme/v18_snippet_api/templates/ajax_snippets/example_theme_popover.html +++ b/ckanext/example_theme/v18_snippet_api/templates/ajax_snippets/example_theme_popover.html @@ -10,7 +10,7 @@
{{ _('Followers') }}
-
{{ h.get_action('dataset_follower_count', {'id': id}) }}
+
{{ h.follow_count('dataset', id) }}
{{ _('Resources') }}
{{ num_resources }}
diff --git a/contrib/docker/my_init.d/50_configure b/contrib/docker/my_init.d/50_configure index e70bbc97b50..af353731fa3 100755 --- a/contrib/docker/my_init.d/50_configure +++ b/contrib/docker/my_init.d/50_configure @@ -25,7 +25,8 @@ write_config () { "solr_url = ${SOLR_URL}" \ "ckan.storage_path = /var/lib/ckan" \ "email_to = disabled@example.com" \ - "error_email_from = ckan@$(hostname -f)" + "error_email_from = ckan@$(hostname -f)" \ + "ckan.site_url = http://192.168.0.6" if [ -n "$ERROR_EMAIL" ]; then sed -i -e "s&^#email_to.*&email_to = ${ERROR_EMAIL}&" "$CONFIG" diff --git a/doc/extensions/index.rst b/doc/extensions/index.rst index 009cbcf658b..01cc6d95a72 100644 --- a/doc/extensions/index.rst +++ b/doc/extensions/index.rst @@ -31,6 +31,7 @@ features by developing your own CKAN extensions. tutorial custom-config-settings + remote-config-update testing-extensions best-practices adding-custom-fields diff --git a/doc/extensions/remote-config-update.rst b/doc/extensions/remote-config-update.rst new file mode 100644 index 00000000000..167021ad126 --- /dev/null +++ b/doc/extensions/remote-config-update.rst @@ -0,0 +1,119 @@ +============================================= +Making configuration options runtime-editable +============================================= + +Extensions can allow certain configuration options to be edited during +:ref:`runtime `, as opposed to having to edit the +`configuration file `_ and restart the server. + +.. warning:: + + Only configuration options which are not critical, sensitive or could cause the + CKAN instance to break should be made runtime-editable. You should only add config options that you are comfortable + they can be edited during runtime, such as ones you've added in your + own extension, or have reviewed the use of in core CKAN. + +.. note:: + + Only sysadmin users are allowed to modify runtime-editable configuration options. + + +In this tutorial we will show how to make changes to our extension to make two +configuration options runtime-editable: :ref:`ckan.datasets_per_page` and a custom one named +``ckanext.example_iconfigurer.test_conf``. You can see the changes in the :py:mod:`~ckanext.example_iconfigurer` extension that's packaged with CKAN. If you haven't done yet, you +should check the :doc:`tutorial` first. + +This tutorial assumes that we have CKAN running on the paster development server at http://localhost:5000, and that we are using the :ref:`API key ` of a sysadmin user. + +First of all, let's call the :py:func:`~ckan.logic.action.get.config_option_list` API action to see what configuration options are editable during runtime (the ``| python -m json.tool`` bit at the end is added to format the output nicely):: + + curl -H "Authorization: XXX" http://localhost:5000/api/action/config_option_list | python -m json.tool + { + "help": "http://localhost:5000/api/3/action/help_show?name=config_option_list", + "result": [ + "ckan.site_custom_css", + "ckan.main_css", + "ckan.site_title", + "ckan.site_about", + "ckan.site_url", + "ckan.site_logo", + "ckan.site_description", + "ckan.site_intro_text", + "ckan.homepage_style", + "ckan.hola" + ], + "success": true + } + +We can see that the two options that we want to make runtime-editable are not on the list. Trying to update one of them with the :py:func:`~ckan.logic.action.update.config_option_update` action would return an error. + +To include them, we need to add them to the schema that CKAN will use to decide which configuration options can be edited safely at runtime. This is done with the :py:meth:`~ckan.plugins.interfaces.IConfigurer.update_config_schema` method of the :py:class:`~ckan.plugins.interfaces.IConfigurer` interface. + +Let's have a look at how our extension should look like: + +.. literalinclude:: ../../ckanext/example_iconfigurer/plugin_v1.py + +The ``update_config_schema`` method will receive the default schema for runtime-editable configuration options used by CKAN core. We can +then add keys to it to make new options runtime-editable (or remove them if we don't want them to be runtime-editable). The schema is a dictionary mapping configuration option keys to lists +of validator and converter functions to be applied to those keys. To get validator functions defined in CKAN core we use the :py:func:`~ckan.plugins.toolkit.get_validator` function. + +.. note:: Make sure that the first validator applied to each key is the ``ignore_missing`` one, + otherwise this key will need to be always set when updating the configuration. + +Restart the web server and do another request to the :py:func:`~ckan.logic.action.get.config_option_list` API action:: + + curl -H "Authorization: XXX" http://localhost:5000/api/action/config_option_list | python -m json.tool + { + "help": "http://localhost:5000/api/3/action/help_show?name=config_option_list", + "result": [ + "ckan.datasets_per_page", + "ckanext.example_iconfigurer.test_conf", + "ckan.site_custom_css", + "ckan.main_css", + "ckan.site_title", + "ckan.site_about", + "ckan.site_url", + "ckan.site_logo", + "ckan.site_description", + "ckan.site_intro_text", + "ckan.homepage_style", + "ckan.hola" + ], + "success": true + } + +Our two new configuration options are available to be edited at runtime. We can test it calling the :py:func:`~ckan.logic.action.update.config_option_update` action:: + + curl -X POST -H "Authorization: XXX" http://localhost:5000/api/action/config_option_update -d "{\"ckan.datasets_per_page\": 5}" | python -m json.tool + { + "help": "http://localhost:5001/api/3/action/help_show?name=config_option_update", + "result": { + "ckan.datasets_per_page": 5 + }, + "success": true + } + +The configuration has now been updated. If you visit the main search page at http://localhost:5000/dataset only 5 datasets should appear in the results as opposed to the usual 20. + +At this point both our configuration options can be updated via the API, but we also want to make them available on the :ref:`administration interface ` so non-technical users don't need to use the API to change them. + +To do so, we will extend the CKAN core template as described in the :doc:`/theming/templates` documentation. + +First add the :py:meth:`~ckan.plugins.interfaces.IConfigurer.update_config` method to your plugin and register the extension ``templates`` folder: + + +.. literalinclude:: ../../ckanext/example_iconfigurer/plugin_v2.py + +Now create a new file ``config.html`` file under ``ckanext/yourextension/templates/admin/`` with the following contents: + + +.. literalinclude:: ../../ckanext/example_iconfigurer/templates/admin/config.html + :language: html + +This template is extending the default core one. The first block adds two new fields for our configuration options below the existing ones. The second adds a helper text for them on the left hand column. + +Restart the server and navigate to http://localhost:5000/ckan-admin/config. You should see the newfields at the bottom of the form: + +.. image:: ../images/custom_config_fields.png + +Updating the values on the form should update the configuration as before. diff --git a/doc/images/custom_config_fields.png b/doc/images/custom_config_fields.png new file mode 100644 index 00000000000..30dd22d1b77 Binary files /dev/null and b/doc/images/custom_config_fields.png differ diff --git a/doc/maintaining/configuration.rst b/doc/maintaining/configuration.rst index 1b315fa9703..dab12ee9895 100644 --- a/doc/maintaining/configuration.rst +++ b/doc/maintaining/configuration.rst @@ -1,15 +1,65 @@ -=================== -Config File Options -=================== +===================== +Configuration Options +===================== + +The functionality and features of CKAN can be modified using many different +configuration options. These are generally set in the `CKAN configuration file`_, +but some of them can also be set via `Environment variables`_ or at :ref:`runtime `. + + + +Environment variables +********************* + +Some of the CKAN configuration options can be defined as `Environment variables `_ +on the server operating system. + +These are generally low-level critical settings needed when setting up the application, like the database +connection, the Solr server URL, etc. Sometimes it can be useful to define them as environment variables to +automate and orchestrate deployments without having to first modify the `configuration file `_. + +These options are only read at startup time to update the ``config`` object used by CKAN, +but they won't we accessed any more during the lifetime of the application. + +CKAN environment variables names match the options in the configuration file, but they are always uppercase +and prefixed with `CKAN_` (this prefix is added even if +the corresponding option in the ini file does not have it), and replacing dots with underscores. + +This is the list of currently supported environment variables, please refer to the entries in the +`configuration file `_ section below for more details about each one: + +.. literalinclude:: /../ckan/config/environment.py + :language: python + :start-after: Start CONFIG_FROM_ENV_VARS + :end-before: End CONFIG_FROM_ENV_VARS + +.. _env-vars-wikipedia: http://en.wikipedia.org/wiki/Environment_variable -You can set many important options in the CKAN config file. By default, the -configuration file is located at ``/etc/ckan/development.ini`` or -``/etc/ckan/production.ini``. This section documents all of the config file -settings, for reference. -.. todo:: +.. _runtime-config: - Insert cross-ref to section about location of config file? +Updating configuration options during runtime +********************************************* + +CKAN configuration options are generally defined before starting the web application (either in the +`configuration file `_ or via `Environment variables`_). + +A limited number of configuration options can also be edited during runtime. This can be done on the +:ref:`administration interface ` or using the :py:func:`~ckan.logic.action.update.config_option_update` +API action. Only :doc:`sysadmins ` can edit these runtime-editable configuration options. Changes made to these configuration options will be stored on the database and persisted when the server is restarted. + +Extensions can add (or remove) configuration options to the ones that can be edited at runtime. For more +details on how to this check :doc:`/extensions/remote-config-update`. + + + +CKAN configuration file +*********************** + +By default, the +configuration file is located at ``/etc/ckan/default/development.ini`` or +``/etc/ckan/default/production.ini``. This section documents all of the config file +settings, for reference. .. note:: After editing your config file, you need to restart your webserver for the changes to take effect. @@ -37,7 +87,6 @@ settings, for reference. If the same option is set more than once in your config file, the last setting given in the file will override the others. - General Settings ---------------- @@ -217,11 +266,13 @@ Example:: ckan.site_url = http://scotdata.ckan.net -Default value: (none) +Default value: (an explicit value is mandatory) The URL of your CKAN site. Many CKAN features that need an absolute URL to your site use this setting. +.. important:: It is mandatory to complete this setting + .. warning:: This setting should not have a trailing / on the end. diff --git a/doc/maintaining/installing/install-from-source.rst b/doc/maintaining/installing/install-from-source.rst index 977d15571b0..4177bf087dd 100644 --- a/doc/maintaining/installing/install-from-source.rst +++ b/doc/maintaining/installing/install-from-source.rst @@ -227,6 +227,13 @@ site_id ckan.site_id = default +site_url + Provide the site's URL (used when putting links to the site into the + FileStore, notification emails etc). For example:: + + ckan.site_url = http://demo.ckan.org + + Do not add a trailing slash to the URL. .. _setting up solr: diff --git a/setup.py b/setup.py index 2d48b908d99..b31d07fe134 100644 --- a/setup.py +++ b/setup.py @@ -127,6 +127,8 @@ 'example_iresourcecontroller = ckanext.example_iresourcecontroller.plugin:ExampleIResourceControllerPlugin', 'example_ivalidators = ckanext.example_ivalidators.plugin:ExampleIValidatorsPlugin', 'example_iconfigurer = ckanext.example_iconfigurer.plugin:ExampleIConfigurerPlugin', + 'example_iconfigurer_v1 = ckanext.example_iconfigurer.plugin_v1:ExampleIConfigurerPlugin', + 'example_iconfigurer_v2 = ckanext.example_iconfigurer.plugin_v2:ExampleIConfigurerPlugin', ], 'ckan.system_plugins': [ 'domain_object_mods = ckan.model.modification:DomainObjectModificationExtension',