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
{% for value, active in items %}
diff --git a/ckan/templates/development/snippets/module.html b/ckan/templates/development/snippets/module.html
index 7228c7d556a..03174fe7d04 100644
--- a/ckan/templates/development/snippets/module.html
+++ b/ckan/templates/development/snippets/module.html
@@ -3,7 +3,7 @@
{% if heading_link %}
{{ heading }}
{% elif heading_action %}
- {{ heading }} Clear All
+ {{ heading }}
{% elif heading_icon %}
{{ heading }}
{% else %}
diff --git a/ckan/templates/group/member_new.html b/ckan/templates/group/member_new.html
index 14e281d75b2..6a8fa691c29 100644
--- a/ckan/templates/group/member_new.html
+++ b/ckan/templates/group/member_new.html
@@ -10,7 +10,45 @@
{% block page_heading %}{{ _('Edit Member') if user else _('Add Member') }}{% endblock %}
{% block form %}
-
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 %}
+
+
+
+
+ {{ _('or') }}
+
+
+
+ {{ _('New User') }}
+
+
+ {{ _('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) }}
\ 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 %}
-
+
{% endblock %}
{% block title %}
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})) }}
-
-
-
- {{ h.follow_button('dataset', pkg.name) }}
-
-
-
+ {% 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 %}
+
+
+
+ {{ _('Upload Data') }}
+
+
+
+ {% 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') }}
+
+ {% 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") }}
-
-
- {% link_for h.dataset_display_name(pkg)|truncate(30), controller='package', action='edit', id=pkg.name %}
-
-
- {% set resources = pkg.resources or [] %}
- {{ _("Edit Resources") }}
-
- {% block package_resource_list %}
- {% for resource in resources %}
-
- {% link_for h.resource_display_name(resource)|truncate(30), controller='package', action='resource_edit', id=pkg.name, resource_id=resource.id, inner_span=true %}
-
- {% endfor %}
- {% endblock %}
- {{ _('Add New Resource') }}
-
+
+
+
{{ pkg.title or pkg.name }}
+
+
+ {{ _('Followers') }}
+ {{ h.SI_number_span(h.get_action('dataset_follower_count', {'id': pkg.id})) }}
+
+
+ {% if not hide_follow_button %}
+
+ {{ h.follow_button('dataset', pkg.name) }}
+
+ {% 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 @@
{{ _('Visibility') }}
- {% for option in [(true, _('Private')), (false, _('Public'))] %}
- {{ option[1] }}
+ {% for option in [('True', _('Private')), ('False', _('Public'))] %}
+ {{ option[1] }}
{% endfor %}
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 %}
+ {% 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) %}
-
+
{% if related.description %}
{{ h.render_markdown(related.description) }}
diff --git a/ckan/templates/snippets/datapusher_status.html b/ckan/templates/snippets/datapusher_status.html
new file mode 100644
index 00000000000..3aa8908aef8
--- /dev/null
+++ b/ckan/templates/snippets/datapusher_status.html
@@ -0,0 +1,14 @@
+{# Datapusher status indicator
+
+resource: the resource
+
+#}
+{% if resource.datastore_active %}
+ {% set job = h.datapusher_status(resource.id) %}
+ {% set title = _('Datapusher status: {status}.').format(status=job.status) %}
+ {% if job.status == 'unknown' %}
+
+ {% else %}
+
+ {% endif %}
+{% endif %}
diff --git a/ckan/templates/snippets/facet_list.html b/ckan/templates/snippets/facet_list.html
index bf1beda36e9..45dc6b6787f 100644
--- a/ckan/templates/snippets/facet_list.html
+++ b/ckan/templates/snippets/facet_list.html
@@ -50,7 +50,6 @@
{% set title = title or h.get_facet_title(name) %}
{{ title }}
- {{ _('Clear All') }}
{% if items %}
diff --git a/ckan/templates/snippets/group.html b/ckan/templates/snippets/group.html
index db549fda75a..db758d9bc96 100644
--- a/ckan/templates/snippets/group.html
+++ b/ckan/templates/snippets/group.html
@@ -14,7 +14,7 @@
-
+
diff --git a/ckan/templates/snippets/group_item.html b/ckan/templates/snippets/group_item.html
index 74c68fbca28..60f43f63b79 100644
--- a/ckan/templates/snippets/group_item.html
+++ b/ckan/templates/snippets/group_item.html
@@ -5,7 +5,7 @@
{% set truncate_title = truncate_title or 0 %}
{% set title = group.title or group.name %}
-
+
{% if group.description %}
diff --git a/ckan/templates/snippets/organization.html b/ckan/templates/snippets/organization.html
index ef08a9ed440..e0f5417024a 100644
--- a/ckan/templates/snippets/organization.html
+++ b/ckan/templates/snippets/organization.html
@@ -23,7 +23,7 @@
{{ _('Organization') }}
{% block image %}
{% endblock %}
diff --git a/ckan/templates/snippets/organization_item.html b/ckan/templates/snippets/organization_item.html
index 0213723defa..06579756f32 100644
--- a/ckan/templates/snippets/organization_item.html
+++ b/ckan/templates/snippets/organization_item.html
@@ -3,7 +3,7 @@
{% set url=h.url_for(controller='organization', action='read', id=organization.name) %}
{% set truncate=truncate or 0 %}
-
+
{% if organization.description %}
diff --git a/ckan/templates/user/edit_user_form.html b/ckan/templates/user/edit_user_form.html
index 19b8755b2bd..ce31e34b583 100644
--- a/ckan/templates/user/edit_user_form.html
+++ b/ckan/templates/user/edit_user_form.html
@@ -33,6 +33,12 @@
diff --git a/ckan/templates/user/login.html b/ckan/templates/user/login.html
index a00796b4586..03131dfe5f4 100644
--- a/ckan/templates/user/login.html
+++ b/ckan/templates/user/login.html
@@ -18,15 +18,17 @@ {% block page_heading %}{{ _('Login') }}{% endblock %}<
{% endblock %}
{% block secondary_content %}
-
- {{ _('Need an Account?') }}
-
-
{% trans %}Then sign right up, it only takes a minute.{% endtrans %}
-
- {{ _('Create an Account') }}
-
-
-
+ {% if h.check_access('user_create') %}
+
+ {{ _('Need an Account?') }}
+
+
{% trans %}Then sign right up, it only takes a minute.{% endtrans %}
+
+ {{ _('Create an Account') }}
+
+
+
+ {% endif %}
{{ _('Forgotten your password?') }}
diff --git a/ckan/templates/user/new_user_form.html b/ckan/templates/user/new_user_form.html
index 3879f2dde6c..57a0f1d85e0 100644
--- a/ckan/templates/user/new_user_form.html
+++ b/ckan/templates/user/new_user_form.html
@@ -7,6 +7,11 @@
{{ form.input("email", id="field-email", label=_("Email"), type="email", placeholder="joe@example.com", value=data.email, error=errors.email, classes=["control-medium"]) }}
{{ form.input("password1", id="field-password", label=_("Password"), type="password", placeholder="••••••••", value=data.password1, error=errors.password1, classes=["control-medium"]) }}
{{ form.input("password2", id="field-confirm-password", label=_("Confirm"), type="password", placeholder="••••••••", value=data.password2, error=errors.password1, classes=["control-medium"]) }}
+
+ {% if g.recaptcha_publickey %}
+ {% snippet "user/snippets/recaptcha.html", public_key=g.recaptcha_publickey %}
+ {% endif %}
+
{{ _("Create Account") }}
diff --git a/ckan/templates/user/read_base.html b/ckan/templates/user/read_base.html
index c0747e2fb9c..3a24c21c5a1 100644
--- a/ckan/templates/user/read_base.html
+++ b/ckan/templates/user/read_base.html
@@ -76,6 +76,10 @@ {{ user.display_name }}
{{ _('Member Since') }}
{{ h.render_datetime(user.created) }}
+
+ {{ _('State') }}
+ {{ user.state }}
+
{% if c.is_myself %}
{{ _('API Key') }} {{ _('Private') }}
diff --git a/ckan/templates/user/snippets/recaptcha.html b/ckan/templates/user/snippets/recaptcha.html
new file mode 100644
index 00000000000..994867dc97f
--- /dev/null
+++ b/ckan/templates/user/snippets/recaptcha.html
@@ -0,0 +1,12 @@
+
diff --git a/ckan/tests/functional/api/model/test_group_and_organization_purge.py b/ckan/tests/functional/api/model/test_group_and_organization_purge.py
index 0770642dbe3..88a44e494f0 100644
--- a/ckan/tests/functional/api/model/test_group_and_organization_purge.py
+++ b/ckan/tests/functional/api/model/test_group_and_organization_purge.py
@@ -266,8 +266,7 @@ def _test_visitors_cannot_purge_groups_or_orgs(self, is_org):
result = tests.call_action_api(self.app, action, id=group_or_org['id'],
status=403,
)
- assert result == {'__type': 'Authorization Error',
- 'message': 'Access denied'}
+ assert result['__type'] == 'Authorization Error'
def test_visitors_cannot_purge_organizations(self):
'''Visitors (who aren't logged in) should not be authorized to purge
diff --git a/ckan/tests/functional/api/model/test_vocabulary.py b/ckan/tests/functional/api/model/test_vocabulary.py
index 5330fb09822..83bd8a57676 100644
--- a/ckan/tests/functional/api/model/test_vocabulary.py
+++ b/ckan/tests/functional/api/model/test_vocabulary.py
@@ -399,7 +399,7 @@ def test_vocabulary_create_not_logged_in(self):
params=param_string,
status=403)
assert response.json['success'] is False
- assert response.json['error']['message'] == 'Access denied'
+ assert response.json['error']['__type'] == 'Authorization Error'
def test_vocabulary_create_not_authorized(self):
'''Test that users who are not authorized cannot create vocabs.'''
@@ -412,7 +412,7 @@ def test_vocabulary_create_not_authorized(self):
str(self.normal_user.apikey)},
status=403)
assert response.json['success'] is False
- assert response.json['error']['message'] == 'Access denied'
+ assert response.json['error']['__type'] == 'Authorization Error'
def test_vocabulary_update_id_only(self):
self._update_vocabulary({'id': self.genre_vocab['id']},
@@ -494,7 +494,7 @@ def test_vocabulary_update_not_logged_in(self):
params=param_string,
status=403)
assert response.json['success'] is False
- assert response.json['error']['message'] == 'Access denied'
+ assert response.json['error']['__type'] == 'Authorization Error'
def test_vocabulary_update_with_tags(self):
tags = [
@@ -630,7 +630,7 @@ def test_vocabulary_delete_not_logged_in(self):
params=param_string,
status=403)
assert response.json['success'] is False
- assert response.json['error']['message'] == 'Access denied'
+ assert response.json['error']['__type'] == 'Authorization Error'
def test_vocabulary_delete_not_authorized(self):
'''Test that users who are not authorized cannot delete vocabs.'''
@@ -642,7 +642,7 @@ def test_vocabulary_delete_not_authorized(self):
str(self.normal_user.apikey)},
status=403)
assert response.json['success'] is False
- assert response.json['error']['message'] == 'Access denied'
+ assert response.json['error']['__type'] == 'Authorization Error'
def test_add_tag_to_vocab(self):
'''Test that a tag can be added to and then retrieved from a vocab.'''
@@ -781,7 +781,7 @@ def test_add_tag_not_logged_in(self):
params=tag_string,
status=403)
assert response.json['success'] is False
- assert response.json['error']['message'] == 'Access denied'
+ assert response.json['error']['__type'] == 'Authorization Error'
def test_add_tag_not_authorized(self):
tag_dict = {
@@ -795,7 +795,7 @@ def test_add_tag_not_authorized(self):
str(self.normal_user.apikey)},
status=403)
assert response.json['success'] is False
- assert response.json['error']['message'] == 'Access denied'
+ assert response.json['error']['__type'] == 'Authorization Error'
def test_add_vocab_tag_to_dataset(self):
'''Test that a tag belonging to a vocab can be added to a dataset,
@@ -1116,8 +1116,8 @@ def test_delete_tag_not_logged_in(self):
params=helpers.json.dumps(params),
status=403)
assert response.json['success'] is False
- msg = response.json['error']['message']
- assert msg == u"Access denied", msg
+ error = response.json['error']['__type']
+ assert error == u"Authorization Error", error
def test_delete_tag_not_authorized(self):
vocab = self.genre_vocab
@@ -1131,5 +1131,5 @@ def test_delete_tag_not_authorized(self):
str(self.normal_user.apikey)},
status=403)
assert response.json['success'] is False
- msg = response.json['error']['message']
- assert msg == u"Access denied"
+ msg = response.json['error']['__type']
+ assert msg == u"Authorization Error"
diff --git a/ckan/tests/functional/api/test_follow.py b/ckan/tests/functional/api/test_follow.py
index 79e6f5dab17..74d8cc75b88 100644
--- a/ckan/tests/functional/api/test_follow.py
+++ b/ckan/tests/functional/api/test_follow.py
@@ -424,36 +424,36 @@ def test_01_user_follow_user_bad_apikey(self):
error = ckan.tests.call_action_api(self.app, 'follow_user',
id=self.russianfan['id'], apikey=apikey,
status=403)
- assert error['message'] == 'Access denied'
+ assert error['__type'] == 'Authorization Error'
def test_01_user_follow_dataset_bad_apikey(self):
for apikey in ('bad api key', '', ' ', 'None', '3', '35.7', 'xxx'):
error = ckan.tests.call_action_api(self.app, 'follow_dataset',
id=self.warandpeace['id'], apikey=apikey,
status=403)
- assert error['message'] == 'Access denied'
+ assert error['__type'] == 'Authorization Error'
def test_01_user_follow_group_bad_apikey(self):
for apikey in ('bad api key', '', ' ', 'None', '3', '35.7', 'xxx'):
error = ckan.tests.call_action_api(self.app, 'follow_group',
id=self.rogers_group['id'], apikey=apikey,
status=403)
- assert error['message'] == 'Access denied'
+ assert error['__type'] == 'Authorization Error'
def test_01_user_follow_user_missing_apikey(self):
error = ckan.tests.call_action_api(self.app, 'follow_user',
id=self.russianfan['id'], status=403)
- assert error['message'] == 'Access denied'
+ assert error['__type'] == 'Authorization Error'
def test_01_user_follow_dataset_missing_apikey(self):
error = ckan.tests.call_action_api(self.app, 'follow_dataset',
id=self.warandpeace['id'], status=403)
- assert error['message'] == 'Access denied'
+ assert error['__type'] == 'Authorization Error'
def test_01_user_follow_group_missing_apikey(self):
error = ckan.tests.call_action_api(self.app, 'follow_group',
id=self.rogers_group['id'], status=403)
- assert error['message'] == 'Access denied'
+ assert error['__type'] == 'Authorization Error'
def test_01_follow_bad_object_id(self):
for action in ('follow_user', 'follow_dataset', 'follow_group'):
@@ -878,14 +878,14 @@ def test_01_unfollow_bad_apikey(self):
'xxx'):
error = ckan.tests.call_action_api(self.app, action,
apikey=apikey, status=403, id=self.joeadmin['id'])
- assert error['message'] == 'Access denied'
+ assert error['__type'] == 'Authorization Error'
def test_01_unfollow_missing_apikey(self):
'''Test error response when calling unfollow_* without api key.'''
for action in ('unfollow_user', 'unfollow_dataset', 'unfollow_group'):
error = ckan.tests.call_action_api(self.app, action, status=403,
id=self.joeadmin['id'])
- assert error['message'] == 'Access denied'
+ assert error['__type'] == 'Authorization Error'
def test_01_unfollow_bad_object_id(self):
'''Test error response when calling unfollow_* with bad object id.'''
diff --git a/ckan/tests/functional/api/test_user.py b/ckan/tests/functional/api/test_user.py
index 317428de865..875e8a668ab 100644
--- a/ckan/tests/functional/api/test_user.py
+++ b/ckan/tests/functional/api/test_user.py
@@ -1,20 +1,27 @@
+import paste
+from pylons import config
from nose.tools import assert_equal
import ckan.logic as logic
+import ckan.new_authz as new_authz
from ckan import model
from ckan.lib.create_test_data import CreateTestData
from ckan.tests import TestController as ControllerTestCase
+from ckan.tests.pylons_controller import PylonsTestCase
from ckan.tests import url_for
+import ckan.config.middleware
+from ckan.common import json
+
class TestUserApi(ControllerTestCase):
@classmethod
def setup_class(cls):
CreateTestData.create()
-
+
@classmethod
def teardown_class(cls):
model.repo.rebuild_db()
-
+
def test_autocomplete(self):
response = self.app.get(
url=url_for(controller='api', action='user_autocomplete', ver=2),
@@ -51,8 +58,186 @@ def test_autocomplete_limit(self):
print response.json
assert_equal(len(response.json), 1)
+
+class TestCreateUserApiDisabled(PylonsTestCase):
+ '''
+ Tests for the creating user when create_user_via_api is disabled.
+ '''
+
+ @classmethod
+ def setup_class(cls):
+ CreateTestData.create()
+ cls._original_config = config.copy()
+ new_authz.clear_auth_functions_cache()
+ wsgiapp = ckan.config.middleware.make_app(
+ config['global_conf'], **config)
+ cls.app = paste.fixture.TestApp(wsgiapp)
+ cls.sysadmin_user = model.User.get('testsysadmin')
+ PylonsTestCase.setup_class()
+
+ @classmethod
+ def teardown_class(cls):
+ config.clear()
+ config.update(cls._original_config)
+ new_authz.clear_auth_functions_cache()
+ PylonsTestCase.teardown_class()
+
+ model.repo.rebuild_db()
+
+ def test_user_create_api_enabled_sysadmin(self):
+ params = {
+ 'name': 'testinganewusersysadmin',
+ 'email': 'testinganewuser@ckan.org',
+ 'password': 'random',
+ }
+ res = self.app.post(
+ '/api/3/action/user_create',
+ json.dumps(params),
+ extra_environ={'Authorization': str(self.sysadmin_user.apikey)},
+ expect_errors=True)
+ res_dict = res.json
+ assert res_dict['success'] is True
+
+ def test_user_create_api_disabled_anon(self):
+ params = {
+ 'name': 'testinganewuseranon',
+ 'email': 'testinganewuser@ckan.org',
+ 'password': 'random',
+ }
+ res = self.app.post('/api/3/action/user_create', json.dumps(params),
+ expect_errors=True)
+ res_dict = res.json
+ assert res_dict['success'] is False
+
+
+class TestCreateUserApiEnabled(PylonsTestCase):
+ '''
+ Tests for the creating user when create_user_via_api is enabled.
+ '''
+
+ @classmethod
+ def setup_class(cls):
+ CreateTestData.create()
+ cls._original_config = config.copy()
+ config['ckan.auth.create_user_via_api'] = True
+ new_authz.clear_auth_functions_cache()
+ wsgiapp = ckan.config.middleware.make_app(
+ config['global_conf'], **config)
+ cls.app = paste.fixture.TestApp(wsgiapp)
+ PylonsTestCase.setup_class()
+ cls.sysadmin_user = model.User.get('testsysadmin')
+
+ @classmethod
+ def teardown_class(cls):
+ config.clear()
+ config.update(cls._original_config)
+ new_authz.clear_auth_functions_cache()
+ PylonsTestCase.teardown_class()
+
+ model.repo.rebuild_db()
+
+ def test_user_create_api_enabled_sysadmin(self):
+ params = {
+ 'name': 'testinganewusersysadmin',
+ 'email': 'testinganewuser@ckan.org',
+ 'password': 'random',
+ }
+ res = self.app.post(
+ '/api/3/action/user_create',
+ json.dumps(params),
+ extra_environ={'Authorization': str(self.sysadmin_user.apikey)})
+ res_dict = res.json
+ assert res_dict['success'] is True
+
+ def test_user_create_api_enabled_anon(self):
+ params = {
+ 'name': 'testinganewuseranon',
+ 'email': 'testinganewuser@ckan.org',
+ 'password': 'random',
+ }
+ res = self.app.post('/api/3/action/user_create', json.dumps(params))
+ res_dict = res.json
+ assert res_dict['success'] is True
+
+
+class TestCreateUserWebDisabled(PylonsTestCase):
+ '''
+ Tests for the creating user by create_user_via_web is disabled.
+ '''
+
+ @classmethod
+ def setup_class(cls):
+ CreateTestData.create()
+ cls._original_config = config.copy()
+ config['ckan.auth.create_user_via_web'] = False
+ new_authz.clear_auth_functions_cache()
+ wsgiapp = ckan.config.middleware.make_app(
+ config['global_conf'], **config)
+ cls.app = paste.fixture.TestApp(wsgiapp)
+ cls.sysadmin_user = model.User.get('testsysadmin')
+ PylonsTestCase.setup_class()
+
+ @classmethod
+ def teardown_class(cls):
+ config.clear()
+ config.update(cls._original_config)
+ new_authz.clear_auth_functions_cache()
+ PylonsTestCase.teardown_class()
+
+ model.repo.rebuild_db()
+
+ def test_user_create_api_disabled(self):
+ params = {
+ 'name': 'testinganewuser',
+ 'email': 'testinganewuser@ckan.org',
+ 'password': 'random',
+ }
+ res = self.app.post('/api/3/action/user_create', json.dumps(params),
+ expect_errors=True)
+ res_dict = res.json
+ assert res_dict['success'] is False
+
+
+class TestCreateUserWebEnabled(PylonsTestCase):
+ '''
+ Tests for the creating user by create_user_via_web is enabled.
+ '''
+
+ @classmethod
+ def setup_class(cls):
+ CreateTestData.create()
+ cls._original_config = config.copy()
+ config['ckan.auth.create_user_via_web'] = True
+ new_authz.clear_auth_functions_cache()
+ wsgiapp = ckan.config.middleware.make_app(
+ config['global_conf'], **config)
+ cls.app = paste.fixture.TestApp(wsgiapp)
+ cls.sysadmin_user = model.User.get('testsysadmin')
+ PylonsTestCase.setup_class()
+
+ @classmethod
+ def teardown_class(cls):
+ config.clear()
+ config.update(cls._original_config)
+ new_authz.clear_auth_functions_cache()
+ PylonsTestCase.teardown_class()
+
+ model.repo.rebuild_db()
+
+ def test_user_create_api_disabled(self):
+ params = {
+ 'name': 'testinganewuser',
+ 'email': 'testinganewuser@ckan.org',
+ 'password': 'random',
+ }
+ res = self.app.post('/api/3/action/user_create', json.dumps(params),
+ expect_errors=True)
+ res_dict = res.json
+ assert res_dict['success'] is False
+
+
class TestUserActions(object):
-
+
@classmethod
def setup_class(cls):
CreateTestData.create()
diff --git a/ckan/tests/functional/test_group.py b/ckan/tests/functional/test_group.py
index b07bbe7ea6f..c5dc63b1097 100644
--- a/ckan/tests/functional/test_group.py
+++ b/ckan/tests/functional/test_group.py
@@ -1,6 +1,7 @@
import re
from nose.tools import assert_equal
+import mock
import ckan.tests.test_plugins as test_plugins
import ckan.model as model
@@ -660,3 +661,32 @@ def test_2_atom_feed(self):
assert '' in res, res
+
+class TestMemberInvite(FunctionalTestCase):
+ @classmethod
+ def setup_class(self):
+ model.Session.remove()
+ model.repo.rebuild_db()
+
+ def teardown(self):
+ model.repo.rebuild_db()
+
+ @mock.patch('ckan.lib.mailer.mail_user')
+ def test_member_new_invites_user_if_received_email(self, mail_user):
+ user = CreateTestData.create_user('a_user', sysadmin=True)
+ group_name = 'a_group'
+ CreateTestData.create_groups([{'name': group_name}], user.name)
+ group = model.Group.get(group_name)
+ url = url_for(controller='group', action='member_new', id=group.id)
+ email = 'invited_user@mailinator.com'
+ role = 'member'
+
+ params = {'email': email, 'role': role}
+ res = self.app.post(url, params,
+ extra_environ={'REMOTE_USER': str(user.name)})
+
+ users = model.User.by_email(email)
+ assert len(users) == 1, users
+ user = users[0]
+ assert user.email == email, user
+ assert group.id in user.get_group_ids(capacity=role)
diff --git a/ckan/tests/functional/test_related.py b/ckan/tests/functional/test_related.py
index 003f0bb424b..6fa2fed4266 100644
--- a/ckan/tests/functional/test_related.py
+++ b/ckan/tests/functional/test_related.py
@@ -498,4 +498,4 @@ def test_api_delete_fail(self):
extra_environ=extra)
r = json.loads(res.body)
assert r['success'] == False, r
- assert r[u'error'][u'message'] == u'Access denied' , r
+ assert r[u'error'][u'__type'] == "Authorization Error", r
diff --git a/ckan/tests/functional/test_user.py b/ckan/tests/functional/test_user.py
index 0ef528fd9ad..fe30f53a505 100644
--- a/ckan/tests/functional/test_user.py
+++ b/ckan/tests/functional/test_user.py
@@ -64,6 +64,25 @@ def test_user_read(self):
'rel="nofollow"')
assert 'Edit Profile' not in main_res, main_res
+ def test_user_delete_redirects_to_user_index(self):
+ user = CreateTestData.create_user('a_user')
+ url = url_for(controller='user', action='delete', id=user.id)
+ extra_environ = {'REMOTE_USER': 'testsysadmin'}
+
+ redirect_url = url_for(controller='user', action='index',
+ qualified=True)
+ res = self.app.get(url, status=302, extra_environ=extra_environ)
+
+ assert user.is_deleted(), user
+ assert res.header('Location').startswith(redirect_url), res.header('Location')
+
+ def test_user_delete_by_unauthorized_user(self):
+ user = model.User.by_name(u'annafan')
+ url = url_for(controller='user', action='delete', id=user.id)
+ extra_environ = {'REMOTE_USER': 'an_unauthorized_user'}
+
+ self.app.get(url, status=401, extra_environ=extra_environ)
+
def test_user_read_without_id(self):
offset = '/user/'
res = self.app.get(offset, status=302)
@@ -936,3 +955,39 @@ def test_perform_reset_user_password_link_user_incorrect(self):
id='randomness', # i.e. incorrect
key='randomness')
res = self.app.get(offset, status=404)
+
+ def test_perform_reset_activates_pending_user(self):
+ password = 'password'
+ params = { 'password1': password, 'password2': password }
+ user = CreateTestData.create_user(name='pending_user',
+ email='user@email.com')
+ user.set_pending()
+ create_reset_key(user)
+ assert user.is_pending(), user.state
+
+ offset = url_for(controller='user',
+ action='perform_reset',
+ id=user.id,
+ key=user.reset_key)
+ res = self.app.post(offset, params=params, status=302)
+
+ user = model.User.get(user.id)
+ assert user.is_active(), user
+
+ def test_perform_reset_doesnt_activate_deleted_user(self):
+ password = 'password'
+ params = { 'password1': password, 'password2': password }
+ user = CreateTestData.create_user(name='deleted_user',
+ email='user@email.com')
+ user.delete()
+ create_reset_key(user)
+ assert user.is_deleted(), user.state
+
+ offset = url_for(controller='user',
+ action='perform_reset',
+ id=user.id,
+ key=user.reset_key)
+ res = self.app.post(offset, params=params, status=302)
+
+ user = model.User.get(user.id)
+ assert user.is_deleted(), user
diff --git a/ckan/tests/lib/test_authenticator.py b/ckan/tests/lib/test_authenticator.py
new file mode 100644
index 00000000000..8b2c6139f0f
--- /dev/null
+++ b/ckan/tests/lib/test_authenticator.py
@@ -0,0 +1,125 @@
+import ckan
+
+import ckan.lib.create_test_data as ctd
+import ckan.lib.authenticator as authenticator
+
+CreateTestData = ctd.CreateTestData
+
+
+class TestUsernamePasswordAuthenticator(object):
+ @classmethod
+ def setup_class(cls):
+ auth = authenticator.UsernamePasswordAuthenticator()
+ cls.authenticate = auth.authenticate
+
+ @classmethod
+ def teardown(cls):
+ ckan.model.repo.rebuild_db()
+
+ def test_authenticate_succeeds_if_login_and_password_are_correct(self):
+ environ = {}
+ password = 'somepass'
+ user = CreateTestData.create_user('a_user', **{'password': password})
+ identity = {'login': user.name, 'password': password}
+
+ username = self.authenticate(environ, identity)
+ assert username == user.name, username
+
+ def test_authenticate_fails_if_user_is_deleted(self):
+ environ = {}
+ password = 'somepass'
+ user = CreateTestData.create_user('a_user', **{'password': password})
+ identity = {'login': user.name, 'password': password}
+ user.delete()
+
+ assert self.authenticate(environ, identity) is None
+
+ def test_authenticate_fails_if_user_is_pending(self):
+ environ = {}
+ password = 'somepass'
+ user = CreateTestData.create_user('a_user', **{'password': password})
+ identity = {'login': user.name, 'password': password}
+ user.set_pending()
+
+ assert self.authenticate(environ, identity) is None
+
+ def test_authenticate_fails_if_password_is_wrong(self):
+ environ = {}
+ user = CreateTestData.create_user('a_user')
+ identity = {'login': user.name, 'password': 'wrong-password'}
+ assert self.authenticate(environ, identity) is None
+
+ def test_authenticate_fails_if_received_no_login_or_pass(self):
+ environ = {}
+ identity = {}
+ assert self.authenticate(environ, identity) is None
+
+ def test_authenticate_fails_if_received_just_login(self):
+ environ = {}
+ identity = {'login': 'some-user'}
+ assert self.authenticate(environ, identity) is None
+
+ def test_authenticate_fails_if_received_just_password(self):
+ environ = {}
+ identity = {'password': 'some-password'}
+ assert self.authenticate(environ, identity) is None
+
+ def test_authenticate_fails_if_user_doesnt_exist(self):
+ environ = {}
+ identity = {'login': 'inexistent-user'}
+ assert self.authenticate(environ, identity) is None
+
+
+class TestOpenIDAuthenticator(object):
+ @classmethod
+ def setup_class(cls):
+ auth = authenticator.OpenIDAuthenticator()
+ cls.authenticate = auth.authenticate
+
+ @classmethod
+ def teardown(cls):
+ ckan.model.repo.rebuild_db()
+
+ def test_authenticate_succeeds_if_openid_is_correct(self):
+ environ = {}
+ openid = 'some-openid-key'
+ user = CreateTestData.create_user('a_user', **{'openid': openid})
+ identity = {'login': user.name,
+ 'repoze.who.plugins.openid.userid': openid}
+
+ username = self.authenticate(environ, identity)
+ assert username == user.name, username
+
+ def test_authenticate_fails_if_openid_is_incorrect(self):
+ environ = {}
+ openid = 'wrong-openid-key'
+ user = CreateTestData.create_user('a_user')
+ identity = {'login': user.name,
+ 'repoze.who.plugins.openid.userid': openid}
+
+ assert self.authenticate(environ, identity) is None
+
+ def test_authenticate_fails_if_user_is_deleted(self):
+ environ = {}
+ openid = 'some-openid-key'
+ user = CreateTestData.create_user('a_user', **{'openid': openid})
+ user.delete()
+ identity = {'login': user.name,
+ 'repoze.who.plugins.openid.userid': openid}
+
+ assert self.authenticate(environ, identity) is None
+
+ def test_authenticate_fails_if_user_is_pending(self):
+ environ = {}
+ openid = 'some-openid-key'
+ user = CreateTestData.create_user('a_user', **{'openid': openid})
+ user.set_pending()
+ identity = {'login': user.name,
+ 'repoze.who.plugins.openid.userid': openid}
+
+ assert self.authenticate(environ, identity) is None
+
+ def test_authenticate_fails_if_user_have_no_openid(self):
+ environ = {}
+ identity = {}
+ assert self.authenticate(environ, identity) is None
diff --git a/ckan/tests/lib/test_dictization.py b/ckan/tests/lib/test_dictization.py
index b7caf6f2913..a1a94fc2b21 100644
--- a/ckan/tests/lib/test_dictization.py
+++ b/ckan/tests/lib/test_dictization.py
@@ -923,6 +923,7 @@ def test_16_group_dictized(self):
'capacity' : 'public',
'display_name': u'simple',
'image_url': u'',
+ 'image_display_url': u'',
'name': u'simple',
'packages': 0,
'state': u'active',
@@ -933,6 +934,7 @@ def test_16_group_dictized(self):
'users': [{'about': u'I love reading Annakarenina. My site: http://anna.com',
'display_name': u'annafan',
'capacity' : 'public',
+ 'state': 'active',
'sysadmin': False,
'email_hash': 'd41d8cd98f00b204e9800998ecf8427e',
'fullname': None,
@@ -944,6 +946,7 @@ def test_16_group_dictized(self):
'name': u'help',
'display_name': u'help',
'image_url': u'',
+ 'image_display_url': u'',
'package_count': 2,
'is_organization': False,
'packages': [{'author': None,
diff --git a/ckan/tests/lib/test_dictization_schema.py b/ckan/tests/lib/test_dictization_schema.py
index 5a94d2e54e8..9507be2806f 100644
--- a/ckan/tests/lib/test_dictization_schema.py
+++ b/ckan/tests/lib/test_dictization_schema.py
@@ -160,6 +160,7 @@ def test_2_group_schema(self):
'is_organization': False,
'type': u'group',
'image_url': u'',
+ 'image_display_url': u'',
'packages': sorted([{'id': group_pack[0].id,
'name': group_pack[0].name,
'title': group_pack[0].title},
diff --git a/ckan/tests/lib/test_mailer.py b/ckan/tests/lib/test_mailer.py
index b95d4d290e3..83a35c3b92c 100644
--- a/ckan/tests/lib/test_mailer.py
+++ b/ckan/tests/lib/test_mailer.py
@@ -1,13 +1,12 @@
-import time
from nose.tools import assert_equal, assert_raises
from pylons import config
from email.mime.text import MIMEText
import hashlib
-from ckan import model
+import ckan.model as model
+import ckan.lib.mailer as mailer
from ckan.tests.pylons_controller import PylonsTestCase
from ckan.tests.mock_mail_server import SmtpServerHarness
-from ckan.lib.mailer import mail_recipient, mail_user, send_reset_link, add_msg_niceties, MailerException, get_reset_link_body, get_reset_link
from ckan.lib.create_test_data import CreateTestData
from ckan.lib.base import g
@@ -35,7 +34,7 @@ def setup(self):
def mime_encode(self, msg, recipient_name):
sender_name = g.site_title
sender_url = g.site_url
- body = add_msg_niceties(recipient_name, msg, sender_name, sender_url)
+ body = mailer.add_msg_niceties(recipient_name, msg, sender_name, sender_url)
encoded_body = MIMEText(body.encode('utf-8'), 'plain', 'utf-8').get_payload().strip()
return encoded_body
@@ -49,8 +48,7 @@ def test_mail_recipient(self):
'subject': 'Meeting',
'body': 'The meeting is cancelled.',
'headers': {'header1': 'value1'}}
- mail_recipient(**test_email)
- time.sleep(0.1)
+ mailer.mail_recipient(**test_email)
# check it went to the mock smtp server
msgs = self.get_smtp_messages()
@@ -74,8 +72,7 @@ def test_mail_user(self):
'subject': 'Meeting',
'body': 'The meeting is cancelled.',
'headers': {'header1': 'value1'}}
- mail_user(**test_email)
- time.sleep(0.1)
+ mailer.mail_user(**test_email)
# check it went to the mock smtp server
msgs = self.get_smtp_messages()
@@ -96,12 +93,11 @@ def test_mail_user_without_email(self):
'subject': 'Meeting',
'body': 'The meeting is cancelled.',
'headers': {'header1': 'value1'}}
- assert_raises(MailerException, mail_user, **test_email)
+ assert_raises(mailer.MailerException, mailer.mail_user, **test_email)
def test_send_reset_email(self):
# send email
- send_reset_link(model.User.by_name(u'bob'))
- time.sleep(0.1)
+ mailer.send_reset_link(model.User.by_name(u'bob'))
# check it went to the mock smtp server
msgs = self.get_smtp_messages()
@@ -110,9 +106,29 @@ def test_send_reset_email(self):
assert_equal(msg[1], config['smtp.mail_from'])
assert_equal(msg[2], [model.User.by_name(u'bob').email])
assert 'Reset' in msg[3], msg[3]
- test_msg = get_reset_link_body(model.User.by_name(u'bob'))
+ test_msg = mailer.get_reset_link_body(model.User.by_name(u'bob'))
+ expected_body = self.mime_encode(test_msg,
+ u'bob')
+ assert expected_body in msg[3], '%r not in %r' % (expected_body, msg[3])
+
+ # reset link tested in user functional test
+
+ def test_send_invite_email(self):
+ user = model.User.by_name(u'bob')
+ assert user.reset_key is None, user
+ # send email
+ mailer.send_invite(user)
+
+ # check it went to the mock smtp server
+ msgs = self.get_smtp_messages()
+ assert_equal(len(msgs), 1)
+ msg = msgs[0]
+ assert_equal(msg[1], config['smtp.mail_from'])
+ assert_equal(msg[2], [model.User.by_name(u'bob').email])
+ test_msg = mailer.get_invite_body(model.User.by_name(u'bob'))
expected_body = self.mime_encode(test_msg,
u'bob')
assert expected_body in msg[3], '%r not in %r' % (expected_body, msg[3])
+ assert user.reset_key is not None, user
# reset link tested in user functional test
diff --git a/ckan/tests/logic/test_action.py b/ckan/tests/logic/test_action.py
index c116f522abd..70857c5eda2 100644
--- a/ckan/tests/logic/test_action.py
+++ b/ckan/tests/logic/test_action.py
@@ -6,7 +6,9 @@
from nose.plugins.skip import SkipTest
from pylons import config
import datetime
+import mock
+import vdm.sqlalchemy
import ckan
from ckan.lib.create_test_data import CreateTestData
from ckan.lib.dictization.model_dictize import resource_dictize
@@ -82,6 +84,36 @@ def test_01_package_list(self):
assert 'warandpeace' in res['result']
assert 'annakarenina' in res['result']
+ def test_01_package_list_private(self):
+ tests.call_action_api(self.app, 'organization_create',
+ name='test_org_2',
+ apikey=self.sysadmin_user.apikey)
+
+ tests.call_action_api(self.app, 'package_create',
+ name='public_dataset',
+ owner_org='test_org_2',
+ apikey=self.sysadmin_user.apikey)
+
+ res = tests.call_action_api(self.app, 'package_list')
+
+ assert len(res) == 3
+ assert 'warandpeace' in res
+ assert 'annakarenina' in res
+ assert 'public_dataset' in res
+
+ tests.call_action_api(self.app, 'package_create',
+ name='private_dataset',
+ owner_org='test_org_2',
+ private=True,
+ apikey=self.sysadmin_user.apikey)
+
+ res = tests.call_action_api(self.app, 'package_list')
+ assert len(res) == 3
+ assert 'warandpeace' in res
+ assert 'annakarenina' in res
+ assert 'public_dataset' in res
+ assert not 'private_dataset' in res
+
def test_01_current_package_list_with_resources(self):
url = '/api/action/current_package_list_with_resources'
@@ -341,6 +373,11 @@ def test_42_create_resource_with_error(self):
def test_04_user_list(self):
+ # Create deleted user to make sure he won't appear in the user_list
+ deleted_user = CreateTestData.create_user('deleted_user')
+ deleted_user.delete()
+ model.repo.commit()
+
postparams = '%s=1' % json.dumps({})
res = self.app.post('/api/action/user_list', params=postparams)
res_obj = json.loads(res.body)
@@ -453,6 +490,206 @@ def test_11_user_create_wrong_password(self):
assert res_obj['error'] == { '__type': 'Validation Error',
'password': ['Your password must be 4 characters or longer']}
+ def test_12_user_update(self):
+ normal_user_dict = {'id': self.normal_user.id,
+ 'name': self.normal_user.name,
+ 'fullname': 'Updated normal user full name',
+ 'email': 'me@test.org',
+ 'about':'Updated normal user about'}
+
+ sysadmin_user_dict = {'id': self.sysadmin_user.id,
+ 'fullname': 'Updated sysadmin user full name',
+ 'email': 'me@test.org',
+ 'about':'Updated sysadmin user about'}
+
+ #Normal users can update themselves
+ postparams = '%s=1' % json.dumps(normal_user_dict)
+ res = self.app.post('/api/action/user_update', params=postparams,
+ extra_environ={'Authorization': str(self.normal_user.apikey)})
+
+ res_obj = json.loads(res.body)
+ assert res_obj['help'].startswith("Update a user account.")
+ assert res_obj['success'] == True
+ result = res_obj['result']
+ assert result['id'] == self.normal_user.id
+ assert result['name'] == self.normal_user.name
+ assert result['fullname'] == normal_user_dict['fullname']
+ assert result['about'] == normal_user_dict['about']
+ assert 'apikey' in result
+ assert 'created' in result
+ assert 'display_name' in result
+ assert 'number_administered_packages' in result
+ assert 'number_of_edits' in result
+ assert not 'password' in result
+
+ #Sysadmin users can update themselves
+ postparams = '%s=1' % json.dumps(sysadmin_user_dict)
+ res = self.app.post('/api/action/user_update', params=postparams,
+ extra_environ={'Authorization': str(self.sysadmin_user.apikey)})
+
+ res_obj = json.loads(res.body)
+ assert res_obj['help'].startswith("Update a user account.")
+ assert res_obj['success'] == True
+ result = res_obj['result']
+ assert result['id'] == self.sysadmin_user.id
+ assert result['name'] == self.sysadmin_user.name
+ assert result['fullname'] == sysadmin_user_dict['fullname']
+ assert result['about'] == sysadmin_user_dict['about']
+
+ #Sysadmin users can update all users
+ postparams = '%s=1' % json.dumps(normal_user_dict)
+ res = self.app.post('/api/action/user_update', params=postparams,
+ extra_environ={'Authorization': str(self.sysadmin_user.apikey)})
+
+ res_obj = json.loads(res.body)
+ assert res_obj['help'].startswith("Update a user account.")
+ assert res_obj['success'] == True
+ result = res_obj['result']
+ assert result['id'] == self.normal_user.id
+ assert result['name'] == self.normal_user.name
+ assert result['fullname'] == normal_user_dict['fullname']
+ assert result['about'] == normal_user_dict['about']
+
+ #Normal users can not update other users
+ postparams = '%s=1' % json.dumps(sysadmin_user_dict)
+ res = self.app.post('/api/action/user_update', params=postparams,
+ extra_environ={'Authorization': str(self.normal_user.apikey)},
+ status=StatusCodes.STATUS_403_ACCESS_DENIED)
+
+ res_obj = json.loads(res.body)
+ assert res_obj['help'].startswith("Update a user account.")
+ assert res_obj['error']['__type'] == 'Authorization Error'
+ assert res_obj['success'] is False
+
+ def test_12_user_update_errors(self):
+ test_calls = (
+ # Empty name
+ {'user_dict': {'id': self.normal_user.id,
+ 'name':'',
+ 'email':'test@test.com'},
+ 'messages': [('name','Name must be at least 2 characters long')]},
+
+ # Invalid characters in name
+ {'user_dict': {'id': self.normal_user.id,
+ 'name':'i++%',
+ 'email':'test@test.com'},
+ 'messages': [('name','Url must be purely lowercase alphanumeric')]},
+ # Existing name
+ {'user_dict': {'id': self.normal_user.id,
+ 'name':self.sysadmin_user.name,
+ 'email':'test@test.com'},
+ 'messages': [('name','That login name is not available')]},
+ # Missing email
+ {'user_dict': {'id': self.normal_user.id,
+ 'name':self.normal_user.name},
+ 'messages': [('email','Missing value')]},
+ )
+
+ for test_call in test_calls:
+ postparams = '%s=1' % json.dumps(test_call['user_dict'])
+ res = self.app.post('/api/action/user_update', params=postparams,
+ extra_environ={'Authorization': str(self.normal_user.apikey)},
+ status=StatusCodes.STATUS_409_CONFLICT)
+ res_obj = json.loads(res.body)
+ for expected_message in test_call['messages']:
+ assert expected_message[1] in ''.join(res_obj['error'][expected_message[0]])
+
+ @mock.patch('ckan.lib.mailer.send_invite')
+ def test_user_invite(self, send_invite):
+ email_username = 'invited_user$ckan'
+ email = '%s@email.com' % email_username
+ organization_name = 'an_organization'
+ CreateTestData.create_groups([{'name': organization_name}])
+ role = 'member'
+ organization = model.Group.get(organization_name)
+ params = {'email': email,
+ 'group_id': organization.id,
+ 'role': role}
+ postparams = '%s=1' % json.dumps(params)
+ extra_environ = {'Authorization': str(self.sysadmin_user.apikey)}
+
+ res = self.app.post('/api/action/user_invite', params=postparams,
+ extra_environ=extra_environ)
+
+ res_obj = json.loads(res.body)
+ user = model.User.get(res_obj['result']['id'])
+ assert res_obj['success'] is True, res_obj
+ assert user.email == email, (user.email, email)
+ assert user.is_pending(), user
+ expected_username = email_username.replace('$', '-')
+ assert user.name.startswith(expected_username), (user.name,
+ expected_username)
+ group_ids = user.get_group_ids(capacity=role)
+ assert organization.id in group_ids, (group_ids, organization.id)
+ assert send_invite.called
+ assert send_invite.call_args[0][0].id == res_obj['result']['id']
+
+ @mock.patch('ckan.lib.mailer.mail_user')
+ def test_user_invite_without_email_raises_error(self, mail_user):
+ user_dict = {}
+ postparams = '%s=1' % json.dumps(user_dict)
+ extra_environ = {'Authorization': str(self.sysadmin_user.apikey)}
+
+ res = self.app.post('/api/action/user_invite', params=postparams,
+ extra_environ=extra_environ,
+ status=StatusCodes.STATUS_409_CONFLICT)
+
+ res_obj = json.loads(res.body)
+ assert res_obj['success'] is False, res_obj
+ assert 'email' in res_obj['error'], res_obj
+
+ @mock.patch('random.SystemRandom')
+ def test_user_invite_should_work_even_if_tried_username_already_exists(self, system_random_mock):
+ patcher = mock.patch('ckan.lib.mailer.mail_user')
+ patcher.start()
+ email = 'invited_user@email.com'
+ organization_name = 'an_organization'
+ CreateTestData.create_groups([{'name': organization_name}])
+ role = 'member'
+ organization = model.Group.get(organization_name)
+ params = {'email': email,
+ 'group_id': organization.id,
+ 'role': role}
+ postparams = '%s=1' % json.dumps(params)
+ extra_environ = {'Authorization': str(self.sysadmin_user.apikey)}
+
+ system_random_mock.return_value.random.side_effect = [1000, 1000, 2000, 3000]
+
+ for _ in range(2):
+ res = self.app.post('/api/action/user_invite', params=postparams,
+ extra_environ=extra_environ)
+
+ res_obj = json.loads(res.body)
+ assert res_obj['success'] is True, res_obj
+ patcher.stop()
+
+ def test_user_delete(self):
+ name = 'normal_user'
+ CreateTestData.create_user(name)
+ user = model.User.get(name)
+ user_dict = {'id': user.id}
+ postparams = '%s=1' % json.dumps(user_dict)
+
+ res = self.app.post('/api/action/user_delete', params=postparams,
+ extra_environ={'Authorization': str(self.sysadmin_user.apikey)})
+
+ res_obj = json.loads(res.body)
+ deleted_user = model.User.get(name)
+ assert res_obj['success'] is True
+ assert deleted_user.is_deleted(), deleted_user
+
+ def test_user_delete_requires_data_dict_with_key_id(self):
+ user_dict = {'name': 'normal_user'}
+ postparams = '%s=1' % json.dumps(user_dict)
+
+ res = self.app.post('/api/action/user_delete', params=postparams,
+ extra_environ={'Authorization': str(self.sysadmin_user.apikey)},
+ status=StatusCodes.STATUS_409_CONFLICT)
+
+ res_obj = json.loads(res.body)
+ assert res_obj['success'] is False
+ assert res_obj['error']['id'] == ['Missing value']
+
def test_13_group_list(self):
postparams = '%s=1' % json.dumps({})
res = self.app.post('/api/action/group_list', params=postparams)
@@ -531,6 +768,11 @@ def test_14_group_show(self):
assert res_obj['success'] is False
def test_16_user_autocomplete(self):
+ # Create deleted user to make sure he won't appear in the user_list
+ deleted_user = CreateTestData.create_user('joe')
+ deleted_user.delete()
+ model.repo.commit()
+
#Empty query
postparams = '%s=1' % json.dumps({})
res = self.app.post(
@@ -678,7 +920,7 @@ def test_22_task_status_normal_user_not_authorized(self):
res_obj = json.loads(res.body)
assert res_obj['help'].startswith("Update a task status.")
assert res_obj['success'] is False
- assert res_obj['error'] == {'message': 'Access denied', '__type': 'Authorization Error'}
+ assert res_obj['error']['__type'] == 'Authorization Error'
def test_23_task_status_validation(self):
task_status = {}
@@ -1274,6 +1516,48 @@ def test_1_basic(self):
assert_equal(result['count'], 1)
assert_equal(result['results'][0]['name'], 'annakarenina')
+ def test_1_facet_limit(self):
+ params = {
+ 'q':'*:*',
+ 'facet.field': ['groups', 'tags', 'res_format', 'license'],
+ 'rows': 20,
+ 'start': 0,
+ }
+ postparams = '%s=1' % json.dumps(params)
+ res = self.app.post('/api/action/package_search', params=postparams)
+ res = json.loads(res.body)
+ assert_equal(res['success'], True)
+
+ assert_equal(len(res['result']['search_facets']['groups']['items']), 2)
+
+ params = {
+ 'q':'*:*',
+ 'facet.field': ['groups', 'tags', 'res_format', 'license'],
+ 'facet.limit': 1,
+ 'rows': 20,
+ 'start': 0,
+ }
+ postparams = '%s=1' % json.dumps(params)
+ res = self.app.post('/api/action/package_search', params=postparams)
+ res = json.loads(res.body)
+ assert_equal(res['success'], True)
+
+ assert_equal(len(res['result']['search_facets']['groups']['items']), 1)
+
+ params = {
+ 'q':'*:*',
+ 'facet.field': ['groups', 'tags', 'res_format', 'license'],
+ 'facet.limit': -1, # No limit
+ 'rows': 20,
+ 'start': 0,
+ }
+ postparams = '%s=1' % json.dumps(params)
+ res = self.app.post('/api/action/package_search', params=postparams)
+ res = json.loads(res.body)
+ assert_equal(res['success'], True)
+
+ assert_equal(len(res['result']['search_facets']['groups']['items']), 2)
+
def test_1_basic_no_params(self):
postparams = '%s=1' % json.dumps({})
res = self.app.post('/api/action/package_search', params=postparams)
@@ -1721,3 +2005,69 @@ def _assert_we_can_add_user_to_group(self, user_id, group_id):
group_ids = [g.id for g in groups]
assert res['success'] is True, res
assert group.id in group_ids, (group, user_groups)
+
+
+class TestRelatedAction(WsgiAppCase):
+
+ sysadmin_user = None
+
+ normal_user = None
+
+ @classmethod
+ def setup_class(cls):
+ search.clear()
+ CreateTestData.create()
+ cls.sysadmin_user = model.User.get('testsysadmin')
+
+ @classmethod
+ def teardown_class(cls):
+ model.repo.rebuild_db()
+
+ def _add_basic_package(self, package_name=u'test_package', **kwargs):
+ package = {
+ 'name': package_name,
+ 'title': u'A Novel By Tolstoy',
+ 'resources': [{
+ 'description': u'Full text.',
+ 'format': u'plain text',
+ 'url': u'http://www.annakarenina.com/download/'
+ }]
+ }
+ package.update(kwargs)
+
+ postparams = '%s=1' % json.dumps(package)
+ res = self.app.post('/api/action/package_create', params=postparams,
+ extra_environ={'Authorization': 'tester'})
+ return json.loads(res.body)['result']
+
+ def test_update_add_related_item(self):
+ package = self._add_basic_package()
+ related_item = {
+ "description": "Testing a Description",
+ "url": "http://example.com/image.png",
+ "title": "Testing",
+ "featured": 0,
+ "image_url": "http://example.com/image.png",
+ "type": "idea",
+ "dataset_id": package['id'],
+ }
+ related_item_json = json.dumps(related_item)
+ res_create = self.app.post('/api/action/related_create',
+ params=related_item_json,
+ extra_environ={'Authorization': 'tester'})
+ assert res_create.json['success']
+
+ related_update = res_create.json['result']
+ related_update = {'id': related_update['id'], 'title': 'Updated'}
+ related_update_json = json.dumps(related_update)
+ res_update = self.app.post('/api/action/related_update',
+ params=related_update_json,
+ extra_environ={'Authorization': 'tester'})
+ assert res_update.json['success']
+ res_update_json = res_update.json['result']
+ assert res_update_json['title'] == related_update['title']
+
+ related_item.pop('title')
+ related_item.pop('dataset_id')
+ for field in related_item:
+ assert related_item[field] == res_update_json[field]
diff --git a/ckan/tests/logic/test_auth.py b/ckan/tests/logic/test_auth.py
index 3ea9f2db39f..45caddd02d4 100644
--- a/ckan/tests/logic/test_auth.py
+++ b/ckan/tests/logic/test_auth.py
@@ -1,3 +1,5 @@
+import mock
+
import ckan.tests as tests
from ckan.logic import get_action
import ckan.model as model
@@ -11,8 +13,9 @@
'user_create_organizations': False,
'user_delete_groups': False,
'user_delete_organizations': False,
- 'create_user_via_api': False,
'create_unowned_dataset': False,
+ 'create_user_via_api': False,
+ 'create_user_via_web': True,
}
@@ -48,8 +51,43 @@ def create_user(self, name):
self.apikeys[name] = str(json.loads(res.body)['result']['apikey'])
-class TestAuthOrgs(TestAuth):
+class TestAuthUsers(TestAuth):
+ @mock.patch('ckan.logic.auth.create.group_member_create')
+ def test_invite_user_prepares_context_and_delegates_to_group_member_create(self, group_member_create):
+ context = {'group_id': 42}
+ group_member_create_context = context
+ group_member_create_context['id'] = context['group_id']
+
+ new_authz.is_authorized_boolean('user_invite', context)
+
+ group_member_create.assert_called(group_member_create_context, None)
+
+ def test_only_sysadmins_can_delete_users(self):
+ username = 'username'
+ user = {'id': username}
+ self.create_user(username)
+
+ self._call_api('user_delete', user, username, 403)
+ self._call_api('user_delete', user, 'sysadmin', 200)
+ def test_auth_deleted_users_are_always_unauthorized(self):
+ always_success = lambda x,y: {'success': True}
+ new_authz._AuthFunctions._build()
+ new_authz._AuthFunctions._functions['always_success'] = always_success
+ # We can't reuse the username with the other tests because we can't
+ # rebuild_db(), because in the setup_class we get the sysadmin. If we
+ # rebuild the DB, we would delete the sysadmin as well.
+ username = 'deleted_user'
+ self.create_user(username)
+ user = model.User.get(username)
+ user.delete()
+
+ assert not new_authz.is_authorized_boolean('always_success', {'user': username})
+
+ del new_authz._AuthFunctions._functions['always_success']
+
+
+class TestAuthOrgs(TestAuth):
def test_01_create_users(self):
# actual roles assigned later
self.create_user('org_admin')
@@ -86,17 +124,20 @@ def test_03_create_dataset_no_org(self):
self._call_api('package_create', dataset, 'no_org', 403)
def test_04_create_dataset_with_org(self):
-
+ org_with_user = self._call_api('organization_show', {'id':
+ 'org_with_user'}, 'sysadmin')
dataset = {'name': 'admin_create_with_user',
- 'owner_org': 'org_with_user'}
+ 'owner_org': org_with_user.json['result']['id']}
self._call_api('package_create', dataset, 'sysadmin', 200)
+ org_no_user = self._call_api('organization_show', {'id':
+ 'org_no_user'}, 'sysadmin')
dataset = {'name': 'sysadmin_create_no_user',
- 'owner_org': 'org_no_user'}
+ 'owner_org': org_no_user.json['result']['id']}
self._call_api('package_create', dataset, 'sysadmin', 200)
dataset = {'name': 'user_create_with_org',
- 'owner_org': 'org_with_user'}
+ 'owner_org': org_with_user.json['result']['id']}
self._call_api('package_create', dataset, 'no_org', 403)
def test_05_add_users_to_org(self):
@@ -127,7 +168,7 @@ def _add_datasets(self, user):
#not able to add dataset to org admin does not belong to.
dataset = {'name': user + '_dataset_bad', 'owner_org': 'org_no_user'}
- self._call_api('package_create', dataset, user, 409)
+ self._call_api('package_create', dataset, user, 403)
#admin not able to make dataset not owned by a org
dataset = {'name': user + '_dataset_bad'}
@@ -135,7 +176,7 @@ def _add_datasets(self, user):
#not able to add org to not existant org
dataset = {'name': user + '_dataset_bad', 'owner_org': 'org_not_exist'}
- self._call_api('package_create', dataset, user, 409)
+ self._call_api('package_create', dataset, user, 403)
def test_07_add_datasets(self):
self._add_datasets('org_admin')
diff --git a/ckan/tests/logic/test_tag.py b/ckan/tests/logic/test_tag.py
index 82d0cdd1e1e..4844fa055ca 100644
--- a/ckan/tests/logic/test_tag.py
+++ b/ckan/tests/logic/test_tag.py
@@ -151,7 +151,7 @@ def test_08_user_create_not_authorized(self):
res_obj = json.loads(res.body)
assert res_obj['help'].startswith("Create a new user.")
assert res_obj['success'] is False
- assert res_obj['error'] == {'message': 'Access denied', '__type': 'Authorization Error'}
+ assert res_obj['error']['__type'] == 'Authorization Error'
def test_09_user_create(self):
user_dict = {'name':'test_create_from_action_api',
diff --git a/ckan/tests/models/test_follower.py b/ckan/tests/models/test_follower.py
new file mode 100644
index 00000000000..9f4eb51ea3f
--- /dev/null
+++ b/ckan/tests/models/test_follower.py
@@ -0,0 +1,124 @@
+import ckan.model as model
+import ckan.lib.create_test_data as ctd
+
+CreateTestData = ctd.CreateTestData
+
+
+class FollowerClassesTests(object):
+ @classmethod
+ def teardown_class(cls):
+ model.repo.rebuild_db()
+
+ def test_get(self):
+ following = self.FOLLOWER_CLASS.get(self.follower.id, self.followee.id)
+ assert following.follower_id == self.follower.id, following
+ assert following.object_id == self.followee.id, following
+
+ def test_get_returns_none_if_couldnt_find_users(self):
+ following = self.FOLLOWER_CLASS.get('some-id', 'other-id')
+ assert following is None, following
+
+ def test_is_following(self):
+ assert self.FOLLOWER_CLASS.is_following(self.follower.id,
+ self.followee.id)
+
+ def test_is_following_returns_false_if_user_isnt_following(self):
+ assert not self.FOLLOWER_CLASS.is_following(self.followee.id,
+ self.follower.id)
+
+ def test_followee_count(self):
+ count = self.FOLLOWER_CLASS.followee_count(self.follower.id)
+ assert count == 1, count
+
+ def test_followee_list(self):
+ followees = self.FOLLOWER_CLASS.followee_list(self.follower.id)
+ object_ids = [f.object_id for f in followees]
+ assert object_ids == [self.followee.id], object_ids
+
+ def test_follower_count(self):
+ count = self.FOLLOWER_CLASS.follower_count(self.followee.id)
+ assert count == 1, count
+
+ def test_follower_list(self):
+ followers = self.FOLLOWER_CLASS.follower_list(self.followee.id)
+ follower_ids = [f.follower_id for f in followers]
+ assert follower_ids == [self.follower.id], follower_ids
+
+
+class TestUserFollowingUser(FollowerClassesTests):
+ FOLLOWER_CLASS = model.UserFollowingUser
+
+ @classmethod
+ def setup_class(cls):
+ model.repo.rebuild_db()
+ cls.follower = CreateTestData.create_user('follower')
+ cls.followee = CreateTestData.create_user('followee')
+ cls.FOLLOWER_CLASS(cls.follower.id, cls.followee.id).save()
+ cls._create_deleted_models()
+
+ @classmethod
+ def _create_deleted_models(cls):
+ deleted_user = CreateTestData.create_user('deleted_user')
+ cls.FOLLOWER_CLASS(deleted_user.id, cls.followee.id).save()
+ cls.FOLLOWER_CLASS(cls.follower.id, deleted_user.id).save()
+ deleted_user.delete()
+ deleted_user.save()
+
+
+class TestUserFollowingDataset(FollowerClassesTests):
+ FOLLOWER_CLASS = model.UserFollowingDataset
+
+ @classmethod
+ def setup_class(cls):
+ model.repo.rebuild_db()
+ cls.follower = CreateTestData.create_user('follower')
+ cls.followee = cls._create_dataset('followee')
+ cls.FOLLOWER_CLASS(cls.follower.id, cls.followee.id).save()
+ cls._create_deleted_models()
+
+ @classmethod
+ def _create_deleted_models(cls):
+ deleted_user = CreateTestData.create_user('deleted_user')
+ cls.FOLLOWER_CLASS(deleted_user.id, cls.followee.id).save()
+ deleted_user.delete()
+ deleted_user.save()
+ deleted_dataset = cls._create_dataset('deleted_dataset')
+ cls.FOLLOWER_CLASS(cls.follower.id, deleted_dataset.id).save()
+ deleted_dataset.delete()
+ deleted_dataset.save()
+
+ @classmethod
+ def _create_dataset(self, name):
+ CreateTestData.create_arbitrary({'name': name})
+ return model.Package.get(name)
+
+
+class TestUserFollowingGroup(FollowerClassesTests):
+ FOLLOWER_CLASS = model.UserFollowingGroup
+
+ @classmethod
+ def setup_class(cls):
+ model.repo.rebuild_db()
+ model.repo.new_revision()
+ cls.follower = CreateTestData.create_user('follower')
+ cls.followee = cls._create_group('followee')
+ cls.FOLLOWER_CLASS(cls.follower.id, cls.followee.id).save()
+ cls._create_deleted_models()
+ model.repo.commit_and_remove()
+
+ @classmethod
+ def _create_deleted_models(cls):
+ deleted_user = CreateTestData.create_user('deleted_user')
+ cls.FOLLOWER_CLASS(deleted_user.id, cls.followee.id).save()
+ deleted_user.delete()
+ deleted_user.save()
+ deleted_group = cls._create_group('deleted_group')
+ cls.FOLLOWER_CLASS(cls.follower.id, deleted_group.id).save()
+ deleted_group.delete()
+ deleted_group.save()
+
+ @classmethod
+ def _create_group(self, name):
+ group = model.Group(name)
+ group.save()
+ return group
diff --git a/ckan/tests/models/test_user.py b/ckan/tests/models/test_user.py
index b9ef7f0624f..bd34641b936 100644
--- a/ckan/tests/models/test_user.py
+++ b/ckan/tests/models/test_user.py
@@ -56,6 +56,39 @@ def test_4_get_openid_missing_slash(self):
assert out
assert out.fullname == u'Sandra'
+ def test_is_deleted(self):
+ user = CreateTestData._create_user_without_commit('a_user')
+ user.state = 'some-state'
+ assert not user.is_deleted(), user
+ user.delete()
+ assert user.is_deleted(), user
+
+ def test_user_is_active_by_default(self):
+ user = CreateTestData._create_user_without_commit('a_user')
+ assert user.is_active(), user
+
+ def test_activate(self):
+ user = CreateTestData._create_user_without_commit('a_user')
+ user.state = 'some-state'
+ assert not user.is_active(), user
+ user.activate()
+ assert user.is_active(), user
+
+ def test_activate(self):
+ user = CreateTestData._create_user_without_commit('a_user')
+ user.state = 'some-state'
+ assert not user.is_active(), user
+ user.activate()
+ assert user.is_active(), user
+
+ def test_is_pending(self):
+ user = CreateTestData._create_user_without_commit('a_user')
+ user.state = 'some-state'
+ assert not user.is_pending(), user
+ user.set_pending()
+ assert user.is_pending(), user
+
+
def to_names(domain_obj_list):
'''Takes a list of domain objects and returns a corresponding list
of their names.'''
diff --git a/ckan/tests/test_coding_standards.py b/ckan/tests/test_coding_standards.py
index f0b7469c3f4..349f4785f6a 100644
--- a/ckan/tests/test_coding_standards.py
+++ b/ckan/tests/test_coding_standards.py
@@ -828,7 +828,6 @@ class TestPep8(object):
'ckanext/example_idatasetform/plugin.py',
'ckanext/example_itemplatehelpers/plugin.py',
'ckanext/multilingual/plugin.py',
- 'ckanext/multilingual/tests/test_multilingual_plugin.py',
'ckanext/reclinepreview/plugin.py',
'ckanext/reclinepreview/tests/test_preview.py',
'ckanext/resourceproxy/plugin.py',
diff --git a/ckanext/datapusher/__init__.py b/ckanext/datapusher/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/ckanext/datapusher/helpers.py b/ckanext/datapusher/helpers.py
new file mode 100644
index 00000000000..d5291425b28
--- /dev/null
+++ b/ckanext/datapusher/helpers.py
@@ -0,0 +1,11 @@
+import ckan.plugins.toolkit as toolkit
+
+
+def datapusher_status(resource_id):
+ try:
+ return toolkit.get_action('datapusher_status')(
+ {}, {'resource_id': resource_id})
+ except toolkit.ObjectNotFound:
+ return {
+ 'status': 'unknown'
+ }
diff --git a/ckanext/datapusher/logic/__init__.py b/ckanext/datapusher/logic/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/ckanext/datapusher/logic/action.py b/ckanext/datapusher/logic/action.py
new file mode 100644
index 00000000000..1872a1ef265
--- /dev/null
+++ b/ckanext/datapusher/logic/action.py
@@ -0,0 +1,209 @@
+import logging
+import json
+import urlparse
+import datetime
+
+import pylons
+import requests
+
+import ckan.lib.navl.dictization_functions
+import ckan.logic as logic
+import ckan.plugins as p
+import ckanext.datapusher.logic.schema as dpschema
+
+log = logging.getLogger(__name__)
+_get_or_bust = logic.get_or_bust
+_validate = ckan.lib.navl.dictization_functions.validate
+
+
+def datapusher_submit(context, data_dict):
+ ''' Submit a job to the datapusher. The datapusher is a service that
+ imports tabular data into the datastore.
+
+ :param resource_id: The resource id of the resource that the data
+ should be imported in. The resource's URL will be used to get the data.
+ :type resource_id: string
+ :param set_url_type: If set to True, the ``url_type`` of the resource will
+ be set to ``datastore`` and the resource URL will automatically point
+ to the :ref:`datastore dump ` URL. (optional, default: False)
+ :type set_url_type: bool
+
+ Returns ``True`` if the job has been submitted and ``False`` if the job
+ has not been submitted, i.e. when the datapusher is not configured.
+
+ :rtype: bool
+ '''
+
+ schema = context.get('schema', dpschema.datapusher_submit_schema())
+ data_dict, errors = _validate(data_dict, schema, context)
+ if errors:
+ raise p.toolkit.ValidationError(errors)
+
+ res_id = data_dict['resource_id']
+
+ p.toolkit.check_access('datapusher_submit', context, data_dict)
+
+ datapusher_url = pylons.config.get('ckan.datapusher.url')
+
+ callback_url = p.toolkit.url_for(
+ controller='api', action='action', logic_function='datapusher_hook',
+ ver=3, qualified=True)
+
+ user = p.toolkit.get_action('user_show')(context, {'id': context['user']})
+
+ task = {
+ 'entity_id': res_id,
+ 'entity_type': 'resource',
+ 'task_type': 'datapusher',
+ 'last_updated': str(datetime.datetime.now()),
+ 'state': 'submitting',
+ 'key': 'datapusher',
+ 'value': '{}',
+ 'error': '{}',
+ }
+ try:
+ task_id = p.toolkit.get_action('task_status_show')(context, {
+ 'entity_id': res_id,
+ 'task_type': 'datapusher',
+ 'key': 'datapusher'
+ })['id']
+ task['id'] = task_id
+ except logic.NotFound:
+ pass
+
+ context['ignore_auth'] = True
+ result = p.toolkit.get_action('task_status_update')(context, task)
+ task_id = result['id']
+
+ try:
+ r = requests.post(
+ urlparse.urljoin(datapusher_url, 'job'),
+ headers={
+ 'Content-Type': 'application/json'
+ },
+ data=json.dumps({
+ 'api_key': user['apikey'],
+ 'job_type': 'push_to_datastore',
+ 'result_url': callback_url,
+ 'metadata': {
+ 'ckan_url': pylons.config['ckan.site_url'],
+ 'resource_id': res_id,
+ 'set_url_type': data_dict.get('set_url_type', False)
+ }
+ }))
+ r.raise_for_status()
+ except requests.exceptions.ConnectionError, e:
+ error = {'message': 'Could not connect to DataPusher.',
+ 'details': str(e)}
+ task['error'] = json.dumps(error)
+ task['state'] = 'error'
+ task['last_updated'] = str(datetime.datetime.now()),
+ p.toolkit.get_action('task_status_update')(context, task)
+ raise p.toolkit.ValidationError(error)
+
+ except requests.exceptions.HTTPError, e:
+ m = 'An Error occurred while sending the job: {0}'.format(e.message)
+ try:
+ body = e.response.json()
+ except ValueError:
+ body = e.response.text
+ error = {'message': m,
+ 'details': body,
+ 'status_code': r.status_code}
+ task['error'] = json.dumps(error)
+ task['state'] = 'error'
+ task['last_updated'] = str(datetime.datetime.now()),
+ p.toolkit.get_action('task_status_update')(context, task)
+ raise p.toolkit.ValidationError(error)
+
+ value = json.dumps({'job_id': r.json()['job_id'],
+ 'job_key': r.json()['job_key']})
+
+ task['value'] = value
+ task['state'] = 'pending'
+ task['last_updated'] = str(datetime.datetime.now()),
+ p.toolkit.get_action('task_status_update')(context, task)
+
+ return True
+
+
+def datapusher_hook(context, data_dict):
+ ''' Update datapusher task. This action is typically called by the
+ datapusher whenever the status of a job changes.
+
+ :param metadata: metadata produced by datapuser service must have
+ resource_id property.
+ :type metadata: dict
+ :param status: status of the job from the datapusher service
+ :type status: string
+ '''
+
+ metadata, status = _get_or_bust(data_dict, ['metadata', 'status'])
+
+ p.toolkit.check_access('datapusher_submit', context, data_dict)
+
+ res_id = metadata.get('resource_id')
+
+ task = p.toolkit.get_action('task_status_show')(context, {
+ 'entity_id': res_id,
+ 'task_type': 'datapusher',
+ 'key': 'datapusher'
+ })
+
+ task['state'] = status
+ task['last_updated'] = str(datetime.datetime.now())
+
+ p.toolkit.get_action('task_status_update')(context, task)
+
+
+def datapusher_status(context, data_dict):
+ ''' Get the status of a datapusher job for a certain resource.
+
+ :param resource_id: The resource id of the resource that you want the
+ datapusher status for.
+ :type resource_id: string
+ '''
+
+ p.toolkit.check_access('datapusher_status', context, data_dict)
+
+ if 'id' in data_dict:
+ data_dict['resource_id'] = data_dict['id']
+ res_id = _get_or_bust(data_dict, 'resource_id')
+
+ task = p.toolkit.get_action('task_status_show')(context, {
+ 'entity_id': res_id,
+ 'task_type': 'datapusher',
+ 'key': 'datapusher'
+ })
+
+ datapusher_url = pylons.config.get('ckan.datapusher.url')
+ if not datapusher_url:
+ raise p.toolkit.ValidationError(
+ {'configuration': ['ckan.datapusher.url not in config file']})
+
+ value = json.loads(task['value'])
+ job_key = value.get('job_key')
+ job_id = value.get('job_id')
+ url = None
+ job_detail = None
+
+ if job_id:
+ url = urlparse.urljoin(datapusher_url, 'job' + '/' + job_id)
+ try:
+ r = requests.get(url, headers={'Content-Type': 'application/json',
+ 'Authorization': job_key})
+ r.raise_for_status()
+ job_detail = r.json()
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.HTTPError), e:
+ job_detail = {'error': 'cannot connect to datapusher'}
+
+ return {
+ 'status': task['state'],
+ 'job_id': job_id,
+ 'job_url': url,
+ 'last_updated': task['last_updated'],
+ 'job_key': job_key,
+ 'task_info': job_detail,
+ 'error': json.loads(task['error'])
+ }
diff --git a/ckanext/datapusher/logic/auth.py b/ckanext/datapusher/logic/auth.py
new file mode 100644
index 00000000000..55a7e832488
--- /dev/null
+++ b/ckanext/datapusher/logic/auth.py
@@ -0,0 +1,9 @@
+import ckanext.datastore.logic.auth as auth
+
+
+def datapusher_submit(context, data_dict):
+ return auth.datastore_auth(context, data_dict)
+
+
+def datapusher_status(context, data_dict):
+ return auth.datastore_auth(context, data_dict)
diff --git a/ckanext/datapusher/logic/schema.py b/ckanext/datapusher/logic/schema.py
new file mode 100644
index 00000000000..07e8a36aec8
--- /dev/null
+++ b/ckanext/datapusher/logic/schema.py
@@ -0,0 +1,25 @@
+import ckan.plugins as p
+import ckanext.datastore.logic.schema as dsschema
+
+get_validator = p.toolkit.get_validator
+
+not_missing = get_validator('not_missing')
+not_empty = get_validator('not_empty')
+resource_id_exists = get_validator('resource_id_exists')
+package_id_exists = get_validator('package_id_exists')
+ignore_missing = get_validator('ignore_missing')
+empty = get_validator('empty')
+boolean_validator = get_validator('boolean_validator')
+int_validator = get_validator('int_validator')
+OneOf = get_validator('OneOf')
+
+
+def datapusher_submit_schema():
+ schema = {
+ 'resource_id': [not_missing, not_empty, unicode],
+ 'id': [ignore_missing],
+ 'set_url_type': [ignore_missing, boolean_validator],
+ '__junk': [empty],
+ '__before': [dsschema.rename('id', 'resource_id')]
+ }
+ return schema
diff --git a/ckanext/datapusher/plugin.py b/ckanext/datapusher/plugin.py
new file mode 100644
index 00000000000..c53dd14790d
--- /dev/null
+++ b/ckanext/datapusher/plugin.py
@@ -0,0 +1,131 @@
+import logging
+
+import ckan.plugins as p
+import ckan.lib.base as base
+import ckan.lib.helpers as core_helpers
+import ckanext.datapusher.logic.action as action
+import ckanext.datapusher.logic.auth as auth
+import ckanext.datapusher.helpers as helpers
+import ckan.logic as logic
+import ckan.model as model
+import ckan.plugins.toolkit as toolkit
+
+log = logging.getLogger(__name__)
+_get_or_bust = logic.get_or_bust
+
+DEFAULT_FORMATS = ['csv', 'xls', 'application/csv', 'application/vnd.ms-excel']
+
+
+class DatastoreException(Exception):
+ pass
+
+
+class ResourceDataController(base.BaseController):
+
+ def resource_data(self, id, resource_id):
+
+ if toolkit.request.method == 'POST':
+ try:
+ toolkit.c.pkg_dict = p.toolkit.get_action('datapusher_submit')(
+ None, {'resource_id': resource_id}
+ )
+ except logic.ValidationError:
+ pass
+
+ base.redirect(core_helpers.url_for(
+ controller='ckanext.datapusher.plugin:ResourceDataController',
+ action='resource_data',
+ id=id,
+ resource_id=resource_id)
+ )
+
+ try:
+ toolkit.c.pkg_dict = p.toolkit.get_action('package_show')(
+ None, {'id': id}
+ )
+ toolkit.c.resource = p.toolkit.get_action('resource_show')(
+ None, {'id': resource_id}
+ )
+ except logic.NotFound:
+ base.abort(404, _('Resource not found'))
+ except logic.NotAuthorized:
+ base.abort(401, _('Unauthorized to edit this resource'))
+
+ try:
+ datapusher_status = p.toolkit.get_action('datapusher_status')(
+ None, {'resource_id': resource_id}
+ )
+ except logic.NotFound:
+ datapusher_status = {}
+
+ return base.render('package/resource_data.html',
+ extra_vars={'status': datapusher_status})
+
+
+class DatapusherPlugin(p.SingletonPlugin):
+ p.implements(p.IConfigurable, inherit=True)
+ p.implements(p.IActions)
+ p.implements(p.IAuthFunctions)
+ p.implements(p.IResourceUrlChange)
+ p.implements(p.IDomainObjectModification, inherit=True)
+ p.implements(p.ITemplateHelpers)
+ p.implements(p.IRoutes, inherit=True)
+
+ legacy_mode = False
+ resource_show_action = None
+
+ def configure(self, config):
+ self.config = config
+
+ datapusher_formats = config.get('ckan.datapusher.formats', '').lower()
+ self.datapusher_formats = datapusher_formats.split() or DEFAULT_FORMATS
+
+ datapusher_url = config.get('ckan.datapusher.url')
+ if not datapusher_url:
+ raise Exception(
+ 'Config option `ckan.datapusher.url` has to be set.')
+
+ def notify(self, entity, operation=None):
+ if isinstance(entity, model.Resource):
+ if (operation == model.domain_object.DomainObjectOperation.new
+ or not operation):
+ # if operation is None, resource URL has been changed, as
+ # the notify function in IResourceUrlChange only takes
+ # 1 parameter
+ context = {'model': model, 'ignore_auth': True,
+ 'defer_commit': True}
+ package = p.toolkit.get_action('package_show')(context, {
+ 'id': entity.get_package_id()
+ })
+ if (not package['private'] and entity.format and
+ entity.format.lower() in self.datapusher_formats and
+ entity.url_type != 'datapusher'):
+ try:
+ p.toolkit.get_action('datapusher_submit')(context, {
+ 'resource_id': entity.id
+ })
+ except p.toolkit.ValidationError, e:
+ # If datapusher is offline want to catch error instead
+ # of raising otherwise resource save will fail with 500
+ log.critical(e)
+ pass
+
+ def before_map(self, m):
+ m.connect(
+ 'resource_data', '/dataset/{id}/resource_data/{resource_id}',
+ controller='ckanext.datapusher.plugin:ResourceDataController',
+ action='resource_data', ckan_icon='cloud-upload')
+ return m
+
+ def get_actions(self):
+ return {'datapusher_submit': action.datapusher_submit,
+ 'datapusher_hook': action.datapusher_hook,
+ 'datapusher_status': action.datapusher_status}
+
+ def get_auth_functions(self):
+ return {'datapusher_submit': auth.datapusher_submit,
+ 'datapusher_status': auth.datapusher_status}
+
+ def get_helpers(self):
+ return {
+ 'datapusher_status': helpers.datapusher_status}
diff --git a/ckanext/datapusher/tests/__init__.py b/ckanext/datapusher/tests/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/ckanext/datapusher/tests/test.py b/ckanext/datapusher/tests/test.py
new file mode 100644
index 00000000000..2ec2df42d21
--- /dev/null
+++ b/ckanext/datapusher/tests/test.py
@@ -0,0 +1,184 @@
+import json
+import httpretty
+import nose
+import sys
+import datetime
+
+import pylons
+from pylons import config
+import sqlalchemy.orm as orm
+import paste.fixture
+
+import ckan.plugins as p
+import ckan.lib.create_test_data as ctd
+import ckan.model as model
+import ckan.tests as tests
+import ckan.config.middleware as middleware
+
+import ckanext.datastore.db as db
+from ckanext.datastore.tests.helpers import rebuild_all_dbs, set_url_type
+
+
+# avoid hanging tests https://github.com/gabrielfalcao/HTTPretty/issues/34
+if sys.version_info < (2, 7, 0):
+ import socket
+ socket.setdefaulttimeout(1)
+
+
+class TestDatastoreCreate(tests.WsgiAppCase):
+ sysadmin_user = None
+ normal_user = None
+
+ @classmethod
+ def setup_class(cls):
+
+ wsgiapp = middleware.make_app(config['global_conf'], **config)
+ cls.app = paste.fixture.TestApp(wsgiapp)
+ if not tests.is_datastore_supported():
+ raise nose.SkipTest("Datastore not supported")
+ p.load('datastore')
+ p.load('datapusher')
+ ctd.CreateTestData.create()
+ cls.sysadmin_user = model.User.get('testsysadmin')
+ cls.normal_user = model.User.get('annafan')
+ engine = db._get_engine(
+ {'connection_url': pylons.config['ckan.datastore.write_url']})
+ cls.Session = orm.scoped_session(orm.sessionmaker(bind=engine))
+ set_url_type(
+ model.Package.get('annakarenina').resources, cls.sysadmin_user)
+
+ @classmethod
+ def teardown_class(cls):
+ rebuild_all_dbs(cls.Session)
+ p.unload('datastore')
+ p.unload('datapusher')
+
+ def test_create_ckan_resource_in_package(self):
+ package = model.Package.get('annakarenina')
+ data = {
+ 'resource': {'package_id': package.id}
+ }
+ postparams = '%s=1' % json.dumps(data)
+ auth = {'Authorization': str(self.sysadmin_user.apikey)}
+ res = self.app.post('/api/action/datastore_create', params=postparams,
+ extra_environ=auth, status=200)
+ res_dict = json.loads(res.body)
+
+ assert 'resource_id' in res_dict['result']
+ assert len(model.Package.get('annakarenina').resources) == 3
+
+ res = tests.call_action_api(
+ self.app, 'resource_show', id=res_dict['result']['resource_id'])
+ assert res['url'] == '/datastore/dump/' + res['id'], res
+
+ @httpretty.activate
+ def test_providing_res_with_url_calls_datapusher_correctly(self):
+ pylons.config['datapusher.url'] = 'http://datapusher.ckan.org'
+ httpretty.HTTPretty.register_uri(
+ httpretty.HTTPretty.POST,
+ 'http://datapusher.ckan.org/job',
+ content_type='application/json',
+ body=json.dumps({'job_id': 'foo', 'job_key': 'bar'}))
+
+ package = model.Package.get('annakarenina')
+
+ tests.call_action_api(
+ self.app, 'datastore_create', apikey=self.sysadmin_user.apikey,
+ resource=dict(package_id=package.id, url='demo.ckan.org'))
+
+ assert len(package.resources) == 4, len(package.resources)
+ resource = package.resources[3]
+ data = json.loads(httpretty.last_request().body)
+ assert data['metadata']['resource_id'] == resource.id, data
+ assert data['result_url'].endswith('/action/datapusher_hook'), data
+ assert data['result_url'].startswith('http://'), data
+
+ def test_cant_provide_resource_and_resource_id(self):
+ package = model.Package.get('annakarenina')
+ resource = package.resources[0]
+ data = {
+ 'resource_id': resource.id,
+ 'resource': {'package_id': package.id}
+ }
+ postparams = '%s=1' % json.dumps(data)
+ auth = {'Authorization': str(self.sysadmin_user.apikey)}
+ res = self.app.post('/api/action/datastore_create', params=postparams,
+ extra_environ=auth, status=409)
+ res_dict = json.loads(res.body)
+
+ assert res_dict['error']['__type'] == 'Validation Error'
+
+ @httpretty.activate
+ def test_send_datapusher_creates_task(self):
+ httpretty.HTTPretty.register_uri(
+ httpretty.HTTPretty.POST,
+ 'http://datapusher.ckan.org/job',
+ content_type='application/json',
+ body=json.dumps({'job_id': 'foo', 'job_key': 'bar'}))
+
+ package = model.Package.get('annakarenina')
+ resource = package.resources[0]
+
+ context = {
+ 'ignore_auth': True,
+ 'user': self.sysadmin_user.name
+ }
+
+ p.toolkit.get_action('datapusher_submit')(context, {
+ 'resource_id': resource.id
+ })
+
+ context.pop('task_status', None)
+
+ task = p.toolkit.get_action('task_status_show')(context, {
+ 'entity_id': resource.id,
+ 'task_type': 'datapusher',
+ 'key': 'datapusher'
+ })
+
+ assert task['state'] == 'pending', task
+
+ def test_datapusher_hook(self):
+ package = model.Package.get('annakarenina')
+ resource = package.resources[0]
+
+ context = {
+ 'user': self.sysadmin_user.name
+ }
+
+ p.toolkit.get_action('task_status_update')(context, {
+ 'entity_id': resource.id,
+ 'entity_type': 'resource',
+ 'task_type': 'datapusher',
+ 'key': 'datapusher',
+ 'value': '{"job_id": "my_id", "job_key":"my_key"}',
+ 'last_updated': str(datetime.datetime.now()),
+ 'state': 'pending'
+ })
+
+ data = {
+ 'status': 'success',
+ 'metadata': {
+ 'resource_id': resource.id
+ }
+ }
+ postparams = '%s=1' % json.dumps(data)
+ auth = {'Authorization': str(self.sysadmin_user.apikey)}
+ res = self.app.post('/api/action/datapusher_hook', params=postparams,
+ extra_environ=auth, status=200)
+ print res.body
+ res_dict = json.loads(res.body)
+
+ assert res_dict['success'] is True
+
+ task = tests.call_action_api(
+ self.app, 'task_status_show', entity_id=resource.id,
+ task_type='datapusher', key='datapusher')
+
+ assert task['state'] == 'success', task
+
+ task = tests.call_action_api(
+ self.app, 'task_status_show', entity_id=resource.id,
+ task_type='datapusher', key='datapusher')
+
+ assert task['state'] == 'success', task
diff --git a/ckanext/datastore/logic/action.py b/ckanext/datastore/logic/action.py
index 47a464518ce..804fec1a197 100644
--- a/ckanext/datastore/logic/action.py
+++ b/ckanext/datastore/logic/action.py
@@ -1,10 +1,6 @@
import logging
-import json
-import urlparse
-import datetime
import pylons
-import requests
import sqlalchemy
import ckan.lib.navl.dictization_functions
@@ -39,6 +35,8 @@ def datastore_create(context, data_dict):
:param resource_id: resource id that the data is going to be stored against.
:type resource_id: string
+ :param force: set to True to edit a read-only resource
+ :type force: bool (optional, default: False)
:param resource: resource dictionary that is passed to
:meth:`~ckan.logic.action.create.resource_create`.
Use instead of ``resource_id`` (optional)
@@ -90,22 +88,32 @@ def datastore_create(context, data_dict):
if 'resource' in data_dict:
has_url = 'url' in data_dict['resource']
- data_dict['resource'].setdefault('url', '_tmp')
+ # A datastore only resource does not have a url in the db
+ data_dict['resource'].setdefault('url', '_datastore_only_resource')
res = p.toolkit.get_action('resource_create')(context,
data_dict['resource'])
data_dict['resource_id'] = res['id']
# create resource from file
if has_url:
+ if not p.plugin_loaded('datapusher'):
+ raise p.toolkit.ValidationError({'resource': [
+ 'The datapusher has to be enabled.']})
p.toolkit.get_action('datapusher_submit')(context, {
'resource_id': res['id'],
- 'set_url_to_dump': True
+ 'set_url_type': True
})
+ # since we'll overwrite the datastore resource anyway, we
+ # don't need to create it here
+ return
+
# create empty resource
else:
# no need to set the full url because it will be set in before_show
res['url_type'] = 'datastore'
p.toolkit.get_action('resource_update')(context, res)
+ else:
+ _check_read_only(context, data_dict)
data_dict['connection_url'] = pylons.config['ckan.datastore.write_url']
@@ -153,6 +161,8 @@ def datastore_upsert(context, data_dict):
:param resource_id: resource id that the data is going to be stored under.
:type resource_id: string
+ :param force: set to True to edit a read-only resource
+ :type force: bool (optional, default: False)
:param records: the data, eg: [{"dob": "2005", "some_stuff": ["a","b"]}] (optional)
:type records: list of dictionaries
:param method: the method to use to put the data into the datastore.
@@ -173,6 +183,10 @@ def datastore_upsert(context, data_dict):
if errors:
raise p.toolkit.ValidationError(errors)
+ p.toolkit.check_access('datastore_upsert', context, data_dict)
+
+ _check_read_only(context, data_dict)
+
data_dict['connection_url'] = pylons.config['ckan.datastore.write_url']
res_id = data_dict['resource_id']
@@ -186,8 +200,6 @@ def datastore_upsert(context, data_dict):
u'Resource "{0}" was not found.'.format(res_id)
))
- p.toolkit.check_access('datastore_upsert', context, data_dict)
-
result = db.upsert(context, data_dict)
result.pop('id', None)
result.pop('connection_url')
@@ -199,6 +211,8 @@ def datastore_delete(context, data_dict):
:param resource_id: resource id that the data will be deleted from. (optional)
:type resource_id: string
+ :param force: set to True to edit a read-only resource
+ :type force: bool (optional, default: False)
:param filters: filters to apply before deleting (eg {"name": "fred"}).
If missing delete whole table and all dependent views. (optional)
:type filters: dictionary
@@ -217,6 +231,10 @@ def datastore_delete(context, data_dict):
if errors:
raise p.toolkit.ValidationError(errors)
+ p.toolkit.check_access('datastore_delete', context, data_dict)
+
+ _check_read_only(context, data_dict)
+
data_dict['connection_url'] = pylons.config['ckan.datastore.write_url']
res_id = data_dict['resource_id']
@@ -230,8 +248,6 @@ def datastore_delete(context, data_dict):
u'Resource "{0}" was not found.'.format(res_id)
))
- p.toolkit.check_access('datastore_delete', context, data_dict)
-
result = db.delete(context, data_dict)
result.pop('id', None)
result.pop('connection_url')
@@ -300,9 +316,7 @@ def datastore_search(context, data_dict):
raise p.toolkit.ValidationError(errors)
res_id = data_dict['resource_id']
- data_dict['connection_url'] = pylons.config.get(
- 'ckan.datastore.read_url',
- pylons.config['ckan.datastore.write_url'])
+ data_dict['connection_url'] = pylons.config['ckan.datastore.write_url']
resources_sql = sqlalchemy.text(u'''SELECT alias_of FROM "_table_metadata"
WHERE name = :id''')
@@ -429,129 +443,8 @@ def datastore_make_public(context, data_dict):
db.make_public(context, data_dict)
-def datapusher_submit(context, data_dict):
- ''' Submit a job to the datapusher. The datapusher is a service that
- imports tabular data into the datastore.
-
- :param resource_id: The resource id of the resource that the data
- should be imported in. The resource's URL will be used to get the data.
- :type resource_id: string
- :param set_url_type: If set to true, the ``url_type`` of the resource will
- be set to ``datastore`` and the resource URL will automatically point
- to the :ref:`datastore dump ` URL. (optional, default: False)
- :type set_url_type: boolean
-
- Returns ``True`` if the job has been submitted and ``False`` if the job
- has not been submitted, i.e. when the datapusher is not configured.
-
- :rtype: boolean
- '''
-
- if 'id' in data_dict:
- data_dict['resource_id'] = data_dict['id']
- res_id = _get_or_bust(data_dict, 'resource_id')
-
- p.toolkit.check_access('datapusher_submit', context, data_dict)
-
- datapusher_url = pylons.config.get('datapusher.url')
-
- # no datapusher url means the datapusher should not be used
- if not datapusher_url:
- return False
-
- callback_url = p.toolkit.url_for(
- controller='api', action='action', logic_function='datapusher_hook',
- ver=3, qualified=True)
-
- user = p.toolkit.get_action('user_show')(context, {'id': context['user']})
- try:
- r = requests.post(
- urlparse.urljoin(datapusher_url, 'job'),
- headers={
- 'Content-Type': 'application/json'
- },
- data=json.dumps({
- 'api_key': user['apikey'],
- 'job_type': 'push_to_datastore',
- 'result_url': callback_url,
- 'metadata': {
- 'ckan_url': pylons.config['ckan.site_url'],
- 'resource_id': res_id,
- 'set_url_type': data_dict.get('set_url_type', False)
- }
- }))
- r.raise_for_status()
- except requests.exceptions.ConnectionError, e:
- raise p.toolkit.ValidationError({'datapusher': {
- 'message': 'Could not connect to DataPusher.',
- 'details': str(e)}})
- except requests.exceptions.HTTPError, e:
- m = 'An Error occurred while sending the job: {0}'.format(e.message)
- try:
- body = e.response.json()
- except ValueError:
- body = e.response.text
- raise p.toolkit.ValidationError({'datapusher': {
- 'message': m,
- 'details': body,
- 'status_code': r.status_code}})
-
- empty_task = {
- 'entity_id': res_id,
- 'entity_type': 'resource',
- 'task_type': 'datapusher',
- 'last_updated': str(datetime.datetime.now()),
- 'state': 'pending'
- }
-
- tasks = []
- for (k, v) in [('job_id', r.json()['job_id']),
- ('job_key', r.json()['job_key'])]:
- t = empty_task.copy()
- t['key'] = k
- t['value'] = v
- tasks.append(t)
- p.toolkit.get_action('task_status_update_many')(context, {'data': tasks})
-
- return True
-
-
-def datapusher_hook(context, data_dict):
- """ Update datapusher task. This action is typically called by the
- datapusher whenever the status of a job changes.
-
- Expects a job with ``status`` and ``metadata`` with a ``resource_id``.
- """
-
- # TODO: use a schema to validate
-
- p.toolkit.check_access('datapusher_submit', context, data_dict)
-
- res_id = data_dict['metadata']['resource_id']
-
- task_id = p.toolkit.get_action('task_status_show')(context, {
- 'entity_id': res_id,
- 'task_type': 'datapusher',
- 'key': 'job_id'
- })
-
- task_key = p.toolkit.get_action('task_status_show')(context, {
- 'entity_id': res_id,
- 'task_type': 'datapusher',
- 'key': 'job_key'
- })
-
- tasks = [task_id, task_key]
-
- for task in tasks:
- task['state'] = data_dict['status']
- task['last_updated'] = str(datetime.datetime.now())
-
- p.toolkit.get_action('task_status_update_many')(context, {'data': tasks})
-
-
def _resource_exists(context, data_dict):
- # Returns true if the resource exists in CKAN and in the datastore
+ ''' Returns true if the resource exists in CKAN and in the datastore '''
model = _get_or_bust(context, 'model')
res_id = _get_or_bust(data_dict, 'resource_id')
if not model.Resource.get(res_id):
@@ -561,3 +454,18 @@ def _resource_exists(context, data_dict):
WHERE name = :id AND alias_of IS NULL''')
results = db._get_engine(data_dict).execute(resources_sql, id=res_id)
return results.rowcount > 0
+
+
+def _check_read_only(context, data_dict):
+ ''' Raises exception if the resource is read-only.
+ Make sure the resource id is in resource_id
+ '''
+ if data_dict.get('force'):
+ return
+ res = p.toolkit.get_action('resource_show')(
+ context, {'id': data_dict['resource_id']})
+ if res.get('url_type') != 'datastore':
+ raise p.toolkit.ValidationError({
+ 'read-only': ['Cannot edit read-only resource. Either pass'
+ '"force=True" or change url-type to "datastore"']
+ })
diff --git a/ckanext/datastore/logic/auth.py b/ckanext/datastore/logic/auth.py
index ff410536e21..f99d7f45ae3 100644
--- a/ckanext/datastore/logic/auth.py
+++ b/ckanext/datastore/logic/auth.py
@@ -1,7 +1,7 @@
import ckan.plugins as p
-def _datastore_auth(context, data_dict, privilege='resource_update'):
+def datastore_auth(context, data_dict, privilege='resource_update'):
if not 'id' in data_dict:
data_dict['id'] = data_dict.get('resource_id')
user = context.get('user')
@@ -19,25 +19,21 @@ def _datastore_auth(context, data_dict, privilege='resource_update'):
def datastore_create(context, data_dict):
- return _datastore_auth(context, data_dict)
+ return datastore_auth(context, data_dict)
def datastore_upsert(context, data_dict):
- return _datastore_auth(context, data_dict)
+ return datastore_auth(context, data_dict)
def datastore_delete(context, data_dict):
- return _datastore_auth(context, data_dict)
+ return datastore_auth(context, data_dict)
@p.toolkit.auth_allow_anonymous_access
def datastore_search(context, data_dict):
- return _datastore_auth(context, data_dict, 'resource_show')
-
-
-def datapusher_submit(context, data_dict):
- return _datastore_auth(context, data_dict)
+ return datastore_auth(context, data_dict, 'resource_show')
def datastore_change_permissions(context, data_dict):
- return _datastore_auth(context, data_dict)
+ return datastore_auth(context, data_dict)
diff --git a/ckanext/datastore/logic/schema.py b/ckanext/datastore/logic/schema.py
index 018eb249d79..6f0a74723ca 100644
--- a/ckanext/datastore/logic/schema.py
+++ b/ckanext/datastore/logic/schema.py
@@ -68,6 +68,7 @@ def json_validator(value, context):
def datastore_create_schema():
schema = {
'resource_id': [ignore_missing, unicode, resource_id_exists],
+ 'force': [ignore_missing, boolean_validator],
'id': [ignore_missing],
'aliases': [ignore_missing, list_of_strings_or_string],
'fields': {
@@ -85,6 +86,7 @@ def datastore_create_schema():
def datastore_upsert_schema():
schema = {
'resource_id': [not_missing, not_empty, unicode],
+ 'force': [ignore_missing, boolean_validator],
'id': [ignore_missing],
'method': [ignore_missing, unicode, OneOf(
['upsert', 'insert', 'update'])],
@@ -97,6 +99,7 @@ def datastore_upsert_schema():
def datastore_delete_schema():
schema = {
'resource_id': [not_missing, not_empty, unicode],
+ 'force': [ignore_missing, boolean_validator],
'id': [ignore_missing],
'__junk': [empty],
'__before': [rename('id', 'resource_id')]
diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py
index 4477ad70526..f91310ecdec 100644
--- a/ckanext/datastore/plugin.py
+++ b/ckanext/datastore/plugin.py
@@ -61,9 +61,9 @@ def configure(self, config):
else:
self.read_url = self.config['ckan.datastore.read_url']
- read_engine = db._get_engine(
+ self.read_engine = db._get_engine(
{'connection_url': self.read_url})
- if not model.engine_is_pg(read_engine):
+ if not model.engine_is_pg(self.read_engine):
log.warn('We detected that you do not use a PostgreSQL '
'database. The DataStore will NOT work and DataStore '
'tests will be skipped.')
@@ -75,63 +75,15 @@ def configure(self, config):
'of _table_metadata are skipped.')
else:
self._check_urls_and_permissions()
-
self._create_alias_table()
- # update the resource_show action to have datastore_active property
- if self.resource_show_action is None:
- resource_show = p.toolkit.get_action('resource_show')
-
- @logic.side_effect_free
- def new_resource_show(context, data_dict):
- new_data_dict = resource_show(context, data_dict)
- try:
- connection = read_engine.connect()
- result = connection.execute(
- 'SELECT 1 FROM "_table_metadata" WHERE name = %s AND alias_of IS NULL',
- new_data_dict['id']
- ).fetchone()
- if result:
- new_data_dict['datastore_active'] = True
- else:
- new_data_dict['datastore_active'] = False
- finally:
- connection.close()
- return new_data_dict
-
- self.resource_show_action = new_resource_show
def notify(self, entity, operation=None):
- '''
- if not isinstance(entity, model.Resource):
- return
- if operation:
- if operation == model.domain_object.DomainObjectOperation.new:
- self._create_datastorer_task(entity)
- else:
- # if operation is None, resource URL has been changed, as the
- # notify function in IResourceUrlChange only takes 1 parameter
- self._create_datastorer_task(entity)
- '''
- context = {'model': model, 'ignore_auth': True}
- if isinstance(entity, model.Resource):
- if (operation == model.domain_object.DomainObjectOperation.new
- or not operation):
- # if operation is None, resource URL has been changed, as
- # the notify function in IResourceUrlChange only takes
- # 1 parameter
- package = p.toolkit.get_action('package_show')(context, {
- 'id': entity.get_package_id()
- })
- if (not package['private'] and
- entity.format in self.datapusher_formats):
- p.toolkit.get_action('datapusher_submit')(context, {
- 'resource_id': entity.id
- })
if not isinstance(entity, model.Package) or self.legacy_mode:
return
# if a resource is new, it cannot have a datastore resource, yet
if operation == model.domain_object.DomainObjectOperation.changed:
+ context = {'model': model, 'ignore_auth': True}
if entity.private:
func = p.toolkit.get_action('datastore_make_private')
else:
@@ -158,7 +110,7 @@ def _check_urls_and_permissions(self):
self._log_or_raise('CKAN and DataStore database '
'cannot be the same.')
- # in legacy mode, the read and write url are ths same (both write url)
+ # in legacy mode, the read and write url are the same (both write url)
# consequently the same url check and and write privilege check
# don't make sense
if not self.legacy_mode:
@@ -256,9 +208,6 @@ def get_actions(self):
'datastore_upsert': action.datastore_upsert,
'datastore_delete': action.datastore_delete,
'datastore_search': action.datastore_search,
- 'datapusher_submit': action.datapusher_submit,
- 'datapusher_hook': action.datapusher_hook,
- 'resource_show': self.resource_show_action,
}
if not self.legacy_mode:
actions.update({
@@ -272,8 +221,7 @@ def get_auth_functions(self):
'datastore_upsert': auth.datastore_upsert,
'datastore_delete': auth.datastore_delete,
'datastore_search': auth.datastore_search,
- 'datastore_change_permissions': auth.datastore_change_permissions,
- 'datapusher_submit': auth.datapusher_submit}
+ 'datastore_change_permissions': auth.datastore_change_permissions}
def before_map(self, m):
m.connect('/datastore/dump/{resource_id}',
@@ -282,11 +230,23 @@ def before_map(self, m):
return m
def before_show(self, resource_dict):
- ''' Modify the resource url of datastore resources so that
- they link to the datastore dumps.
- '''
- if resource_dict['url_type'] == 'datastore':
+ # Modify the resource url of datastore resources so that
+ # they link to the datastore dumps.
+ if resource_dict.get('url_type') == 'datastore':
resource_dict['url'] = p.toolkit.url_for(
controller='ckanext.datastore.controller:DatastoreController',
action='dump', resource_id=resource_dict['id'])
+
+ try:
+ connection = self.read_engine.connect()
+ result = connection.execute(
+ 'SELECT 1 FROM "_table_metadata" WHERE name = %s AND alias_of IS NULL',
+ resource_dict['id']
+ ).fetchone()
+ if result:
+ resource_dict['datastore_active'] = True
+ else:
+ resource_dict['datastore_active'] = False
+ finally:
+ connection.close()
return resource_dict
diff --git a/ckanext/datastore/tests/helpers.py b/ckanext/datastore/tests/helpers.py
index cf83f57378a..3ee89cdda20 100644
--- a/ckanext/datastore/tests/helpers.py
+++ b/ckanext/datastore/tests/helpers.py
@@ -1,6 +1,8 @@
import ckan.model as model
import ckan.lib.cli as cli
+import ckan.plugins as p
+
def extract(d, keys):
return dict((k, d[k]) for k in keys if k in d)
@@ -29,3 +31,12 @@ def rebuild_all_dbs(Session):
model.repo.tables_created_and_initialised = False
clear_db(Session)
model.repo.rebuild_db()
+
+
+def set_url_type(resources, user):
+ context = {'user': user.name}
+ for resource in resources:
+ resource = p.toolkit.get_action('resource_show')(
+ context, {'id': resource.id})
+ resource['url_type'] = 'datastore'
+ p.toolkit.get_action('resource_update')(context, resource)
diff --git a/ckanext/datastore/tests/test_create.py b/ckanext/datastore/tests/test_create.py
index d7b893235ed..f2db232af8a 100644
--- a/ckanext/datastore/tests/test_create.py
+++ b/ckanext/datastore/tests/test_create.py
@@ -17,7 +17,7 @@
import ckan.config.middleware as middleware
import ckanext.datastore.db as db
-from ckanext.datastore.tests.helpers import rebuild_all_dbs
+from ckanext.datastore.tests.helpers import rebuild_all_dbs, set_url_type
# avoid hanging tests https://github.com/gabrielfalcao/HTTPretty/issues/34
@@ -44,6 +44,8 @@ def setup_class(cls):
engine = db._get_engine(
{'connection_url': pylons.config['ckan.datastore.write_url']})
cls.Session = orm.scoped_session(orm.sessionmaker(bind=engine))
+ set_url_type(
+ model.Package.get('annakarenina').resources, cls.sysadmin_user)
@classmethod
def teardown_class(cls):
@@ -536,144 +538,6 @@ def test_create_basic(self):
assert res_dict['success'] is True, res_dict
- def test_create_ckan_resource_in_package(self):
- package = model.Package.get('annakarenina')
- data = {
- 'resource': {'package_id': package.id}
- }
- postparams = '%s=1' % json.dumps(data)
- auth = {'Authorization': str(self.sysadmin_user.apikey)}
- res = self.app.post('/api/action/datastore_create', params=postparams,
- extra_environ=auth, status=200)
- res_dict = json.loads(res.body)
-
- assert 'resource_id' in res_dict['result']
- assert len(model.Package.get('annakarenina').resources) == 3
-
- res = tests.call_action_api(
- self.app, 'resource_show', id=res_dict['result']['resource_id'])
- assert res['url'] == '/datastore/dump/' + res['id'], res
-
- @httpretty.activate
- def test_providing_res_with_url_calls_datapusher_correctly(self):
- pylons.config['datapusher.url'] = 'http://datapusher.ckan.org'
- httpretty.HTTPretty.register_uri(
- httpretty.HTTPretty.POST,
- 'http://datapusher.ckan.org/job',
- content_type='application/json',
- body=json.dumps({'job_id': 'foo', 'job_key': 'bar'}))
-
- package = model.Package.get('annakarenina')
-
- tests.call_action_api(
- self.app, 'datastore_create', apikey=self.sysadmin_user.apikey,
- resource=dict(package_id=package.id, url='demo.ckan.org'))
-
- assert len(package.resources) == 4, len(package.resources)
- resource = package.resources[3]
- data = json.loads(httpretty.last_request().body)
- assert data['metadata']['resource_id'] == resource.id, data
- assert data['result_url'].endswith('/action/datapusher_hook'), data
- assert data['result_url'].startswith('http://'), data
-
- def test_cant_provide_resource_and_resource_id(self):
- package = model.Package.get('annakarenina')
- resource = package.resources[0]
- data = {
- 'resource_id': resource.id,
- 'resource': {'package_id': package.id}
- }
- postparams = '%s=1' % json.dumps(data)
- auth = {'Authorization': str(self.sysadmin_user.apikey)}
- res = self.app.post('/api/action/datastore_create', params=postparams,
- extra_environ=auth, status=409)
- res_dict = json.loads(res.body)
-
- assert res_dict['error']['__type'] == 'Validation Error'
-
- @httpretty.activate
- def test_send_datapusher_creates_task(self):
- httpretty.HTTPretty.register_uri(
- httpretty.HTTPretty.POST,
- 'http://datapusher.ckan.org/job',
- content_type='application/json',
- body=json.dumps({'job_id': 'foo', 'job_key': 'bar'}))
-
- package = model.Package.get('annakarenina')
- resource = package.resources[0]
-
- context = {
- 'ignore_auth': True,
- 'user': self.sysadmin_user.name
- }
-
- p.toolkit.get_action('datapusher_submit')(context, {
- 'resource_id': resource.id
- })
-
- task = p.toolkit.get_action('task_status_show')(context, {
- 'entity_id': resource.id,
- 'task_type': 'datapusher',
- 'key': 'job_id'
- })
-
- assert task['state'] == 'pending', task
-
- def test_datapusher_hook(self):
- package = model.Package.get('annakarenina')
- resource = package.resources[0]
-
- context = {
- 'user': self.sysadmin_user.name
- }
-
- p.toolkit.get_action('task_status_update')(context, {
- 'entity_id': resource.id,
- 'entity_type': 'resource',
- 'task_type': 'datapusher',
- 'key': 'job_id',
- 'value': 'my_id',
- 'last_updated': str(datetime.datetime.now()),
- 'state': 'pending'
- })
-
- p.toolkit.get_action('task_status_update')(context, {
- 'entity_id': resource.id,
- 'entity_type': 'resource',
- 'task_type': 'datapusher',
- 'key': 'job_key',
- 'value': 'my_key',
- 'last_updated': str(datetime.datetime.now()),
- 'state': 'pending'
- })
-
- data = {
- 'status': 'success',
- 'metadata': {
- 'resource_id': resource.id
- }
- }
- postparams = '%s=1' % json.dumps(data)
- auth = {'Authorization': str(self.sysadmin_user.apikey)}
- res = self.app.post('/api/action/datapusher_hook', params=postparams,
- extra_environ=auth, status=200)
- print res.body
- res_dict = json.loads(res.body)
-
- assert res_dict['success'] is True
-
- task = tests.call_action_api(
- self.app, 'task_status_show', entity_id=resource.id,
- task_type='datapusher', key='job_id')
-
- assert task['state'] == 'success', task
-
- task = tests.call_action_api(
- self.app, 'task_status_show', entity_id=resource.id,
- task_type='datapusher', key='job_key')
-
- assert task['state'] == 'success', task
-
def test_guess_types(self):
resource = model.Package.get('annakarenina').resources[1]
diff --git a/ckanext/datastore/tests/test_delete.py b/ckanext/datastore/tests/test_delete.py
index ce1b02efdb1..bb7db39217a 100644
--- a/ckanext/datastore/tests/test_delete.py
+++ b/ckanext/datastore/tests/test_delete.py
@@ -11,7 +11,7 @@
import ckan.tests as tests
import ckanext.datastore.db as db
-from ckanext.datastore.tests.helpers import rebuild_all_dbs
+from ckanext.datastore.tests.helpers import rebuild_all_dbs, set_url_type
class TestDatastoreDelete(tests.WsgiAppCase):
@@ -43,6 +43,8 @@ def setup_class(cls):
engine = db._get_engine(
{'connection_url': pylons.config['ckan.datastore.write_url']})
cls.Session = orm.scoped_session(orm.sessionmaker(bind=engine))
+ set_url_type(
+ model.Package.get('annakarenina').resources, cls.sysadmin_user)
@classmethod
def teardown_class(cls):
diff --git a/ckanext/datastore/tests/test_dump.py b/ckanext/datastore/tests/test_dump.py
index 041105af986..6061535f4ba 100644
--- a/ckanext/datastore/tests/test_dump.py
+++ b/ckanext/datastore/tests/test_dump.py
@@ -32,6 +32,7 @@ def setup_class(cls):
resource = model.Package.get('annakarenina').resources[0]
cls.data = {
'resource_id': resource.id,
+ 'force': True,
'aliases': 'books',
'fields': [{'id': u'b\xfck', 'type': 'text'},
{'id': 'author', 'type': 'text'},
diff --git a/ckanext/datastore/tests/test_search.py b/ckanext/datastore/tests/test_search.py
index 21cb19de4e2..1b7c9486da7 100644
--- a/ckanext/datastore/tests/test_search.py
+++ b/ckanext/datastore/tests/test_search.py
@@ -30,6 +30,7 @@ def setup_class(cls):
cls.resource = cls.dataset.resources[0]
cls.data = {
'resource_id': cls.resource.id,
+ 'force': True,
'aliases': 'books3',
'fields': [{'id': u'b\xfck', 'type': 'text'},
{'id': 'author', 'type': 'text'},
@@ -116,7 +117,7 @@ def test_search_private_dataset(self):
context,
{'name': 'privatedataset',
'private': True,
- 'owner_org' : self.organization['id'],
+ 'owner_org': self.organization['id'],
'groups': [{
'id': group.id
}]})
@@ -128,6 +129,7 @@ def test_search_private_dataset(self):
postparams = '%s=1' % json.dumps({
'resource_id': resource['id'],
+ 'force': True
})
auth = {'Authorization': str(self.sysadmin_user.apikey)}
res = self.app.post('/api/action/datastore_create', params=postparams,
@@ -425,6 +427,7 @@ def setup_class(cls):
resource = model.Package.get('annakarenina').resources[0]
cls.data = dict(
resource_id=resource.id,
+ force=True,
fields=[
{'id': 'id'},
{'id': 'date', 'type':'date'},
@@ -499,6 +502,7 @@ def setup_class(cls):
resource = cls.dataset.resources[0]
cls.data = {
'resource_id': resource.id,
+ 'force': True,
'aliases': 'books4',
'fields': [{'id': u'b\xfck', 'type': 'text'},
{'id': 'author', 'type': 'text'},
@@ -517,7 +521,7 @@ def setup_class(cls):
extra_environ=auth)
res_dict = json.loads(res.body)
assert res_dict['success'] is True
-
+
# Make an organization, because private datasets must belong to one.
cls.organization = tests.call_action_api(
cls.app, 'organization_create',
@@ -669,6 +673,7 @@ def test_new_datastore_table_from_private_resource(self):
postparams = '%s=1' % json.dumps({
'resource_id': resource['id'],
+ 'force': True
})
auth = {'Authorization': str(self.sysadmin_user.apikey)}
res = self.app.post('/api/action/datastore_create', params=postparams,
@@ -708,7 +713,9 @@ def test_making_resource_private_makes_datastore_private(self):
'package_id': package['id']})
postparams = '%s=1' % json.dumps({
- 'resource_id': resource['id']})
+ 'resource_id': resource['id'],
+ 'force': True
+ })
auth = {'Authorization': str(self.sysadmin_user.apikey)}
res = self.app.post('/api/action/datastore_create', params=postparams,
extra_environ=auth)
diff --git a/ckanext/datastore/tests/test_upsert.py b/ckanext/datastore/tests/test_upsert.py
index e27f8b9c523..9d65700198a 100644
--- a/ckanext/datastore/tests/test_upsert.py
+++ b/ckanext/datastore/tests/test_upsert.py
@@ -11,7 +11,7 @@
import ckan.tests as tests
import ckanext.datastore.db as db
-from ckanext.datastore.tests.helpers import rebuild_all_dbs
+from ckanext.datastore.tests.helpers import rebuild_all_dbs, set_url_type
class TestDatastoreUpsert(tests.WsgiAppCase):
@@ -26,6 +26,8 @@ def setup_class(cls):
ctd.CreateTestData.create()
cls.sysadmin_user = model.User.get('testsysadmin')
cls.normal_user = model.User.get('annafan')
+ set_url_type(
+ model.Package.get('annakarenina').resources, cls.sysadmin_user)
resource = model.Package.get('annakarenina').resources[0]
cls.data = {
'resource_id': resource.id,
@@ -249,6 +251,8 @@ def setup_class(cls):
ctd.CreateTestData.create()
cls.sysadmin_user = model.User.get('testsysadmin')
cls.normal_user = model.User.get('annafan')
+ set_url_type(
+ model.Package.get('annakarenina').resources, cls.sysadmin_user)
resource = model.Package.get('annakarenina').resources[0]
cls.data = {
'resource_id': resource.id,
@@ -349,6 +353,8 @@ def setup_class(cls):
ctd.CreateTestData.create()
cls.sysadmin_user = model.User.get('testsysadmin')
cls.normal_user = model.User.get('annafan')
+ set_url_type(
+ model.Package.get('annakarenina').resources, cls.sysadmin_user)
resource = model.Package.get('annakarenina').resources[0]
hhguide = u"hitchhiker's guide to the galaxy"
cls.data = {
diff --git a/ckanext/example_iauthfunctions/tests/test_example_iauthfunctions.py b/ckanext/example_iauthfunctions/tests/test_example_iauthfunctions.py
index 49b250c62b0..7031867df27 100644
--- a/ckanext/example_iauthfunctions/tests/test_example_iauthfunctions.py
+++ b/ckanext/example_iauthfunctions/tests/test_example_iauthfunctions.py
@@ -103,8 +103,7 @@ def test_group_create_with_visitor(self):
result = tests.call_action_api(self.app, 'group_create',
name='this_group_should_not_be_created',
status=403)
- assert result == {'__type': 'Authorization Error',
- 'message': 'Access denied'}
+ assert result['__type'] == 'Authorization Error'
def test_group_create_with_non_curator(self):
'''A user who isn't a member of the curators group should not be able
@@ -116,8 +115,7 @@ def test_group_create_with_non_curator(self):
name='this_group_should_not_be_created',
apikey=noncurator['apikey'],
status=403)
- assert result == {'__type': 'Authorization Error',
- 'message': 'Access denied'}
+ assert result['__type'] == 'Authorization Error'
def test_group_create_with_curator(self):
'''A member of the curators group should be able to create a group.
@@ -174,8 +172,7 @@ def test_group_create_with_visitor(self):
response = tests.call_action_api(self.app, 'group_create',
name='this_group_shouldnt_be_created',
status=403)
- assert response == {'__type': 'Authorization Error',
- 'message': 'Access denied'}
+ assert response['__type'] == 'Authorization Error'
class TestExampleIAuthFunctionsPluginV2(TestExampleIAuthFunctionsPlugin):
@@ -203,5 +200,4 @@ def test_group_create_with_curator(self):
name='this_group_should_not_be_created',
apikey=curator['apikey'],
status=403)
- assert result == {'__type': 'Authorization Error',
- 'message': 'Access denied'}
+ assert result['__type'] == 'Authorization Error'
diff --git a/ckanext/multilingual/plugin.py b/ckanext/multilingual/plugin.py
index a554042f209..a4ee31e3b48 100644
--- a/ckanext/multilingual/plugin.py
+++ b/ckanext/multilingual/plugin.py
@@ -1,7 +1,7 @@
import sets
import ckan
from ckan.plugins import SingletonPlugin, implements, IPackageController
-from ckan.plugins import IGroupController, ITagController
+from ckan.plugins import IGroupController, IOrganizationController, ITagController
import pylons
import ckan.logic.action.get as action_get
from pylons import config
@@ -253,6 +253,7 @@ class MultilingualGroup(SingletonPlugin):
'''
implements(IGroupController, inherit=True)
+ implements(IOrganizationController, inherit=True)
def before_view(self, data_dict):
translated_data_dict = translate_data_dict(data_dict)
diff --git a/ckanext/multilingual/tests/test_multilingual_plugin.py b/ckanext/multilingual/tests/test_multilingual_plugin.py
index ecc22b745f7..88041b54d59 100644
--- a/ckanext/multilingual/tests/test_multilingual_plugin.py
+++ b/ckanext/multilingual/tests/test_multilingual_plugin.py
@@ -3,16 +3,18 @@
import ckan.lib.helpers
import ckan.lib.create_test_data
import ckan.logic.action.update
+import ckan.model as model
import ckan.tests
import ckan.tests.html_check
import routes
import paste.fixture
import pylons.test
-class TestDatasetTermTranslation(ckan.tests.html_check.HtmlCheckMethods):
- '''Test the translation of datasets by the multilingual_dataset plugin.
+_create_test_data = ckan.lib.create_test_data
+
- '''
+class TestDatasetTermTranslation(ckan.tests.html_check.HtmlCheckMethods):
+ 'Test the translation of datasets by the multilingual_dataset plugin.'
@classmethod
def setup(cls):
cls.app = paste.fixture.TestApp(pylons.test.pylonsapp)
@@ -20,24 +22,35 @@ def setup(cls):
ckan.plugins.load('multilingual_group')
ckan.plugins.load('multilingual_tag')
ckan.tests.setup_test_search_index()
- ckan.lib.create_test_data.CreateTestData.create_translations_test_data()
+ _create_test_data.CreateTestData.create_translations_test_data()
+
+ cls.sysadmin_user = model.User.get('testsysadmin')
+ cls.org = {'name': 'test_org',
+ 'title': 'russian',
+ 'description': 'Roger likes these books.'}
+ ckan.tests.call_action_api(cls.app, 'organization_create',
+ apikey=cls.sysadmin_user.apikey,
+ **cls.org)
+ dataset = {'name': 'test_org_dataset',
+ 'title': 'A Novel By Tolstoy',
+ 'owner_org': cls.org['name']}
+ ckan.tests.call_action_api(cls.app, 'package_create',
+ apikey=cls.sysadmin_user.apikey,
+ **dataset)
+
# Add translation terms that match a couple of group names and package
# names. Group names and package names should _not_ get translated even
# if there are terms matching them, because they are used to form URLs.
for term in ('roger', 'david', 'annakarenina', 'warandpeace'):
for lang_code in ('en', 'de', 'fr'):
- data_dict = {
- 'term': term,
- 'term_translation': 'this should not be rendered',
- 'lang_code': lang_code,
- }
- context = {
- 'model': ckan.model,
- 'session': ckan.model.Session,
- 'user': 'testsysadmin',
- }
- ckan.logic.action.update.term_translation_update(context,
- data_dict)
+ data_dict = {'term': term,
+ 'term_translation': 'this should not be rendered',
+ 'lang_code': lang_code}
+ context = {'model': ckan.model,
+ 'session': ckan.model.Session,
+ 'user': 'testsysadmin'}
+ ckan.logic.action.update.term_translation_update(
+ context, data_dict)
@classmethod
def teardown(cls):
@@ -54,34 +67,34 @@ def test_dataset_read_translation(self):
'''
# Fetch the dataset view page for a number of different languages and
# test for the presence of translated and not translated terms.
- offset = routes.url_for(controller='package', action='read',
- id='annakarenina')
+ offset = routes.url_for(
+ controller='package', action='read', id='annakarenina')
for (lang_code, translations) in (
- ('de', ckan.lib.create_test_data.german_translations),
- ('fr', ckan.lib.create_test_data.french_translations),
- ('en', ckan.lib.create_test_data.english_translations),
+ ('de', _create_test_data.german_translations),
+ ('fr', _create_test_data.french_translations),
+ ('en', _create_test_data.english_translations),
('pl', {})):
response = self.app.get(offset, status=200,
- extra_environ={'CKAN_LANG': lang_code,
- 'CKAN_CURRENT_URL': offset})
+ extra_environ={'CKAN_LANG': lang_code,
+ 'CKAN_CURRENT_URL': offset})
terms = ('A Novel By Tolstoy',
- 'Index of the novel',
- 'russian',
- 'tolstoy',
- "Dave's books",
- "Roger's books",
- 'romantic novel',
- 'book',
- '123',
- '456',
- '789',
- 'plain text',
- )
+ 'Index of the novel',
+ 'russian',
+ 'tolstoy',
+ "Dave's books",
+ "Roger's books",
+ 'romantic novel',
+ 'book',
+ '123',
+ '456',
+ '789',
+ 'plain text',)
for term in terms:
if term in translations:
assert translations[term] in response
- elif term in ckan.lib.create_test_data.english_translations:
- assert ckan.lib.create_test_data.english_translations[term] in response
+ elif term in _create_test_data.english_translations:
+ assert (_create_test_data.english_translations[term]
+ in response)
else:
assert term in response
for tag_name in ('123', '456', '789', 'russian', 'tolstoy'):
@@ -96,23 +109,25 @@ def test_tag_read_translation(self):
'''
for tag_name in ('123', '456', '789', 'russian', 'tolstoy'):
- offset = routes.url_for(controller='tag', action='read',
- id=tag_name)
+ offset = routes.url_for(
+ controller='tag', action='read', id=tag_name)
for (lang_code, translations) in (
- ('de', ckan.lib.create_test_data.german_translations),
- ('fr', ckan.lib.create_test_data.french_translations),
- ('en', ckan.lib.create_test_data.english_translations),
+ ('de', _create_test_data.german_translations),
+ ('fr', _create_test_data.french_translations),
+ ('en', _create_test_data.english_translations),
('pl', {})):
- response = self.app.get(offset, status=200,
- extra_environ={'CKAN_LANG': lang_code,
- 'CKAN_CURRENT_URL': offset})
+ response = self.app.get(
+ offset,
+ status=200,
+ extra_environ={'CKAN_LANG': lang_code,
+ 'CKAN_CURRENT_URL': offset})
terms = ('A Novel By Tolstoy', tag_name, 'plain text', 'json')
for term in terms:
if term in translations:
assert translations[term] in response
- elif term in (
- ckan.lib.create_test_data.english_translations):
- assert ckan.lib.create_test_data.english_translations[term] in response
+ elif term in _create_test_data.english_translations:
+ assert (_create_test_data.english_translations[term]
+ in response)
else:
assert term in response
assert 'this should not be rendered' not in response
@@ -123,88 +138,111 @@ def test_user_read_translation(self):
'''
for user_name in ('annafan',):
- offset = routes.url_for(controller='user', action='read',
- id=user_name)
+ offset = routes.url_for(
+ controller='user', action='read', id=user_name)
for (lang_code, translations) in (
- ('de', ckan.lib.create_test_data.german_translations),
- ('fr', ckan.lib.create_test_data.french_translations),
- ('en', ckan.lib.create_test_data.english_translations),
+ ('de', _create_test_data.german_translations),
+ ('fr', _create_test_data.french_translations),
+ ('en', _create_test_data.english_translations),
('pl', {})):
- response = self.app.get(offset, status=200,
- extra_environ={'CKAN_LANG': lang_code,
- 'CKAN_CURRENT_URL': offset})
+ response = self.app.get(
+ offset,
+ status=200,
+ extra_environ={'CKAN_LANG': lang_code,
+ 'CKAN_CURRENT_URL': offset})
terms = ('A Novel By Tolstoy', 'plain text', 'json')
for term in terms:
if term in translations:
assert translations[term] in response
- elif term in (
- ckan.lib.create_test_data.english_translations):
- assert ckan.lib.create_test_data.english_translations[term] in response
+ elif term in _create_test_data.english_translations:
+ assert (_create_test_data.english_translations[term]
+ in response)
else:
assert term in response
assert 'this should not be rendered' not in response
def test_group_read_translation(self):
for (lang_code, translations) in (
- ('de', ckan.lib.create_test_data.german_translations),
- ('fr', ckan.lib.create_test_data.french_translations),
- ('en', ckan.lib.create_test_data.english_translations),
+ ('de', _create_test_data.german_translations),
+ ('fr', _create_test_data.french_translations),
+ ('en', _create_test_data.english_translations),
('pl', {})):
offset = '/%s/group/roger' % lang_code
response = self.app.get(offset, status=200)
terms = ('A Novel By Tolstoy',
- 'Index of the novel',
- 'russian',
- 'tolstoy',
- #"Dave's books",
- "Roger's books",
- #'Other (Open)',
- #'romantic novel',
- #'book',
- '123',
- '456',
- '789',
- 'plain text',
- 'Roger likes these books.',
- )
+ 'Index of the novel',
+ 'russian',
+ 'tolstoy',
+ "Roger's books",
+ '123',
+ '456',
+ '789',
+ 'plain text',
+ 'Roger likes these books.',)
for term in terms:
if term in translations:
assert translations[term] in response
- elif term in ckan.lib.create_test_data.english_translations:
- assert ckan.lib.create_test_data.english_translations[term] in response
+ elif term in _create_test_data.english_translations:
+ assert (_create_test_data.english_translations[term]
+ in response)
else:
assert term in response
for tag_name in ('123', '456', '789', 'russian', 'tolstoy'):
assert '%s?tags=%s' % (offset, tag_name) in response
assert 'this should not be rendered' not in response
+ def test_org_read_translation(self):
+ for (lang_code, translations) in (
+ ('de', _create_test_data.german_translations),
+ ('fr', _create_test_data.french_translations),
+ ('en', _create_test_data.english_translations),
+ ('pl', {})):
+ offset = '/{0}/organization/{1}'.format(
+ lang_code, self.org['name'])
+ response = self.app.get(offset, status=200)
+ terms = ('A Novel By Tolstoy',
+ 'russian',
+ 'Roger likes these books.')
+ for term in terms:
+ if term in translations:
+ assert translations[term] in response
+ elif term in _create_test_data.english_translations:
+ assert (_create_test_data.english_translations[term]
+ in response)
+ else:
+ assert term in response
+ assert 'this should not be rendered' not in response
+
def test_dataset_index_translation(self):
for (lang_code, translations) in (
- ('de', ckan.lib.create_test_data.german_translations),
- ('fr', ckan.lib.create_test_data.french_translations),
- ('en', ckan.lib.create_test_data.english_translations),
+ ('de', _create_test_data.german_translations),
+ ('fr', _create_test_data.french_translations),
+ ('en', _create_test_data.english_translations),
('pl', {})):
offset = '/%s/dataset' % lang_code
response = self.app.get(offset, status=200)
for term in ('Index of the novel', 'russian', 'tolstoy',
- "Dave's books", "Roger's books", 'plain text'):
+ "Dave's books", "Roger's books", 'plain text'):
if term in translations:
assert translations[term] in response
- elif term in ckan.lib.create_test_data.english_translations:
- assert ckan.lib.create_test_data.english_translations[term] in response
+ elif term in _create_test_data.english_translations:
+ assert (_create_test_data.english_translations[term]
+ in response)
else:
assert term in response
for tag_name in ('123', '456', '789', 'russian', 'tolstoy'):
- assert '/%s/dataset?tags=%s' % (lang_code, tag_name) in response
+ assert ('/%s/dataset?tags=%s' % (lang_code, tag_name)
+ in response)
for group_name in ('david', 'roger'):
- assert '/%s/dataset?groups=%s' % (lang_code, group_name) in response
+ assert ('/%s/dataset?groups=%s' % (lang_code, group_name)
+ in response)
assert 'this should not be rendered' not in response
def test_group_index_translation(self):
for (lang_code, translations) in (
- ('de', ckan.lib.create_test_data.german_translations),
- ('fr', ckan.lib.create_test_data.french_translations),
- ('en', ckan.lib.create_test_data.english_translations),
+ ('de', _create_test_data.german_translations),
+ ('fr', _create_test_data.french_translations),
+ ('en', _create_test_data.english_translations),
('pl', {})):
offset = '/%s/group' % lang_code
response = self.app.get(offset, status=200)
@@ -217,19 +255,40 @@ def test_group_index_translation(self):
for term in terms:
if term in translations:
assert translations[term] in response
- elif term in ckan.lib.create_test_data.english_translations:
- assert ckan.lib.create_test_data.english_translations[term] in response
+ elif term in _create_test_data.english_translations:
+ assert (_create_test_data.english_translations[term]
+ in response)
else:
assert term in response
for group_name in ('david', 'roger'):
assert '/%s/group/%s' % (lang_code, group_name) in response
assert 'this should not be rendered' not in response
+ def test_org_index_translation(self):
+ for (lang_code, translations) in (
+ ('de', _create_test_data.german_translations),
+ ('fr', _create_test_data.french_translations),
+ ('en', _create_test_data.english_translations),
+ ('pl', {})):
+ offset = '/{0}/organization'.format(lang_code)
+ response = self.app.get(offset, status=200)
+ for term in ('russian', 'Roger likes these books.'):
+ if term in translations:
+ assert translations[term] in response
+ elif term in _create_test_data.english_translations:
+ assert (_create_test_data.english_translations[term]
+ in response)
+ else:
+ assert term in response, response
+ assert ('/{0}/organization/{1}'.format(lang_code, self.org['name'])
+ in response)
+ assert 'this should not be rendered' not in response
+
def test_tag_index_translation(self):
for (lang_code, translations) in (
- ('de', ckan.lib.create_test_data.german_translations),
- ('fr', ckan.lib.create_test_data.french_translations),
- ('en', ckan.lib.create_test_data.english_translations),
+ ('de', _create_test_data.german_translations),
+ ('fr', _create_test_data.french_translations),
+ ('en', _create_test_data.english_translations),
('pl', {})):
offset = '/%s/tag' % lang_code
response = self.app.get(offset, status=200)
@@ -243,13 +302,15 @@ def test_tag_index_translation(self):
for term in terms:
if term in translations:
assert translations[term] in response
- elif term in ckan.lib.create_test_data.english_translations:
- assert ckan.lib.create_test_data.english_translations[term] in response
+ elif term in _create_test_data.english_translations:
+ assert (_create_test_data.english_translations[term]
+ in response)
else:
assert term in response
assert '/%s/tag/%s' % (lang_code, term) in response
assert 'this should not be rendered' not in response
+
class TestDatasetSearchIndex():
@classmethod
@@ -260,36 +321,28 @@ def setup_class(cls):
data_dicts = [
{'term': 'moo',
'term_translation': 'french_moo',
- 'lang_code': 'fr',
- }, #
+ 'lang_code': 'fr'},
{'term': 'moo',
'term_translation': 'this should not be rendered',
- 'lang_code': 'fsdas',
- },
+ 'lang_code': 'fsdas'},
{'term': 'an interesting note',
'term_translation': 'french note',
- 'lang_code': 'fr',
- },
+ 'lang_code': 'fr'},
{'term': 'moon',
'term_translation': 'french moon',
- 'lang_code': 'fr',
- },
+ 'lang_code': 'fr'},
{'term': 'boon',
'term_translation': 'french boon',
- 'lang_code': 'fr',
- },
+ 'lang_code': 'fr'},
{'term': 'boon',
'term_translation': 'italian boon',
- 'lang_code': 'it',
- },
+ 'lang_code': 'it'},
{'term': 'david',
'term_translation': 'french david',
- 'lang_code': 'fr',
- },
+ 'lang_code': 'fr'},
{'term': 'david',
'term_translation': 'italian david',
- 'lang_code': 'it',
- },
+ 'lang_code': 'it'}
]
context = {
@@ -299,39 +352,41 @@ def setup_class(cls):
'ignore_auth': True,
}
for data_dict in data_dicts:
- ckan.logic.action.update.term_translation_update(context,
- data_dict)
+ ckan.logic.action.update.term_translation_update(
+ context, data_dict)
@classmethod
def teardown(cls):
ckan.plugins.unload('multilingual_dataset')
ckan.plugins.unload('multilingual_group')
-
def test_translate_terms(self):
sample_index_data = {
- 'download_url': u'moo',
- 'notes': u'an interesting note',
- 'tags': [u'moon', 'boon'],
- 'title': u'david',
- }
+ 'download_url': u'moo',
+ 'notes': u'an interesting note',
+ 'tags': [u'moon', 'boon'],
+ 'title': u'david',
+ }
- result = mulilingual_plugin.MultilingualDataset().before_index(sample_index_data)
+ result = mulilingual_plugin.MultilingualDataset().before_index(
+ sample_index_data)
- assert result == {'text_pl': '',
- 'text_de': '',
- 'text_ro': '',
- 'title': u'david',
- 'notes': u'an interesting note',
- 'tags': [u'moon', 'boon'],
- 'title_en': u'david',
- 'download_url': u'moo',
- 'text_it': u'italian boon',
- 'text_es': '',
- 'text_en': u'an interesting note moon boon moo',
- 'text_nl': '',
- 'title_it': u'italian david',
- 'text_pt': '',
- 'title_fr': u'french david',
- 'text_fr': u'french note french boon french_moo french moon'}, result
+ assert result == {
+ 'text_pl': '',
+ 'text_de': '',
+ 'text_ro': '',
+ 'title': u'david',
+ 'notes': u'an interesting note',
+ 'tags': [u'moon', 'boon'],
+ 'title_en': u'david',
+ 'download_url': u'moo',
+ 'text_it': u'italian boon',
+ 'text_es': '',
+ 'text_en': u'an interesting note moon boon moo',
+ 'text_nl': '',
+ 'title_it': u'italian david',
+ 'text_pt': '',
+ 'title_fr': u'french david',
+ 'text_fr': u'french note french boon french_moo french moon'
+ }, result
diff --git a/dev-requirements.txt b/dev-requirements.txt
index b55719c6388..7dc81203fa7 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -9,3 +9,4 @@ Sphinx==1.1.3
polib==1.0.3
mock==1.0.1
factory-boy==2.1.1
+coveralls==0.3
diff --git a/doc/_static/ckanlogo.png b/doc/_static/ckanlogo.png
new file mode 100644
index 00000000000..a234cc085bb
Binary files /dev/null and b/doc/_static/ckanlogo.png differ
diff --git a/doc/_themes/sphinx-theme-okfn b/doc/_themes/sphinx-theme-okfn
index 320555990b6..4628f26abf4 160000
--- a/doc/_themes/sphinx-theme-okfn
+++ b/doc/_themes/sphinx-theme-okfn
@@ -1 +1 @@
-Subproject commit 320555990b639c99ac3bcc79024140c9588e2bdf
+Subproject commit 4628f26abf401fdb63ec099384bff44a27dcda4c
diff --git a/doc/authorization.rst b/doc/authorization.rst
index 937a833e29e..900d02e52fb 100644
--- a/doc/authorization.rst
+++ b/doc/authorization.rst
@@ -1,6 +1,6 @@
-=============
-Authorization
-=============
+===============================
+Organizations and authorization
+===============================
.. versionchanged:: 2.0
Previous versions of CKAN used a different authorization system.
diff --git a/doc/conf.py b/doc/conf.py
index 118df583f33..22e1049600a 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -80,7 +80,7 @@
master_doc = 'index'
# General information about the project.
-project = u'CKAN Documentation'
+project = u'CKAN'
project_short_name = u'CKAN'
copyright = u'''© 2009-2013, Open Knowledge Foundation .
Licensed under `_.
+By default the tests will keep the database between test runs. If you wish to
+drop and reinitialize the database before the run you can use the ``reset-db``
+option::
+
+ nosetests --ckan --reset-db --with-pylons=test-core.ini ckan
+
+If you are have the ``ckan-migration`` option on the tests will reset the
+reset the database before the test run.
+
.. _migrationtesting:
diff --git a/doc/upgrade-source.rst b/doc/upgrade-source.rst
index af70ac4ea05..539b96dcb62 100644
--- a/doc/upgrade-source.rst
+++ b/doc/upgrade-source.rst
@@ -37,6 +37,12 @@ CKAN release you're upgrading to:
pip install --upgrade -r requirements.txt
+#. Register any new or updated plugins:
+
+ ::
+
+ python setup.py develop
+
#. If you are upgrading to a new :ref:`major release ` you need to
update your Solr schema symlink.
diff --git a/doc/upgrading-dependencies.rst b/doc/upgrading-dependencies.rst
new file mode 100644
index 00000000000..6d55201d7fb
--- /dev/null
+++ b/doc/upgrading-dependencies.rst
@@ -0,0 +1,62 @@
+--------------------------
+Upgrading the dependencies
+--------------------------
+
+The Python modules that CKAN depends on are pinned to specific versions, so we
+can guarantee that whenever anyone installs CKAN, they'll always get the same
+versions of the Python modules in their virtual environment.
+
+Our dependencies are defined in three files:
+
+requirements.in
+ This file is only used to create a new version of the ``requirements.txt``
+ file when upgrading the dependencies.
+ Contains our direct dependencies only (not dependencies of dependencies)
+ with loosely defined versions. For example, ``apachemiddleware>=0.1.1,<0.2``.
+
+requirements.txt
+ This is the file that people actually use to install CKAN's dependencies into
+ their virtualenvs. It contains every dependency, including dependencies of
+ dependencies, each pinned to a specific version.
+ For example, ``simplejson==3.3.1``.
+
+dev-requirements.txt
+ Contains those dependencies only needed by developers, not needed for
+ production sites. These are pinned to a specific version. For example,
+ ``factory-boy==2.1.1``.
+
+We haven't created a ``dev-requirements.in`` file because we have too few dev
+dependencies, we don't update them often, and none of them have a known
+incompatible version.
+
+Steps to upgrade
+================
+
+These steps will upgrade all of CKAN's dependencies to the latest versions that
+work with CKAN:
+
+#. Create a new virtualenv: ``virtualenv --no-site-packages upgrading``
+
+#. Install the requirements with unpinned versions: ``pip install -r
+ requirements.in``
+
+#. Save the new dependencies versions: ``pip freeze > requirements.txt``. We
+ have to do this before installing the other dependencies so we get only what
+ was in ``requirements.in``
+
+#. Install CKAN: ``python setup.py develop``
+
+#. Install the development dependencies: ``pip install -r
+ dev-requirements.txt``
+
+#. Run the tests to make sure everything still works (see :doc:`test`).
+
+ - If not, try to fix the problem. If it's too complicated, pinpoint which
+ dependency's version broke our tests, find an older version that still
+ works, and add it to ``requirements.in`` (i.e., if ``python-dateutil``
+ 2.0.0 broke CKAN, you'd add ``python-dateutil>=1.5.0,<2.0.0``). Go back to
+ step 1.
+
+#. Navigate a bit on CKAN to make sure the tests didn't miss anything. Review
+ the dependencies changes and their changelogs. If everything seems fine, go
+ ahead and make a pull request (see :ref:`making a pull request`).
diff --git a/setup.py b/setup.py
index 58baa982702..b9332afdac2 100644
--- a/setup.py
+++ b/setup.py
@@ -5,7 +5,113 @@
use_setuptools()
from setuptools import setup, find_packages
-from ckan import __version__, __description__, __long_description__, __license__
+from ckan import (__version__, __description__, __long_description__,
+ __license__)
+
+entry_points = {
+ 'nose.plugins.0.10': [
+ 'main = ckan.ckan_nose_plugin:CkanNose',
+ ],
+ 'paste.app_factory': [
+ 'main = ckan.config.middleware:make_app',
+ ],
+ 'paste.app_install': [
+ 'main = ckan.config.install:CKANInstaller',
+ ],
+ 'paste.paster_command': [
+ 'db = ckan.lib.cli:ManageDb',
+ 'create-test-data = ckan.lib.cli:CreateTestDataCommand',
+ 'sysadmin = ckan.lib.cli:Sysadmin',
+ 'user = ckan.lib.cli:UserCmd',
+ 'dataset = ckan.lib.cli:DatasetCmd',
+ 'search-index = ckan.lib.cli:SearchIndexCommand',
+ 'ratings = ckan.lib.cli:Ratings',
+ 'notify = ckan.lib.cli:Notification',
+ 'celeryd = ckan.lib.cli:Celery',
+ 'rdf-export = ckan.lib.cli:RDFExport',
+ 'tracking = ckan.lib.cli:Tracking',
+ 'plugin-info = ckan.lib.cli:PluginInfo',
+ 'profile = ckan.lib.cli:Profile',
+ 'color = ckan.lib.cli:CreateColorSchemeCommand',
+ 'check-po-files = ckan.i18n.check_po_files:CheckPoFiles',
+ 'trans = ckan.lib.cli:TranslationsCommand',
+ 'minify = ckan.lib.cli:MinifyCommand',
+ 'less = ckan.lib.cli:LessCommand',
+ 'datastore = ckanext.datastore.commands:SetupDatastoreCommand',
+ 'front-end-build = ckan.lib.cli:FrontEndBuildCommand',
+ ],
+ 'console_scripts': [
+ 'ckan-admin = bin.ckan_admin:Command',
+ ],
+ 'paste.paster_create_template': [
+ 'ckanext = ckan.pastertemplates:CkanextTemplate',
+ ],
+ 'ckan.forms': [
+ 'standard = ckan.forms.package:get_standard_fieldset',
+ 'package = ckan.forms.package:get_standard_fieldset',
+ 'group = ckan.forms.group:get_group_fieldset',
+ 'package_group = ckan.forms.group:get_package_group_fieldset',
+ ],
+ 'ckan.search': [
+ 'sql = ckan.lib.search.sql:SqlSearchBackend',
+ 'solr = ckan.lib.search.solr_backend:SolrSearchBackend',
+ ],
+ 'ckan.plugins': [
+ 'synchronous_search = ckan.lib.search:SynchronousSearchPlugin',
+ 'stats = ckanext.stats.plugin:StatsPlugin',
+ 'publisher_form = ckanext.publisher_form.forms:PublisherForm',
+ 'publisher_dataset_form = ckanext.publisher_form.forms:PublisherDatasetForm',
+ 'multilingual_dataset = ckanext.multilingual.plugin:MultilingualDataset',
+ 'multilingual_group = ckanext.multilingual.plugin:MultilingualGroup',
+ 'multilingual_tag = ckanext.multilingual.plugin:MultilingualTag',
+ 'organizations = ckanext.organizations.forms:OrganizationForm',
+ 'organizations_dataset = ckanext.organizations.forms:OrganizationDatasetForm',
+ 'datastore = ckanext.datastore.plugin:DatastorePlugin',
+ 'datapusher=ckanext.datapusher.plugin:DatapusherPlugin',
+ 'test_tag_vocab_plugin = ckanext.test_tag_vocab_plugin:MockVocabTagsPlugin',
+ 'resource_proxy = ckanext.resourceproxy.plugin:ResourceProxy',
+ 'text_preview = ckanext.textpreview.plugin:TextPreview',
+ 'pdf_preview = ckanext.pdfpreview.plugin:PdfPreview',
+ 'recline_preview = ckanext.reclinepreview.plugin:ReclinePreview',
+ 'example_itemplatehelpers = ckanext.example_itemplatehelpers.plugin:ExampleITemplateHelpersPlugin',
+ 'example_idatasetform = ckanext.example_idatasetform.plugin:ExampleIDatasetFormPlugin',
+ 'example_iauthfunctions_v1 = ckanext.example_iauthfunctions.plugin_v1:ExampleIAuthFunctionsPlugin',
+ 'example_iauthfunctions_v2 = ckanext.example_iauthfunctions.plugin_v2:ExampleIAuthFunctionsPlugin',
+ 'example_iauthfunctions_v3 = ckanext.example_iauthfunctions.plugin_v3:ExampleIAuthFunctionsPlugin',
+ 'example_iauthfunctions = ckanext.example_iauthfunctions.plugin:ExampleIAuthFunctionsPlugin',
+ 'example_theme_v1 = ckanext.example_theme.v1_empty_extension.plugin:ExampleThemePlugin',
+ 'example_theme_v2 = ckanext.example_theme.v2_empty_template.plugin:ExampleThemePlugin',
+ 'example_theme_v3 = ckanext.example_theme.v3_ckan_extends.plugin:ExampleThemePlugin',
+ 'example_theme_v4 = ckanext.example_theme.v4_block.plugin:ExampleThemePlugin',
+ 'example_theme_v5 = ckanext.example_theme.v5_super.plugin:ExampleThemePlugin',
+ 'example_theme_v6 = ckanext.example_theme.v6_helper_function.plugin:ExampleThemePlugin',
+ 'example_theme_v7 = ckanext.example_theme.v7_custom_helper_function.plugin:ExampleThemePlugin',
+ 'example_theme_v8 = ckanext.example_theme.v8_snippet.plugin:ExampleThemePlugin',
+ 'example_theme_v9 = ckanext.example_theme.v9_custom_snippet.plugin:ExampleThemePlugin',
+ 'example_theme_v10 = ckanext.example_theme.v10_HTML_and_CSS.plugin:ExampleThemePlugin',
+ 'example_theme_v11 = ckanext.example_theme.v11_extra_public_directory.plugin:ExampleThemePlugin',
+ ],
+ 'ckan.system_plugins': [
+ 'domain_object_mods = ckan.model.modification:DomainObjectModificationExtension',
+ ],
+ 'ckan.test_plugins': [
+ 'routes_plugin = tests.ckantestplugins:RoutesPlugin',
+ 'mapper_plugin = tests.ckantestplugins:MapperPlugin',
+ 'session_plugin = tests.ckantestplugins:SessionPlugin',
+ 'mapper_plugin2 = tests.ckantestplugins:MapperPlugin2',
+ 'authorizer_plugin = tests.ckantestplugins:AuthorizerPlugin',
+ 'test_observer_plugin = tests.ckantestplugins:PluginObserverPlugin',
+ 'action_plugin = tests.ckantestplugins:ActionPlugin',
+ 'auth_plugin = tests.ckantestplugins:AuthPlugin',
+ 'test_group_plugin = tests.ckantestplugins:MockGroupControllerPlugin',
+ 'test_package_controller_plugin = tests.ckantestplugins:MockPackageControllerPlugin',
+ 'test_resource_preview = tests.ckantestplugins:MockResourcePreviewExtension',
+ 'test_json_resource_preview = tests.ckantestplugins:JsonMockResourcePreviewExtension',
+ ],
+ 'babel.extractors': [
+ 'ckan = ckan.lib.extract:extract_ckan',
+ ],
+}
setup(
name='ckan',
@@ -16,7 +122,7 @@
url='http://ckan.org/',
description=__description__,
keywords='data packaging component tool server',
- long_description =__long_description__,
+ long_description=__long_description__,
zip_safe=False,
packages=find_packages(exclude=['ez_setup']),
namespace_packages=['ckanext', 'ckanext.stats'],
@@ -28,7 +134,7 @@
'migration/tests/test_dumps/*',
'migration/versions/*',
]},
- message_extractors = {
+ message_extractors={
'ckan': [
('**.py', 'python', None),
('**.js', 'javascript', None),
@@ -55,109 +161,7 @@
}),
]
},
- entry_points="""
- [nose.plugins.0.10]
- main = ckan.ckan_nose_plugin:CkanNose
-
- [paste.app_factory]
- main = ckan.config.middleware:make_app
-
- [paste.app_install]
- main = ckan.config.install:CKANInstaller
-
- [paste.paster_command]
- db = ckan.lib.cli:ManageDb
- create-test-data = ckan.lib.cli:CreateTestDataCommand
- sysadmin = ckan.lib.cli:Sysadmin
- user = ckan.lib.cli:UserCmd
- dataset = ckan.lib.cli:DatasetCmd
- search-index = ckan.lib.cli:SearchIndexCommand
- ratings = ckan.lib.cli:Ratings
- notify = ckan.lib.cli:Notification
- celeryd = ckan.lib.cli:Celery
- rdf-export = ckan.lib.cli:RDFExport
- tracking = ckan.lib.cli:Tracking
- plugin-info = ckan.lib.cli:PluginInfo
- profile = ckan.lib.cli:Profile
- color = ckan.lib.cli:CreateColorSchemeCommand
- check-po-files = ckan.i18n.check_po_files:CheckPoFiles
- trans = ckan.lib.cli:TranslationsCommand
- minify = ckan.lib.cli:MinifyCommand
- less = ckan.lib.cli:LessCommand
- datastore = ckanext.datastore.commands:SetupDatastoreCommand
- front-end-build = ckan.lib.cli:FrontEndBuildCommand
-
-
- [console_scripts]
- ckan-admin = bin.ckan_admin:Command
-
- [paste.paster_create_template]
- ckanext=ckan.pastertemplates:CkanextTemplate
-
- [ckan.forms]
- standard = ckan.forms.package:get_standard_fieldset
- package = ckan.forms.package:get_standard_fieldset
- group = ckan.forms.group:get_group_fieldset
- package_group = ckan.forms.group:get_package_group_fieldset
-
- [ckan.search]
- sql = ckan.lib.search.sql:SqlSearchBackend
- solr = ckan.lib.search.solr_backend:SolrSearchBackend
-
- [ckan.plugins]
- synchronous_search = ckan.lib.search:SynchronousSearchPlugin
- stats=ckanext.stats.plugin:StatsPlugin
- publisher_form=ckanext.publisher_form.forms:PublisherForm
- publisher_dataset_form=ckanext.publisher_form.forms:PublisherDatasetForm
- multilingual_dataset=ckanext.multilingual.plugin:MultilingualDataset
- multilingual_group=ckanext.multilingual.plugin:MultilingualGroup
- multilingual_tag=ckanext.multilingual.plugin:MultilingualTag
- organizations=ckanext.organizations.forms:OrganizationForm
- organizations_dataset=ckanext.organizations.forms:OrganizationDatasetForm
- datastore=ckanext.datastore.plugin:DatastorePlugin
- test_tag_vocab_plugin=ckanext.test_tag_vocab_plugin:MockVocabTagsPlugin
- resource_proxy=ckanext.resourceproxy.plugin:ResourceProxy
- text_preview=ckanext.textpreview.plugin:TextPreview
- pdf_preview=ckanext.pdfpreview.plugin:PdfPreview
- recline_preview=ckanext.reclinepreview.plugin:ReclinePreview
- example_itemplatehelpers=ckanext.example_itemplatehelpers.plugin:ExampleITemplateHelpersPlugin
- example_idatasetform=ckanext.example_idatasetform.plugin:ExampleIDatasetFormPlugin
- example_iauthfunctions_v1=ckanext.example_iauthfunctions.plugin_v1:ExampleIAuthFunctionsPlugin
- example_iauthfunctions_v2=ckanext.example_iauthfunctions.plugin_v2:ExampleIAuthFunctionsPlugin
- example_iauthfunctions_v3=ckanext.example_iauthfunctions.plugin_v3:ExampleIAuthFunctionsPlugin
- example_iauthfunctions=ckanext.example_iauthfunctions.plugin:ExampleIAuthFunctionsPlugin
- example_theme_v1=ckanext.example_theme.v1_empty_extension.plugin:ExampleThemePlugin
- example_theme_v2=ckanext.example_theme.v2_empty_template.plugin:ExampleThemePlugin
- example_theme_v3=ckanext.example_theme.v3_ckan_extends.plugin:ExampleThemePlugin
- example_theme_v4=ckanext.example_theme.v4_block.plugin:ExampleThemePlugin
- example_theme_v5=ckanext.example_theme.v5_super.plugin:ExampleThemePlugin
- example_theme_v6=ckanext.example_theme.v6_helper_function.plugin:ExampleThemePlugin
- example_theme_v7=ckanext.example_theme.v7_custom_helper_function.plugin:ExampleThemePlugin
- example_theme_v8=ckanext.example_theme.v8_snippet.plugin:ExampleThemePlugin
- example_theme_v9=ckanext.example_theme.v9_custom_snippet.plugin:ExampleThemePlugin
- example_theme_v10=ckanext.example_theme.v10_HTML_and_CSS.plugin:ExampleThemePlugin
- example_theme_v11=ckanext.example_theme.v11_extra_public_directory.plugin:ExampleThemePlugin
-
- [ckan.system_plugins]
- domain_object_mods = ckan.model.modification:DomainObjectModificationExtension
-
- [ckan.test_plugins]
- routes_plugin=tests.ckantestplugins:RoutesPlugin
- mapper_plugin=tests.ckantestplugins:MapperPlugin
- session_plugin=tests.ckantestplugins:SessionPlugin
- mapper_plugin2=tests.ckantestplugins:MapperPlugin2
- authorizer_plugin=tests.ckantestplugins:AuthorizerPlugin
- test_observer_plugin=tests.ckantestplugins:PluginObserverPlugin
- action_plugin=tests.ckantestplugins:ActionPlugin
- auth_plugin=tests.ckantestplugins:AuthPlugin
- test_group_plugin=tests.ckantestplugins:MockGroupControllerPlugin
- test_package_controller_plugin=tests.ckantestplugins:MockPackageControllerPlugin
- test_resource_preview=tests.ckantestplugins:MockResourcePreviewExtension
- test_json_resource_preview=tests.ckantestplugins:JsonMockResourcePreviewExtension
-
- [babel.extractors]
- ckan = ckan.lib.extract:extract_ckan
- """,
+ entry_points=entry_points,
# setup.py test command needs a TestSuite so does not work with py.test
# test_suite = 'nose.collector',
# tests_require=[ 'py >= 0.8.0-alpha2' ]
diff --git a/test-core.ini b/test-core.ini
index c4278e6540c..74e35eb5ef0 100644
--- a/test-core.ini
+++ b/test-core.ini
@@ -25,12 +25,15 @@ sqlalchemy.url = postgresql://ckan_default:pass@localhost/ckan_test
ckan.datastore.write_url = postgresql://ckan_default:pass@localhost/datastore_test
ckan.datastore.read_url = postgresql://datastore_default:pass@localhost/datastore_test
+ckan.datapusher.url = http://datapusher.ckan.org/
+
## Solr support
solr_url = http://127.0.0.1:8983/solr
ckan.auth.user_create_organizations = true
ckan.auth.user_create_groups = true
ckan.auth.create_user_via_api = false
+ckan.auth.create_user_via_web = true
ckan.auth.create_dataset_if_not_in_organization = true
ckan.auth.anon_create_dataset = false
ckan.auth.user_delete_groups=true
@@ -80,7 +83,7 @@ smtp.mail_from = info@test.ckan.net
ckan.locale_default = en
ckan.locale_order = en pt_BR ja it cs_CZ ca es fr el sv sr sr@latin no sk fi ru de pl nl bg ko_KR hu sa sl lv
-ckan.locales_filtered_out =
+ckan.locales_filtered_out =
ckan.datastore.enabled = 1