diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000000..ed2c3b6bc6f --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = /ckan/migration/*, /ckan/tests/*, */tests/* +source = ckan, ckanext diff --git a/.travis.yml b/.travis.yml index af450298b6c..c6503aca704 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,8 @@ python: env: - PGVERSION=9.1 - PGVERSION=8.4 -script: ./bin/travis-build +install: ./bin/travis-install-dependencies +script: ./bin/travis-run-tests notifications: irc: channels: @@ -14,3 +15,5 @@ notifications: on_failure: change template: - "%{repository} %{branch} %{commit} %{build_url} %{author}: %{message}" +after_success: + - coveralls diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b97798a4522..10ecc8fcce2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,10 +12,28 @@ v2.2 API changes and deprecations: + +* The `ckan.api_url` has been completely removed and it can no longer be used * The edit() and after_update() methods of IPackageController plugins are now called when updating a resource using the web frontend or the resource_update API action [#1052] +v2.1.1 2013-11-8 +================ + +Bug fixes: + * Fix errors on preview on non-root locations (#960) + * Fix place-holder images on non-root locations (#1309) + * Don't accept invalid URLs in resource proxy (#1106) + * Make sure came_from url is local (#1039) + * Fix logout redirect in non-root locations (#1025) + * Wrong auth checks for sysadmins on package_create (#1184) + * Don't return private datasets on package_list (#1295) + * Stop tracking failing when no lang/encoding headers (#1192) + * Fix for paster db clean command getting frozen + * Fix organization not set when editing a dataset (#1199) + * Fix PDF previews (#1194) + * Fix preview failing on private datastore resources (#1221) v2.1 2013-08-13 =============== @@ -96,6 +114,18 @@ Known issues: * Under certain authorization setups the frontend for the groups functionality may not work as expected (See #1176 #1175). +v2.0.3 2013-11-8 +================ + +Bug fixes: + * Fix errors on preview on non-root locations (#960) + * Don't accept invalid URLs in resource proxy (#1106) + * Make sure came_from url is local (#1039) + * Fix logout redirect in non-root locations (#1025) + * Don't return private datasets on package_list (#1295) + * Stop tracking failing when no lang/encoding headers (#1192) + * Fix for paster db clean command getting frozen + v2.0.2 2013-08-13 ================= diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 7b15f8d5531..b2e324ac204 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -61,6 +61,7 @@ When writing code for CKAN, try to respect our coding standards: css-coding-standards javascript-coding-standards testing-coding-standards + upgrading-dependencies * `CKAN coding standards `_ * `Python coding standards `_ @@ -68,6 +69,7 @@ When writing code for CKAN, try to respect our coding standards: * `CSS coding standards `_ * `JavaScript coding standards `_ * `Testing coding standards `_ +* `Upgrading CKAN's dependencies `_ --------------- diff --git a/README.rst b/README.rst index 93d80e668cc..9c810209231 100644 --- a/README.rst +++ b/README.rst @@ -5,6 +5,10 @@ CKAN: The Open Source Data Portal Software :target: http://travis-ci.org/okfn/ckan :alt: Build Status +.. image:: https://coveralls.io/repos/okfn/ckan/badge.png + :target: https://coveralls.io/r/okfn/ckan + :alt: Test coverage + **CKAN is the world’s leading open-source data portal platform**. CKAN makes it easy to publish, share and work with data. It's a data management system that provides a powerful platform for cataloging, storing and accessing diff --git a/bin/travis-build b/bin/travis-install-dependencies similarity index 54% rename from bin/travis-build rename to bin/travis-install-dependencies index 51e633781b4..9c699767555 100755 --- a/bin/travis-build +++ b/bin/travis-install-dependencies @@ -1,4 +1,7 @@ -#!/bin/sh +#!/bin/bash + +# Exit immediately if any command fails +set -e # Drop Travis' postgres cluster if we're building using a different pg version TRAVIS_PGVERSION='9.1' @@ -13,7 +16,7 @@ fi # We need this ppa so we can install postgres-8.4 sudo add-apt-repository -yy ppa:pitti/postgresql sudo apt-get update -qq -sudo apt-get install solr-jetty postgresql-$PGVERSION +sudo apt-get install postgresql-$PGVERSION solr-jetty libcommons-fileupload-java:amd64=1.2.2-1 sudo service postgresql reload @@ -32,43 +35,15 @@ python setup.py develop # Install npm dpes for mocha npm install -g mocha-phantomjs phantomjs -# Configure Solr -echo "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo tee /etc/default/jetty -# FIXME the solr schema cannot be hardcoded as it is dependent on the ckan version -sudo cp ckan/config/solr/schema-2.0.xml /etc/solr/conf/schema.xml -sudo service jetty restart - paster db init -c test-core.ini # If Postgres >= 9.0, we don't need to use datastore's legacy mode. if [ $PGVERSION != '8.4' ] then - psql -c 'CREATE USER datastore_default;' -U postgres - sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/datastore_default@\/datastore_test/' test-core.ini + sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/datastore_default:pass@\/datastore_test/' test-core.ini paster datastore set-permissions postgres -c test-core.ini else sed -i -e 's/.*datastore.read_url.*//' test-core.ini fi cat test-core.ini - -# Run mocha front-end tests -# We need ckan to be running for some tests -paster serve test-core.ini & -sleep 5 # Make sure the server has fully started -mocha-phantomjs http://localhost:5000/base/test/index.html -# Did an error occur? -MOCHA_ERROR=$? -# We are done so kill ckan -killall paster - -# And finally, run the nosetests -nosetests --ckan --with-pylons=test-core.ini --nologcapture ckan ckanext -# Did an error occur? -NOSE_ERROR=$? - -[ "0" -ne "$MOCHA_ERROR" ] && echo MOCKA tests have failed -[ "0" -ne "$NOSE_ERROR" ] && echo NOSE tests have failed - -# If an error occurred in our tests make sure travis knows -exit `expr $MOCHA_ERROR + $NOSE_ERROR` diff --git a/bin/travis-run-tests b/bin/travis-run-tests new file mode 100755 index 00000000000..28ddd58e04b --- /dev/null +++ b/bin/travis-run-tests @@ -0,0 +1,28 @@ +#!/bin/sh + +# Configure Solr +echo "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo tee /etc/default/jetty +# FIXME the solr schema cannot be hardcoded as it is dependent on the ckan version +sudo cp ckan/config/solr/schema-2.0.xml /etc/solr/conf/schema.xml +sudo service jetty restart + +# Run mocha front-end tests +# We need ckan to be running for some tests +paster serve test-core.ini & +sleep 5 # Make sure the server has fully started +mocha-phantomjs http://localhost:5000/base/test/index.html +# Did an error occur? +MOCHA_ERROR=$? +# We are done so kill ckan +killall paster + +# And finally, run the nosetests +nosetests --ckan --reset-db --with-pylons=test-core.ini --nologcapture ckan ckanext +# Did an error occur? +NOSE_ERROR=$? + +[ "0" -ne "$MOCHA_ERROR" ] && echo MOCKA tests have failed +[ "0" -ne "$NOSE_ERROR" ] && echo NOSE tests have failed + +# If an error occurred in our tests make sure travis knows +exit `expr $MOCHA_ERROR + $NOSE_ERROR` diff --git a/ckan/ckan_nose_plugin.py b/ckan/ckan_nose_plugin.py index 2ec4d0988b9..877589a2204 100644 --- a/ckan/ckan_nose_plugin.py +++ b/ckan/ckan_nose_plugin.py @@ -19,21 +19,26 @@ def startContext(self, ctx): if 'new_tests' in repr(ctx): # We don't want to do the stuff below for new-style tests. + if not CkanNose.settings.reset_database: + model.repo.tables_created_and_initialised = True return if isclass(ctx): if hasattr(ctx, "no_db") and ctx.no_db: return - if self.is_first_test or CkanNose.settings.ckan_migration: + if (not CkanNose.settings.reset_database + and not CkanNose.settings.ckan_migration): + model.Session.close_all() + model.repo.tables_created_and_initialised = True + model.repo.rebuild_db() + self.is_first_test = False + elif self.is_first_test or CkanNose.settings.ckan_migration: model.Session.close_all() model.repo.clean_db() self.is_first_test = False if CkanNose.settings.ckan_migration: model.Session.close_all() model.repo.upgrade_db() - # init_db is run at the start of every class because - # when you use an in-memory sqlite db, it appears that - # the db is destroyed after every test when you Session.Remove(). ## This is to make sure the configuration is run again. ## Plugins use configure to make their own tables and they @@ -43,6 +48,9 @@ def startContext(self, ctx): for plugin in PluginImplementations(IConfigurable): plugin.configure(config) + # init_db is run at the start of every class because + # when you use an in-memory sqlite db, it appears that + # the db is destroyed after every test when you Session.Remove(). model.repo.init_db() def options(self, parser, env): @@ -66,6 +74,11 @@ def options(self, parser, env): dest='segments', help='A string containing a hex digits that represent which of' 'the 16 test segments to run. i.e 15af will run segments 1,5,a,f') + parser.add_option( + '--reset-db', + action='store_true', + dest='reset_database', + help='drop database and reinitialize before tests are run') def wantClass(self, cls): name = cls.__name__ diff --git a/ckan/config/deployment.ini_tmpl b/ckan/config/deployment.ini_tmpl index 2f91158ea49..5d89ad2a155 100644 --- a/ckan/config/deployment.ini_tmpl +++ b/ckan/config/deployment.ini_tmpl @@ -66,6 +66,7 @@ ckan.auth.user_create_organizations = true ckan.auth.user_delete_groups = true ckan.auth.user_delete_organizations = true ckan.auth.create_user_via_api = false +ckan.auth.create_user_via_web = true ## Search Settings @@ -79,6 +80,7 @@ ckan.site_id = default ## Plugins Settings # Note: Add ``datastore`` to enable the CKAN DataStore +# Add ``datapusher`` to enable DataPusher # Add ``pdf_preview`` to enable the resource preview for PDFs # Add ``resource_proxy`` to enable resorce proxying and get around the # same origin policy @@ -146,8 +148,8 @@ ckan.feeds.author_link = # Make sure you have set up the DataStore -datapusher.formats = csv -datapusher.url = http://datapusher.ckan.org/ +ckan.datapusher.formats = csv +ckan.datapusher.url = http://datapusher.ckan.org/ ## Activity Streams Settings diff --git a/ckan/config/environment.py b/ckan/config/environment.py index 49be9b726a6..409f77435a4 100644 --- a/ckan/config/environment.py +++ b/ckan/config/environment.py @@ -159,7 +159,7 @@ def find_controller(self, controller): ''' This code is based on Genshi code - Copyright © 2006-2012 Edgewall Software + Copyright © 2006-2012 Edgewall Software All rights reserved. Redistribution and use in source and binary forms, with or diff --git a/ckan/config/middleware.py b/ckan/config/middleware.py index 4df0aa84cb0..5ba02933d46 100644 --- a/ckan/config/middleware.py +++ b/ckan/config/middleware.py @@ -4,6 +4,7 @@ import logging import json import hashlib +import os import sqlalchemy as sa from beaker.middleware import CacheMiddleware, SessionMiddleware @@ -22,6 +23,7 @@ from ckan.plugins import PluginImplementations from ckan.plugins.interfaces import IMiddleware from ckan.lib.i18n import get_locales_from_config +import ckan.lib.uploader as uploader from ckan.config.environment import load_environment import ckan.lib.app_globals as app_globals @@ -147,6 +149,20 @@ def make_app(conf, full_stack=True, static_files=True, **app_conf): cache_max_age=static_max_age) static_parsers = [static_app, app] + storage_directory = uploader.get_storage_path() + if storage_directory: + path = os.path.join(storage_directory, 'storage') + try: + os.makedirs(path) + except OSError, e: + ## errno 17 is file already exists + if e.errno != 17: + raise + + storage_app = StaticURLParser(path, + cache_max_age=static_max_age) + static_parsers.insert(0, storage_app) + # Configurable extra static file paths extra_static_parsers = [] for public_path in config.get('extra_public_paths', '').split(','): diff --git a/ckan/config/routing.py b/ckan/config/routing.py index 0fbb02bb886..016c65ae0e4 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -222,7 +222,6 @@ def make_map(): ]))) m.connect('/dataset/{action}/{id}', requirements=dict(action='|'.join([ - 'edit', 'new_metadata', 'new_resource', 'history', @@ -234,20 +233,24 @@ def make_map(): 'delete', 'api_data', ]))) + m.connect('dataset_edit', '/dataset/edit/{id}', action='edit', + ckan_icon='edit') m.connect('dataset_followers', '/dataset/followers/{id}', action='followers', ckan_icon='group') m.connect('dataset_activity', '/dataset/activity/{id}', action='activity', ckan_icon='time') m.connect('/dataset/activity/{id}/{offset}', action='activity') m.connect('/dataset/{id}.{format}', action='read') + m.connect('dataset_resources', '/dataset/resources/{id}', + action='resources', ckan_icon='reorder') m.connect('dataset_read', '/dataset/{id}', action='read', ckan_icon='sitemap') m.connect('/dataset/{id}/resource/{resource_id}', action='resource_read') m.connect('/dataset/{id}/resource_delete/{resource_id}', action='resource_delete') - m.connect('/dataset/{id}/resource_edit/{resource_id}', - action='resource_edit') + m.connect('resource_edit', '/dataset/{id}/resource_edit/{resource_id}', + action='resource_edit', ckan_icon='edit') m.connect('/dataset/{id}/resource/{resource_id}/download', action='resource_download') m.connect('/dataset/{id}/resource/{resource_id}/embed', @@ -359,6 +362,7 @@ def make_map(): action='followers', ckan_icon='group') m.connect('user_edit', '/user/edit/{id:.*}', action='edit', ckan_icon='cog') + m.connect('user_delete', '/user/delete/{id}', action='delete') m.connect('/user/reset/{id:.*}', action='perform_reset') m.connect('register', '/user/register', action='register') m.connect('login', '/user/login', action='login') diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index 8f63e2779ca..bcff15841a5 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -114,8 +114,10 @@ def _finish_ok(self, response_data=None, return self._finish(status_int, response_data, content_type) - def _finish_not_authz(self): + def _finish_not_authz(self, extra_msg=None): response_data = _('Access denied') + if extra_msg: + response_data = '%s - %s' % (response_data, extra_msg) return self._finish(403, response_data, 'json') def _finish_not_found(self, extra_msg=None): @@ -194,10 +196,14 @@ def action(self, logic_function, ver=None): 'data': request_data} return_dict['success'] = False return self._finish(400, return_dict, content_type='json') - except NotAuthorized: + except NotAuthorized, e: return_dict['error'] = {'__type': 'Authorization Error', 'message': _('Access denied')} return_dict['success'] = False + + if e.extra_msg: + return_dict['error']['message'] += ': %s' % e.extra_msg + return self._finish(403, return_dict, content_type='json') except NotFound, e: return_dict['error'] = {'__type': 'Not Found Error', @@ -277,8 +283,9 @@ def list(self, ver=None, register=None, subregister=None, id=None): except NotFound, e: extra_msg = e.extra_msg return self._finish_not_found(extra_msg) - except NotAuthorized: - return self._finish_not_authz() + except NotAuthorized, e: + extra_msg = e.extra_msg + return self._finish_not_authz(extra_msg) def show(self, ver=None, register=None, subregister=None, id=None, id2=None): @@ -308,8 +315,9 @@ def show(self, ver=None, register=None, subregister=None, except NotFound, e: extra_msg = e.extra_msg return self._finish_not_found(extra_msg) - except NotAuthorized: - return self._finish_not_authz() + except NotAuthorized, e: + extra_msg = e.extra_msg + return self._finish_not_authz(extra_msg) def _represent_package(self, package): return package.as_dict(ref_package_by=self.ref_package_by, @@ -354,8 +362,9 @@ def create(self, ver=None, register=None, subregister=None, data_dict.get("id"))) return self._finish_ok(response_data, resource_location=location) - except NotAuthorized: - return self._finish_not_authz() + except NotAuthorized, e: + extra_msg = e.extra_msg + return self._finish_not_authz(extra_msg) except NotFound, e: extra_msg = e.extra_msg return self._finish_not_found(extra_msg) @@ -410,8 +419,9 @@ def update(self, ver=None, register=None, subregister=None, try: response_data = action(context, data_dict) return self._finish_ok(response_data) - except NotAuthorized: - return self._finish_not_authz() + except NotAuthorized, e: + extra_msg = e.extra_msg + return self._finish_not_authz(extra_msg) except NotFound, e: extra_msg = e.extra_msg return self._finish_not_found(extra_msg) @@ -458,8 +468,9 @@ def delete(self, ver=None, register=None, subregister=None, try: response_data = action(context, data_dict) return self._finish_ok(response_data) - except NotAuthorized: - return self._finish_not_authz() + except NotAuthorized, e: + extra_msg = e.extra_msg + return self._finish_not_authz(extra_msg) except NotFound, e: extra_msg = e.extra_msg return self._finish_not_found(extra_msg) diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index ccef46bfe78..1f57d6dc416 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -1,6 +1,8 @@ import re +import os import logging import genshi +import cgi import datetime from urllib import urlencode @@ -278,7 +280,8 @@ def pager_url(q=None, page=None): facets = OrderedDict() - default_facet_titles = {'groups': _('Groups'), + default_facet_titles = {'organization': _('Organizations'), + 'groups': _('Groups'), 'tags': _('Tags'), 'res_format': _('Formats'), 'license_id': _('License')} @@ -344,6 +347,9 @@ def pager_url(q=None, page=None): c.facets = {} c.page = h.Page(collection=[]) + self._setup_template_variables(context, {'id':id}, + group_type=group_type) + def bulk_process(self, id): ''' Allow bulk processing of datasets for an organization. Make private/public or delete. For organization admins.''' @@ -421,6 +427,9 @@ def new(self, data=None, errors=None, error_summary=None): return self._save_new(context, group_type) data = data or {} + if not data.get('image_url', '').startswith('http'): + data.pop('image_url', None) + errors = errors or {} error_summary = error_summary or {} vars = {'data': data, 'errors': errors, @@ -520,7 +529,6 @@ def _save_edit(self, id, context): data_dict['id'] = id context['allow_partial_update'] = True group = self._action('group_update')(context, data_dict) - if id != group['name']: self._force_reindex(group) @@ -612,6 +620,19 @@ def member_new(self, id): data_dict = clean_dict(dict_fns.unflatten( tuplize_dict(parse_params(request.params)))) data_dict['id'] = id + + email = data_dict.get('email') + if email: + user_data_dict = { + 'email': email, + 'group_id': data_dict['id'], + 'role': data_dict['role'] + } + del data_dict['email'] + user_dict = self._action('user_invite')(context, + user_data_dict) + data_dict['username'] = user_dict['name'] + c.group_dict = self._action('group_member_create')(context, data_dict) self._redirect_to(controller='group', action='members', id=id) else: @@ -815,7 +836,9 @@ def admins(self, id): def about(self, id): c.group_dict = self._get_group_dict(id) - return render(self._about_template(c.group_dict['type'])) + group_type = c.group_dict['type'] + self._setup_template_variables({}, {'id': id}, group_type=group_type) + return render(self._about_template(group_type)) def _get_group_dict(self, id): ''' returns the result of group_show action or aborts if there is a diff --git a/ckan/controllers/home.py b/ckan/controllers/home.py index da3d4b3901b..7aa15be87bd 100644 --- a/ckan/controllers/home.py +++ b/ckan/controllers/home.py @@ -67,6 +67,7 @@ def index(self): c.search_facets = query['search_facets'] c.facet_titles = { + 'organization': _('Organizations'), 'groups': _('Groups'), 'tags': _('Tags'), 'res_format': _('Formats'), diff --git a/ckan/controllers/organization.py b/ckan/controllers/organization.py index b44d95e057f..3907143a187 100644 --- a/ckan/controllers/organization.py +++ b/ckan/controllers/organization.py @@ -15,46 +15,5 @@ class OrganizationController(group.GroupController): # this makes us use organization actions group_type = 'organization' - def _group_form(self, group_type=None): - return 'organization/new_organization_form.html' - - def _form_to_db_schema(self, group_type=None): - return group.lookup_group_plugin(group_type).form_to_db_schema() - - def _db_to_form_schema(self, group_type=None): - '''This is an interface to manipulate data from the database - into a format suitable for the form (optional)''' - pass - - def _setup_template_variables(self, context, data_dict, group_type=None): - pass - - def _new_template(self, group_type): - return 'organization/new.html' - - def _about_template(self, group_type): - return 'organization/about.html' - - def _index_template(self, group_type): - return 'organization/index.html' - - def _admins_template(self, group_type): - return 'organization/admins.html' - - def _bulk_process_template(self, group_type): - return 'organization/bulk_process.html' - - def _read_template(self, group_type): - return 'organization/read.html' - - def _history_template(self, group_type): - return group.lookup_group_plugin(group_type).history_template() - - def _edit_template(self, group_type): - return 'organization/edit.html' - - def _activity_template(self, group_type): - return 'organization/activity_stream.html' - def _guess_group_type(self, expecting_name=False): return 'organization' diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py index d1852cd95b7..b0adf5c5ac5 100644 --- a/ckan/controllers/package.py +++ b/ckan/controllers/package.py @@ -19,6 +19,7 @@ import ckan.lib.datapreview as datapreview import ckan.lib.plugins import ckan.plugins as p +import ckan.lib.render from ckan.common import OrderedDict, _, json, request, c, g, response from home import CACHE_PARAMETERS @@ -301,6 +302,31 @@ def _content_type_from_accept(self): ct, mu, ext = accept.parse_header(request.headers.get('Accept', '')) return ct, ext, (NewTextTemplate, MarkupTemplate)[mu] + def resources(self, id): + package_type = self._get_package_type(id.split('@')[0]) + context = {'model': model, 'session': model.Session, + 'user': c.user or c.author, 'for_view': True, + 'auth_user_obj': c.userobj} + data_dict = {'id': id} + + try: + check_access('package_update', context) + except NotAuthorized, e: + abort(401, _('User %r not authorized to edit %s') % (c.user, id)) + # check if package exists + try: + c.pkg_dict = get_action('package_show')(context, data_dict) + c.pkg = context['package'] + except NotFound: + abort(404, _('Dataset not found')) + except NotAuthorized: + abort(401, _('Unauthorized to read package %s') % id) + + self._setup_template_variables(context, {'id': id}, + package_type=package_type) + + return render('package/resources.html') + def read(self, id, format='html'): if not format == 'html': ctype, extension, loader = \ @@ -366,7 +392,15 @@ def read(self, id, format='html'): template = self._read_template(package_type) template = template[:template.index('.') + 1] + format - return render(template, loader_class=loader) + try: + return render(template, loader_class=loader) + except ckan.lib.render.TemplateNotFound: + msg = _("Viewing {package_type} datasets in {format} format is " + "not supported (template file {file} not found).".format( + package_type=package_type, format=format, file=template)) + abort(404, msg) + + assert False, "We should never get here" def history(self, id): package_type = self._get_package_type(id.split('@')[0]) @@ -666,11 +700,14 @@ def new_resource(self, id, data=None, errors=None, error_summary=None): abort(404, _('The dataset {id} could not be found.').format(id=id)) # required for nav menu vars['pkg_dict'] = pkg_dict + template = 'package/new_resource_not_draft.html' if pkg_dict['state'] == 'draft': vars['stage'] = ['complete', 'active'] + template = 'package/new_resource.html' elif pkg_dict['state'] == 'draft-complete': vars['stage'] = ['complete', 'active', 'complete'] - return render('package/new_resource.html', extra_vars=vars) + template = 'package/new_resource.html' + return render(template, extra_vars=vars) def new_metadata(self, id, data=None, errors=None, error_summary=None): ''' FIXME: This is a temporary action to allow styling of the diff --git a/ckan/controllers/template.py b/ckan/controllers/template.py index 1ebc700345b..0755b1a2cf5 100644 --- a/ckan/controllers/template.py +++ b/ckan/controllers/template.py @@ -1,6 +1,5 @@ -from genshi.template.loader import TemplateNotFound - import ckan.lib.base as base +import ckan.lib.render class TemplateController(base.BaseController): @@ -29,11 +28,11 @@ def view(self, url): """ try: return base.render(url) - except TemplateNotFound: + except ckan.lib.render.TemplateNotFound: if url.endswith('.html'): base.abort(404) url += '.html' try: return base.render(url) - except TemplateNotFound: + except ckan.lib.render.TemplateNotFound: base.abort(404) diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py index e1bd29b2b26..28f62b85e01 100644 --- a/ckan/controllers/user.py +++ b/ckan/controllers/user.py @@ -68,7 +68,7 @@ def _setup_template_variables(self, context, data_dict): try: user_dict = get_action('user_show')(context, data_dict) except NotFound: - h.redirect_to(controller='user', action='login', id=None) + abort(404, _('User not found')) except NotAuthorized: abort(401, _('Not authorized to see this page')) c.user_dict = user_dict @@ -117,10 +117,6 @@ def read(self, id=None): 'for_view': True} data_dict = {'id': id, 'user_obj': c.userobj} - try: - check_access('user_show', context, data_dict) - except NotAuthorized: - abort(401, _('Not authorized to see this page')) context['with_related'] = True @@ -183,6 +179,22 @@ def new(self, data=None, errors=None, error_summary=None): c.form = render(self.new_user_form, extra_vars=vars) return render('user/new.html') + def delete(self, id): + '''Delete user with id passed as parameter''' + context = {'model': model, + 'session': model.Session, + 'user': c.user, + 'auth_user_obj': c.userobj} + data_dict = {'id': id} + + try: + get_action('user_delete')(context, data_dict) + user_index = h.url_for(controller='user', action='index') + h.redirect_to(user_index) + except NotAuthorized: + msg = _('Unauthorized to delete user with id "{user_id}".') + abort(401, msg.format(user_id=id)) + def _save_new(self, context): try: data_dict = logic.clean_dict(unflatten( @@ -392,6 +404,9 @@ def request_reset(self): if request.method == 'POST': id = request.params.get('user') + context = {'model': model, + 'user': c.user} + data_dict = {'id': id} user_obj = None try: @@ -435,18 +450,16 @@ def perform_reset(self, id): # FIXME We should reset the reset key when it is used to prevent # reuse of the url context = {'model': model, 'session': model.Session, - 'user': c.user, - 'auth_user_obj': c.userobj, + 'user': id, 'keep_sensitive_data': True} - data_dict = {'id': id} - try: check_access('user_reset', context) except NotAuthorized: abort(401, _('Unauthorized to reset password.')) try: + data_dict = {'id': id} user_dict = get_action('user_show')(context, data_dict) # Be a little paranoid, and get rid of sensitive data that's @@ -468,6 +481,7 @@ def perform_reset(self, id): new_password = self._get_form_password() user_dict['password'] = new_password user_dict['reset_key'] = c.reset_key + user_dict['state'] = model.State.ACTIVE user = get_action('user_update')(context, user_dict) h.flash_success(_("Your password has been reset.")) diff --git a/ckan/lib/app_globals.py b/ckan/lib/app_globals.py index dde98f0a1ed..38138e87815 100644 --- a/ckan/lib/app_globals.py +++ b/ckan/lib/app_globals.py @@ -39,13 +39,11 @@ # has been setup in load_environment(): 'ckan.site_id': {}, 'ckan.recaptcha.publickey': {'name': 'recaptcha_publickey'}, - 'ckan.recaptcha.privatekey': {'name': 'recaptcha_publickey'}, 'ckan.template_title_deliminater': {'default': '-'}, 'ckan.template_head_end': {}, 'ckan.template_footer_end': {}, 'ckan.dumps_url': {}, 'ckan.dumps_format': {}, - 'ckan.api_url': {}, 'ofs.impl': {'name': 'ofs_impl'}, 'ckan.homepage_style': {'default': '1'}, diff --git a/ckan/lib/authenticator.py b/ckan/lib/authenticator.py index 6f061caad91..cf6ed016694 100644 --- a/ckan/lib/authenticator.py +++ b/ckan/lib/authenticator.py @@ -12,9 +12,9 @@ class OpenIDAuthenticator(object): def authenticate(self, environ, identity): if 'repoze.who.plugins.openid.userid' in identity: - openid = identity.get('repoze.who.plugins.openid.userid') + openid = identity['repoze.who.plugins.openid.userid'] user = User.by_openid(openid) - if user is None: + if user is None or not user.is_active(): return None else: return user.name @@ -25,14 +25,20 @@ class UsernamePasswordAuthenticator(object): implements(IAuthenticator) def authenticate(self, environ, identity): - if not 'login' in identity or not 'password' in identity: + if not ('login' in identity and 'password' in identity): return None - user = User.by_name(identity.get('login')) + + login = identity['login'] + user = User.by_name(login) + if user is None: - log.debug('Login failed - username %r not found', identity.get('login')) - return None - if user.validate_password(identity.get('password')): + log.debug('Login failed - username %r not found', login) + elif not user.is_active(): + log.debug('Login as %r failed - user isn\'t active', login) + elif not user.validate_password(identity['password']): + log.debug('Login as %r failed - password not valid', login) + else: return user.name - log.debug('Login as %r failed - password not valid', identity.get('login')) + return None diff --git a/ckan/lib/base.py b/ckan/lib/base.py index 3260bbb7e83..bb7e16682cb 100644 --- a/ckan/lib/base.py +++ b/ckan/lib/base.py @@ -128,8 +128,7 @@ def render_template(): try: template_path, template_type = render_.template_info(template_name) except render_.TemplateNotFound: - template_type = 'genshi' - template_path = '' + raise # snippets should not pass the context # but allow for legacy genshi templates @@ -227,6 +226,8 @@ def render_template(): raise ckan.exceptions.CkanUrlException( '\nAn Exception has been raised for template %s\n%s' % (template_name, e.message)) + except render_.TemplateNotFound: + raise class ValidationException(Exception): @@ -314,8 +315,9 @@ def _identify_user_default(self): if c.user: c.user = c.user.decode('utf8') c.userobj = model.User.by_name(c.user) - if c.userobj is None: - # This occurs when you are logged in, clean db + if c.userobj is None or not c.userobj.is_active(): + # This occurs when a user that was still logged in is deleted, + # or when you are logged in, clean db # and then restart (or when you change your username) # There is no user object, so even though repoze thinks you # are logged in and your cookie has ckan_display_name, we diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py index 14c217ed096..72d971dbb71 100644 --- a/ckan/lib/cli.py +++ b/ckan/lib/cli.py @@ -10,6 +10,8 @@ import ckan.include.rcssmin as rcssmin import ckan.lib.fanstatic_resources as fanstatic_resources import sqlalchemy as sa +import urlparse +import routes import paste.script from paste.registry import Registry @@ -99,6 +101,12 @@ def _load_config(self): self.translator_obj = MockTranslator() self.registry.register(pylons.translator, self.translator_obj) + ## give routes enough information to run url_for + parsed = urlparse.urlparse(conf.get('ckan.site_url', 'http://0.0.0.0')) + request_config = routes.request_config() + request_config.host = parsed.netloc + parsed.path + request_config.protocol = parsed.scheme + def _setup_app(self): cmd = paste.script.appinstall.SetupCommand('setup-app') cmd.run([self.filename]) @@ -842,12 +850,10 @@ def show(self, dataset_ref): pprint.pprint(dataset.as_dict()) def delete(self, dataset_ref): - from ckan import plugins import ckan.model as model dataset = self._get_dataset(dataset_ref) old_state = dataset.state - plugins.load('synchronous_search') rev = model.repo.new_revision() dataset.delete() model.repo.commit_and_remove() @@ -855,12 +861,10 @@ def delete(self, dataset_ref): print '%s %s -> %s' % (dataset.name, old_state, dataset.state) def purge(self, dataset_ref): - from ckan import plugins import ckan.model as model dataset = self._get_dataset(dataset_ref) name = dataset.name - plugins.load('synchronous_search') rev = model.repo.new_revision() dataset.purge() model.repo.commit_and_remove() @@ -1286,8 +1290,6 @@ class CreateTestDataCommand(CkanCommand): def command(self): self._load_config() self._setup_app() - from ckan import plugins - plugins.load('synchronous_search') # so packages get indexed from create_test_data import CreateTestData if self.args: diff --git a/ckan/lib/create_test_data.py b/ckan/lib/create_test_data.py index 3edf865c7c7..6c7a275c36f 100644 --- a/ckan/lib/create_test_data.py +++ b/ckan/lib/create_test_data.py @@ -519,8 +519,9 @@ def _create_user_without_commit(cls, name='', **user_dict): @classmethod def create_user(cls, name='', **kwargs): - cls._create_user_without_commit(name, **kwargs) + user = cls._create_user_without_commit(name, **kwargs) model.Session.commit() + return user @classmethod def flag_for_deletion(cls, pkg_names=[], tag_names=[], group_names=[], diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py index 34fb738b950..610aeaeb41e 100644 --- a/ckan/lib/dictization/model_dictize.py +++ b/ckan/lib/dictization/model_dictize.py @@ -10,6 +10,7 @@ import ckan.lib.dictization as d import ckan.new_authz as new_authz import ckan.lib.search as search +import ckan.lib.munge as munge ## package save @@ -41,6 +42,16 @@ def group_list_dictize(obj_list, context, group_dict['display_name'] = obj.display_name + image_url = group_dict.get('image_url') + group_dict['image_display_url'] = image_url + if image_url and not image_url.startswith('http'): + #munge here should not have an effect only doing it incase + #of potential vulnerability of dodgy api input + image_url = munge.munge_filename(image_url) + group_dict['image_display_url'] = h.url_for_static( + 'uploads/group/%s' % group_dict.get('image_url') + ) + if obj.is_organization: group_dict['packages'] = query.facets['owner_org'].get(obj.id, 0) else: @@ -357,6 +368,16 @@ def group_dictize(group, context): for item in plugins.PluginImplementations(plugin): result_dict = item.before_view(result_dict) + image_url = result_dict.get('image_url') + result_dict['image_display_url'] = image_url + if image_url and not image_url.startswith('http'): + #munge here should not have an effect only doing it incase + #of potential vulnerability of dodgy api input + image_url = munge.munge_filename(image_url) + result_dict['image_display_url'] = h.url_for_static( + 'uploads/group/%s' % result_dict.get('image_url'), + qualified = True + ) return result_dict def tag_list_dictize(tag_list, context): diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index 57799090fc4..94e30bc8fc2 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -37,6 +37,7 @@ import ckan.lib.maintain as maintain import ckan.lib.datapreview as datapreview import ckan.logic as logic +import ckan.lib.uploader as uploader from ckan.common import ( _, ungettext, g, c, request, session, json, OrderedDict @@ -597,7 +598,8 @@ def get_facet_title(name): if config_title: return config_title - facet_titles = {'groups': _('Groups'), + facet_titles = {'organization': _('Organizations'), + 'groups': _('Groups'), 'tags': _('Tags'), 'res_format': _('Formats'), 'license': _('License'), } @@ -1667,6 +1669,10 @@ def new_activities(): action = logic.get_action('dashboard_new_activities_count') return action({}, {}) +def uploads_enabled(): + if uploader.get_storage_path(): + return True + return False def get_featured_organizations(count=1): '''Returns a list of favourite organization in the form @@ -1839,6 +1845,7 @@ def get_site_statistics(): 'radio', 'submit', 'asbool', + 'uploads_enabled', 'get_featured_organizations', 'get_featured_groups', 'get_site_statistics', diff --git a/ckan/lib/mailer.py b/ckan/lib/mailer.py index f0e1978ac2c..72ebe33e2e0 100644 --- a/ckan/lib/mailer.py +++ b/ckan/lib/mailer.py @@ -100,21 +100,37 @@ def mail_user(recipient, subject, body, headers={}): mail_recipient(recipient.display_name, recipient.email, subject, body, headers=headers) +def get_reset_link_body(user): + reset_link_message = _( + '''You have requested your password on %(site_title)s to be reset. -RESET_LINK_MESSAGE = _( -'''You have requested your password on %(site_title)s to be reset. + Please click the following link to confirm this request: -Please click the following link to confirm this request: + %(reset_link)s + ''') - %(reset_link)s -''') + d = { + 'reset_link': get_reset_link(user), + 'site_title': g.site_title + } + return reset_link_message % d -def make_key(): - return uuid.uuid4().hex[:10] +def get_invite_body(user): + invite_message = _( + '''You have been invited to %(site_title)s. A user has already been created to + you with the username %(user_name)s. You can change it later. -def create_reset_key(user): - user.reset_key = unicode(make_key()) - model.repo.commit_and_remove() + To accept this invite, please reset your password at: + + %(reset_link)s + ''') + + d = { + 'reset_link': get_reset_link(user), + 'site_title': g.site_title, + 'user_name': user.name, + } + return invite_message % d def get_reset_link(user): return urljoin(g.site_url, @@ -123,17 +139,24 @@ def get_reset_link(user): id=user.id, key=user.reset_key)) -def get_reset_link_body(user): - d = { - 'reset_link': get_reset_link(user), - 'site_title': g.site_title - } - return RESET_LINK_MESSAGE % d - def send_reset_link(user): create_reset_key(user) body = get_reset_link_body(user) - mail_user(user, _('Reset your password'), body) + subject = _('Reset your password') + mail_user(user, subject, body) + +def send_invite(user): + create_reset_key(user) + body = get_invite_body(user) + subject = _('Invite for {site_title}'.format(site_title=g.site_title)) + mail_user(user, subject, body) + +def create_reset_key(user): + user.reset_key = unicode(make_key()) + model.repo.commit_and_remove() + +def make_key(): + return uuid.uuid4().hex[:10] def verify_reset_link(user, key): if not key: diff --git a/ckan/lib/munge.py b/ckan/lib/munge.py index c943e1b113f..a47c8c02258 100644 --- a/ckan/lib/munge.py +++ b/ckan/lib/munge.py @@ -105,6 +105,13 @@ def munge_tag(tag): tag = _munge_to_length(tag, model.MIN_TAG_LENGTH, model.MAX_TAG_LENGTH) return tag +def munge_filename(filename): + filename = substitute_ascii_equivalents(filename) + filename = filename.lower().strip() + filename = re.sub(r'[^a-zA-Z0-9. ]', '', filename).replace(' ', '-') + filename = _munge_to_length(filename, 3, 100) + return filename + def _munge_to_length(string, min_length, max_length): '''Pad/truncates a string''' if len(string) < min_length: diff --git a/ckan/lib/plugins.py b/ckan/lib/plugins.py index 97263dcf7f8..40a398bd3f4 100644 --- a/ckan/lib/plugins.py +++ b/ckan/lib/plugins.py @@ -53,7 +53,8 @@ def lookup_group_plugin(group_type=None): """ if group_type is None: return _default_group_plugin - return _group_plugins.get(group_type, _default_group_plugin) + return _group_plugins.get(group_type, _default_organization_plugin + if group_type == 'organization' else _default_group_plugin) def register_package_plugins(map): @@ -418,3 +419,39 @@ def setup_template_variables(self, context, data_dict): c.auth_for_change_state = True except logic.NotAuthorized: c.auth_for_change_state = False + + +class DefaultOrganizationForm(DefaultGroupForm): + def group_form(self): + return 'organization/new_organization_form.html' + + def setup_template_variables(self, context, data_dict): + pass + + def new_template(self): + return 'organization/new.html' + + def about_template(self): + return 'organization/about.html' + + def index_template(self): + return 'organization/index.html' + + def admins_template(self): + return 'organization/admins.html' + + def bulk_process_template(self): + return 'organization/bulk_process.html' + + def read_template(self): + return 'organization/read.html' + + # don't override history_template - use group template for history + + def edit_template(self): + return 'organization/edit.html' + + def activity_template(self): + return 'organization/activity_stream.html' + +_default_organization_plugin = DefaultOrganizationForm() diff --git a/ckan/lib/uploader.py b/ckan/lib/uploader.py new file mode 100644 index 00000000000..cc4e8751d57 --- /dev/null +++ b/ckan/lib/uploader.py @@ -0,0 +1,131 @@ +import os +import cgi +import pylons +import datetime +import ckan.lib.munge as munge +import logging +import ckan.logic as logic + +log = logging.getLogger(__name__) + +_storage_path = None + + +def get_storage_path(): + '''Function to cache storage path''' + global _storage_path + + #None means it has not been set. False means not in config. + if _storage_path is None: + storage_path = pylons.config.get('ckan.storage_path') + ofs_impl = pylons.config.get('ofs.impl') + ofs_storage_dir = pylons.config.get('ofs.storage_dir') + if storage_path: + _storage_path = storage_path + elif ofs_impl == 'pairtree' and ofs_storage_dir: + log.warn('''Please use config option ckan.storage_path instaed of + ofs.storage_path''') + _storage_path = ofs_storage_dir + return _storage_path + elif ofs_impl: + log.critical('''We only support local file storage form version 2.2 + of ckan please specify ckan.storage_path in your + config for your uploads''') + _storage_path = False + else: + log.critical('''Please specify a ckan.storage_path in your config + for your uploads''') + _storage_path = False + + return _storage_path + + +class Upload(object): + def __init__(self, object_type, old_filename=None): + ''' Setup upload by creating a subdirectory of the storage directory + of name object_type. old_filename is the name of the file in the url + field last time''' + + self.storage_path = None + self.filename = None + self.filepath = None + path = get_storage_path() + if not path: + return + self.storage_path = os.path.join(path, 'storage', + 'uploads', object_type) + try: + os.makedirs(self.storage_path) + except OSError, e: + ## errno 17 is file already exists + if e.errno != 17: + raise + self.object_type = object_type + self.old_filename = old_filename + if old_filename: + self.old_filepath = os.path.join(self.storage_path, old_filename) + + def update_data_dict(self, data_dict, url_field, file_field, clear_field): + ''' Manipulate data from the data_dict. url_field is the name of the + field where the upload is going to be. file_field is name of the key + where the FieldStorage is kept (i.e the field where the file data + actually is). clear_field is the name of a boolean field which + requests the upload to be deleted. This needs to be called before + it reaches any validators''' + + self.url = data_dict.get(url_field, '') + self.clear = data_dict.pop(clear_field, None) + self.file_field = file_field + self.upload_field_storage = data_dict.pop(file_field, None) + + if not self.storage_path: + return + + if isinstance(self.upload_field_storage, cgi.FieldStorage): + self.filename = self.upload_field_storage.filename + self.filename = str(datetime.datetime.utcnow()) + self.filename + self.filename = munge.munge_filename(self.filename) + self.filepath = os.path.join(self.storage_path, self.filename) + data_dict[url_field] = self.filename + self.upload_file = self.upload_field_storage.file + self.tmp_filepath = self.filepath + '~' + ### keep the file if there has been no change + elif self.old_filename and not self.old_filename.startswith('http'): + if not self.clear: + data_dict[url_field] = self.old_filename + if self.clear and self.url == self.old_filename: + data_dict[url_field] = '' + + def upload(self, max_size=2): + ''' Actually upload the file. + This should happen just before a commit but after the data has + been validated and flushed to the db. This is so we do not store + anything unless the request is actually good. + max_size is size in MB maximum of the file''' + + if self.filename: + output_file = open(self.tmp_filepath, 'wb') + self.upload_file.seek(0) + current_size = 0 + while True: + current_size = current_size + 1 + # MB chuncks + data = self.upload_file.read(2 ** 20) + if not data: + break + output_file.write(data) + if current_size > max_size: + os.remove(self.tmp_filepath) + raise logic.ValidationError( + {self.file_field: ['File upload too large']} + ) + output_file.close() + os.rename(self.tmp_filepath, self.filepath) + self.clear = True + + if (self.clear and self.old_filename + and not self.old_filename.startswith('http')): + try: + os.remove(self.old_filepath) + except OSError, e: + pass diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index baac5a2154c..bc2e30d2ec3 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -1,6 +1,8 @@ '''API functions for adding data to CKAN.''' import logging +import random +import re from pylons import config import paste.deploy.converters @@ -15,6 +17,9 @@ import ckan.lib.dictization.model_dictize as model_dictize import ckan.lib.dictization.model_save as model_save import ckan.lib.navl.dictization_functions +import ckan.lib.uploader as uploader +import ckan.lib.navl.validators as validators +import ckan.lib.mailer as mailer from ckan.common import _ @@ -86,7 +91,7 @@ def package_create(context, data_dict): :param extras: the dataset's extras (optional), extras are arbitrary (key: value) metadata items that can be added to datasets, each extra dictionary should have keys ``'key'`` (a string), ``'value'`` (a - string), and optionally ``'deleted'`` + string) :type extras: list of dataset extra dictionaries :param relationships_as_object: see ``package_relationship_create()`` for the format of relationship dictionaries (optional) @@ -446,6 +451,7 @@ def member_create(context, data_dict=None): if not obj: raise NotFound('%s was not found.' % obj_type.title()) + # User must be able to update the group to add a member to it _check_access('group_update', context, data_dict) @@ -475,7 +481,9 @@ def _group_or_org_create(context, data_dict, is_org=False): parent = context.get('parent', None) data_dict['is_organization'] = is_org - + upload = uploader.Upload('group') + upload.update_data_dict(data_dict, 'image_url', + 'image_upload', 'clear_upload') # get the schema group_plugin = lib_plugins.lookup_group_plugin( group_type=data_dict.get('type')) @@ -561,6 +569,7 @@ def _group_or_org_create(context, data_dict, is_org=False): logic.get_action('activity_create')(activity_create_context, activity_dict) + upload.upload() if not context.get('defer_commit'): model.repo.commit() context["group"] = group @@ -837,6 +846,65 @@ def user_create(context, data_dict): log.debug('Created user {name}'.format(name=user.name)) return user_dict + +def user_invite(context, data_dict): + '''Invite a new user. + + You must be authorized to create group members. + + :param email: the email of the user to be invited to the group + :type email: string + :param group_id: the id or name of the group + :type group_id: string + :param role: role of the user in the group. One of ``member``, ``editor``, + or ``admin`` + :type role: string + + :returns: the newly created yser + :rtype: dictionary + ''' + _check_access('user_invite', context, data_dict) + + schema = context.get('schema', + ckan.logic.schema.default_user_invite_schema()) + data, errors = _validate(data_dict, schema, context) + if errors: + raise ValidationError(errors) + + name = _get_random_username_from_email(data['email']) + password = str(random.SystemRandom().random()) + data['name'] = name + data['password'] = password + data['state'] = ckan.model.State.PENDING + user_dict = _get_action('user_create')(context, data) + user = ckan.model.User.get(user_dict['id']) + member_dict = { + 'username': user.id, + 'id': data['group_id'], + 'role': data['role'] + } + _get_action('group_member_create')(context, member_dict) + mailer.send_invite(user) + return model_dictize.user_dictize(user, context) + + +def _get_random_username_from_email(email): + localpart = email.split('@')[0] + cleaned_localpart = re.sub(r'[^\w]', '-', localpart) + + # if we can't create a unique user name within this many attempts + # then something else is probably wrong and we should give up + max_name_creation_attempts = 100 + + for i in range(max_name_creation_attempts): + random_number = random.SystemRandom().random() * 10000 + name = '%s-%d' % (cleaned_localpart, random_number) + if not ckan.model.User.get(name): + return name + + return cleaned_localpart + + ## Modifications for rest api def package_create_rest(context, data_dict): diff --git a/ckan/logic/action/delete.py b/ckan/logic/action/delete.py index 60ea1a5bed8..9e3ee3244ab 100644 --- a/ckan/logic/action/delete.py +++ b/ckan/logic/action/delete.py @@ -18,6 +18,29 @@ _get_or_bust = ckan.logic.get_or_bust _get_action = ckan.logic.get_action + +def user_delete(context, data_dict): + '''Delete a user. + + Only sysadmins can delete users. + + :param id: the id or usernamename of the user to delete + :type id: string + ''' + + _check_access('user_delete', context, data_dict) + + model = context['model'] + user_id = _get_or_bust(data_dict, 'id') + user = model.User.get(user_id) + + if user is None: + raise NotFound('User "{id}" was not found.'.format(id=user_id)) + + user.delete() + model.repo.commit() + + def package_delete(context, data_dict): '''Delete a dataset (package). diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 4f4b2fb57ee..ca46ac2ff57 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -15,6 +15,7 @@ import ckan.logic.schema import ckan.lib.dictization.model_dictize as model_dictize import ckan.lib.navl.dictization_functions +import ckan.model as model import ckan.model.misc as misc import ckan.plugins as plugins import ckan.lib.search as search @@ -90,8 +91,11 @@ def package_list(context, data_dict): col = (package_revision_table.c.id if api == 2 else package_revision_table.c.name) query = _select([col]) - query = query.where(_and_(package_revision_table.c.state=='active', - package_revision_table.c.current==True)) + query = query.where(_and_( + package_revision_table.c.state=='active', + package_revision_table.c.current==True, + package_revision_table.c.private==False, + )) query = query.order_by(col) limit = data_dict.get('limit') @@ -688,6 +692,9 @@ def user_list(context, data_dict): else_=model.User.fullname) ) + # Filter deleted users + query = query.filter(model.User.state != model.State.DELETED) + ## hack for pagination if context.get('return_query'): return query @@ -1266,7 +1273,9 @@ def user_autocomplete(context, data_dict): q = data_dict['q'] limit = data_dict.get('limit', 20) - query = model.User.search(q).limit(limit) + query = model.User.search(q) + query = query.filter(model.User.state != model.State.DELETED) + query = query.limit(limit) user_list = [] for user in query.all(): @@ -1314,8 +1323,9 @@ def package_search(context, data_dict): :param facet.mincount: the minimum counts for facet fields should be included in the results. :type facet.mincount: int - :param facet.limit: the maximum number of constraint counts that should be - returned for the facet fields. A negative value means unlimited + :param facet.limit: the maximum number of values the facet fields return. + A negative value means unlimited. This can be set instance-wide with + the :ref:`search.facets.limit` config option. Default is 50. :type facet.limit: int :param facet.field: the fields to facet upon. Default empty. If empty, then the returned facet information is empty. @@ -1861,11 +1871,11 @@ def task_status_show(context, data_dict): context['task_status'] = task_status + _check_access('task_status_show', context, data_dict) + if task_status is None: raise NotFound - _check_access('task_status_show', context, data_dict) - task_status_dict = model_dictize.task_status_dictize(task_status, context) return task_status_dict diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index 3db2d0d19cf..b054f75a4d1 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -19,6 +19,7 @@ import ckan.lib.plugins as lib_plugins import ckan.lib.email_notifications as email_notifications import ckan.lib.search as search +import ckan.lib.uploader as uploader from ckan.common import _, request @@ -132,7 +133,7 @@ def related_update(context, data_dict): id = _get_or_bust(data_dict, "id") session = context['session'] - schema = context.get('schema') or schema_.default_related_schema() + schema = context.get('schema') or schema_.default_update_related_schema() related = model.Related.get(id) context["related"] = related @@ -424,6 +425,10 @@ def _group_or_org_update(context, data_dict, is_org=False): except AttributeError: schema = group_plugin.form_to_db_schema() + upload = uploader.Upload('group', group.image_url) + upload.update_data_dict(data_dict, 'image_url', + 'image_upload', 'clear_upload') + if is_org: _check_access('organization_update', context, data_dict) else: @@ -528,9 +533,11 @@ def _group_or_org_update(context, data_dict, is_org=False): # TODO: Also create an activity detail recording what exactly changed # in the group. + upload.upload() if not context.get('defer_commit'): model.repo.commit() + return model_dictize.group_dictize(group, context) def group_update(context, data_dict): diff --git a/ckan/logic/auth/create.py b/ckan/logic/auth/create.py index a18485c2c57..c43d24fcd9b 100644 --- a/ckan/logic/auth/create.py +++ b/ckan/logic/auth/create.py @@ -23,7 +23,7 @@ def package_create(context, data_dict=None): # If an organization is given are we able to add a dataset to it? data_dict = data_dict or {} - org_id = data_dict.get('organization_id') + org_id = data_dict.get('owner_org') if org_id and not new_authz.has_user_permission_for_group_or_org( org_id, user, 'create_dataset'): return {'success': False, 'msg': _('User %s not authorized to add dataset to this organization') % user} @@ -103,16 +103,26 @@ def rating_create(context, data_dict): # No authz check in the logic function return {'success': True} + @logic.auth_allow_anonymous_access def user_create(context, data_dict=None): - user = context['user'] - - if ('api_version' in context - and not new_authz.check_config_permission('create_user_via_api')): - return {'success': False, 'msg': _('User %s not authorized to create users') % user} - else: - return {'success': True} + using_api = 'api_version' in context + create_user_via_api = new_authz.check_config_permission( + 'create_user_via_api') + create_user_via_web = new_authz.check_config_permission( + 'create_user_via_web') + + if using_api and not create_user_via_api: + return {'success': False, 'msg': _('User {user} not authorized to ' + 'create users via the API').format(user=context.get('user'))} + if not using_api and not create_user_via_web: + return {'success': False, 'msg': _('Not authorized to ' + 'create users')} + return {'success': True} +def user_invite(context, data_dict=None): + context['id'] = context.get('group_id') + return group_member_create(context, data_dict) def _check_group_auth(context, data_dict): # FIXME This code is shared amoung other logic.auth files and should be diff --git a/ckan/logic/auth/delete.py b/ckan/logic/auth/delete.py index 61b44697407..08974144a71 100644 --- a/ckan/logic/auth/delete.py +++ b/ckan/logic/auth/delete.py @@ -4,6 +4,12 @@ from ckan.logic.auth import get_resource_object from ckan.lib.base import _ + +def user_delete(context, data_dict): + # sysadmins only + return {'success': False} + + def package_delete(context, data_dict): user = context['user'] package = get_package_object(context, data_dict) diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index f8fe8df7fbd..761119ee569 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -85,6 +85,7 @@ def default_resource_schema(): 'cache_last_updated': [ignore_missing, isodate], 'webstore_last_updated': [ignore_missing, isodate], 'tracking_summary': [ignore_missing], + 'datastore_active': [ignore], '__extras': [ignore_missing, extras_unicode_convert, keep_extras], } @@ -262,6 +263,7 @@ def default_group_schema(): 'title': [ignore_missing, unicode], 'description': [ignore_missing, unicode], 'image_url': [ignore_missing, unicode], + 'image_display_url': [ignore_missing, unicode], 'type': [ignore_missing, unicode], 'state': [ignore_not_group_admin, ignore_missing], 'created': [ignore], @@ -331,6 +333,15 @@ def default_related_schema(): return schema +def default_update_related_schema(): + schema = default_related_schema() + schema['id'] = [not_empty, unicode] + schema['title'] = [ignore_missing, unicode] + schema['type'] = [ignore_missing, unicode] + schema['owner_id'] = [ignore_missing, unicode] + return schema + + def default_extras_schema(): schema = { @@ -395,6 +406,7 @@ def default_user_schema(): 'apikey': [ignore], 'reset_key': [ignore], 'activity_streams_email_notifications': [ignore_missing], + 'state': [ignore_missing], } return schema @@ -423,6 +435,14 @@ def default_update_user_schema(): return schema +def default_user_invite_schema(): + schema = { + 'email': [not_empty, unicode], + 'group_id': [not_empty], + 'role': [not_empty], + } + return schema + def default_task_status_schema(): schema = { 'id': [ignore], @@ -539,7 +559,7 @@ def default_package_search_schema(): 'qf': [ignore_missing, unicode], 'facet': [ignore_missing, unicode], 'facet.mincount': [ignore_missing, natural_number_validator], - 'facet.limit': [ignore_missing, natural_number_validator], + 'facet.limit': [ignore_missing, int_validator], 'facet.field': [ignore_missing, list_of_strings], 'extras': [ignore_missing] # Not used by Solr, but useful for extensions } diff --git a/ckan/migration/versions/071_add_state_column_to_user_table.py b/ckan/migration/versions/071_add_state_column_to_user_table.py new file mode 100644 index 00000000000..828ebfb5f44 --- /dev/null +++ b/ckan/migration/versions/071_add_state_column_to_user_table.py @@ -0,0 +1,9 @@ +import ckan.model + + +def upgrade(migrate_engine): + migrate_engine.execute( + ''' + ALTER TABLE "user" ADD COLUMN "state" text NOT NULL DEFAULT '%s' + ''' % ckan.model.State.ACTIVE + ) diff --git a/ckan/model/follower.py b/ckan/model/follower.py index 6e6096ed5cc..eb54cd9ca67 100644 --- a/ckan/model/follower.py +++ b/ckan/model/follower.py @@ -1,30 +1,28 @@ -import sqlalchemy import meta import datetime +import sqlalchemy + +import core +import ckan.model import domain_object -class UserFollowingUser(domain_object.DomainObject): - '''A many-many relationship between users. - A relationship between one user (the follower) and another (the object), - that means that the follower is currently following the object. - - ''' +class ModelFollowingModel(domain_object.DomainObject): def __init__(self, follower_id, object_id): self.follower_id = follower_id self.object_id = object_id self.datetime = datetime.datetime.now() @classmethod - def get(self, follower_id, object_id): - '''Return a UserFollowingUser object for the given follower_id and + def get(cls, follower_id, object_id): + '''Return a ModelFollowingModel object for the given follower_id and object_id, or None if no such follower exists. ''' - query = meta.Session.query(UserFollowingUser) - query = query.filter(UserFollowingUser.follower_id==follower_id) - query = query.filter(UserFollowingUser.object_id==object_id) - return query.first() + query = cls._get(follower_id, object_id) + following = cls._filter_following_objects(query) + if len(following) == 1: + return following[0] @classmethod def is_following(cls, follower_id, object_id): @@ -32,33 +30,78 @@ def is_following(cls, follower_id, object_id): otherwise. ''' - return UserFollowingUser.get(follower_id, object_id) is not None - + return cls.get(follower_id, object_id) is not None @classmethod def followee_count(cls, follower_id): - '''Return the number of users followed by a user.''' - return meta.Session.query(UserFollowingUser).filter( - UserFollowingUser.follower_id == follower_id).count() + '''Return the number of objects followed by the follower.''' + return cls._get_followees(follower_id).count() @classmethod def followee_list(cls, follower_id): - '''Return a list of users followed by a user.''' - return meta.Session.query(UserFollowingUser).filter( - UserFollowingUser.follower_id == follower_id).all() + '''Return a list of objects followed by the follower.''' + query = cls._get_followees(follower_id).all() + followees = cls._filter_following_objects(query) + return followees + @classmethod + def follower_count(cls, object_id): + '''Return the number of followers of the object.''' + return cls._get_followers(object_id).count() @classmethod - def follower_count(cls, user_id): - '''Return the number of followers of a user.''' - return meta.Session.query(UserFollowingUser).filter( - UserFollowingUser.object_id == user_id).count() + def follower_list(cls, object_id): + '''Return a list of followers of the object.''' + query = cls._get_followers(object_id).all() + followers = cls._filter_following_objects(query) + return followers + + @classmethod + def _filter_following_objects(cls, query): + return [q[0] for q in query] + + @classmethod + def _get_followees(cls, follower_id): + return cls._get(follower_id) @classmethod - def follower_list(cls, user_id): - '''Return a list of followers of a user.''' - return meta.Session.query(UserFollowingUser).filter( - UserFollowingUser.object_id == user_id).all() + def _get_followers(cls, object_id): + return cls._get(None, object_id) + + @classmethod + def _get(cls, follower_id=None, object_id=None): + follower_alias = sqlalchemy.orm.aliased(cls._follower_class()) + object_alias = sqlalchemy.orm.aliased(cls._object_class()) + + follower_id = follower_id or cls.follower_id + object_id = object_id or cls.object_id + + query = meta.Session.query(cls, follower_alias, object_alias)\ + .filter(sqlalchemy.and_( + follower_alias.id == follower_id, + cls.follower_id == follower_alias.id, + cls.object_id == object_alias.id, + follower_alias.state != core.State.DELETED, + object_alias.state != core.State.DELETED, + object_alias.id == object_id)) + + return query + + +class UserFollowingUser(ModelFollowingModel): + '''A many-many relationship between users. + + A relationship between one user (the follower) and another (the object), + that means that the follower is currently following the object. + + ''' + @classmethod + def _follower_class(cls): + return ckan.model.User + + @classmethod + def _object_class(cls): + return ckan.model.User user_following_user_table = sqlalchemy.Table('user_following_user', @@ -76,62 +119,20 @@ def follower_list(cls, user_id): meta.mapper(UserFollowingUser, user_following_user_table) -class UserFollowingDataset(domain_object.DomainObject): +class UserFollowingDataset(ModelFollowingModel): '''A many-many relationship between users and datasets (packages). A relationship between a user (the follower) and a dataset (the object), that means that the user is currently following the dataset. ''' - def __init__(self, follower_id, object_id): - self.follower_id = follower_id - self.object_id = object_id - self.datetime = datetime.datetime.now() - @classmethod - def get(self, follower_id, object_id): - '''Return a UserFollowingDataset object for the given follower_id and - object_id, or None if no such follower exists. - - ''' - query = meta.Session.query(UserFollowingDataset) - query = query.filter(UserFollowingDataset.follower_id==follower_id) - query = query.filter(UserFollowingDataset.object_id==object_id) - return query.first() + def _follower_class(cls): + return ckan.model.User @classmethod - def is_following(cls, follower_id, object_id): - '''Return True if follower_id is currently following object_id, False - otherwise. - - ''' - return UserFollowingDataset.get(follower_id, object_id) is not None - - - @classmethod - def followee_count(cls, follower_id): - '''Return the number of datasets followed by a user.''' - return meta.Session.query(UserFollowingDataset).filter( - UserFollowingDataset.follower_id == follower_id).count() - - @classmethod - def followee_list(cls, follower_id): - '''Return a list of datasets followed by a user.''' - return meta.Session.query(UserFollowingDataset).filter( - UserFollowingDataset.follower_id == follower_id).all() - - - @classmethod - def follower_count(cls, dataset_id): - '''Return the number of followers of a dataset.''' - return meta.Session.query(UserFollowingDataset).filter( - UserFollowingDataset.object_id == dataset_id).count() - - @classmethod - def follower_list(cls, dataset_id): - '''Return a list of followers of a dataset.''' - return meta.Session.query(UserFollowingDataset).filter( - UserFollowingDataset.object_id == dataset_id).all() + def _object_class(cls): + return ckan.model.Package user_following_dataset_table = sqlalchemy.Table('user_following_dataset', @@ -150,60 +151,20 @@ def follower_list(cls, dataset_id): meta.mapper(UserFollowingDataset, user_following_dataset_table) -class UserFollowingGroup(domain_object.DomainObject): +class UserFollowingGroup(ModelFollowingModel): '''A many-many relationship between users and groups. A relationship between a user (the follower) and a group (the object), that means that the user is currently following the group. ''' - def __init__(self, follower_id, object_id): - self.follower_id = follower_id - self.object_id = object_id - self.datetime = datetime.datetime.now() - - @classmethod - def get(self, follower_id, object_id): - '''Return a UserFollowingGroup object for the given follower_id and - object_id, or None if no such relationship exists. - - ''' - query = meta.Session.query(UserFollowingGroup) - query = query.filter(UserFollowingGroup.follower_id == follower_id) - query = query.filter(UserFollowingGroup.object_id == object_id) - return query.first() - - @classmethod - def is_following(cls, follower_id, object_id): - '''Return True if follower_id is currently following object_id, False - otherwise. - - ''' - return UserFollowingGroup.get(follower_id, object_id) is not None - - @classmethod - def followee_count(cls, follower_id): - '''Return the number of groups followed by a user.''' - return meta.Session.query(UserFollowingGroup).filter( - UserFollowingGroup.follower_id == follower_id).count() - @classmethod - def followee_list(cls, follower_id): - '''Return a list of groups followed by a user.''' - return meta.Session.query(UserFollowingGroup).filter( - UserFollowingGroup.follower_id == follower_id).all() + def _follower_class(cls): + return ckan.model.User @classmethod - def follower_count(cls, object_id): - '''Return the number of users following a group.''' - return meta.Session.query(UserFollowingGroup).filter( - UserFollowingGroup.object_id == object_id).count() - - @classmethod - def follower_list(cls, object_id): - '''Return a list of the users following a group.''' - return meta.Session.query(UserFollowingGroup).filter( - UserFollowingGroup.object_id == object_id).all() + def _object_class(cls): + return ckan.model.Group user_following_group_table = sqlalchemy.Table('user_following_group', meta.metadata, diff --git a/ckan/model/group.py b/ckan/model/group.py index c16424affee..86963983e9a 100644 --- a/ckan/model/group.py +++ b/ckan/model/group.py @@ -195,8 +195,8 @@ def packages(self, with_private=False, limit=None, query = meta.Session.query(_package.Package).\ filter( - or_(_package.Package.state == vdm.sqlalchemy.State.ACTIVE, - _package.Package.state == vdm.sqlalchemy.State.PENDING)). \ + or_(_package.Package.state == core.State.ACTIVE, + _package.Package.state == core.State.PENDING)). \ filter(group_table.c.id == self.id).\ filter(member_table.c.state == 'active') diff --git a/ckan/model/meta.py b/ckan/model/meta.py index 795094965c5..2b6cb4581ba 100644 --- a/ckan/model/meta.py +++ b/ckan/model/meta.py @@ -162,5 +162,5 @@ def engine_is_sqlite(sa_engine=None): def engine_is_pg(sa_engine=None): # Returns true iff the engine is connected to a postgresql database. # According to http://docs.sqlalchemy.org/en/latest/core/engines.html#postgresql - # all Postgres driver names start with `postgresql` - return (sa_engine or engine).url.drivername.startswith('postgresql') + # all Postgres driver names start with `postgres` + return (sa_engine or engine).url.drivername.startswith('postgres') diff --git a/ckan/model/user.py b/ckan/model/user.py index d3eb3d6f1c1..da92a57c11a 100644 --- a/ckan/model/user.py +++ b/ckan/model/user.py @@ -6,8 +6,10 @@ from sqlalchemy.sql.expression import or_ from sqlalchemy.orm import synonym from sqlalchemy import types, Column, Table +import vdm.sqlalchemy import meta +import core import types as _types import domain_object @@ -28,8 +30,11 @@ Column('sysadmin', types.Boolean, default=False), ) +vdm.sqlalchemy.make_table_stateful(user_table) -class User(domain_object.DomainObject): + +class User(vdm.sqlalchemy.StatefulObjectMixin, + domain_object.DomainObject): VALID_NAME = re.compile(r"^[a-zA-Z0-9_\-]{3,255}$") DOUBLE_SLASH = re.compile(':\/([^/])') @@ -39,6 +44,10 @@ def by_openid(cls, openid): obj = meta.Session.query(cls).autoflush(False) return obj.filter_by(openid=openid).first() + @classmethod + def by_email(cls, email): + return meta.Session.query(cls).filter_by(email=email).all() + @classmethod def get(cls, user_reference): # double slashes in an openid often get turned into single slashes @@ -162,20 +171,35 @@ def number_administered_packages(self): q = q.filter_by(user=self, role=model.Role.ADMIN) return q.count() - def is_in_group(self, group): - return group in self.get_group_ids() + def activate(self): + ''' Activate the user ''' + self.state = core.State.ACTIVE + + def set_pending(self): + ''' Set the user as pending ''' + self.state = core.State.PENDING + + def is_deleted(self): + return self.state == core.State.DELETED + + def is_pending(self): + return self.state == core.State.PENDING + + def is_in_group(self, group_id): + return group_id in self.get_group_ids() - def is_in_groups(self, groupids): + def is_in_groups(self, group_ids): ''' Given a list of group ids, returns True if this user is in any of those groups ''' guser = set(self.get_group_ids()) - gids = set(groupids) + gids = set(group_ids) return len(guser.intersection(gids)) > 0 - def get_group_ids(self, group_type=None): + def get_group_ids(self, group_type=None, capacity=None): ''' Returns a list of group ids that the current user belongs to ''' - return [g.id for g in self.get_groups(group_type=group_type)] + return [g.id for g in + self.get_groups(group_type=group_type, capacity=capacity)] def get_groups(self, group_type=None, capacity=None): import ckan.model as model diff --git a/ckan/new_authz.py b/ckan/new_authz.py index abeb8f9b57a..70c8d1c1ab4 100644 --- a/ckan/new_authz.py +++ b/ckan/new_authz.py @@ -96,6 +96,7 @@ def _build(self): def clear_auth_functions_cache(): _AuthFunctions.clear() + CONFIG_PERMISSIONS.clear() def auth_functions_list(): @@ -113,23 +114,24 @@ def clean_action_name(action_name): def is_sysadmin(username): - ''' returns True is username is a sysadmin ''' + ''' Returns True is username is a sysadmin ''' + user = _get_user(username) + return user and user.sysadmin + + +def _get_user(username): + ''' Try to get the user from c, if possible, and fallback to using the DB ''' if not username: - return False - # see if we can authorise without touching the database + return None + # See if we can get the user without touching the DB try: if c.userobj and c.userobj.name == username: - if c.userobj.sysadmin: - return True - return False + return c.userobj except TypeError: # c is not available pass - # get user from the database - user = model.User.get(username) - if user and user.sysadmin: - return True - return False + # Get user from the DB + return model.User.get(username) def get_group_or_org_admin_ids(group_id): @@ -158,12 +160,19 @@ def is_authorized(action, context, data_dict=None): action = clean_action_name(action) auth_function = _AuthFunctions.get(action) if auth_function: - # sysadmins can do anything unless the auth_sysadmins_check - # decorator was used in which case they are treated like all other - # users. - if is_sysadmin(context.get('user')): - if not getattr(auth_function, 'auth_sysadmins_check', False): - return {'success': True} + username = context.get('user') + user = _get_user(username) + + if user: + # deleted users are always unauthorized + if user.is_deleted(): + return {'success': False} + # sysadmins can do anything unless the auth_sysadmins_check + # decorator was used in which case they are treated like all other + # users. + elif user.sysadmin: + if not getattr(auth_function, 'auth_sysadmins_check', False): + return {'success': True} # If the auth function is flagged as not allowing anonymous access, # and an existing user object is not provided in the context, deny @@ -235,7 +244,10 @@ def has_user_permission_for_group_or_org(group_id, user_name, permission): ''' Check if the user has the given permission for the group ''' if not group_id: return False - group_id = model.Group.get(group_id).id + group = model.Group.get(group_id) + if not group: + return False + group_id = group.id # Sys admins can do anything if is_sysadmin(user_name): @@ -339,6 +351,7 @@ def get_user_id_for_username(user_name, allow_none=False): 'user_delete_groups': True, 'user_delete_organizations': True, 'create_user_via_api': False, + 'create_user_via_web': True, } CONFIG_PERMISSIONS = {} diff --git a/ckan/new_tests/helpers.py b/ckan/new_tests/helpers.py index 51640a7e3dd..4e1e1ffd0c7 100644 --- a/ckan/new_tests/helpers.py +++ b/ckan/new_tests/helpers.py @@ -38,7 +38,7 @@ def reset_db(): # This prevents CKAN from hanging waiting for some unclosed connection. model.Session.close_all() - model.repo.clean_db() + model.repo.rebuild_db() def call_action(action_name, context=None, **kwargs): diff --git a/ckan/plugins/core.py b/ckan/plugins/core.py index 8f3752117ea..f39c8062917 100644 --- a/ckan/plugins/core.py +++ b/ckan/plugins/core.py @@ -18,7 +18,7 @@ 'PluginNotFoundException', 'Plugin', 'SingletonPlugin', 'load', 'load_all', 'unload', 'unload_all', 'get_plugin', 'plugins_update', - 'use_plugin', + 'use_plugin', 'plugin_loaded', ] log = logging.getLogger(__name__) @@ -210,6 +210,15 @@ def unload(*plugins): plugins_update() +def plugin_loaded(name): + ''' + See if a particular plugin is loaded. + ''' + if name in _PLUGINS: + return True + return False + + def find_system_plugins(): ''' Return all plugins in the ckan.system_plugins entry point group. diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index 74462835474..14d64b01146 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -443,7 +443,6 @@ def before_view(self, pkg_dict): class IResourceController(Interface): """ Hook into the resource controller. - (see IGroupController) """ def before_show(self, resource_dict): @@ -769,6 +768,15 @@ def read_template(self): The path should be relative to the plugin's templates dir, e.g. ``'package/read.html'``. + If the user requests the dataset in a format other than HTML + (CKAN supports returning datasets in RDF or N3 format by appending .rdf + or .n3 to the dataset read URL, see :doc:`linked-data-and-rdf`) then + CKAN will try to render + a template file with the same path as returned by this function, + but a different filename extension, e.g. ``'package/read.rdf'``. + If your extension doesn't have this RDF version of the template + file, the user will get a 404 error. + :rtype: string ''' diff --git a/ckan/plugins/toolkit.py b/ckan/plugins/toolkit.py index 4e4ac5da2d0..34e9d671cf1 100644 --- a/ckan/plugins/toolkit.py +++ b/ckan/plugins/toolkit.py @@ -348,7 +348,7 @@ def _requires_ckan_version(cls, min_version, max_version=None): else: error = 'Requires ckan version between %s and %s' % \ (min_version, max_version) - raise cls.CkanVersionException(error) + raise CkanVersionException(error) def __getattr__(self, name): ''' return the function/object requested ''' diff --git a/ckan/public/base/css/fuchsia.css b/ckan/public/base/css/fuchsia.css index a8a33f21105..c87cd3b10a1 100644 --- a/ckan/public/base/css/fuchsia.css +++ b/ckan/public/base/css/fuchsia.css @@ -4898,16 +4898,6 @@ a.tag:hover { .module-heading:after { clear: both; } -.module-heading .action { - float: right; - color: #888888; - font-size: 12px; - line-height: 20px; - text-decoration: underline; -} -.module-heading .action:hover { - color: #444444; -} .module-content { padding: 0 25px; margin: 20px 0; @@ -6130,6 +6120,34 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.add-member-form .control-label { + width: 100%; + text-align: left; +} +.add-member-form .controls { + margin-left: auto; +} +.add-member-or { + float: left; + margin-top: 75px; + width: 7%; + text-align: center; + text-transform: uppercase; + color: #999999; + font-weight: bold; +} +.add-member-form .row-fluid .control-group { + float: left; + width: 45%; +} +.add-member-form .row-fluid .select2-container, +.add-member-form .row-fluid input { + width: 100% !important; +} +#recaptcha_table { + table-layout: inherit; + line-height: 1; +} .dataset-item { border-bottom: 1px dotted #dddddd; padding-bottom: 20px; @@ -8246,11 +8264,6 @@ iframe { .ie7 .module-heading .media-content { position: relative; } -.ie7 .module-heading .action { - position: absolute; - top: 9px; - right: 10px; -} .ie7 .module-heading .media-image img { float: left; } diff --git a/ckan/public/base/css/green.css b/ckan/public/base/css/green.css index 83bbcd5eefa..6558ce82f8d 100644 --- a/ckan/public/base/css/green.css +++ b/ckan/public/base/css/green.css @@ -4898,16 +4898,6 @@ a.tag:hover { .module-heading:after { clear: both; } -.module-heading .action { - float: right; - color: #888888; - font-size: 12px; - line-height: 20px; - text-decoration: underline; -} -.module-heading .action:hover { - color: #444444; -} .module-content { padding: 0 25px; margin: 20px 0; @@ -6130,6 +6120,34 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.add-member-form .control-label { + width: 100%; + text-align: left; +} +.add-member-form .controls { + margin-left: auto; +} +.add-member-or { + float: left; + margin-top: 75px; + width: 7%; + text-align: center; + text-transform: uppercase; + color: #999999; + font-weight: bold; +} +.add-member-form .row-fluid .control-group { + float: left; + width: 45%; +} +.add-member-form .row-fluid .select2-container, +.add-member-form .row-fluid input { + width: 100% !important; +} +#recaptcha_table { + table-layout: inherit; + line-height: 1; +} .dataset-item { border-bottom: 1px dotted #dddddd; padding-bottom: 20px; @@ -8246,11 +8264,6 @@ iframe { .ie7 .module-heading .media-content { position: relative; } -.ie7 .module-heading .action { - position: absolute; - top: 9px; - right: 10px; -} .ie7 .module-heading .media-image img { float: left; } diff --git a/ckan/public/base/css/main.css b/ckan/public/base/css/main.css index 77b93ef3cfc..e3069b76c89 100644 --- a/ckan/public/base/css/main.css +++ b/ckan/public/base/css/main.css @@ -49,16 +49,12 @@ sub { } img { /* Responsive images (ensure images don't scale beyond their parents) */ - max-width: 100%; /* Part 1: Set a maxium relative to the parent */ - width: auto\9; /* IE7-8 need help adjusting responsive images */ - height: auto; /* Part 2: Scale the height according to the width, otherwise you get stretching */ - vertical-align: middle; border: 0; -ms-interpolation-mode: bicubic; @@ -153,7 +149,7 @@ textarea { img { max-width: 100% !important; } - @page { + @page { margin: 0.5cm; } p, @@ -693,7 +689,6 @@ ol.inline > li { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; padding-left: 5px; padding-right: 5px; @@ -972,7 +967,6 @@ input[type="color"]:focus, outline: 0; outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); @@ -982,10 +976,8 @@ input[type="checkbox"] { margin: 4px 0 0; *margin-top: 0; /* IE7 */ - margin-top: 1px \9; /* IE8-9 */ - line-height: normal; } input[type="file"], @@ -1001,10 +993,8 @@ select, input[type="file"] { height: 30px; /* In IE7, the height of the select element cannot be changed by height, only font-size */ - *margin-top: 4px; /* For IE7, add top margin to align select with labels */ - line-height: 30px; } select { @@ -1402,7 +1392,6 @@ select:focus:invalid:focus { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; vertical-align: middle; padding-left: 5px; @@ -1553,7 +1542,6 @@ input.search-query { padding-left: 14px; padding-left: 4px \9; /* IE7-8 doesn't have border-radius, so don't indent the padding */ - margin-bottom: 0; -webkit-border-radius: 15px; -moz-border-radius: 15px; @@ -1610,7 +1598,6 @@ input.search-query { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; margin-bottom: 0; vertical-align: middle; @@ -2220,7 +2207,6 @@ button.close { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; padding: 4px 12px; margin-bottom: 0; @@ -2243,7 +2229,6 @@ button.close { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #eaeaea; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); border: 1px solid #cccccc; *border: 0; @@ -2379,7 +2364,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #085871; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-primary:hover, @@ -2411,7 +2395,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #f89406; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-warning:hover, @@ -2443,7 +2426,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #bd362f; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-danger:hover, @@ -2475,7 +2457,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #51a351; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-success:hover, @@ -2507,7 +2488,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #2f96b4; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-info:hover, @@ -2539,7 +2519,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #222222; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-inverse:hover, @@ -2614,7 +2593,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; font-size: 0; vertical-align: middle; @@ -2790,7 +2768,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .btn-group-vertical > .btn { @@ -3487,7 +3464,6 @@ input[type="submit"].btn.btn-mini { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #e5e5e5; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.075); -moz-box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.075); @@ -3721,7 +3697,6 @@ input[type="submit"].btn.btn-mini { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #040404; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .navbar-inverse .btn-navbar:hover, @@ -3751,7 +3726,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; text-shadow: 0 1px 0 #ffffff; } @@ -3769,7 +3743,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; margin-left: 0; margin-bottom: 0; @@ -3970,7 +3943,6 @@ input[type="submit"].btn.btn-mini { border: 1px solid rgba(0, 0, 0, 0.3); *border: 1px solid #999; /* IE6-7 */ - -webkit-border-radius: 6px; -moz-border-radius: 6px; border-radius: 6px; @@ -4898,16 +4870,6 @@ a.tag:hover { .module-heading:after { clear: both; } -.module-heading .action { - float: right; - color: #888888; - font-size: 12px; - line-height: 20px; - text-decoration: underline; -} -.module-heading .action:hover { - color: #444444; -} .module-content { padding: 0 25px; margin: 20px 0; @@ -5828,7 +5790,6 @@ textarea { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #085871; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .control-custom.disabled .checkbox.btn:hover, @@ -6107,7 +6068,6 @@ textarea { outline: 0; outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); @@ -6130,6 +6090,71 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.js .image-upload #field-image-upload { + cursor: pointer; + position: absolute; + z-index: 1; + opacity: 0; + filter: alpha(opacity=0); +} +.js .image-upload .controls { + position: relative; +} +.js .image-upload .btn { + position: relative; + top: 0; + margin-right: 10px; +} +.js .image-upload .btn.hover { + color: #333333; + text-decoration: none; + background-position: 0 -15px; + -webkit-transition: background-position 0.1s linear; + -moz-transition: background-position 0.1s linear; + -o-transition: background-position 0.1s linear; + transition: background-position 0.1s linear; +} +.js .image-upload .btn-remove-url { + position: absolute; + margin-right: 0; + top: 4px; + right: 5px; + padding: 0 4px; + -webkit-border-radius: 100px; + -moz-border-radius: 100px; + border-radius: 100px; +} +.js .image-upload .btn-remove-url .icon-remove { + margin-right: 0; +} +.add-member-form .control-label { + width: 100%; + text-align: left; +} +.add-member-form .controls { + margin-left: auto; +} +.add-member-or { + float: left; + margin-top: 75px; + width: 7%; + text-align: center; + text-transform: uppercase; + color: #999999; + font-weight: bold; +} +.add-member-form .row-fluid .control-group { + float: left; + width: 45%; +} +.add-member-form .row-fluid .select2-container, +.add-member-form .row-fluid input { + width: 100% !important; +} +#recaptcha_table { + table-layout: inherit; + line-height: 1; +} .dataset-item { border-bottom: 1px dotted #dddddd; padding-bottom: 20px; @@ -6516,7 +6541,6 @@ textarea { margin-right: 5px; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .actions li:last-of-type { @@ -7693,6 +7717,9 @@ h4 small { -moz-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } +.activity .item.no-avatar p { + margin-left: 40px; +} .activity .load-less { margin-bottom: 15px; } @@ -7710,9 +7737,28 @@ h4 small { float: right; text-decoration: none; } +.popover .popover-content { + font-size: 14px; + line-height: 20px; + color: #444444; + word-break: break-all; +} +.popover .popover-content dl { + margin: 0; +} +.popover .popover-content dl dd { + margin-left: 0; + margin-bottom: 10px; +} .activity .item .icon { background-color: #999999; } +.activity .item.failure .icon { + background-color: #b95252; +} +.activity .item.success .icon { + background-color: #69a67a; +} .activity .item.added-tag .icon { background-color: #6995a6; } @@ -7963,6 +8009,21 @@ h4 small { font-size: 16px; margin: 3px 0; } +.datapusher-status-link:hover { + text-decoration: none; +} +.datapusher-status.status-unknown { + color: #bbb; +} +.datapusher-status.status-pending { + color: #FFCC00; +} +.datapusher-status.status-error { + color: red; +} +.datapusher-status.status-complete { + color: #009900; +} body { background: #005d7a url("../../../base/images/bg.png"); } @@ -8164,7 +8225,6 @@ iframe { .ie7 .control-custom .checkbox { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .stages { @@ -8198,7 +8258,6 @@ iframe { .ie7 .masthead nav { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .masthead .header-image { @@ -8246,11 +8305,6 @@ iframe { .ie7 .module-heading .media-content { position: relative; } -.ie7 .module-heading .action { - position: absolute; - top: 9px; - right: 10px; -} .ie7 .module-heading .media-image img { float: left; } diff --git a/ckan/public/base/css/maroon.css b/ckan/public/base/css/maroon.css index 585ed6129c2..4de9736ff0e 100644 --- a/ckan/public/base/css/maroon.css +++ b/ckan/public/base/css/maroon.css @@ -4898,16 +4898,6 @@ a.tag:hover { .module-heading:after { clear: both; } -.module-heading .action { - float: right; - color: #888888; - font-size: 12px; - line-height: 20px; - text-decoration: underline; -} -.module-heading .action:hover { - color: #444444; -} .module-content { padding: 0 25px; margin: 20px 0; @@ -6130,6 +6120,34 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.add-member-form .control-label { + width: 100%; + text-align: left; +} +.add-member-form .controls { + margin-left: auto; +} +.add-member-or { + float: left; + margin-top: 75px; + width: 7%; + text-align: center; + text-transform: uppercase; + color: #999999; + font-weight: bold; +} +.add-member-form .row-fluid .control-group { + float: left; + width: 45%; +} +.add-member-form .row-fluid .select2-container, +.add-member-form .row-fluid input { + width: 100% !important; +} +#recaptcha_table { + table-layout: inherit; + line-height: 1; +} .dataset-item { border-bottom: 1px dotted #dddddd; padding-bottom: 20px; @@ -8246,11 +8264,6 @@ iframe { .ie7 .module-heading .media-content { position: relative; } -.ie7 .module-heading .action { - position: absolute; - top: 9px; - right: 10px; -} .ie7 .module-heading .media-image img { float: left; } diff --git a/ckan/public/base/css/red.css b/ckan/public/base/css/red.css index d24e241544a..4b1a9181328 100644 --- a/ckan/public/base/css/red.css +++ b/ckan/public/base/css/red.css @@ -4898,16 +4898,6 @@ a.tag:hover { .module-heading:after { clear: both; } -.module-heading .action { - float: right; - color: #888888; - font-size: 12px; - line-height: 20px; - text-decoration: underline; -} -.module-heading .action:hover { - color: #444444; -} .module-content { padding: 0 25px; margin: 20px 0; @@ -6130,6 +6120,34 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.add-member-form .control-label { + width: 100%; + text-align: left; +} +.add-member-form .controls { + margin-left: auto; +} +.add-member-or { + float: left; + margin-top: 75px; + width: 7%; + text-align: center; + text-transform: uppercase; + color: #999999; + font-weight: bold; +} +.add-member-form .row-fluid .control-group { + float: left; + width: 45%; +} +.add-member-form .row-fluid .select2-container, +.add-member-form .row-fluid input { + width: 100% !important; +} +#recaptcha_table { + table-layout: inherit; + line-height: 1; +} .dataset-item { border-bottom: 1px dotted #dddddd; padding-bottom: 20px; @@ -8246,11 +8264,6 @@ iframe { .ie7 .module-heading .media-content { position: relative; } -.ie7 .module-heading .action { - position: absolute; - top: 9px; - right: 10px; -} .ie7 .module-heading .media-image img { float: left; } diff --git a/ckan/public/base/javascript/client.js b/ckan/public/base/javascript/client.js index 45940c0086d..e1c60ccf24e 100644 --- a/ckan/public/base/javascript/client.js +++ b/ckan/public/base/javascript/client.js @@ -370,7 +370,7 @@ }); ckan.sandbox.setup(function (instance) { - instance.client = new Client({endpoint: ckan.API_ROOT}); + instance.client = new Client({endpoint: ckan.SITE_ROOT}); }); ckan.Client = Client; diff --git a/ckan/public/base/javascript/main.js b/ckan/public/base/javascript/main.js index e307fe8403c..c5f36ef7176 100644 --- a/ckan/public/base/javascript/main.js +++ b/ckan/public/base/javascript/main.js @@ -29,13 +29,13 @@ this.ckan = this.ckan || {}; ckan.SITE_ROOT = getRootFromData('siteRoot'); ckan.LOCALE_ROOT = getRootFromData('localeRoot'); - ckan.API_ROOT = getRootFromData('apiRoot'); // Load the localisations before instantiating the modules. ckan.sandbox().client.getLocaleData(locale).done(function (data) { ckan.i18n.load(data); ckan.module.initialize(); }); + jQuery('[data-target="popover"]').popover(); }; /* Returns a full url for the current site with the provided path appended. diff --git a/ckan/public/base/javascript/modules/autocomplete.js b/ckan/public/base/javascript/modules/autocomplete.js index 768a708c9c3..648fe5960d0 100644 --- a/ckan/public/base/javascript/modules/autocomplete.js +++ b/ckan/public/base/javascript/modules/autocomplete.js @@ -86,6 +86,8 @@ this.ckan.module('autocomplete', function (jQuery, _) { $('.select2-choice', select2.container).on('click', function() { return false; }); + + this._select2 = select2; }, /* Looks up the completions for the current search term and passes them @@ -140,29 +142,28 @@ this.ckan.module('autocomplete', function (jQuery, _) { // old data. this._lastTerm = string; + // Kills previous timeout + clearTimeout(this._debounced); + + // OK, wipe the dropdown before we start ajaxing the completions + fn({results:[]}); + if (string) { - if (!this._debounced) { - // Set a timer to prevent the search lookup occurring too often. - this._debounced = setTimeout(function () { - var term = module._lastTerm; + // Set a timer to prevent the search lookup occurring too often. + this._debounced = setTimeout(function () { + var term = module._lastTerm; - delete module._debounced; + // Cancel the previous request if it hasn't yet completed. + if (module._last && typeof module._last.abort == 'function') { + module._last.abort(); + } - // Cancel the previous request if it hasn't yet completed. - if (module._last) { - module._last.abort(); - } + module._last = module.getCompletions(term, fn); + }, this.options.interval); - module._last = module.getCompletions(term, function (terms) { - fn(module._lastResults = terms); - }); - }, this.options.interval); - } else { - // Re-use the last set of terms. - fn(this._lastResults || {results: []}); - } - } else { - fn({results: []}); + // This forces the ajax throbber to appear, because we've called the + // callback already and that hides the throbber + $('.select2-search input', this._select2.dropdown).addClass('select2-active'); } }, diff --git a/ckan/public/base/javascript/modules/image-upload.js b/ckan/public/base/javascript/modules/image-upload.js new file mode 100644 index 00000000000..96308ed5764 --- /dev/null +++ b/ckan/public/base/javascript/modules/image-upload.js @@ -0,0 +1,184 @@ +/* Image Upload + * + */ +this.ckan.module('image-upload', function($, _) { + return { + /* options object can be extended using data-module-* attributes */ + options: { + is_url: true, + has_image: false, + field_upload: 'input[name="image_upload"]', + field_url: 'input[name="image_url"]', + field_clear: 'input[name="clear_upload"]', + i18n: { + upload: _('From computer'), + url: _('From web'), + remove: _('Remove'), + label: _('Upload image'), + label_url: _('Image URL'), + remove_tooltip: _('Reset this') + }, + template: [ + '' + ].join("\n") + }, + + state: { + attached: 1, + blank: 2, + web: 3 + }, + + /* Initialises the module setting up elements and event listeners. + * + * Returns nothing. + */ + initialize: function () { + $.proxyAll(this, /_on/); + var options = this.options; + + // firstly setup the fields + this.input = $(options.field_upload, this.el); + this.field_url = $(options.field_url, this.el).parents('.control-group'); + this.field_image = this.input.parents('.control-group'); + + // Is there a clear checkbox on the form already? + var checkbox = $(options.field_clear, this.el); + if (checkbox.length > 0) { + options.has_image = true; + checkbox.parents('.control-group').remove(); + } + + // Adds the hidden clear input to the form + this.field_clear = $('') + .appendTo(this.el); + + // Button to set the field to be a URL + this.button_url = $(' '+this.i18n('url')+'') + .on('click', this._onFromWeb) + .insertAfter(this.input); + + // Button to attach local file to the form + this.button_upload = $(''+this.i18n('upload')+'') + .insertAfter(this.input); + + // Button to reset the form back to the first from when there is a image uploaded + this.button_remove = $('') + .text(this.i18n('remove')) + .on('click', this._onRemove) + .insertAfter(this.button_upload); + + // Button for resetting the form when there is a URL set + $('') + .prop('title', this.i18n('remove_tooltip')) + .on('click', this._onRemove) + .insertBefore($('input', this.field_url)); + + // Update the main label + $('label[for="field-image-upload"]').text(this.i18n('label')); + + // Setup the file input + this.input + .on('mouseover', this._onInputMouseOver) + .on('mouseout', this._onInputMouseOut) + .on('change', this._onInputChange) + .css('width', this.button_upload.outerWidth()); + + // Fields storage. Used in this.changeState + this.fields = $('') + .add(this.button_remove) + .add(this.button_upload) + .add(this.button_url) + .add(this.input) + .add(this.field_url) + .add(this.field_image); + + // Setup the initial state + if (options.is_url) { + this.changeState(this.state.web); + } else if (options.has_image) { + this.changeState(this.state.attached); + } else { + this.changeState(this.state.blank); + } + + }, + + /* Method to change the display state of the image fields + * + * state - Pseudo constant for passing the state we should be in now + * + * Examples + * + * this.changeState(this.state.web); // Sets the state in URL mode + * + * Returns nothing. + */ + changeState: function(state) { + this.fields.hide(); + if (state == this.state.blank) { + this.button_upload + .add(this.field_image) + .add(this.button_url) + .add(this.input) + .show(); + } else if (state == this.state.attached) { + this.button_remove + .add(this.field_image) + .show(); + } else if (state == this.state.web) { + this.field_url + .show(); + } + }, + + /* Event listener for when someone sets the field to URL mode + * + * Returns nothing. + */ + _onFromWeb: function() { + this.changeState(this.state.web); + $('input', this.field_url).focus(); + if (this.options.has_image) { + this.field_clear.val('true'); + } + }, + + /* Event listener for resetting the field back to the blank state + * + * Returns nothing. + */ + _onRemove: function() { + this.changeState(this.state.blank); + $('input', this.field_url).val(''); + this.field_clear.val('true'); + }, + + /* Event listener for when someone chooses a file to upload + * + * Returns nothing. + */ + _onInputChange: function() { + this.file_name = this.input.val(); + this.field_clear.val(''); + this.changeState(this.state.attached); + }, + + /* Event listener for when a user mouseovers the hidden file input + * + * Returns nothing. + */ + _onInputMouseOver: function() { + this.button_upload.addClass('hover'); + }, + + /* Event listener for when a user mouseouts the hidden file input + * + * Returns nothing. + */ + _onInputMouseOut: function() { + this.button_upload.removeClass('hover'); + } + + } +}); diff --git a/ckan/public/base/javascript/resource.config b/ckan/public/base/javascript/resource.config index b1a7302d9e8..564da811a6e 100644 --- a/ckan/public/base/javascript/resource.config +++ b/ckan/public/base/javascript/resource.config @@ -38,6 +38,7 @@ ckan = modules/table-toggle-more.js modules/dataset-visibility.js modules/media-grid.js + modules/image-upload.js main = apply_html_class diff --git a/ckan/public/base/less/activity.less b/ckan/public/base/less/activity.less index dc75ede5e37..6071fabc5f0 100644 --- a/ckan/public/base/less/activity.less +++ b/ckan/public/base/less/activity.less @@ -56,6 +56,9 @@ .border-radius(100px); .box-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); } + &.no-avatar p { + margin-left: 40px; + } } .load-less { margin-bottom: 15px; @@ -76,11 +79,26 @@ float: right; text-decoration: none; } + .popover-content { + font-size: @baseFontSize; + line-height: @baseLineHeight; + color: @layoutTextColor; + word-break: break-all; + dl { + margin: 0; + dd { + margin-left: 0; + margin-bottom: 10px; + } + } + } } // colors .activity .item { & .icon { background-color: @activityColorBlank; } // Non defined + &.failure .icon { background-color: @activityColorDelete; } + &.success .icon { background-color: @activityColorNew; } &.added-tag .icon { background-color: spin(@activityColorNew, 60); } &.changed-group .icon { background-color: @activityColorModify; } &.changed-package .icon { background-color: spin(@activityColorModify, 20); } diff --git a/ckan/public/base/less/ckan.less b/ckan/public/base/less/ckan.less index f7c108f8734..f842bd6dbae 100644 --- a/ckan/public/base/less/ckan.less +++ b/ckan/public/base/less/ckan.less @@ -20,6 +20,7 @@ @import "activity.less"; @import "dropdown.less"; @import "dashboard.less"; +@import "datapusher.less"; body { // Using the masthead/footer gradient prevents the color from changing diff --git a/ckan/public/base/less/datapusher.less b/ckan/public/base/less/datapusher.less new file mode 100644 index 00000000000..f147c0c3f6c --- /dev/null +++ b/ckan/public/base/less/datapusher.less @@ -0,0 +1,18 @@ +.datapusher-status-link:hover { + text-decoration: none; +} + +.datapusher-status { + &.status-unknown { + color: #bbb; + } + &.status-pending { + color: #FFCC00; + } + &.status-error { + color: red; + } + &.status-complete { + color: #009900; + } +} \ No newline at end of file diff --git a/ckan/public/base/less/forms.less b/ckan/public/base/less/forms.less index 289439c291a..ba02fb6b49e 100644 --- a/ckan/public/base/less/forms.less +++ b/ckan/public/base/less/forms.less @@ -670,3 +670,71 @@ textarea { .box-shadow(none); } } + +.js .image-upload { + #field-image-upload { + cursor: pointer; + position: absolute; + z-index: 1; + .opacity(0); + } + .controls { + position: relative; + } + .btn { + position: relative; + top: 0; + margin-right: 10px; + &.hover { + color: @grayDark; + text-decoration: none; + background-position: 0 -15px; + .transition(background-position .1s linear); + } + } + .btn-remove-url { + position: absolute; + margin-right: 0; + top: 4px; + right: 5px; + padding: 0 4px; + .border-radius(100px); + .icon-remove { + margin-right: 0; + } + } +} +.add-member-form .control-label { + width: 100%; + text-align: left; +} + +.add-member-form .controls { + margin-left: auto; +} + +.add-member-or { + float: left; + margin-top: 75px; + width: 7%; + text-align: center; + text-transform: uppercase; + color: @grayLight; + font-weight: bold; +} + +.add-member-form .row-fluid .control-group { + float: left; + width: 45%; +} + +.add-member-form .row-fluid { + .select2-container, input { + width: 100% !important; + } +} + +#recaptcha_table { + table-layout: inherit; + line-height: 1; +} diff --git a/ckan/public/base/less/iehacks.less b/ckan/public/base/less/iehacks.less index 232ee72ee9c..7d9bc5cc274 100644 --- a/ckan/public/base/less/iehacks.less +++ b/ckan/public/base/less/iehacks.less @@ -200,11 +200,6 @@ .media-content { position: relative; } - .action { - position: absolute; - top: 9px; - right: 10px; - } .media-image img { float: left; } diff --git a/ckan/public/base/less/module.less b/ckan/public/base/less/module.less index a575abcbda4..aa964cf5ea3 100644 --- a/ckan/public/base/less/module.less +++ b/ckan/public/base/less/module.less @@ -13,17 +13,6 @@ border-bottom: 1px solid @moduleHeadingBorderColor; } -.module-heading .action { - float: right; - color: @moduleHeadingActionTextColor; - font-size: 12px; - line-height: @baseLineHeight; - text-decoration: underline; - &:hover { - color: @layoutTextColor; - } -} - .module-content { padding: 0 @gutterX; margin: 20px 0; diff --git a/ckan/public/base/test/spec/modules/autocomplete.spec.js b/ckan/public/base/test/spec/modules/autocomplete.spec.js index 2a9c63ab45c..93a8fb57a54 100644 --- a/ckan/public/base/test/spec/modules/autocomplete.spec.js +++ b/ckan/public/base/test/spec/modules/autocomplete.spec.js @@ -152,10 +152,11 @@ describe('ckan.modules.AutocompleteModule()', function () { beforeEach(function () { sinon.stub(this.module, 'getCompletions'); this.target = sinon.spy(); + this.module.setupAutoComplete(); }); it('should set the _lastTerm property', function () { - this.module.lookup('term'); + this.module.lookup('term', this.target); assert.equal(this.module._lastTerm, 'term'); }); diff --git a/ckan/templates/base.html b/ckan/templates/base.html index 6012bf65928..b6290af0ad0 100644 --- a/ckan/templates/base.html +++ b/ckan/templates/base.html @@ -85,7 +85,7 @@ {# Allows custom attributes to be added to the tag #} - + {# The page block allows you to add content to the page. Most of the time it is diff --git a/ckan/templates/development/snippets/facet.html b/ckan/templates/development/snippets/facet.html index ac9b9df1a0c..7280bf9a3d2 100644 --- a/ckan/templates/development/snippets/facet.html +++ b/ckan/templates/development/snippets/facet.html @@ -1,6 +1,6 @@
{% with items=(("First", true), ("Second", false), ("Third", true), ("Fourth", false), ("Last", false)) %} -

