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) }} +
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 @@