Facet List Clear All

+

Facet List

diff --git a/ckan/templates/macros/form.html b/ckan/templates/macros/form.html index 497dea6bf02..db93f046961 100644 --- a/ckan/templates/macros/form.html +++ b/ckan/templates/macros/form.html @@ -395,3 +395,30 @@

{% endmacro %} +{# +Builds a file upload for input + +Example + {% import 'macros/form.html' as form %} + {{ form.image_upload(data, errors, is_upload_enabled=true) }} + +#} +{% macro image_upload(data, errors, field_url='image_url', field_upload='image_upload', field_clear='clear_upload', image_url=false, is_upload_enabled=false, placeholder=false) %} + {% set placeholder = placeholder if placeholder else _('http://example.com/my-image.jpg') %} + {% set has_uploaded_data = data.get(field_url) and not data[field_url].startswith('http') %} + {% set is_url = data.get(field_url) and data[field_url].startswith('http') %} + + {% if is_upload_enabled %}
{% endif %} + + {{ input(field_url, label=_('Image URL'), id='field-image-url', placeholder=placeholder, value=data.get(field_url), error=errors.get(field_url), classes=['control-full']) }} + + {% if is_upload_enabled %} + {{ input(field_upload, label=_('Image Upload'), id='field-image-upload', type='file', placeholder='', value='', error='', classes=['control-full']) }} + {% if has_uploaded_data %} + {{ checkbox(field_clear, label=_('Clear Upload'), id='field-clear-upload', value='true', error='', classes=['control-full']) }} + {% endif %} + {% endif %} + + {% if is_upload_enabled %}
{% endif %} + +{% endmacro %} diff --git a/ckan/templates/organization/member_new.html b/ckan/templates/organization/member_new.html index 8c1a21c6751..a121549dab4 100644 --- a/ckan/templates/organization/member_new.html +++ b/ckan/templates/organization/member_new.html @@ -12,15 +12,42 @@

{% block page_heading %}{{ _('Edit Member') if user else _('Add Member') }}{% endblock %}

{% block form %} - - {% if user %} - - {% set format_attrs = {'disabled': true} %} - {{ form.input('username', label=_('User'), value=user.name, classes=['control-medium'], attrs=format_attrs) }} - {% else %} - {% set format_attrs = {'data-module': 'autocomplete', 'data-module-source': '/api/2/util/user/autocomplete?q=?'} %} - {{ form.input('username', id='field-username', label=_('User'), placeholder=_('Username'), value='', error='', classes=['control-medium'], attrs=format_attrs) }} - {% endif %} + +
+
+ + + {{ _('If you wish to add an existing user, search for their username below.') }} + +
+ {% if user %} + + + {% else %} + + {% endif %} +
+
+
+ {{ _('or') }} +
+
+ + + {{ _('If you wish to invite a new user, enter their email address.') }} + +
+ +
+
+
{% set format_attrs = {'data-module': 'autocomplete'} %} {{ form.select('role', label=_('Role'), options=c.roles, selected=c.user_role, error='', attrs=format_attrs) }}
diff --git a/ckan/templates/organization/snippets/helper.html b/ckan/templates/organization/snippets/helper.html index f0acbd46d73..6ec71c0d67e 100644 --- a/ckan/templates/organization/snippets/helper.html +++ b/ckan/templates/organization/snippets/helper.html @@ -4,14 +4,12 @@

{{ _('What are Organizations?') }}

- {% trans %} -

Organizations act like publishing departments for datasets (for - example, the Department of Health). This means that datasets can be - published by and belong to a department instead of an individual - user.

-

Within organizations, admins can assign roles and authorisation its - members, giving individual users the right to publish datasets from - that particular organisation (e.g. Office of National Statistics).

- {% endtrans %} +

+ {% trans %} + CKAN Organizations are used to create, manage and publish collections + of datasets. Users can have different roles within an Organization, + depending on their level of authorisation to create, edit and publish. + {% endtrans %} +

\ No newline at end of file diff --git a/ckan/templates/organization/snippets/organization_form.html b/ckan/templates/organization/snippets/organization_form.html index 7b05e7d1542..0d62e36cac1 100644 --- a/ckan/templates/organization/snippets/organization_form.html +++ b/ckan/templates/organization/snippets/organization_form.html @@ -1,6 +1,6 @@ {% import 'macros/form.html' as form %} - + {% block error_summary %} {{ form.errors(error_summary) }} {% endblock %} @@ -19,7 +19,7 @@ {{ form.markdown('description', label=_('Description'), id='field-description', placeholder=_('A little information about my organization...'), value=data.description, error=errors.description) }} - {{ form.input('image_url', label=_('Image URL'), id='field-image-url', type='url', placeholder=_('http://example.com/my-image.jpg'), value=data.image_url, error=errors.image_url, classes=['control-full']) }} + {{ form.image_upload(data, errors, image_url=c.group_dict.image_display_url, is_upload_enabled=h.uploads_enabled()) }} {% endblock %} @@ -71,8 +71,8 @@ {% endblock %} #} -
{{ form.required_message() }} +
{% block delete_button %} {% if h.check_access('organization_delete', {'id': data.id}) %} {% set locale = h.dump_json({'content': _('Are you sure you want to delete this Organization? This will delete all the public and private datasets belonging to this organization.')}) %} diff --git a/ckan/templates/organization/snippets/organization_item.html b/ckan/templates/organization/snippets/organization_item.html index 818d5cc338a..5d774cb7f39 100644 --- a/ckan/templates/organization/snippets/organization_item.html +++ b/ckan/templates/organization/snippets/organization_item.html @@ -14,7 +14,7 @@ {% set url = h.url_for(organization.type ~ '_read', action='read', id=organization.name) %}
  • {% block image %} - {{ organization.name }} + {{ organization.name }} {% endblock %} {% block title %}

    {{ organization.display_name }}

    diff --git a/ckan/templates/package/base_form_page.html b/ckan/templates/package/base_form_page.html index 049d83b1ebb..b4d7dc440c0 100644 --- a/ckan/templates/package/base_form_page.html +++ b/ckan/templates/package/base_form_page.html @@ -2,8 +2,11 @@ {% block primary_content %}
    + {% block page_header %}{% endblock %}
    - {% block form %}{{ c.form | safe }}{% endblock %} + {% block primary_content_inner %} + {% block form %}{{ c.form | safe }}{% endblock %} + {% endblock %}
    {% endblock %} @@ -15,9 +18,9 @@

    {{ _('What are dataset

    {% trans %} - Datasets are simply used to group related pieces of data. These - can then be found under a single url with a description and - licensing information. + A CKAN Dataset is a collection of data resources (such as files), + together with a description and other information, at a fixed URL. + Datasets are what users see when searching for data. {% endtrans %}

    diff --git a/ckan/templates/package/edit.html b/ckan/templates/package/edit.html index 4f15985d033..c3a7a5074e5 100644 --- a/ckan/templates/package/edit.html +++ b/ckan/templates/package/edit.html @@ -1,28 +1,5 @@ -{% extends 'package/base_form_page.html' %} +{% extends 'package/edit_base.html' %} -{% set pkg = c.pkg_dict %} - -{% block breadcrumb_content_selected %}{% endblock %} - -{% block breadcrumb_content %} - {{ super() }} -
  • {% link_for _('Edit'), controller='package', action='edit', id=pkg.name %}
  • -{% endblock %} - -{% block resources_module %} - {% snippet 'package/snippets/resources.html', pkg=pkg, action='resource_edit' %} -{% endblock %} - -{% block content_action %} - {% link_for _('View dataset'), controller='package', action='read', id=pkg.name, class_='btn', icon='eye-open' %} +{% block primary_content_inner %} + {% block form %}{{ c.form | safe }}{% endblock %} {% endblock %} - -{% block secondary_content %} - {% snippet 'package/snippets/info.html', pkg=pkg, action='package_edit' %} -{% endblock %} - -{% block primary_content %} - - {{ super() }} -{% endblock %} - diff --git a/ckan/templates/package/edit_base.html b/ckan/templates/package/edit_base.html new file mode 100644 index 00000000000..10c1aa977ee --- /dev/null +++ b/ckan/templates/package/edit_base.html @@ -0,0 +1,24 @@ +{% extends 'package/base.html' %} + +{% set pkg = c.pkg_dict %} +{% set pkg_dict = c.pkg_dict %} + +{% block breadcrumb_content_selected %}{% endblock %} + +{% block breadcrumb_content %} + {{ super() }} +
  • {% link_for _('Edit'), controller='package', action='edit', id=pkg.name %}
  • +{% endblock %} + +{% block content_action %} + {% link_for _('View dataset'), controller='package', action='read', id=pkg.name, class_='btn', icon='eye-open' %} +{% endblock %} + +{% block content_primary_nav %} + {{ h.build_nav_icon('dataset_edit', _('Edit metadata'), id=pkg.name) }} + {{ h.build_nav_icon('dataset_resources', _('Resources'), id=pkg.name) }} +{% endblock %} + +{% block secondary_content %} + {% snippet 'package/snippets/info.html', pkg=pkg, hide_follow_button=true %} +{% endblock %} diff --git a/ckan/templates/package/new_resource.html b/ckan/templates/package/new_resource.html index 13cce195453..edd34bb5213 100644 --- a/ckan/templates/package/new_resource.html +++ b/ckan/templates/package/new_resource.html @@ -1,10 +1,6 @@ {% extends "package/base_form_page.html" %} -{% if c.userobj %} - {% set logged_in = true %} -{% else %} - {% set logged_in = false %} -{% endif %} +{% set logged_in = true if c.userobj else false %} {% block subtitle %}{{ _('Add data to the dataset') }}{% endblock %} @@ -17,26 +13,10 @@ {% block form %}{% snippet 'package/snippets/resource_form.html', data=data, errors=errors, error_summary=error_summary, include_metadata=false, pkg_name=pkg_name, stage=stage, allow_upload=g.ofs_impl and logged_in %}{% endblock %} {% block secondary_content %} - {% if pkg_dict and pkg_dict.state != 'draft' %} - {% snippet 'package/snippets/info.html', pkg=pkg_dict, action='resource_new' %} - {% else %} -
    -

    {{ _('What\'s a resource?') }}

    -
    -

    {{ _('A resource can be any file or link to a file containing useful data.') }}

    -
    -
    - {% endif %} + {% snippet 'package/snippets/resource_help.html' %} {% endblock %} {% block scripts %} {{ super() }} {% resource 'vendor/fileupload' %} {% endblock %} - -{% block primary_content %} - {% if pkg_dict and pkg_dict.state != 'draft' %} - - {% endif %} - {{ super() }} -{% endblock %} diff --git a/ckan/templates/package/new_resource_not_draft.html b/ckan/templates/package/new_resource_not_draft.html new file mode 100644 index 00000000000..7e1638c51e7 --- /dev/null +++ b/ckan/templates/package/new_resource_not_draft.html @@ -0,0 +1,20 @@ +{% extends "package/resource_edit_base.html" %} + +{% block subtitle %}{{ _('Add resource') }} - {{ h.dataset_display_name(pkg) }}{% endblock %} +{% block form_title %}{{ _('Add resource') }}{% endblock %} + +{% block breadcrumb_content %} +
  • {{ _('Add New Resource') }}
  • +{% endblock %} + +{% block form %} + {% snippet 'package/snippets/resource_form.html', data=data, errors=errors, error_summary=error_summary, include_metadata=false, pkg_name=pkg_name, stage=stage, allow_upload=g.ofs_impl and logged_in %} +{% endblock %} + +{% block content_primary_nav %} +
  • {{ _('New resource') }}
  • +{% endblock %} + +{% block secondary_content %} + {% snippet 'package/snippets/resource_help.html' %} +{% endblock %} diff --git a/ckan/templates/package/read_base.html b/ckan/templates/package/read_base.html index 9cf73a5c5a1..f0a58a21018 100644 --- a/ckan/templates/package/read_base.html +++ b/ckan/templates/package/read_base.html @@ -50,20 +50,7 @@ {% block secondary_help_content %}{% endblock %} {% block package_info %} -
    -
    -

    {{ pkg.title or pkg.name }}

    -
    -
    -
    {{ _('Followers') }}
    -
    {{ h.SI_number_span(h.get_action('dataset_follower_count', {'id': pkg.id})) }}
    -
    -
    - -
    -
    + {% snippet 'package/snippets/info.html', pkg=pkg %} {% endblock %} {% block package_organization %} diff --git a/ckan/templates/package/resource_data.html b/ckan/templates/package/resource_data.html new file mode 100644 index 00000000000..05bd66fdfee --- /dev/null +++ b/ckan/templates/package/resource_data.html @@ -0,0 +1,68 @@ +{% extends "package/resource_edit_base.html" %} + +{% block subtitle %}{{ h.dataset_display_name(pkg) }} - {{ h.resource_display_name(res) }}{% endblock %} + +{% block primary_content_inner %} + + {% set action = h.url_for(controller='ckanext.datapusher.plugin:ResourceDataController', action='resource_data', id=pkg.name, resource_id=res.id) %} + {% set show_table = true %} + + + + + + {% if status.error and status.error.message %} + {% set show_table = false %} +
    + {{ _('Upload error:') }} {{ status.error.message }} +
    + {% elif status.task_info and status.task_info.error %} + {% set show_table = false %} +
    + {{ _('Error:') }} {{ status.task_info.error }} +
    + {% endif %} + + + + + + + + + + + + + + +
    {{ _('Status') }}{{ status.status.capitalize() if status.status else _('Not Uploaded Yet') }}
    {{ _('Last updated') }}{{ h.time_ago_from_timestamp(status.last_updated) if status.status else _('Never') }}
    + + {% if status.status and status.task_info and show_table %} +

    {{ _('Upload Log') }}

    +
      + {% for item in status.task_info.logs %} + {% set icon = 'ok' if item.level == 'INFO' else 'exclamation' %} + {% set class = ' failure' if icon == 'exclamation' else ' success' %} + {% set popover_content = 'test' %} +
    • + +

      + {{ item.message | urlize }}
      + + {{ h.time_ago_from_timestamp(item.timestamp) }} + more info + +

      +
    • + {% endfor %} +
    • + +

      {{ _('End of log') }}

      +
    • +
    + {% endif %} + +{% endblock %} diff --git a/ckan/templates/package/resource_edit.html b/ckan/templates/package/resource_edit.html index 52ac2ab4722..5e335d9114b 100644 --- a/ckan/templates/package/resource_edit.html +++ b/ckan/templates/package/resource_edit.html @@ -1,24 +1,7 @@ -{% extends "package/new_resource.html" %} +{% extends "package/resource_edit_base.html" %} -{% set pkg_dict = c.pkg_dict %} -{% set res = c.resource %} +{% block subtitle %}{{ _('Edit') }} - {{ h.resource_display_name(res) }} - {{ h.dataset_display_name(pkg) }}{% endblock %} -{% block subtitle %}{{ h.dataset_display_name(pkg_dict) }} - {{ h.resource_display_name(res) }}{% endblock %} - -{% block breadcrumb_content_selected %}{% endblock %} - -{% block breadcrumb_content %} - {{ super() }} -
  • {{ _('Edit') }}
  • -{% endblock %} - -{% block content_action %} - {% link_for _('View resource'), controller='package', action='resource_read', id=pkg_dict.name, resource_id=res.id, class_='btn', icon='eye-open' %} -{% endblock %} - -{# logged_in is defined in new_resource.html #} -{% block form %}{{ h.snippet('package/snippets/resource_edit_form.html', data=data, errors=errors, error_summary=error_summary, pkg_name=pkg_dict.name, form_action=c.form_action, allow_upload=g.ofs_impl and logged_in) }}{% endblock %} - -{% block secondary_content %} - {% snippet 'package/snippets/info.html', pkg=pkg_dict, active=data.id, action='resource_edit' %} +{% block form %} + {% snippet 'package/snippets/resource_edit_form.html', data=data, errors=errors, error_summary=error_summary, pkg_name=pkg.name, form_action=c.form_action, allow_upload=g.ofs_impl and logged_in %} {% endblock %} diff --git a/ckan/templates/package/resource_edit_base.html b/ckan/templates/package/resource_edit_base.html new file mode 100644 index 00000000000..0682cbbf014 --- /dev/null +++ b/ckan/templates/package/resource_edit_base.html @@ -0,0 +1,40 @@ +{% extends "package/base.html" %} + +{% set logged_in = true if c.userobj else false %} +{% set res = c.resource %} + +{% block breadcrumb_content_selected %}{% endblock %} + +{% block breadcrumb_content %} + {{ super() }} +
  • {% link_for h.resource_display_name(res)|truncate(30), controller='package', action='resource_read', id=pkg.name, resource_id=res.id %}
  • +
  • {{ _('Edit') }}
  • +{% endblock %} + +{% block content_action %} + {% link_for _('All resources'), controller='package', action='resources', id=pkg.name, class_='btn', icon='arrow-left' %} + {% if res %} + {% link_for _('View resource'), controller='package', action='resource_read', id=pkg.name, resource_id=res.id, class_='btn', icon='eye-open' %} + {% endif %} +{% endblock %} + +{% block content_primary_nav %} + {{ h.build_nav_icon('resource_edit', _('Edit resource'), id=pkg.name, resource_id=res.id) }} + {% if 'datapusher' in g.plugins %} + {{ h.build_nav_icon('resource_data', _('Resource Data'), id=pkg.name, resource_id=res.id) }} + {% endif %} +{% endblock %} + +{% block primary_content_inner %} +

    {% block form_title %}{{ _('Edit resource') }}{% endblock %}

    + {% block form %}{% endblock %} +{% endblock %} + +{% block secondary_content %} + {% snippet 'package/snippets/resource_info.html', res=res %} +{% endblock %} + +{% block scripts %} + {{ super() }} + {% resource 'vendor/fileupload' %} +{% endblock %} diff --git a/ckan/templates/package/resources.html b/ckan/templates/package/resources.html new file mode 100644 index 00000000000..e353d03f10b --- /dev/null +++ b/ckan/templates/package/resources.html @@ -0,0 +1,21 @@ +{% extends "package/edit_base.html" %} + +{% block subtitle %}{{ _('Resources') }} - {{ h.dataset_display_name(pkg) }}{% endblock %} + +{% block page_primary_action %} + {% link_for _('Add new resource'), controller='package', action='new_resource', id=c.pkg_dict.name, class_='btn btn-primary', icon='plus' %} +{% endblock %} + +{% block primary_content_inner %} + {% if pkg.resources %} +
      + {% for resource in pkg.resources %} + {% snippet 'package/snippets/resource_item.html', pkg=pkg, res=resource, url_is_edit=true %} + {% endfor %} +
    + {% else %} + {% trans url=h.url_for(controller='package', action='new_resource', id=pkg.name) %} +

    This dataset has no data, why not add some?

    + {% endtrans %} + {% endif %} +{% endblock %} diff --git a/ckan/templates/package/snippets/info.html b/ckan/templates/package/snippets/info.html index 49efa7b8fc7..39ad092ff94 100644 --- a/ckan/templates/package/snippets/info.html +++ b/ckan/templates/package/snippets/info.html @@ -2,33 +2,29 @@ Displays a sidebard module with information for given package pkg - The package dict that owns the resources. -active - The active resource. -action - The action that this is coming from. Example: {% snippet "package/snippets/info.html", pkg=pkg %} #} -{% if pkg and h.check_access('package_update', {'id':pkg.id }) %} +{% if pkg %}
    -

    {{ _("Edit Dataset") }}

    - - {% set resources = pkg.resources or [] %} -

    {{ _("Edit Resources") }}

    - +
    +
    +

    {{ pkg.title or pkg.name }}

    +
    +
    +
    {{ _('Followers') }}
    +
    {{ h.SI_number_span(h.get_action('dataset_follower_count', {'id': pkg.id})) }}
    +
    +
    + {% if not hide_follow_button %} + + {% endif %} +
    +
    {% endif %} diff --git a/ckan/templates/package/snippets/package_basic_fields.html b/ckan/templates/package/snippets/package_basic_fields.html index 044b9ebe5bc..d1ac7e25577 100644 --- a/ckan/templates/package/snippets/package_basic_fields.html +++ b/ckan/templates/package/snippets/package_basic_fields.html @@ -86,8 +86,8 @@
    diff --git a/ckan/templates/package/snippets/resource_help.html b/ckan/templates/package/snippets/resource_help.html new file mode 100644 index 00000000000..d1724956909 --- /dev/null +++ b/ckan/templates/package/snippets/resource_help.html @@ -0,0 +1,6 @@ +
    +

    {{ _('What\'s a resource?') }}

    +
    +

    {{ _('A resource can be any file or link to a file containing useful data.') }}

    +
    +
    diff --git a/ckan/templates/package/snippets/resource_info.html b/ckan/templates/package/snippets/resource_info.html new file mode 100644 index 00000000000..d8ada30d83a --- /dev/null +++ b/ckan/templates/package/snippets/resource_info.html @@ -0,0 +1,21 @@ +{# +Displays a sidebard module with information for given resource + +res - The respource dict + +Example: + + {% snippet "package/snippets/resrouce_info.html", res=res %} + +#} +
    +
    +

    {{ res.name or res.id }}

    +
    +
    +
    {{ _('Format') }}
    +
    {{ res.format }}
    +
    +
    +
    +
    diff --git a/ckan/templates/package/snippets/resource_item.html b/ckan/templates/package/snippets/resource_item.html index fccd94ff4d2..c5b22521d64 100644 --- a/ckan/templates/package/snippets/resource_item.html +++ b/ckan/templates/package/snippets/resource_item.html @@ -1,4 +1,7 @@ -{% set url = h.url_for(controller='package', action='resource_read', id=pkg.name, resource_id=res.id) %} +{% set can_edit = h.check_access('package_update', {'id':pkg.id }) %} +{% set url_action = 'resource_edit' if url_is_edit and can_edit else 'resource_read' %} +{% set url = h.url_for(controller='package', action=url_action, id=pkg.name, resource_id=res.id) %} +
  • {% block resource_item_title %} @@ -14,6 +17,7 @@ {% endif %}

    {% block resource_item_explore %} + {% if not url_is_edit %}
  • + {% if can_edit %} +
  • + + + {{ _('Edit') }} + +
  • + {% endif %} {% endblock %}
    + {% endif %} {% endblock %} diff --git a/ckan/templates/related/snippets/related_item.html b/ckan/templates/related/snippets/related_item.html index 39231a7a217..df1c1300025 100644 --- a/ckan/templates/related/snippets/related_item.html +++ b/ckan/templates/related/snippets/related_item.html @@ -11,11 +11,11 @@ #} {% set placeholder_map = { -'application':'/base/images/placeholder-application.png' +'application': h.url_for_static('/base/images/placeholder-application.png') } %} {% set tooltip = _('Go to {related_item_type}').format(related_item_type=related.type|replace('_', ' ')|title) %}