diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 10ecc8fcce2..d0eeaa96d98 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,7 +12,9 @@ v2.2 API changes and deprecations: - +* The Solr schema file is now always named ``schema.xml`` regardless of the + CKAN version. Old schema files have been kept for backwards compatibility + but users are encouraged to point to the new unified one. * 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 diff --git a/MANIFEST.in b/MANIFEST.in index a0f2eb1d374..4043246a3df 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,9 +5,13 @@ recursive-include ckan/config *.xml recursive-include ckan/i18n * recursive-include ckan/templates * recursive-include ckan *.ini -recursive-include ckanext/**/public * -recursive-include ckanext/**/templates * -recursive-include ckanext/**/i18n * + +recursive-include ckanext/*/i18n * +recursive-include ckanext/*/public * +recursive-include ckanext/*/templates * +recursive-include ckanext/*/theme/public * +recursive-include ckanext/*/theme/templates * + prune .git include CHANGELOG.txt include ckan/migration/migrate.cfg diff --git a/bin/travis-run-tests b/bin/travis-run-tests index 28ddd58e04b..5d60ea0646d 100755 --- a/bin/travis-run-tests +++ b/bin/travis-run-tests @@ -2,8 +2,7 @@ # 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 cp ckan/config/solr/schema.xml /etc/solr/conf/schema.xml sudo service jetty restart # Run mocha front-end tests diff --git a/ckan/config/deployment.ini_tmpl b/ckan/config/deployment.ini_tmpl index 5d89ad2a155..b3148cc6d68 100644 --- a/ckan/config/deployment.ini_tmpl +++ b/ckan/config/deployment.ini_tmpl @@ -67,6 +67,7 @@ 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 +ckan.auth.roles_that_cascade_to_sub_groups = admin ## Search Settings @@ -121,28 +122,9 @@ ckan.feeds.author_link = ## Storage Settings -# Local file storage: -#ofs.impl = pairtree -#ofs.storage_dir = /var/lib/ckan/default - -# Google cloud storage: -#ofs.impl = google -#ofs.gs_access_key_id = -#ofs.gs_secret_access_key = - -# S3 cloud storage: -#ofs.impl = s3 -#ofs.aws_access_key_id = .... -#ofs.aws_secret_access_key = .... - -# 'Bucket' to use for file storage -#ckan.storage.bucket = default - -# Prefix for uploaded files (only used for pairtree) -#ckan.storage.key_prefix = file/ - -# The maximum content size, in bytes, for uploads -#ckan.storage.max_content_length = 50000000 +#ckan.storage_path = /var/lib/ckan +#ckan.max_resource_size = 10 +#ckan.max_image_size = 2 ## Datapusher settings diff --git a/ckan/config/environment.py b/ckan/config/environment.py index 409f77435a4..0a1269c64df 100644 --- a/ckan/config/environment.py +++ b/ckan/config/environment.py @@ -367,8 +367,10 @@ def template_loaded(template): # Here we create the site user if they are not already in the database try: logic.get_action('get_site_user')({'ignore_auth': True}, None) - except sqlalchemy.exc.ProgrammingError: - # The database is not initialised. This is a bit dirty. + except (sqlalchemy.exc.ProgrammingError, sqlalchemy.exc.OperationalError): + # (ProgrammingError for Postgres, OperationalError for SQLite) + # The database is not initialised. This is a bit dirty. This occurs + # when running tests. pass except sqlalchemy.exc.InternalError: # The database is not initialised. Travis hits this diff --git a/ckan/config/routing.py b/ckan/config/routing.py index 016c65ae0e4..345ff070724 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -229,6 +229,7 @@ def make_map(): 'history_ajax', 'follow', 'activity', + 'groups', 'unfollow', 'delete', 'api_data', @@ -240,6 +241,8 @@ def make_map(): m.connect('dataset_activity', '/dataset/activity/{id}', action='activity', ckan_icon='time') m.connect('/dataset/activity/{id}/{offset}', action='activity') + m.connect('dataset_groups', '/dataset/groups/{id}', + action='groups', ckan_icon='group') m.connect('/dataset/{id}.{format}', action='read') m.connect('dataset_resources', '/dataset/resources/{id}', action='resources', ckan_icon='reorder') @@ -253,6 +256,8 @@ def make_map(): action='resource_edit', ckan_icon='edit') m.connect('/dataset/{id}/resource/{resource_id}/download', action='resource_download') + m.connect('/dataset/{id}/resource/{resource_id}/download/{filename}', + action='resource_download') m.connect('/dataset/{id}/resource/{resource_id}/embed', action='resource_embedded_dataviewer') m.connect('/dataset/{id}/resource/{resource_id}/viewer', diff --git a/ckan/config/solr/CHANGELOG.txt b/ckan/config/solr/CHANGELOG.txt deleted file mode 100644 index 769c6b53735..00000000000 --- a/ckan/config/solr/CHANGELOG.txt +++ /dev/null @@ -1,29 +0,0 @@ -CKAN SOLR schemas changelog -=========================== - -v2.0 - (ckan>=2.0) --------------------- -* Add _version_ field to make it compatible with solr 4.0 -* Remove stopwords -* Add dataset_type field. -* Add *_date autofield. - -v1.4 - (ckan>=1.7) --------------------- -* Add Ascii folding filter to text fields. -* Add capacity field for public, private access. -* Add title_string so you can sort alphabetically on title. -* Fields related to analytics, access and view counts. -* Add data_dict field for the whole package_dict. -* Add vocab_* dynamic field so it is possible to facet by vocabulary tags -* Add copyField for text with source vocab_* - -v1.3 - (ckan>=1.5.1) --------------------- -* Use the index_id (hash of dataset id + site_id) as uniqueKey (#1430) -* Store extras (#1455) -* Store dataset creation and modification date (#191) - -v1.2 - (ckan<=1.5) --------------------- -* Original version diff --git a/ckan/config/solr/README.txt b/ckan/config/solr/README.txt index 9a3bccebfac..d8fcff40f42 100644 --- a/ckan/config/solr/README.txt +++ b/ckan/config/solr/README.txt @@ -1,30 +1,12 @@ -CKAN SOLR schemas -================= +CKAN Solr schema +================ -This folder contains the latest and previous versions of the SOLR XML -schema files used by CKAN. These can be use on the SOLR server to -override the default SOLR schema. Please note that not all schemas are -backwards compatible with old CKAN versions. Check the CHANGELOG.txt file -in this same folder to check which version of the schema should you use -depending on the CKAN version you are using. +This folder contains the Solr schema file used by CKAN (schema.xml). -Developers, when pushing changes to the SOLR schema: +Starting from 2.2 this is the only file that should be used by users and +modified by devs. The rest of files (schema-{version}.xml) are kept for +backwards compatibility purposes and should not be used, as they might be +removed in future versions. -* Note that updates on the schema are only release based, i.e. all changes - in the schema between releases will be part of the same new version of - the schema. - -* Name the new version of the file using the following convention:: - - schema-.xml - -* Update the `version` attribute of the `schema` tag in the new file:: - - - -* Update the SUPPORTED_SCHEMA_VERSIONS list in `ckan/lib/search/__init__.py` - Consider if the changes introduced are or are not compatible with - previous schema versions. - -* Update the CHANGELOG.txt file with the new version, the CKAN version - required and changes made to the schema. +When upgrading CKAN, always check the CHANGELOG on each release to see if +you need to update the schema file and reindex your datasets. diff --git a/ckan/config/solr/schema-1.2.xml b/ckan/config/solr/schema-1.2.xml index 2fb41dd96a1..4f9b11a580b 100644 --- a/ckan/config/solr/schema-1.2.xml +++ b/ckan/config/solr/schema-1.2.xml @@ -1,4 +1,12 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +index_id +text + + + + + + + + + + + + + + + + + + + + + diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index bcff15841a5..08ba3a8e641 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -839,7 +839,9 @@ def make_unicode(entity): cls.log.debug('Retrieving request POST: %r' % request.POST) cls.log.debug('Retrieving request GET: %r' % request.GET) request_data = None - if request.POST: + if request.POST and request.content_type == 'multipart/form-data': + request_data = dict(request.POST) + elif request.POST: try: keys = request.POST.keys() # Parsing breaks if there is a = in the value, so for now @@ -873,7 +875,7 @@ def make_unicode(entity): raise ValueError(msg) else: request_data = {} - if request_data: + if request_data and request.content_type != 'multipart/form-data': try: request_data = h.json.loads(request_data, encoding='utf8') except ValueError, e: diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index 8d4a90d4b7a..387732426c4 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -168,6 +168,9 @@ def index(self): def read(self, id, limit=20): group_type = self._get_group_type(id.split('@')[0]) + if group_type != self.group_type: + abort(404, _('Incorrect group type')) + context = {'model': model, 'session': model.Session, 'user': c.user or c.author, 'schema': self._db_to_form_schema(group_type=group_type), @@ -178,6 +181,9 @@ def read(self, id, limit=20): q = c.q = request.params.get('q', '') try: + # Do not query for the group datasets when dictizing, as they will + # be ignored and get requested on the controller anyway + context['include_datasets'] = False c.group_dict = self._action('group_show')(context, data_dict) c.group = context['group'] except NotFound: @@ -327,6 +333,7 @@ def pager_url(q=None, page=None): items_per_page=limit ) + c.group_dict['package_count'] = query['count'] c.facets = query['facets'] maintain.deprecate_context_item('facets', 'Use `c.search_facets` instead.') @@ -369,6 +376,9 @@ def bulk_process(self, id): data_dict = {'id': id} try: + # Do not query for the group datasets when dictizing, as they will + # be ignored and get requested on the controller anyway + context['include_datasets'] = False c.group_dict = self._action('group_show')(context, data_dict) c.group = context['group'] except NotFound: @@ -643,7 +653,7 @@ def member_new(self, id): else: c.user_role = 'member' c.group_dict = self._action('group_show')(context, {'id': id}) - c.roles = self._action('member_roles_list')(context, {}) + c.roles = self._action('member_roles_list')(context, {'group_type': 'group'}) except NotAuthorized: abort(401, _('Unauthorized to add member to group %s') % '') except NotFound: diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py index b0adf5c5ac5..689b8e64355 100644 --- a/ckan/controllers/package.py +++ b/ckan/controllers/package.py @@ -1,11 +1,15 @@ import logging from urllib import urlencode import datetime +import os +import mimetypes +import cgi from pylons import config from genshi.template import MarkupTemplate from genshi.template.text import NewTextTemplate from paste.deploy.converters import asbool +import paste.fileapp import ckan.logic as logic import ckan.lib.base as base @@ -18,6 +22,7 @@ import ckan.model as model import ckan.lib.datapreview as datapreview import ckan.lib.plugins +import ckan.lib.uploader as uploader import ckan.plugins as p import ckan.lib.render @@ -221,7 +226,7 @@ def pager_url(q=None, page=None): 'groups': _('Groups'), 'tags': _('Tags'), 'res_format': _('Formats'), - 'license_id': _('License'), + 'license_id': _('Licenses'), } for facet in g.facets: @@ -310,7 +315,7 @@ def resources(self, id): data_dict = {'id': id} try: - check_access('package_update', context) + check_access('package_update', context, data_dict) except NotAuthorized, e: abort(401, _('User %r not authorized to edit %s') % (c.user, id)) # check if package exists @@ -550,7 +555,7 @@ def resource_edit(self, id, resource_id, data=None, errors=None, del data['save'] context = {'model': model, 'session': model.Session, - 'api_version': 3, + 'api_version': 3, 'for_edit': True, 'user': c.user or c.author, 'auth_user_obj': c.userobj} data['package_id'] = id @@ -571,7 +576,7 @@ def resource_edit(self, id, resource_id, data=None, errors=None, id=id, resource_id=resource_id)) context = {'model': model, 'session': model.Session, - 'api_version': 3, + 'api_version': 3, 'for_edit': True, 'user': c.user or c.author, 'auth_user_obj': c.userobj} pkg_dict = get_action('package_show')(context, {'id': id}) if pkg_dict['state'].startswith('draft'): @@ -621,7 +626,8 @@ def new_resource(self, id, data=None, errors=None, error_summary=None): # see if we have any data that we are trying to save data_provided = False for key, value in data.iteritems(): - if value and key != 'resource_type': + if ((value or isinstance(value, cgi.FieldStorage)) + and key != 'resource_type'): data_provided = True break @@ -1204,10 +1210,10 @@ def _resource_preview(self, data_dict): or datapreview.get_preview_plugin( data_dict, return_first=True)) - def resource_download(self, id, resource_id): + def resource_download(self, id, resource_id, filename=None): """ - Provides a direct download by redirecting the user to the url stored - against this resource. + Provides a direct download by either redirecting the user to the url stored + or downloading an uploaded file directly. """ context = {'model': model, 'session': model.Session, 'user': c.user or c.author, 'auth_user_obj': c.userobj} @@ -1220,7 +1226,20 @@ def resource_download(self, id, resource_id): except NotAuthorized: abort(401, _('Unauthorized to read resource %s') % id) - if not 'url' in rsc: + if rsc.get('url_type') == 'upload': + upload = uploader.ResourceUpload(rsc) + filepath = upload.get_path(rsc['id']) + fileapp = paste.fileapp.FileApp(filepath) + try: + status, headers, app_iter = request.call_application(fileapp) + except OSError: + abort(404, _('Resource data not found')) + response.headers.update(dict(headers)) + content_type, content_enc = mimetypes.guess_type(rsc.get('url','')) + response.headers['Content-Type'] = content_type + response.status = status + return app_iter + elif not 'url' in rsc: abort(404, _('No download is available')) redirect(rsc['url']) @@ -1283,6 +1302,62 @@ def followers(self, id=None): return render('package/followers.html') + def groups(self, id): + context = {'model': model, 'session': model.Session, + 'user': c.user or c.author, 'for_view': True, + 'auth_user_obj': c.userobj, 'use_cache': False} + data_dict = {'id': id} + try: + c.pkg_dict = get_action('package_show')(context, data_dict) + except NotFound: + abort(404, _('Dataset not found')) + except NotAuthorized: + abort(401, _('Unauthorized to read dataset %s') % id) + + if request.method == 'POST': + new_group = request.POST.get('group_added') + if new_group: + data_dict = {"id": new_group, + "object": id, + "object_type": 'package', + "capacity": 'public'} + try: + get_action('member_create')(context, data_dict) + except NotFound: + abort(404, _('Group not found')) + + removed_group = request.POST.get('group_removed') + if removed_group: + data_dict = {"id": removed_group, + "object": id, + "object_type": 'package'} + + try: + get_action('member_delete')(context, data_dict) + except NotFound: + abort(404, _('Group not found')) + redirect(h.url_for(controller='package', + action='groups', id=id)) + + + + context['is_member'] = True + users_groups = get_action('group_list_authz')(context, data_dict) + + pkg_group_ids = set(group['id'] for group + in c.pkg_dict.get('groups', [])) + user_group_ids = set(group['id'] for group + in users_groups) + + c.group_dropdown = [[group['id'], group['display_name']] + for group in users_groups if + group['id'] not in pkg_group_ids] + + for group in c.pkg_dict.get('groups', []): + group['user_member'] = (group['id'] in user_group_ids) + + return render('package/group_list.html') + def activity(self, id): '''Render this package's public activity stream page.''' diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py index 28f62b85e01..4f30bb4ed91 100644 --- a/ckan/controllers/user.py +++ b/ckan/controllers/user.py @@ -451,7 +451,7 @@ def perform_reset(self, id): # reuse of the url context = {'model': model, 'session': model.Session, 'user': id, - 'keep_sensitive_data': True} + 'keep_email': True} try: check_access('user_reset', context) @@ -462,10 +462,6 @@ def perform_reset(self, id): 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 - # not needed. - user_dict.pop('apikey', None) - user_dict.pop('reset_key', None) user_obj = context['user_obj'] except NotFound, e: abort(404, _('User not found')) diff --git a/ckan/lib/accept.py b/ckan/lib/accept.py index 0817e7d5b0c..447797b4033 100644 --- a/ckan/lib/accept.py +++ b/ckan/lib/accept.py @@ -12,7 +12,6 @@ # Name : ContentType, Is Markup?, Extension "text/html": ("text/html; charset=utf-8", True, 'html'), "text/n3": ("text/n3; charset=utf-8", False, 'n3'), - "text/plain": ("text/plain; charset=utf-8", False, 'txt'), "application/rdf+xml": ("application/rdf+xml; charset=utf-8", True, 'rdf'), } accept_by_extension = { diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py index 72d971dbb71..0b0ebd84287 100644 --- a/ckan/lib/cli.py +++ b/ckan/lib/cli.py @@ -130,6 +130,7 @@ class ManageDb(CkanCommand): db load-only FILE_PATH - load a pg_dump from a file but don\'t do the schema upgrade or search indexing db create-from-model - create database from the model (indexes not made) + db migrate-filestore - migrate all uploaded data from the 2.1 filesore. ''' summary = __doc__.split('\n')[0] usage = __doc__ @@ -187,6 +188,8 @@ def command(self): print 'Creating DB: SUCCESS' elif cmd == 'send-rdf': self.send_rdf() + elif cmd == 'migrate-filestore': + self.migrate_filestore() else: print 'Command %s not recognized' % cmd sys.exit(1) @@ -319,6 +322,45 @@ def send_rdf(self): talis = ckan.lib.talis.Talis() return talis.send_rdf(talis_store, username, password) + def migrate_filestore(self): + from ckan.model import Session + import requests + from ckan.lib.uploader import ResourceUpload + results = Session.execute("select id, revision_id, url from resource " + "where resource_type = 'file.upload' " + "and (url_type <> 'upload' or url_type is null)" + "and url like '%storage%'") + for id, revision_id, url in results: + response = requests.get(url, stream=True) + if response.status_code != 200: + print "failed to fetch %s (code %s)" % (url, + response.status_code) + continue + resource_upload = ResourceUpload({'id': id}) + assert resource_upload.storage_path, "no storage configured aborting" + + directory = resource_upload.get_directory(id) + filepath = resource_upload.get_path(id) + try: + os.makedirs(directory) + except OSError, e: + ## errno 17 is file already exists + if e.errno != 17: + raise + + with open(filepath, 'wb+') as out: + for chunk in response.iter_content(1024): + if chunk: + out.write(chunk) + + Session.execute("update resource set url_type = 'upload'" + "where id = '%s'" % id) + Session.execute("update resource_revision set url_type = 'upload'" + "where id = '%s' and " + "revision_id = '%s'" % (id, revision_id)) + Session.commit() + print "Saved url %s" % url + def version(self): from ckan.model import Session print Session.execute('select version from migrate_version;').fetchall() @@ -1280,7 +1322,7 @@ class CreateTestDataCommand(CkanCommand): translations of terms create-test-data vocabs - annakerenina, warandpeace, and some test vocabularies - + create-test-data hierarchy - hierarchy of groups ''' summary = __doc__.split('\n')[0] usage = __doc__ @@ -1290,6 +1332,7 @@ class CreateTestDataCommand(CkanCommand): def command(self): self._load_config() self._setup_app() + from ckan import plugins from create_test_data import CreateTestData if self.args: @@ -1314,6 +1357,8 @@ def command(self): CreateTestData.create_translations_test_data() elif cmd == 'vocabs': CreateTestData.create_vocabs_test_data() + elif cmd == 'hierarchy': + CreateTestData.create_group_hierarchy_test_data() else: print 'Command %s not recognized' % cmd raise NotImplementedError diff --git a/ckan/lib/create_test_data.py b/ckan/lib/create_test_data.py index 6c7a275c36f..324531ee70d 100644 --- a/ckan/lib/create_test_data.py +++ b/ckan/lib/create_test_data.py @@ -38,6 +38,12 @@ def create_family_test_data(cls, extra_users=[]): relationships=family_relationships, extra_user_names=extra_users) + @classmethod + def create_group_hierarchy_test_data(cls, extra_users=[]): + cls.create_users(group_hierarchy_users) + cls.create_groups(group_hierarchy_groups) + cls.create_arbitrary(group_hierarchy_datasets) + @classmethod def create_test_user(cls): tester = model.User.by_name(u'tester') @@ -215,18 +221,26 @@ def create_arbitrary(cls, package_dicts, relationships=[], group = model.Group.by_name(unicode(group_name)) if not group: if not group_name in new_groups: - group = model.Group(name=unicode(group_name)) + group = model.Group(name= + unicode(group_name)) model.Session.add(group) new_group_names.add(group_name) new_groups[group_name] = group else: - # If adding multiple packages with the same group name, - # model.Group.by_name will not find the group as the - # session has not yet been committed at this point. - # Fetch from the new_groups dict instead. + # If adding multiple packages with the same + # group name, model.Group.by_name will not + # find the group as the session has not yet + # been committed at this point. Fetch from + # the new_groups dict instead. group = new_groups[group_name] - member = model.Member(group=group, table_id=pkg.id, table_name='package') + capacity = 'organization' if group.is_organization\ + else 'public' + member = model.Member(group=group, table_id=pkg.id, + table_name='package', + capacity=capacity) model.Session.add(member) + if group.is_organization: + pkg.owner_org = group.id elif attr == 'license': pkg.license_id = val elif attr == 'license_id': @@ -315,21 +329,21 @@ def pkg(pkg_name): @classmethod def create_groups(cls, group_dicts, admin_user_name=None, auth_profile=""): '''A more featured interface for creating groups. - All group fields can be filled, packages added and they can - have an admin user.''' + All group fields can be filled, packages added, can have + an admin user and be a member of other groups.''' rev = model.repo.new_revision() - # same name as user we create below rev.author = cls.author if admin_user_name: admin_users = [model.User.by_name(admin_user_name)] else: admin_users = [] assert isinstance(group_dicts, (list, tuple)) - group_attributes = set(('name', 'title', 'description', 'parent_id')) + group_attributes = set(('name', 'title', 'description', 'parent_id', + 'type', 'is_organization')) for group_dict in group_dicts: - if model.Group.by_name(group_dict['name']): - log.warning('Cannot create group "%s" as it already exists.' % \ - (group_dict['name'])) + if model.Group.by_name(unicode(group_dict['name'])): + log.warning('Cannot create group "%s" as it already exists.' % + group_dict['name']) continue pkg_names = group_dict.pop('packages', []) group = model.Group(name=unicode(group_dict['name'])) @@ -337,16 +351,45 @@ def create_groups(cls, group_dicts, admin_user_name=None, auth_profile=""): for key in group_dict: if key in group_attributes: setattr(group, key, group_dict[key]) - else: + elif key not in ('admins', 'editors', 'parent'): group.extras[key] = group_dict[key] assert isinstance(pkg_names, (list, tuple)) for pkg_name in pkg_names: pkg = model.Package.by_name(unicode(pkg_name)) assert pkg, pkg_name - member = model.Member(group=group, table_id=pkg.id, table_name='package') + member = model.Member(group=group, table_id=pkg.id, + table_name='package') model.Session.add(member) model.Session.add(group) - model.setup_default_user_roles(group, admin_users) + admins = [model.User.by_name(user_name) + for user_name in group_dict.get('admins', [])] + \ + admin_users + for admin in admins: + member = model.Member(group=group, table_id=admin.id, + table_name='user', capacity='admin') + model.Session.add(member) + editors = [model.User.by_name(user_name) + for user_name in group_dict.get('editors', [])] + for editor in editors: + member = model.Member(group=group, table_id=editor.id, + table_name='user', capacity='editor') + model.Session.add(member) + # Need to commit the current Group for two reasons: + # 1. It might have a parent, and the Member will need the Group.id + # value allocated on commit. + # 2. The next Group created may have this Group as a parent so + # creation of the Member needs to refer to this one. + model.Session.commit() + rev = model.repo.new_revision() + rev.author = cls.author + # add it to a parent's group + if 'parent' in group_dict: + parent = model.Group.by_name(unicode(group_dict['parent'])) + assert parent, group_dict['parent'] + member = model.Member(group=group, table_id=parent.id, + table_name='group', capacity='parent') + model.Session.add(member) + #model.setup_default_user_roles(group, admin_users) cls.group_names.add(group_dict['name']) model.repo.commit_and_remove() @@ -362,7 +405,8 @@ def create(cls, auth_profile="", package_type=None): * Associated tags, etc etc ''' if auth_profile == "publisher": - organization_group = model.Group(name=u"organization_group", type="organization") + organization_group = model.Group(name=u"organization_group", + type="organization") cls.pkg_names = [u'annakarenina', u'warandpeace'] pkg1 = model.Package(name=cls.pkg_names[0], type=package_type) @@ -483,11 +527,12 @@ def create(cls, auth_profile="", package_type=None): roger = model.Group.by_name(u'roger') model.setup_default_user_roles(david, [russianfan]) model.setup_default_user_roles(roger, [russianfan]) - model.add_user_to_role(visitor, model.Role.ADMIN, roger) + # in new_authz you can't give a visitor permissions to a + # group it seems, so this is a bit meaningless + model.add_user_to_role(visitor, model.Role.ADMIN, roger) model.repo.commit_and_remove() - # method used in DGU and all good tests elsewhere @classmethod def create_users(cls, user_dicts): @@ -501,9 +546,11 @@ def create_users(cls, user_dicts): @classmethod def _create_user_without_commit(cls, name='', **user_dict): - if model.User.by_name(name) or (user_dict.get('open_id') and model.User.by_openid(user_dict.get('openid'))): - log.warning('Cannot create user "%s" as it already exists.' % \ - (name or user_dict['name'])) + if model.User.by_name(name) or \ + (user_dict.get('open_id') and + model.User.by_openid(user_dict.get('openid'))): + log.warning('Cannot create user "%s" as it already exists.' % + name or user_dict['name']) return # User objects are not revisioned so no need to create a revision user_ref = name or user_dict['openid'] @@ -826,6 +873,65 @@ def make_some_vocab_tags(cls): } ] +group_hierarchy_groups = [ + {'name': 'department-of-health', + 'title': 'Department of Health', + 'contact-email': 'contact@doh.gov.uk', + 'type': 'organization', + 'is_organization': True + }, + {'name': 'food-standards-agency', + 'title': 'Food Standards Agency', + 'contact-email': 'contact@fsa.gov.uk', + 'parent': 'department-of-health', + 'type': 'organization', + 'is_organization': True}, + {'name': 'national-health-service', + 'title': 'National Health Service', + 'contact-email': 'contact@nhs.gov.uk', + 'parent': 'department-of-health', + 'type': 'organization', + 'is_organization': True, + 'editors': ['nhseditor'], + 'admins': ['nhsadmin']}, + {'name': 'nhs-wirral-ccg', + 'title': 'NHS Wirral CCG', + 'contact-email': 'contact@wirral.nhs.gov.uk', + 'parent': 'national-health-service', + 'type': 'organization', + 'is_organization': True, + 'editors': ['wirraleditor'], + 'admins': ['wirraladmin']}, + {'name': 'nhs-southwark-ccg', + 'title': 'NHS Southwark CCG', + 'contact-email': 'contact@southwark.nhs.gov.uk', + 'parent': 'national-health-service', + 'type': 'organization', + 'is_organization': True}, + {'name': 'cabinet-office', + 'title': 'Cabinet Office', + 'contact-email': 'contact@cabinet-office.gov.uk', + 'type': 'organization', + 'is_organization': True}, + ] + +group_hierarchy_datasets = [ + {'name': 'doh-spend', 'title': 'Department of Health Spend Data', + 'groups': ['department-of-health']}, + {'name': 'nhs-spend', 'title': 'NHS Spend Data', + 'groups': ['national-health-service']}, + {'name': 'wirral-spend', 'title': 'Wirral Spend Data', + 'groups': ['nhs-wirral-ccg']}, + {'name': 'southwark-spend', 'title': 'Southwark Spend Data', + 'groups': ['nhs-southwark-ccg']}, + ] + +group_hierarchy_users = [{'name': 'nhsadmin', 'password': 'pass'}, + {'name': 'nhseditor', 'password': 'pass'}, + {'name': 'wirraladmin', 'password': 'pass'}, + {'name': 'wirraleditor', 'password': 'pass'}, + ] + # Some test terms and translations. terms = ('A Novel By Tolstoy', 'Index of the novel', diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py index 610aeaeb41e..1ad4a08ca14 100644 --- a/ckan/lib/dictization/model_dictize.py +++ b/ckan/lib/dictization/model_dictize.py @@ -15,18 +15,18 @@ ## package save def group_list_dictize(obj_list, context, - sort_key=lambda x:x['display_name'], reverse=False): + sort_key=lambda x:x['display_name'], reverse=False, + with_package_counts=True): active = context.get('active', True) with_private = context.get('include_private_packages', False) - query = search.PackageSearchQuery() - - q = {'q': '+capacity:public' if not with_private else '*:*', - 'fl': 'groups', 'facet.field': ['groups', 'owner_org'], - 'facet.limit': -1, 'rows': 1} - - query.run(q) + if with_package_counts: + query = search.PackageSearchQuery() + q = {'q': '+capacity:public' if not with_private else '*:*', + 'fl': 'groups', 'facet.field': ['groups', 'owner_org'], + 'facet.limit': -1, 'rows': 1} + query.run(q) result_list = [] @@ -40,7 +40,8 @@ def group_list_dictize(obj_list, context, if active and obj.state not in ('active', 'pending'): continue - group_dict['display_name'] = obj.display_name + group_dict['display_name'] = (group_dict.get('title') or + group_dict.get('name')) image_url = group_dict.get('image_url') group_dict['image_display_url'] = image_url @@ -49,13 +50,16 @@ def group_list_dictize(obj_list, context, #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') + 'uploads/group/%s' % group_dict.get('image_url'), + qualified=True ) - if obj.is_organization: - group_dict['packages'] = query.facets['owner_org'].get(obj.id, 0) - else: - group_dict['packages'] = query.facets['groups'].get(obj.name, 0) + if with_package_counts: + facets = query.facets + if obj.is_organization: + group_dict['packages'] = facets['owner_org'].get(obj.id, 0) + else: + group_dict['packages'] = facets['groups'].get(obj.name, 0) if context.get('for_view'): if group_dict['is_organization']: @@ -139,14 +143,29 @@ def _unified_resource_format(format_): return format_new def resource_dictize(res, context): + model = context['model'] resource = d.table_dictize(res, context) + resource_group_id = resource['resource_group_id'] extras = resource.pop("extras", None) if extras: resource.update(extras) resource['format'] = _unified_resource_format(res.format) # some urls do not have the protocol this adds http:// to these url = resource['url'] - if not urlparse.urlsplit(url).scheme: + ## for_edit is only called at the times when the dataset is to be edited + ## in the frontend. Without for_edit the whole qualified url is returned. + if resource.get('url_type') == 'upload' and not context.get('for_edit'): + resource_group = model.Session.query( + model.ResourceGroup).get(resource_group_id) + last_part = url.split('/')[-1] + cleaned_name = munge.munge_filename(last_part) + resource['url'] = h.url_for(controller='package', + action='resource_download', + id=resource_group.package_id, + resource_id=res.id, + filename=cleaned_name, + qualified=True) + elif not urlparse.urlsplit(url).scheme and not context.get('for_edit'): resource['url'] = u'http://' + url.lstrip('/') return resource @@ -259,7 +278,11 @@ def package_dictize(pkg, context): .where(member_rev.c.state == 'active') \ .where(group.c.is_organization == False) result = _execute_with_revision(q, member_rev, context) - result_dict["groups"] = d.obj_list_dictize(result, context) + context['with_capacity'] = False + ## no package counts as cannot fetch from search index at the same + ## time as indexing to it. + result_dict["groups"] = group_list_dictize(result, context, + with_package_counts=False) #owning organization group_rev = model.group_revision_table q = select([group_rev] @@ -325,7 +348,6 @@ def _get_members(context, group, member_type): def group_dictize(group, context): - model = context['model'] result_dict = d.table_dictize(group, context) result_dict['display_name'] = group.display_name @@ -335,16 +357,32 @@ def group_dictize(group, context): context['with_capacity'] = True - result_dict['packages'] = d.obj_list_dictize( - _get_members(context, group, 'packages'), - context) + include_datasets = context.get('include_datasets', True) + + q = { + 'facet': 'false', + 'rows': 0, + } - query = search.PackageSearchQuery() if group.is_organization: - q = {'q': 'owner_org:"%s" +capacity:public' % group.id, 'rows': 1} + q['fq'] = 'owner_org:"{0}"'.format(group.id) else: - q = {'q': 'groups:"%s" +capacity:public' % group.name, 'rows': 1} - result_dict['package_count'] = query.run(q)['count'] + q['fq'] = 'groups:"{0}"'.format(group.name) + + is_group_member = (context.get('user') and + new_authz.has_user_permission_for_group_or_org(group.id, context.get('user'), 'read')) + if is_group_member: + context['ignore_capacity_check'] = True + + if include_datasets: + q['rows'] = 1000 # Only the first 1000 datasets are returned + + search_results = logic.get_action('package_search')(context, q) + + if include_datasets: + result_dict['packages'] = search_results['results'] + + result_dict['package_count'] = search_results['count'] result_dict['tags'] = tag_list_dictize( _get_members(context, group, 'tags'), @@ -452,6 +490,9 @@ def user_list_dictize(obj_list, context, for obj in obj_list: user_dict = user_dictize(obj, context) + user_dict.pop('reset_key', None) + user_dict.pop('apikey', None) + user_dict.pop('email', None) result_list.append(user_dict) return sorted(result_list, key=sort_key, reverse=reverse) @@ -476,13 +517,24 @@ def user_dictize(user, context): requester = context.get('user') - if not (new_authz.is_sysadmin(requester) or - requester == user.name or - context.get('keep_sensitive_data', False)): - # If not sysadmin or the same user, strip sensible info - result_dict.pop('apikey', None) - result_dict.pop('reset_key', None) - result_dict.pop('email', None) + reset_key = result_dict.pop('reset_key', None) + apikey = result_dict.pop('apikey', None) + email = result_dict.pop('email', None) + + if context.get('keep_email', False): + result_dict['email'] = email + + if context.get('keep_apikey', False): + result_dict['apikey'] = email + + if requester == user.name: + result_dict['apikey'] = apikey + result_dict['email'] = email + + ## this should not really really be needed but tests r it + if new_authz.is_sysadmin(requester): + result_dict['apikey'] = apikey + result_dict['email'] = email model = context['model'] session = model.Session @@ -653,3 +705,20 @@ def user_following_dataset_dictize(follower, context): def user_following_group_dictize(follower, context): return d.table_dictize(follower, context) + + base_columns = set(['id', 'resource_id', 'title', 'description', + 'view_type', 'order', 'config']) + +def resource_view_dictize(resource_view, context): + dictized = d.table_dictize(resource_view, context) + dictized.pop('order') + config = dictized.pop('config', {}) + dictized.update(config) + return dictized + +def resource_view_list_dictize(resource_views, context): + resource_view_dicts = [] + for view in resource_views: + resource_view_dicts.append(resource_view_dictize(view, context)) + return resource_view_dicts + diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py index 4122172b837..56ba6ec0758 100644 --- a/ckan/lib/dictization/model_save.py +++ b/ckan/lib/dictization/model_save.py @@ -41,8 +41,8 @@ def resource_dict_save(res_dict, context): # this is an internal field so ignore # FIXME This helps get the tests to pass but is a hack and should # be fixed properly. basically don't update the format if not needed - if (key == 'format' and value == obj.format - or value == d.model_dictize._unified_resource_format(obj.format)): + if (key == 'format' and (value == obj.format + or value == d.model_dictize._unified_resource_format(obj.format))): continue setattr(obj, key, value) else: @@ -342,7 +342,11 @@ def group_member_save(context, group_dict, member_table_name): entities = {} Member = model.Member - ModelClass = getattr(model, member_table_name[:-1].capitalize()) + classname = member_table_name[:-1].capitalize() + if classname == 'Organization': + # Organizations use the model.Group class + classname = 'Group' + ModelClass = getattr(model, classname) for entity_dict in entity_list: name_or_id = entity_dict.get('id') or entity_dict.get('name') diff --git a/ckan/lib/email_notifications.py b/ckan/lib/email_notifications.py index ba245872531..7f24b978285 100644 --- a/ckan/lib/email_notifications.py +++ b/ckan/lib/email_notifications.py @@ -221,7 +221,7 @@ def get_and_send_notifications_for_user(user): def get_and_send_notifications_for_all_users(): context = {'model': model, 'session': model.Session, 'ignore_auth': True, - 'keep_sensitive_data': True} + 'keep_email': True} users = logic.get_action('user_list')(context, {}) for user in users: get_and_send_notifications_for_user(user) diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index f5f0a7e197f..e0642ca1c8f 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -607,7 +607,10 @@ def get_facet_title(name): def get_param_int(name, default=10): - return int(request.params.get(name, default)) + try: + return int(request.params.get(name, default)) + except ValueError: + return default def _url_with_params(url, params): diff --git a/ckan/lib/plugins.py b/ckan/lib/plugins.py index 40a398bd3f4..5e2aa9116c3 100644 --- a/ckan/lib/plugins.py +++ b/ckan/lib/plugins.py @@ -115,7 +115,7 @@ def register_group_plugins(map): """ Register the various IGroupForm instances. - This method will setup the mappings between package types and the + This method will setup the mappings between group types and the registered IGroupForm instances. If it's called more than once an exception will be raised. """ @@ -160,7 +160,7 @@ def register_group_plugins(map): if group_type in _group_plugins: raise ValueError, "An existing IGroupForm is "\ - "already associated with the package type "\ + "already associated with the group type "\ "'%s'" % group_type _group_plugins[group_type] = plugin diff --git a/ckan/lib/uploader.py b/ckan/lib/uploader.py index cc4e8751d57..a9c843a0d0d 100644 --- a/ckan/lib/uploader.py +++ b/ckan/lib/uploader.py @@ -6,9 +6,13 @@ import logging import ckan.logic as logic + +config = pylons.config log = logging.getLogger(__name__) _storage_path = None +_max_resource_size = None +_max_image_size = None def get_storage_path(): @@ -17,9 +21,9 @@ def get_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') + storage_path = config.get('ckan.storage_path') + ofs_impl = config.get('ofs.impl') + ofs_storage_dir = config.get('ofs.storage_dir') if storage_path: _storage_path = storage_path elif ofs_impl == 'pairtree' and ofs_storage_dir: @@ -40,6 +44,20 @@ def get_storage_path(): return _storage_path +def get_max_image_size(): + global _max_image_size + if _max_image_size is None: + _max_image_size = int(config.get('ckan.max_image_size', 2)) + return _max_image_size + + +def get_max_resource_size(): + global _max_resource_size + if _max_resource_size is None: + _max_resource_size = int(config.get('ckan.max_resource_size', 10)) + return _max_resource_size + + class Upload(object): def __init__(self, object_type, old_filename=None): ''' Setup upload by creating a subdirectory of the storage directory @@ -129,3 +147,79 @@ def upload(self, max_size=2): os.remove(self.old_filepath) except OSError, e: pass + + +class ResourceUpload(object): + def __init__(self, resource): + path = get_storage_path() + if not path: + self.storage_path = None + return + self.storage_path = os.path.join(path, 'resources') + try: + os.makedirs(self.storage_path) + except OSError, e: + ## errno 17 is file already exists + if e.errno != 17: + raise + self.filename = None + + url = resource.get('url') + upload_field_storage = resource.pop('upload', None) + self.clear = resource.pop('clear_upload', None) + + if isinstance(upload_field_storage, cgi.FieldStorage): + self.filename = upload_field_storage.filename + self.filename = munge.munge_filename(self.filename) + resource['url'] = self.filename + resource['url_type'] = 'upload' + self.upload_file = upload_field_storage.file + elif self.clear: + resource['url_type'] = '' + + def get_directory(self, id): + directory = os.path.join(self.storage_path, + id[0:3], id[3:6]) + return directory + + def get_path(self, id): + directory = self.get_directory(id) + filepath = os.path.join(directory, id[6:]) + return filepath + + def upload(self, id, max_size=10): + if not self.storage_path: + return + directory = self.get_directory(id) + filepath = self.get_path(id) + if self.filename: + try: + os.makedirs(directory) + except OSError, e: + ## errno 17 is file already exists + if e.errno != 17: + raise + tmp_filepath = filepath + '~' + output_file = open(tmp_filepath, 'wb+') + self.upload_file.seek(0) + current_size = 0 + while True: + current_size = current_size + 1 + #MB chunks + data = self.upload_file.read(2 ** 20) + if not data: + break + output_file.write(data) + if current_size > max_size: + os.remove(tmp_filepath) + raise logic.ValidationError( + {'upload': ['File upload too large']} + ) + output_file.close() + os.rename(tmp_filepath, filepath) + + if self.clear: + try: + os.remove(filepath) + except OSError, e: + pass diff --git a/ckan/logic/__init__.py b/ckan/logic/__init__.py index 4a474e26432..7227da77429 100644 --- a/ckan/logic/__init__.py +++ b/ckan/logic/__init__.py @@ -388,7 +388,7 @@ def get_action(action): resolved_action_plugins[name] ) ) - log.debug('Auth function %r was inserted', plugin.name) + log.debug('Action function {0} from plugin {1} was inserted'.format(name, plugin.name)) resolved_action_plugins[name] = plugin.name # Extensions are exempted from the auth audit for now # This needs to be resolved later @@ -502,6 +502,23 @@ def get_or_bust(data_dict, keys): return values[0] return tuple(values) +def validate(schema_func, can_skip_validator=False): + ''' A decorator that validates an action function against a given schema + ''' + def action_decorator(action): + @functools.wraps(action) + def wrapper(context, data_dict): + if can_skip_validator: + if context.get('skip_validation'): + return action(context, data_dict) + + schema = context.get('schema', schema_func()) + data_dict, errors = _validate(data_dict, schema, context) + if errors: + raise ValidationError(errors) + return action(context, data_dict) + return wrapper + return action_decorator def side_effect_free(action): '''A decorator that marks the given action function as side-effect-free. diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index bc2e30d2ec3..83500a28abc 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -239,6 +239,8 @@ def resource_create(context, data_dict): :type cache_last_updated: iso date string :param webstore_last_updated: (optional) :type webstore_last_updated: iso date string + :param upload: (optional) + :type upload: FieldStorage (optional) needs multipart/form-data :returns: the newly created resource :rtype: dictionary @@ -256,15 +258,31 @@ def resource_create(context, data_dict): if not 'resources' in pkg_dict: pkg_dict['resources'] = [] + + upload = uploader.ResourceUpload(data_dict) + pkg_dict['resources'].append(data_dict) try: - pkg_dict = _get_action('package_update')(context, pkg_dict) + context['defer_commit'] = True + context['use_cache'] = False + _get_action('package_update')(context, pkg_dict) + context.pop('defer_commit') except ValidationError, e: errors = e.error_dict['resources'][-1] raise ValidationError(errors) - return pkg_dict['resources'][-1] + ## Get out resource_id resource from model as it will not appear in + ## package_show until after commit + upload.upload(context['package'].resources[-1].id, + uploader.get_max_resource_size()) + model.repo.commit() + + ## Run package show again to get out actual last_resource + pkg_dict = _get_action('package_show')(context, {'id': package_id}) + resource = pkg_dict['resources'][-1] + + return resource def related_create(context, data_dict): @@ -451,9 +469,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) + _check_access('member_create', context, data_dict) # Look up existing, in case it exists member = model.Session.query(model.Member).\ @@ -478,7 +494,6 @@ def _group_or_org_create(context, data_dict, is_org=False): model = context['model'] user = context['user'] session = context['session'] - parent = context.get('parent', None) data_dict['is_organization'] = is_org upload = uploader.Upload('group') @@ -520,14 +535,6 @@ def _group_or_org_create(context, data_dict, is_org=False): group = model_save.group_dict_save(data, context) - if parent: - parent_group = model.Group.get( parent ) - if parent_group: - member = model.Member(group=parent_group, table_id=group.id, table_name='group') - session.add(member) - log.debug('Group %s is made child of group %s', - group.name, parent_group.name) - if user: admins = [model.User.by_name(user.decode('utf8'))] else: @@ -569,7 +576,7 @@ def _group_or_org_create(context, data_dict, is_org=False): logic.get_action('activity_create')(activity_create_context, activity_dict) - upload.upload() + upload.upload(uploader.get_max_image_size()) if not context.get('defer_commit'): model.repo.commit() context["group"] = group @@ -835,7 +842,8 @@ def user_create(context, data_dict): # # The context is copied so as not to clobber the caller's context dict. user_dictize_context = context.copy() - user_dictize_context['keep_sensitive_data'] = True + user_dictize_context['keep_apikey'] = True + user_dictize_context['keep_email'] = True user_dict = model_dictize.user_dictize(user, user_dictize_context) context['user_obj'] = user diff --git a/ckan/logic/action/delete.py b/ckan/logic/action/delete.py index 9e3ee3244ab..2fdaf2f6563 100644 --- a/ckan/logic/action/delete.py +++ b/ckan/logic/action/delete.py @@ -1,5 +1,7 @@ '''API functions for deleting data from CKAN.''' +from sqlalchemy import or_ + import ckan.logic import ckan.logic.action import ckan.plugins as plugins @@ -226,8 +228,7 @@ def member_delete(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 remove a member from it - _check_access('group_update', context, data_dict) + _check_access('member_create', context, data_dict) member = model.Session.query(model.Member).\ filter(model.Member.table_name == obj_type).\ @@ -276,6 +277,15 @@ def _group_or_org_delete(context, data_dict, is_org=False): rev = model.repo.new_revision() rev.author = user rev.message = _(u'REST API: Delete %s') % revisioned_details + + # The group's Member objects are deleted + # (including hierarchy connections to parent and children groups) + for member in model.Session.query(model.Member).\ + filter(or_(model.Member.table_id == id, + model.Member.group_id == id)).\ + filter(model.Member.state == 'active').all(): + member.delete() + group.delete() if is_org: diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 21612765a48..b197b58dcc0 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -63,6 +63,7 @@ def site_read(context,data_dict=None): _check_access('site_read',context,data_dict) return True +@logic.validate(logic.schema.default_pagination_schema) def package_list(context, data_dict): '''Return a list of the names of the site's datasets (packages). @@ -81,12 +82,6 @@ def package_list(context, data_dict): _check_access('package_list', context, data_dict) - schema = context.get('schema', logic.schema.default_pagination_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - - package_revision_table = model.package_revision_table col = (package_revision_table.c.id if api == 2 else package_revision_table.c.name) @@ -107,6 +102,7 @@ def package_list(context, data_dict): query = query.offset(offset) return list(zip(*query.execute())[0]) +@logic.validate(logic.schema.default_package_list_schema) def current_package_list_with_resources(context, data_dict): '''Return a list of the site's datasets (packages) and their resources. @@ -125,20 +121,13 @@ def current_package_list_with_resources(context, data_dict): ''' model = context["model"] - schema = context.get('schema', logic.schema.default_package_list_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - limit = data_dict.get('limit') - offset = int(data_dict.get('offset', 0)) + offset = data_dict.get('offset', 0) if not 'offset' in data_dict and 'page' in data_dict: log.warning('"page" parameter is deprecated. ' 'Use the "offset" parameter instead') - page = int(data_dict['page']) - if page < 1: - raise ValidationError(_('Must be larger than 0')) + page = data_dict['page'] if limit: offset = (page - 1) * limit else: @@ -335,6 +324,7 @@ def translated_capacity(capacity): def _group_or_org_list(context, data_dict, is_org=False): model = context['model'] + user = context['user'] api = context.get('api_version') groups = data_dict.get('groups') ref_group_by = 'id' if api == 2 else 'name' @@ -464,7 +454,7 @@ def group_list_authz(context, data_dict): _check_access('group_list_authz',context, data_dict) sysadmin = new_authz.is_sysadmin(user) - roles = ckan.new_authz.get_roles_with_permission('edit_group') + roles = ckan.new_authz.get_roles_with_permission('manage_group') if not roles: return [] user_id = new_authz.get_user_id_for_username(user, allow_none=True) @@ -551,7 +541,8 @@ def organization_list_for_user(context, data_dict): orgs_list = model_dictize.group_list_dictize(orgs_q.all(), context) return orgs_list -def group_revision_list(context, data_dict): + +def _group_or_org_revision_list(context, data_dict): '''Return a group's revisions. :param id: the name or id of the group @@ -566,7 +557,6 @@ def group_revision_list(context, data_dict): if group is None: raise NotFound - _check_access('group_revision_list',context, data_dict) revision_dicts = [] for revision, object_revisions in group.all_related_revisions: @@ -575,6 +565,32 @@ def group_revision_list(context, data_dict): include_groups=False)) return revision_dicts +def group_revision_list(context, data_dict): + '''Return a group's revisions. + + :param id: the name or id of the group + :type id: string + + :rtype: list of dictionaries + + ''' + + _check_access('group_revision_list',context, data_dict) + return _group_or_org_revision_list(context, data_dict) + +def organization_revision_list(context, data_dict): + '''Return an organization's revisions. + + :param id: the name or id of the organization + :type id: string + + :rtype: list of dictionaries + + ''' + + _check_access('organization_revision_list',context, data_dict) + return _group_or_org_revision_list(context, data_dict) + def license_list(context, data_dict): '''Return the list of licenses available for datasets on the site. @@ -944,6 +960,11 @@ def _group_or_org_show(context, data_dict, is_org=False): group = model.Group.get(id) context['group'] = group + include_datasets = data_dict.get('include_datasets', True) + if isinstance(include_datasets, basestring): + include_datasets = (include_datasets.lower() in ('true', '1')) + context['include_datasets'] = include_datasets + if group is None: raise NotFound if is_org and not group.is_organization: @@ -990,9 +1011,14 @@ def group_show(context, data_dict): :param id: the id or name of the group :type id: string + :param include_datasets: include a list of the group's datasets + (optional, default: ``True``) + :type id: boolean :rtype: dictionary + .. note:: Only its first 1000 datasets are returned + ''' return _group_or_org_show(context, data_dict) @@ -1001,9 +1027,13 @@ def organization_show(context, data_dict): :param id: the id or name of the organization :type id: string + :param include_datasets: include a list of the organization's datasets + (optional, default: ``True``) + :type id: boolean :rtype: dictionary + .. note:: Only its first 1000 datasets are returned ''' return _group_or_org_show(context, data_dict, is_org=True) @@ -1156,6 +1186,7 @@ def tag_show_rest(context, data_dict): return tag_dict +@logic.validate(logic.schema.default_autocomplete_schema) def package_autocomplete(context, data_dict): '''Return a list of datasets (packages) that match a string. @@ -1171,11 +1202,6 @@ def package_autocomplete(context, data_dict): :rtype: list of dictionaries ''' - schema = context.get('schema', logic.schema.default_autocomplete_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - model = context['model'] _check_access('package_autocomplete', context, data_dict) @@ -1207,6 +1233,7 @@ def package_autocomplete(context, data_dict): return pkg_list +@logic.validate(logic.schema.default_autocomplete_schema) def format_autocomplete(context, data_dict): '''Return a list of resource formats whose names contain a string. @@ -1219,11 +1246,6 @@ def format_autocomplete(context, data_dict): :rtype: list of strings ''' - schema = context.get('schema', logic.schema.default_autocomplete_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - model = context['model'] session = context['session'] @@ -1247,6 +1269,7 @@ def format_autocomplete(context, data_dict): return [resource.format.lower() for resource in query] +@logic.validate(logic.schema.default_autocomplete_schema) def user_autocomplete(context, data_dict): '''Return a list of user names that contain a string. @@ -1260,11 +1283,6 @@ def user_autocomplete(context, data_dict): ``'fullname'``, and ``'id'`` ''' - schema = context.get('schema', logic.schema.default_autocomplete_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - model = context['model'] user = context['user'] @@ -1392,8 +1410,6 @@ def package_search(context, data_dict): # sometimes context['schema'] is None schema = (context.get('schema') or logic.schema.default_package_search_schema()) - if isinstance(data_dict.get('facet.field'), basestring): - data_dict['facet.field'] = json.loads(data_dict['facet.field']) data_dict, errors = _validate(data_dict, schema, context) # put the extras back into the data_dict so that the search can # report needless parameters @@ -1532,6 +1548,7 @@ def package_search(context, data_dict): return search_results +@logic.validate(logic.schema.default_resource_search_schema) def resource_search(context, data_dict): ''' Searches for resources satisfying a given search criteria. @@ -1603,11 +1620,6 @@ def resource_search(context, data_dict): :rtype: dict ''' - schema = context.get('schema', logic.schema.default_resource_search_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - model = context['model'] # Allow either the `query` or `fields` parameter to be given, but not both. @@ -2046,6 +2058,7 @@ def vocabulary_show(context, data_dict): vocabulary_dict = model_dictize.vocabulary_dictize(vocabulary, context) return vocabulary_dict +@logic.validate(logic.schema.default_activity_list_schema) def user_activity_list(context, data_dict): '''Return a user's public activity stream. @@ -2071,12 +2084,12 @@ def user_activity_list(context, data_dict): model = context['model'] - user_ref = _get_or_bust(data_dict, 'id') # May be user name or id. + user_ref = data_dict.get('id') # May be user name or id. user = model.User.get(user_ref) if user is None: raise logic.NotFound - offset = int(data_dict.get('offset', 0)) + offset = data_dict.get('offset', 0) limit = int( data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) @@ -2084,6 +2097,7 @@ def user_activity_list(context, data_dict): offset=offset) return model_dictize.activity_list_dictize(activity_objects, context) +@logic.validate(logic.schema.default_activity_list_schema) def package_activity_list(context, data_dict): '''Return a package's activity stream. @@ -2108,7 +2122,7 @@ def package_activity_list(context, data_dict): model = context['model'] - package_ref = _get_or_bust(data_dict, 'id') # May be name or ID. + package_ref = data_dict.get('id') # May be name or ID. package = model.Package.get(package_ref) if package is None: raise logic.NotFound @@ -2121,6 +2135,7 @@ def package_activity_list(context, data_dict): limit=limit, offset=offset) return model_dictize.activity_list_dictize(activity_objects, context) +@logic.validate(logic.schema.default_activity_list_schema) def group_activity_list(context, data_dict): '''Return a group's activity stream. @@ -2144,8 +2159,8 @@ def group_activity_list(context, data_dict): _check_access('group_show', context, data_dict) model = context['model'] - group_id = _get_or_bust(data_dict, 'id') - offset = int(data_dict.get('offset', 0)) + group_id = data_dict.get('id') + offset = data_dict.get('offset', 0) limit = int( data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) @@ -2157,6 +2172,7 @@ def group_activity_list(context, data_dict): limit=limit, offset=offset) return model_dictize.activity_list_dictize(activity_objects, context) +@logic.validate(logic.schema.default_activity_list_schema) def organization_activity_list(context, data_dict): '''Return a organization's activity stream. @@ -2171,8 +2187,8 @@ def organization_activity_list(context, data_dict): _check_access('organization_show', context, data_dict) model = context['model'] - org_id = _get_or_bust(data_dict, 'id') - offset = int(data_dict.get('offset', 0)) + org_id = data_dict.get('id') + offset = data_dict.get('offset', 0) limit = int( data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) @@ -2184,6 +2200,7 @@ def organization_activity_list(context, data_dict): limit=limit, offset=offset) return model_dictize.activity_list_dictize(activity_objects, context) +@logic.validate(logic.schema.default_pagination_schema) def recently_changed_packages_activity_list(context, data_dict): '''Return the activity stream of all recently added or changed packages. @@ -2201,7 +2218,7 @@ def recently_changed_packages_activity_list(context, data_dict): # FIXME: Filter out activities whose subject or object the user is not # authorized to read. model = context['model'] - offset = int(data_dict.get('offset', 0)) + offset = data_dict.get('offset', 0) limit = int( data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) @@ -2623,6 +2640,7 @@ def group_followee_count(context, data_dict): context['model'].UserFollowingGroup) +@logic.validate(logic.schema.default_follow_user_schema) def followee_list(context, data_dict): '''Return the list of objects that are followed by the given user. @@ -2645,11 +2663,6 @@ def followee_list(context, data_dict): ''' _check_access('followee_list', context, data_dict) - schema = context.get('schema') or ( - ckan.logic.schema.default_follow_user_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) def display_name(followee): '''Return a display name for the given user, group or dataset dict.''' @@ -2782,6 +2795,7 @@ def group_followee_list(context, data_dict): return [model_dictize.group_dictize(group, context) for group in groups] +@logic.validate(logic.schema.default_pagination_schema) def dashboard_activity_list(context, data_dict): '''Return the authorized user's dashboard activity stream. @@ -2802,17 +2816,11 @@ def dashboard_activity_list(context, data_dict): :rtype: list of activity dictionaries ''' - schema = context.get('schema', - ckan.logic.schema.default_pagination_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - _check_access('dashboard_activity_list', context, data_dict) model = context['model'] user_id = model.User.get(context['user']).id - offset = int(data_dict.get('offset', 0)) + offset = data_dict.get('offset', 0) limit = int( data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) @@ -2839,6 +2847,7 @@ def dashboard_activity_list(context, data_dict): return activity_dicts +@logic.validate(ckan.logic.schema.default_pagination_schema) def dashboard_activity_list_html(context, data_dict): '''Return the authorized user's dashboard activity stream as HTML. @@ -2858,15 +2867,9 @@ def dashboard_activity_list_html(context, data_dict): :rtype: string ''' - schema = context.get( - 'schema', ckan.logic.schema.default_pagination_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - activity_stream = dashboard_activity_list(context, data_dict) model = context['model'] - offset = int(data_dict.get('offset', 0)) + offset = data_dict.get('offset', 0) extra_vars = { 'controller': 'user', 'action': 'dashboard', @@ -2925,11 +2928,20 @@ def _unpick_search(sort, allowed_fields=None, total=None): def member_roles_list(context, data_dict): '''Return the possible roles for members of groups and organizations. + :param group_type: the group type, either "group" or "organization" + (optional, default "organization") + :type id: string :returns: a list of dictionaries each with two keys: "text" (the display name of the role, e.g. "Admin") and "value" (the internal name of the role, e.g. "admin") :rtype: list of dictionaries ''' + group_type = data_dict.get('group_type', 'organization') + roles_list = new_authz.roles_list() + if group_type == 'group': + roles_list = [role for role in roles_list + if role['value'] != 'editor'] + _check_access('member_roles_list', context, data_dict) - return new_authz.roles_list() + return roles_list diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index b054f75a4d1..a0b80db7c6e 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -219,15 +219,23 @@ def resource_update(context, data_dict): else: logging.error('Could not find resource ' + id) raise NotFound(_('Resource was not found.')) + + upload = uploader.ResourceUpload(data_dict) + pkg_dict['resources'][n] = data_dict try: + context['defer_commit'] = True + context['use_cache'] = False pkg_dict = _get_action('package_update')(context, pkg_dict) + context.pop('defer_commit') except ValidationError, e: errors = e.error_dict['resources'][n] raise ValidationError(errors) - return pkg_dict['resources'][n] + upload.upload(id, uploader.get_max_resource_size()) + model.repo.commit() + return _get_action('resource_show')(context, {'id': id}) def package_update(context, data_dict): @@ -336,6 +344,50 @@ def package_update(context, data_dict): return output +def package_resource_reorder(context, data_dict): + '''Reorder resources against datasets. If only partial resource ids are + supplied then these are assumed to be first and the other resources will + stay in their original order + + :param id: the id or name of the package to update + :type id: string + :param order: a list of resource ids in the order needed + :type list: list + ''' + + id = _get_or_bust(data_dict, "id") + order = _get_or_bust(data_dict, "order") + if not isinstance(order, list): + raise ValidationError({'order': 'Must be a list of resource'}) + + if len(set(order)) != len(order): + raise ValidationError({'order': 'Must supply unique resource_ids'}) + + package_dict = _get_action('package_show')(context, {'id': id}) + existing_resources = package_dict.get('resources', []) + ordered_resources = [] + + for resource_id in order: + for i in range(0, len(existing_resources)): + if existing_resources[i]['id'] == resource_id: + resource = existing_resources.pop(i) + ordered_resources.append(resource) + break + else: + raise ValidationError( + {'order': + 'resource_id {id} can not be found'.format(id=resource_id)} + ) + + new_resources = ordered_resources + existing_resources + package_dict['resources'] = new_resources + + _check_access('package_resource_reorder', context, package_dict) + _get_action('package_update')(context, package_dict) + + return {'id': id, 'order': [resource['id'] for resource in new_resources]} + + def _update_package_relationship(relationship, comment, context): model = context['model'] api = context.get('api_version') @@ -409,7 +461,6 @@ def _group_or_org_update(context, data_dict, is_org=False): user = context['user'] session = context['session'] id = _get_or_bust(data_dict, 'id') - parent = context.get('parent', None) group = model.Group.get(id) context["group"] = group @@ -469,23 +520,6 @@ def _group_or_org_update(context, data_dict, is_org=False): context['prevent_packages_update'] = True group = model_save.group_dict_save(data, context) - if parent: - parent_group = model.Group.get( parent ) - if parent_group and not parent_group in group.get_groups(group.type): - # Delete all of this groups memberships - current = session.query(model.Member).\ - filter(model.Member.table_id == group.id).\ - filter(model.Member.table_name == "group").all() - if current: - log.debug('Parents of group %s deleted: %r', group.name, - [membership.group.name for membership in current]) - for c in current: - session.delete(c) - member = model.Member(group=parent_group, table_id=group.id, table_name='group') - session.add(member) - log.debug('Group %s is made child of group %s', - group.name, parent_group.name) - if is_org: plugin_type = plugins.IOrganizationController else: @@ -533,7 +567,7 @@ 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() + upload.upload(uploader.get_max_image_size()) if not context.get('defer_commit'): model.repo.commit() diff --git a/ckan/logic/auth/create.py b/ckan/logic/auth/create.py index c43d24fcd9b..0c7be8de7a4 100644 --- a/ckan/logic/auth/create.py +++ b/ckan/logic/auth/create.py @@ -1,5 +1,6 @@ import ckan.logic as logic import ckan.new_authz as new_authz +import ckan.logic.auth as logic_auth from ckan.common import _ @@ -125,6 +126,14 @@ def user_invite(context, data_dict=None): return group_member_create(context, data_dict) def _check_group_auth(context, data_dict): + '''Has this user got update permission for all of the given groups? + If there is a package in the context then ignore that package's groups. + (owner_org is checked elsewhere.) + :returns: False if not allowed to update one (or more) of the given groups. + True otherwise. i.e. True is the default. A blank data_dict + mentions no groups, so it returns True. + + ''' # FIXME This code is shared amoung other logic.auth files and should be # somewhere better if not data_dict: @@ -136,7 +145,7 @@ def _check_group_auth(context, data_dict): api_version = context.get('api_version') or '1' - group_blobs = data_dict.get("groups", []) + group_blobs = data_dict.get('groups', []) groups = set() for group_blob in group_blobs: # group_blob might be a dict or a group_ref @@ -207,3 +216,23 @@ def organization_member_create(context, data_dict): def group_member_create(context, data_dict): return _group_or_org_member_create(context, data_dict) + +def member_create(context, data_dict): + group = logic_auth.get_group_object(context, data_dict) + user = context['user'] + + # User must be able to update the group to add a member to it + permission = 'update' + # However if the user is member of group then they can add/remove datasets + if not group.is_organization and data_dict.get('object_type') == 'package': + permission = 'manage_group' + + authorized = new_authz.has_user_permission_for_group_or_org(group.id, + user, + permission) + if not authorized: + return {'success': False, + 'msg': _('User %s not authorized to edit group %s') % + (str(user), group.id)} + else: + return {'success': True} diff --git a/ckan/logic/auth/get.py b/ckan/logic/auth/get.py index c00624bb5d6..37de61d975a 100644 --- a/ckan/logic/auth/get.py +++ b/ckan/logic/auth/get.py @@ -39,6 +39,9 @@ def revision_list(context, data_dict): def group_revision_list(context, data_dict): return group_show(context, data_dict) +def organization_revision_list(context, data_dict): + return group_show(context, data_dict) + def package_revision_list(context, data_dict): return package_show(context, data_dict) diff --git a/ckan/logic/auth/update.py b/ckan/logic/auth/update.py index d52a2705914..176c333a5ff 100644 --- a/ckan/logic/auth/update.py +++ b/ckan/logic/auth/update.py @@ -17,7 +17,7 @@ def package_update(context, data_dict): if package.owner_org: # if there is an owner org then we must have update_dataset - # premission for that organization + # permission for that organization check1 = new_authz.has_user_permission_for_group_or_org( package.owner_org, user, 'update_dataset' ) @@ -41,6 +41,9 @@ def package_update(context, data_dict): return {'success': True} +def package_resource_reorder(context, data_dict): + ## the action function runs package update so no need to run it twice + return {'success': True} def resource_update(context, data_dict): model = context['model'] diff --git a/ckan/logic/converters.py b/ckan/logic/converters.py index 74aab6928a6..f6a844afb99 100644 --- a/ckan/logic/converters.py +++ b/ckan/logic/converters.py @@ -1,3 +1,5 @@ +import json + import ckan.model as model import ckan.lib.navl.dictization_functions as df import ckan.lib.field_types as field_types @@ -169,3 +171,13 @@ def convert_group_name_or_id_to_id(group_name_or_id, context): if not result: raise df.Invalid('%s: %s' % (_('Not found'), _('Group'))) return result.id + + +def convert_to_json_if_string(value, context): + if isinstance(value, basestring): + try: + return json.loads(value) + except ValueError: + raise df.Invalid(_('Could not parse as valid JSON')) + else: + return value diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index 761119ee569..e9bd4077260 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -32,6 +32,7 @@ isodate, int_validator, natural_number_validator, + is_positive_integer, boolean_validator, user_about_validator, vocabulary_name_validator, @@ -50,10 +51,13 @@ url_validator, datasets_with_no_organization_cannot_be_private, list_of_strings, + no_loops_in_hierarchy, ) from ckan.logic.converters import (convert_user_name_or_id_to_id, convert_package_name_or_id_to_id, - convert_group_name_or_id_to_id,) + convert_group_name_or_id_to_id, + convert_to_json_if_string, + ) from formencode.validators import OneOf import ckan.model import ckan.lib.maintain as maintain @@ -223,6 +227,8 @@ def default_show_package_schema(): schema['groups'].update({ 'description': [ignore_missing], + 'display_name': [ignore_missing], + 'image_display_url': [ignore_missing], }) # Remove validators for several keys from the schema so validation doesn't @@ -271,16 +277,12 @@ def default_group_schema(): 'approval_status': [ignore_missing, unicode], 'extras': default_extras_schema(), '__extras': [ignore], + '__junk': [ignore], 'packages': { "id": [not_empty, unicode, package_id_or_name_exists], "title":[ignore_missing, unicode], "name":[ignore_missing, unicode], "__extras": [ignore] - }, - 'groups': { - "name": [not_empty, unicode], - "capacity": [ignore_missing], - "__extras": [ignore] }, 'users': { "name": [not_empty, unicode], @@ -288,7 +290,7 @@ def default_group_schema(): "__extras": [ignore] }, 'groups': { - "name": [not_empty, unicode], + "name": [not_empty, no_loops_in_hierarchy, unicode], "capacity": [ignore_missing], "__extras": [ignore] } @@ -351,6 +353,7 @@ def default_extras_schema(): 'state': [ignore], 'deleted': [ignore_missing], 'revision_timestamp': [ignore], + '__extras': [ignore], } return schema @@ -522,7 +525,7 @@ def default_package_list_schema(): schema = { 'limit': [ignore_missing, natural_number_validator], 'offset': [ignore_missing, natural_number_validator], - 'page': [ignore_missing, natural_number_validator] + 'page': [ignore_missing, is_positive_integer] } return schema @@ -541,6 +544,12 @@ def default_dashboard_activity_list_schema(): return schema +def default_activity_list_schema(): + schema = default_pagination_schema() + schema['id'] = [not_missing, unicode] + return schema + + def default_autocomplete_schema(): schema = { 'q': [not_missing, unicode], @@ -560,7 +569,8 @@ def default_package_search_schema(): 'facet': [ignore_missing, unicode], 'facet.mincount': [ignore_missing, natural_number_validator], 'facet.limit': [ignore_missing, int_validator], - 'facet.field': [ignore_missing, list_of_strings], + 'facet.field': [ignore_missing, convert_to_json_if_string, + list_of_strings], 'extras': [ignore_missing] # Not used by Solr, but useful for extensions } return schema diff --git a/ckan/logic/validators.py b/ckan/logic/validators.py index c860b6f0c2b..c239af796cd 100644 --- a/ckan/logic/validators.py +++ b/ckan/logic/validators.py @@ -44,7 +44,9 @@ def owner_org_validator(key, data, errors, context): if not group: raise Invalid(_('Organization does not exist')) group_id = group.id - if not(user.sysadmin or user.is_in_group(group_id)): + if not(user.sysadmin or + new_authz.has_user_permission_for_group_or_org( + group_id, user.name, 'create_dataset')): raise Invalid(_('You cannot add a dataset to this organization')) data[key] = group_id @@ -70,7 +72,13 @@ def int_validator(value, context): def natural_number_validator(value, context): value = int_validator(value, context) if value < 0: - raise Invalid(_('Must be natural number')) + raise Invalid(_('Must be a natural number')) + return value + +def is_positive_integer(value, context): + value = int_validator(value, context) + if value < 1: + raise Invalid(_('Must be a postive integer')) return value def boolean_validator(value, context): @@ -697,3 +705,22 @@ def list_of_strings(key, data, errors, context): if not isinstance(x, basestring): raise Invalid('%s: %s' % (_('Not a string'), x)) +def no_loops_in_hierarchy(key, data, errors, context): + '''Checks that the parent groups specified in the data would not cause + a loop in the group hierarchy, and therefore cause the recursion up/down + the hierarchy to get into an infinite loop. + ''' + if not 'id' in data: + # Must be a new group - has no children, so no chance of loops + return + group = context['model'].Group.get(data['id']) + allowable_parents = group.\ + groups_allowed_to_be_its_parent(type=group.type) + for parent in data['groups']: + parent_name = parent['name'] + # a blank name signifies top level, which is always allowed + if parent_name and context['model'].Group.get(parent_name) \ + not in allowable_parents: + raise Invalid(_('This parent would create a loop in the ' + 'hierarchy')) + diff --git a/ckan/model/group.py b/ckan/model/group.py index 86963983e9a..0d2102375f2 100644 --- a/ckan/model/group.py +++ b/ckan/model/group.py @@ -1,6 +1,6 @@ import datetime -from sqlalchemy import orm, types, Column, Table, ForeignKey, or_ +from sqlalchemy import orm, types, Column, Table, ForeignKey, or_, and_ import vdm.sqlalchemy import meta @@ -54,6 +54,20 @@ class Member(vdm.sqlalchemy.RevisionedObjectMixin, vdm.sqlalchemy.StatefulObjectMixin, domain_object.DomainObject): + '''A Member object represents any other object being a 'member' of a + particular Group. + + Meanings: + * Package - the Group is a collection of Packages + - capacity is 'public', 'private' + or 'organization' if the Group is an Organization + (see ckan.logic.action.package_owner_org_update) + * User - the User is granted permissions for the Group + - capacity is 'admin', 'editor' or 'member' + * Group - the Group (Member.group_id) is a parent of the Group (Member.id) + in a hierarchy. + - capacity is 'parent' + ''' def __init__(self, group=None, table_id=None, group_id=None, table_name=None, capacity='public', state='active'): self.group = group @@ -87,6 +101,21 @@ def related_packages(self): return meta.Session.query(_package.Package).filter_by( id=self.table_id).all() + def __unicode__(self): + # refer to objects by name, not ID, to help debugging + if self.table_name == 'package': + table_info = 'package=%s' % meta.Session.query(_package.Package).\ + get(self.table_id).name + elif self.table_name == 'group': + table_info = 'group=%s' % meta.Session.query(Group).\ + get(self.table_id).name + else: + table_info = 'table_name=%s table_id=%s' % (self.table_name, + self.table_id) + return u'' % \ + (self.group.name if self.group else repr(self.group), + table_info, self.capacity, self.state) + class Group(vdm.sqlalchemy.RevisionedObjectMixin, vdm.sqlalchemy.StatefulObjectMixin, @@ -145,17 +174,93 @@ def set_approval_status(self, status): pass def get_children_groups(self, type='group'): - # Returns a list of dicts where each dict contains "id", "name", - # and "title" When querying with a CTE specifying a model in the - # query parameter causes problems as it returns only the first - # level deep apparently not recursing any deeper than that. If - # we simplify and request only specific fields then if returns - # the full depth of the hierarchy. - results = meta.Session.query("id", "name", "title").\ - from_statement(HIERARCHY_CTE).params(id=self.id, type=type).all() - return [{"id":idf, "name": name, "title": title} - for idf, name, title in results] + '''Returns the groups one level underneath this group in the hierarchy. + ''' + # The original intention of this method was to provide the full depth + # of the tree, but the CTE was incorrect. This new query does what that + # old CTE actually did, but is now far simpler, and returns Group objects + # instead of a dict. + return meta.Session.query(Group).\ + filter_by(type=type).\ + filter_by(state='active').\ + join(Member, Member.group_id == Group.id).\ + filter_by(table_id=self.id).\ + filter_by(table_name='group').\ + filter_by(state='active').\ + all() + + def get_children_group_hierarchy(self, type='group'): + '''Returns the groups in all levels underneath this group in the + hierarchy. The ordering is such that children always come after their + parent. + + :rtype: a list of tuples, each one a Group ID, name and title and then + the ID of its parent group. + + e.g. + >>> dept-health.get_children_group_hierarchy() + [(u'8ac0...', u'national-health-service', u'National Health Service', u'e041...'), + (u'b468...', u'nhs-wirral-ccg', u'NHS Wirral CCG', u'8ac0...')] + ''' + results = meta.Session.query(Group.id, Group.name, Group.title, + 'parent_id').\ + from_statement(HIERARCHY_DOWNWARDS_CTE).\ + params(id=self.id, type=type).all() + return results + + def get_parent_groups(self, type='group'): + '''Returns this group's parent groups. + Returns a list. Will have max 1 value for organizations. + + ''' + return meta.Session.query(Group).\ + join(Member, + and_(Member.table_id == Group.id, + Member.table_name == 'group', + Member.state == 'active')).\ + filter(Member.group_id == self.id).\ + filter(Group.type == type).\ + filter(Group.state == 'active').\ + all() + + def get_parent_group_hierarchy(self, type='group'): + '''Returns this group's parent, parent's parent, parent's parent's + parent etc.. Sorted with the top level parent first.''' + return meta.Session.query(Group).\ + from_statement(HIERARCHY_UPWARDS_CTE).\ + params(id=self.id, type=type).all() + + @classmethod + def get_top_level_groups(cls, type='group'): + '''Returns a list of the groups (of the specified type) which have + no parent groups. Groups are sorted by title. + ''' + return meta.Session.query(cls).\ + outerjoin(Member, + and_(Member.group_id == Group.id, + Member.table_name == 'group', + Member.state == 'active')).\ + filter(Member.id == None).\ + filter(Group.type == type).\ + filter(Group.state == 'active').\ + order_by(Group.title).all() + + def groups_allowed_to_be_its_parent(self, type='group'): + '''Returns a list of the groups (of the specified type) which are + allowed to be this group's parent. It excludes ones which would + create a loop in the hierarchy, causing the recursive CTE to + be in an infinite loop. + + :returns: A list of group objects ordered by group title + ''' + all_groups = self.all(group_type=type) + excluded_groups = set(group_name + for group_id, group_name, group_title, parent in + self.get_children_group_hierarchy(type=type)) + excluded_groups.add(self.name) + return [group for group in all_groups + if group.name not in excluded_groups] def packages(self, with_private=False, limit=None, return_query=False, context=None): @@ -245,23 +350,6 @@ def add_package_by_name(self, package_name): table_name='package') meta.Session.add(member) - def get_groups(self, group_type=None, capacity=None): - """ Get all groups that this group is within """ - import ckan.model as model - if '_groups' not in self.__dict__: - self._groups = meta.Session.query(model.Group).\ - join(model.Member, model.Member.group_id == model.Group.id and - model.Member.table_name == 'group').\ - filter(model.Member.state == 'active').\ - filter(model.Member.table_id == self.id).all() - - groups = self._groups - if group_type: - groups = [g for g in groups if g.type == group_type] - if capacity: - groups = [g for g in groups if g.capacity == capacity] - return groups - @property def all_related_revisions(self): '''Returns chronological list of all object revisions related to @@ -289,7 +377,6 @@ def all_related_revisions(self): def __repr__(self): return '' % self.name - meta.mapper(Group, group_table, extension=[vdm.sqlalchemy.Revisioner(group_revision_table), ], ) @@ -313,16 +400,41 @@ def __repr__(self): #TODO MemberRevision.related_packages = lambda self: [self.continuity.package] +# Should there arise a bug that allows loops in the group hierarchy, then it +# will lead to infinite recursion, tieing up postgres processes at 100%, and +# the server will suffer. To avoid ever failing this badly, we put in this +# limit on recursion. +MAX_RECURSES = 8 + +HIERARCHY_DOWNWARDS_CTE = """WITH RECURSIVE child(depth) AS +( + -- non-recursive term + SELECT 0, * FROM member + WHERE table_id = :id AND table_name = 'group' AND state = 'active' + UNION ALL + -- recursive term + SELECT c.depth + 1, m.* FROM member AS m, child AS c + WHERE m.table_id = c.group_id AND m.table_name = 'group' + AND m.state = 'active' AND c.depth < {max_recurses} +) +SELECT G.id, G.name, G.title, child.depth, child.table_id as parent_id FROM child + INNER JOIN public.group G ON G.id = child.group_id + WHERE G.type = :type AND G.state='active' + ORDER BY child.depth ASC;""".format(max_recurses=MAX_RECURSES) + +HIERARCHY_UPWARDS_CTE = """WITH RECURSIVE parenttree(depth) AS ( + -- non-recursive term + SELECT 0, M.* FROM public.member AS M + WHERE group_id = :id AND M.table_name = 'group' AND M.state = 'active' + UNION + -- recursive term + SELECT PG.depth + 1, M.* FROM parenttree PG, public.member M + WHERE PG.table_id = M.group_id AND M.table_name = 'group' + AND M.state = 'active' AND PG.depth < {max_recurses} + ) + +SELECT G.*, PT.depth FROM parenttree AS PT + INNER JOIN public.group G ON G.id = PT.table_id + WHERE G.type = :type AND G.state='active' + ORDER BY PT.depth DESC;""".format(max_recurses=MAX_RECURSES) -HIERARCHY_CTE = """WITH RECURSIVE subtree(id) AS ( - SELECT M.* FROM public.member AS M - WHERE M.table_name = 'group' AND M.state = 'active' - UNION - SELECT M.* FROM public.member M, subtree SG - WHERE M.table_id = SG.group_id AND M.table_name = 'group' - AND M.state = 'active') - - SELECT G.* FROM subtree AS ST - INNER JOIN public.group G ON G.id = ST.table_id - WHERE group_id = :id AND G.type = :type and table_name='group' - and G.state='active'""" diff --git a/ckan/new_authz.py b/ckan/new_authz.py index 70c8d1c1ab4..44eb09f5dc9 100644 --- a/ckan/new_authz.py +++ b/ckan/new_authz.py @@ -83,7 +83,7 @@ def _build(self): resolved_auth_function_plugins[name] ) ) - log.debug('Auth function %r was inserted', plugin.name) + log.debug('Auth function {0} from plugin {1} was inserted'.format(name, plugin.name)) resolved_auth_function_plugins[name] = plugin.name fetched_auth_functions[name] = auth_function # Use the updated ones in preference to the originals. @@ -192,8 +192,8 @@ def is_authorized(action, context, data_dict=None): # these are the permissions that roles have ROLE_PERMISSIONS = OrderedDict([ ('admin', ['admin']), - ('editor', ['read', 'delete_dataset', 'create_dataset', 'update_dataset']), - ('member', ['read']), + ('editor', ['read', 'delete_dataset', 'create_dataset', 'update_dataset', 'manage_group']), + ('member', ['read', 'manage_group']), ]) @@ -241,7 +241,10 @@ def get_roles_with_permission(permission): def has_user_permission_for_group_or_org(group_id, user_name, permission): - ''' Check if the user has the given permission for the group ''' + ''' Check if the user has the given permissions for the group, allowing for + sysadmin rights and permission cascading down a group hierarchy. + + ''' if not group_id: return False group = model.Group.get(group_id) @@ -256,12 +259,35 @@ def has_user_permission_for_group_or_org(group_id, user_name, permission): user_id = get_user_id_for_username(user_name, allow_none=True) if not user_id: return False + if _has_user_permission_for_groups(user_id, permission, [group_id]): + return True + # Handle when permissions cascade. Check the user's roles on groups higher + # in the group hierarchy for permission. + for capacity in check_config_permission('roles_that_cascade_to_sub_groups'): + parent_groups = group.get_parent_group_hierarchy(type=group.type) + group_ids = [group_.id for group_ in parent_groups] + if _has_user_permission_for_groups(user_id, permission, group_ids, + capacity=capacity): + return True + return False + + +def _has_user_permission_for_groups(user_id, permission, group_ids, + capacity=None): + ''' Check if the user has the given permissions for the particular + group (ignoring permissions cascading in a group hierarchy). + Can also be filtered by a particular capacity. + ''' + if not group_ids: + return False # get any roles the user has for the group q = model.Session.query(model.Member) \ - .filter(model.Member.group_id == group_id) \ + .filter(model.Member.group_id.in_(group_ids)) \ .filter(model.Member.table_name == 'user') \ .filter(model.Member.state == 'active') \ .filter(model.Member.table_id == user_id) + if capacity: + q = q.filter(model.Member.capacity == capacity) # see if any role has the required permission # admin permission allows anything for the group for row in q.all(): @@ -272,7 +298,10 @@ def has_user_permission_for_group_or_org(group_id, user_name, permission): def users_role_for_group_or_org(group_id, user_name): - ''' Check if the user role for the group ''' + ''' Returns the user's role for the group. (Ignores privileges that cascade + in a group hierarchy.) + + ''' if not group_id: return None group_id = model.Group.get(group_id).id @@ -293,7 +322,7 @@ def users_role_for_group_or_org(group_id, user_name): def has_user_permission_for_some_org(user_name, permission): - ''' Check if the user has the given permission for the group ''' + ''' Check if the user has the given permission for any organization. ''' user_id = get_user_id_for_username(user_name, allow_none=True) if not user_id: return False @@ -352,19 +381,28 @@ def get_user_id_for_username(user_name, allow_none=False): 'user_delete_organizations': True, 'create_user_via_api': False, 'create_user_via_web': True, + 'roles_that_cascade_to_sub_groups': 'admin', } CONFIG_PERMISSIONS = {} def check_config_permission(permission): - ''' Returns the permission True/False based on config ''' + ''' Returns the permission configuration, usually True/False ''' # set up perms if not already done if not CONFIG_PERMISSIONS: for perm in CONFIG_PERMISSIONS_DEFAULTS: key = 'ckan.auth.' + perm default = CONFIG_PERMISSIONS_DEFAULTS[perm] - CONFIG_PERMISSIONS[perm] = asbool(config.get(key, default)) + CONFIG_PERMISSIONS[perm] = config.get(key, default) + if perm == 'roles_that_cascade_to_sub_groups': + # this permission is a list of strings (space separated) + CONFIG_PERMISSIONS[perm] = \ + CONFIG_PERMISSIONS[perm].split(' ') \ + if CONFIG_PERMISSIONS[perm] else [] + else: + # most permissions are boolean + CONFIG_PERMISSIONS[perm] = asbool(CONFIG_PERMISSIONS[perm]) if permission in CONFIG_PERMISSIONS: return CONFIG_PERMISSIONS[permission] return False diff --git a/ckan/new_tests/factories.py b/ckan/new_tests/factories.py index 6e8385eeacf..b049038b6dc 100644 --- a/ckan/new_tests/factories.py +++ b/ckan/new_tests/factories.py @@ -99,6 +99,85 @@ def _create(cls, target_class, *args, **kwargs): return user_dict +class Group(factory.Factory): + '''A factory class for creating CKAN groups.''' + + # This is the class that GroupFactory will create and return instances + # of. + FACTORY_FOR = ckan.model.Group + + # These are the default params that will be used to create new groups. + type = 'group' + is_organization = False + + title = 'Test Group' + description = 'Just another test group.' + image_url = 'http://placekitten.com/g/200/200' + + # Generate a different group name param for each user that gets created. + name = factory.Sequence(lambda n: 'test_group_{n}'.format(n=n)) + + @classmethod + def _build(cls, target_class, *args, **kwargs): + raise NotImplementedError(".build() isn't supported in CKAN") + + @classmethod + def _create(cls, target_class, *args, **kwargs): + if args: + assert False, "Positional args aren't supported, use keyword args." + + #TODO: we will need to be able to define this when creating the + # instance perhaps passing a 'user' param? + context = { + 'user': helpers.call_action('get_site_user')['name'] + } + + group_dict = helpers.call_action('group_create', + context=context, + **kwargs) + return group_dict + + +class Organization(factory.Factory): + '''A factory class for creating CKAN organizations.''' + + # This is the class that OrganizationFactory will create and return + # instances of. + FACTORY_FOR = ckan.model.Group + + # These are the default params that will be used to create new + # organizations. + type = 'organization' + is_organization = True + + title = 'Test Organization' + description = 'Just another test organization.' + image_url = 'http://placekitten.com/g/200/100' + + # Generate a different group name param for each user that gets created. + name = factory.Sequence(lambda n: 'test_org_{n}'.format(n=n)) + + @classmethod + def _build(cls, target_class, *args, **kwargs): + raise NotImplementedError(".build() isn't supported in CKAN") + + @classmethod + def _create(cls, target_class, *args, **kwargs): + if args: + assert False, "Positional args aren't supported, use keyword args." + + #TODO: we will need to be able to define this when creating the + # instance perhaps passing a 'user' param? + context = { + 'user': helpers.call_action('get_site_user')['name'] + } + + group_dict = helpers.call_action('organization_create', + context=context, + **kwargs) + return group_dict + + class MockUser(factory.Factory): '''A factory class for creating mock CKAN users using the mock library.''' diff --git a/ckan/new_tests/logic/action/test_get.py b/ckan/new_tests/logic/action/test_get.py new file mode 100644 index 00000000000..f2207d2a6d0 --- /dev/null +++ b/ckan/new_tests/logic/action/test_get.py @@ -0,0 +1,183 @@ +import nose.tools + +import ckan.logic as logic +import ckan.lib.search as search +import ckan.new_tests.helpers as helpers +import ckan.new_tests.factories as factories + + +class TestGet(object): + + @classmethod + def setup_class(cls): + helpers.reset_db() + + def setup(self): + import ckan.model as model + + # Reset the db before each test method. + model.repo.rebuild_db() + + # Clear the search index + search.clear() + + def test_group_list(self): + + group1 = factories.Group() + group2 = factories.Group() + + group_list = helpers.call_action('group_list') + + assert (sorted(group_list) == + sorted([g['name'] for g in [group1, group2]])) + + def test_group_show(self): + + group = factories.Group() + + group_dict = helpers.call_action('group_show', id=group['id']) + + # FIXME: Should this be returned by group_create? + group_dict.pop('num_followers', None) + assert group_dict == group + + def test_group_show_packages_returned(self): + + user_name = helpers.call_action('get_site_user')['name'] + + group = factories.Group() + + datasets = [ + {'name': 'dataset_1', 'groups': [{'name': group['name']}]}, + {'name': 'dataset_2', 'groups': [{'name': group['name']}]}, + ] + + for dataset in datasets: + helpers.call_action('package_create', + context={'user': user_name}, + **dataset) + + group_dict = helpers.call_action('group_show', id=group['id']) + + assert len(group_dict['packages']) == 2 + assert group_dict['package_count'] == 2 + + def test_group_show_no_packages_returned(self): + + user_name = helpers.call_action('get_site_user')['name'] + + group = factories.Group() + + datasets = [ + {'name': 'dataset_1', 'groups': [{'name': group['name']}]}, + {'name': 'dataset_2', 'groups': [{'name': group['name']}]}, + ] + + for dataset in datasets: + helpers.call_action('package_create', + context={'user': user_name}, + **dataset) + + group_dict = helpers.call_action('group_show', id=group['id'], + include_datasets=False) + + assert not 'packages' in group_dict + assert group_dict['package_count'] == 2 + + def test_organization_list(self): + + org1 = factories.Organization() + org2 = factories.Organization() + + org_list = helpers.call_action('organization_list') + + assert (sorted(org_list) == + sorted([g['name'] for g in [org1, org2]])) + + def test_organization_show(self): + + org = factories.Organization() + + org_dict = helpers.call_action('organization_show', id=org['id']) + + # FIXME: Should this be returned by organization_create? + org_dict.pop('num_followers', None) + assert org_dict == org + + def test_organization_show_packages_returned(self): + + user_name = helpers.call_action('get_site_user')['name'] + + org = factories.Organization() + + datasets = [ + {'name': 'dataset_1', 'owner_org': org['name']}, + {'name': 'dataset_2', 'owner_org': org['name']}, + ] + + for dataset in datasets: + helpers.call_action('package_create', + context={'user': user_name}, + **dataset) + + org_dict = helpers.call_action('organization_show', id=org['id']) + + assert len(org_dict['packages']) == 2 + assert org_dict['package_count'] == 2 + + def test_organization_show_private_packages_not_returned(self): + + user_name = helpers.call_action('get_site_user')['name'] + + org = factories.Organization() + + datasets = [ + {'name': 'dataset_1', 'owner_org': org['name']}, + {'name': 'dataset_2', 'owner_org': org['name'], 'private': True}, + ] + + for dataset in datasets: + helpers.call_action('package_create', + context={'user': user_name}, + **dataset) + + org_dict = helpers.call_action('organization_show', id=org['id']) + + assert len(org_dict['packages']) == 1 + assert org_dict['packages'][0]['name'] == 'dataset_1' + assert org_dict['package_count'] == 1 + + +class TestBadLimitQueryParameters(object): + '''test class for #1258 non-int query parameters cause 500 errors + + Test that validation errors are raised when calling actions with + bad parameters. + ''' + + def test_activity_list_actions(self): + actions = [ + 'user_activity_list', + 'package_activity_list', + 'group_activity_list', + 'organization_activity_list', + 'recently_changed_packages_activity_list', + 'user_activity_list_html', + 'package_activity_list_html', + 'group_activity_list_html', + 'organization_activity_list_html', + 'recently_changed_packages_activity_list_html', + ] + for action in actions: + nose.tools.assert_raises( + logic.ValidationError, helpers.call_action, action, + id='test_user', limit='not_an_int', offset='not_an_int') + nose.tools.assert_raises( + logic.ValidationError, helpers.call_action, action, + id='test_user', limit=-1, offset=-1) + + def test_package_search_facet_field_is_json(self): + kwargs = {'facet.field': 'notjson'} + nose.tools.assert_raises( + logic.ValidationError, helpers.call_action, 'package_search', + **kwargs) diff --git a/ckan/new_tests/logic/action/test_update.py b/ckan/new_tests/logic/action/test_update.py index 8413a3a8178..268ca339c36 100644 --- a/ckan/new_tests/logic/action/test_update.py +++ b/ckan/new_tests/logic/action/test_update.py @@ -344,3 +344,44 @@ def test_user_update_does_not_return_reset_key(self): updated_user = helpers.call_action('user_update', **params) assert 'reset_key' not in updated_user + + def test_resource_reorder(self): + resource_urls = ["http://a.html", "http://b.html", "http://c.html"] + dataset = {"name": "basic", + "resources": [{'url': url} for url in resource_urls] + } + + dataset = helpers.call_action('package_create', **dataset) + created_resource_urls = [resource['url'] for resource + in dataset['resources']] + assert created_resource_urls == resource_urls + mapping = dict((resource['url'], resource['id']) for resource + in dataset['resources']) + + ## This should put c.html at the front + reorder = {'id': dataset['id'], 'order': + [mapping["http://c.html"]]} + + helpers.call_action('package_resource_reorder', **reorder) + + dataset = helpers.call_action('package_show', id=dataset['id']) + reordered_resource_urls = [resource['url'] for resource + in dataset['resources']] + + assert reordered_resource_urls == ["http://c.html", + "http://a.html", + "http://b.html"] + + reorder = {'id': dataset['id'], 'order': [mapping["http://b.html"], + mapping["http://c.html"], + mapping["http://a.html"]]} + + helpers.call_action('package_resource_reorder', **reorder) + dataset = helpers.call_action('package_show', id=dataset['id']) + + reordered_resource_urls = [resource['url'] for resource + in dataset['resources']] + + assert reordered_resource_urls == ["http://b.html", + "http://c.html", + "http://a.html"] diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index 14d64b01146..f3dfce7d962 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -770,7 +770,7 @@ def read_template(self): 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 + 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'``. diff --git a/ckan/public/base/css/fuchsia.css b/ckan/public/base/css/fuchsia.css index c87cd3b10a1..2017ab41be6 100644 --- a/ckan/public/base/css/fuchsia.css +++ b/ckan/public/base/css/fuchsia.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: #e73892; /* 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; @@ -5575,6 +5547,12 @@ textarea { } .form-actions .control-required-message { float: left; + margin-left: 20px; + margin-bottom: 0; + line-height: 30px; +} +.form-actions .control-required-message:first-child { + margin-left: 0; } .form-actions { background: none; @@ -5692,27 +5670,24 @@ textarea { padding: 7px 5px; } .simple-input .field .btn-search { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - width: 16px; - height: 16px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - background-position: -51px -16px; position: absolute; display: block; height: 17px; width: 17px; + padding: 0; top: 50%; right: 0; - margin-top: -8px; + margin-top: -10px; background-color: transparent; border: none; - text-indent: -999em; + color: #999; + -webkit-transition: color 0.2s ease-in; + -moz-transition: color 0.2s ease-in; + -o-transition: color 0.2s ease-in; + transition: color 0.2s ease-in; +} +.simple-input .field .btn-search:hover { + color: #000; } .editor textarea { -webkit-border-radius: 3px 3px 0 0; @@ -5818,7 +5793,6 @@ textarea { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #e73892; /* 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, @@ -6097,7 +6071,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); @@ -6120,6 +6093,43 @@ 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; @@ -6414,6 +6424,10 @@ textarea { font-weight: normal; color: #000000; } +.search-form.no-bottom-border { + border-bottom-width: 0; + margin-bottom: 0; +} .tertiary .control-order-by { float: none; margin: 0; @@ -6534,7 +6548,6 @@ textarea { margin-right: 5px; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .actions li:last-of-type { @@ -7159,6 +7172,11 @@ h4 small { .context-info.editing .module-content { margin-top: 0; } +.flash-messages .alert { + -webkit-box-shadow: 0 0 0 1px #ffffff; + -moz-box-shadow: 0 0 0 1px #ffffff; + box-shadow: 0 0 0 1px #ffffff; +} .homepage [role=main] { padding: 20px 0; min-height: 0; @@ -7711,6 +7729,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; } @@ -7728,9 +7749,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; } @@ -7981,6 +8021,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: #e73892 url("../../../base/images/bg.png"); } @@ -8182,7 +8237,6 @@ iframe { .ie7 .control-custom .checkbox { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .stages { @@ -8216,7 +8270,6 @@ iframe { .ie7 .masthead nav { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .masthead .header-image { diff --git a/ckan/public/base/css/green.css b/ckan/public/base/css/green.css index 6558ce82f8d..0b6f45d9109 100644 --- a/ckan/public/base/css/green.css +++ b/ckan/public/base/css/green.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: #2f9b45; /* 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; @@ -5575,6 +5547,12 @@ textarea { } .form-actions .control-required-message { float: left; + margin-left: 20px; + margin-bottom: 0; + line-height: 30px; +} +.form-actions .control-required-message:first-child { + margin-left: 0; } .form-actions { background: none; @@ -5692,27 +5670,24 @@ textarea { padding: 7px 5px; } .simple-input .field .btn-search { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - width: 16px; - height: 16px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - background-position: -51px -16px; position: absolute; display: block; height: 17px; width: 17px; + padding: 0; top: 50%; right: 0; - margin-top: -8px; + margin-top: -10px; background-color: transparent; border: none; - text-indent: -999em; + color: #999; + -webkit-transition: color 0.2s ease-in; + -moz-transition: color 0.2s ease-in; + -o-transition: color 0.2s ease-in; + transition: color 0.2s ease-in; +} +.simple-input .field .btn-search:hover { + color: #000; } .editor textarea { -webkit-border-radius: 3px 3px 0 0; @@ -5818,7 +5793,6 @@ textarea { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #2f9b45; /* 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, @@ -6097,7 +6071,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); @@ -6120,6 +6093,43 @@ 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; @@ -6414,6 +6424,10 @@ textarea { font-weight: normal; color: #000000; } +.search-form.no-bottom-border { + border-bottom-width: 0; + margin-bottom: 0; +} .tertiary .control-order-by { float: none; margin: 0; @@ -6534,7 +6548,6 @@ textarea { margin-right: 5px; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .actions li:last-of-type { @@ -7159,6 +7172,11 @@ h4 small { .context-info.editing .module-content { margin-top: 0; } +.flash-messages .alert { + -webkit-box-shadow: 0 0 0 1px #ffffff; + -moz-box-shadow: 0 0 0 1px #ffffff; + box-shadow: 0 0 0 1px #ffffff; +} .homepage [role=main] { padding: 20px 0; min-height: 0; @@ -7711,6 +7729,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; } @@ -7728,9 +7749,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; } @@ -7981,6 +8021,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: #2f9b45 url("../../../base/images/bg.png"); } @@ -8182,7 +8237,6 @@ iframe { .ie7 .control-custom .checkbox { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .stages { @@ -8216,7 +8270,6 @@ iframe { .ie7 .masthead nav { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .masthead .header-image { diff --git a/ckan/public/base/css/main.css b/ckan/public/base/css/main.css index e3069b76c89..df4c0110922 100644 --- a/ckan/public/base/css/main.css +++ b/ckan/public/base/css/main.css @@ -5547,6 +5547,12 @@ textarea { } .form-actions .control-required-message { float: left; + margin-left: 20px; + margin-bottom: 0; + line-height: 30px; +} +.form-actions .control-required-message:first-child { + margin-left: 0; } .form-actions { background: none; @@ -5664,27 +5670,24 @@ textarea { padding: 7px 5px; } .simple-input .field .btn-search { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - width: 16px; - height: 16px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - background-position: -51px -16px; position: absolute; display: block; height: 17px; width: 17px; + padding: 0; top: 50%; right: 0; - margin-top: -8px; + margin-top: -10px; background-color: transparent; border: none; - text-indent: -999em; + color: #999; + -webkit-transition: color 0.2s ease-in; + -moz-transition: color 0.2s ease-in; + -o-transition: color 0.2s ease-in; + transition: color 0.2s ease-in; +} +.simple-input .field .btn-search:hover { + color: #000; } .editor textarea { -webkit-border-radius: 3px 3px 0 0; @@ -6421,6 +6424,10 @@ textarea { font-weight: normal; color: #000000; } +.search-form.no-bottom-border { + border-bottom-width: 0; + margin-bottom: 0; +} .tertiary .control-order-by { float: none; margin: 0; @@ -7165,6 +7172,11 @@ h4 small { .context-info.editing .module-content { margin-top: 0; } +.flash-messages .alert { + -webkit-box-shadow: 0 0 0 1px #ffffff; + -moz-box-shadow: 0 0 0 1px #ffffff; + box-shadow: 0 0 0 1px #ffffff; +} .homepage [role=main] { padding: 20px 0; min-height: 0; diff --git a/ckan/public/base/css/maroon.css b/ckan/public/base/css/maroon.css index 4de9736ff0e..c93b5426b72 100644 --- a/ckan/public/base/css/maroon.css +++ b/ckan/public/base/css/maroon.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: #810606; /* 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; @@ -5575,6 +5547,12 @@ textarea { } .form-actions .control-required-message { float: left; + margin-left: 20px; + margin-bottom: 0; + line-height: 30px; +} +.form-actions .control-required-message:first-child { + margin-left: 0; } .form-actions { background: none; @@ -5692,27 +5670,24 @@ textarea { padding: 7px 5px; } .simple-input .field .btn-search { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - width: 16px; - height: 16px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - background-position: -51px -16px; position: absolute; display: block; height: 17px; width: 17px; + padding: 0; top: 50%; right: 0; - margin-top: -8px; + margin-top: -10px; background-color: transparent; border: none; - text-indent: -999em; + color: #999; + -webkit-transition: color 0.2s ease-in; + -moz-transition: color 0.2s ease-in; + -o-transition: color 0.2s ease-in; + transition: color 0.2s ease-in; +} +.simple-input .field .btn-search:hover { + color: #000; } .editor textarea { -webkit-border-radius: 3px 3px 0 0; @@ -5818,7 +5793,6 @@ textarea { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #810606; /* 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, @@ -6097,7 +6071,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); @@ -6120,6 +6093,43 @@ 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; @@ -6414,6 +6424,10 @@ textarea { font-weight: normal; color: #000000; } +.search-form.no-bottom-border { + border-bottom-width: 0; + margin-bottom: 0; +} .tertiary .control-order-by { float: none; margin: 0; @@ -6534,7 +6548,6 @@ textarea { margin-right: 5px; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .actions li:last-of-type { @@ -7159,6 +7172,11 @@ h4 small { .context-info.editing .module-content { margin-top: 0; } +.flash-messages .alert { + -webkit-box-shadow: 0 0 0 1px #ffffff; + -moz-box-shadow: 0 0 0 1px #ffffff; + box-shadow: 0 0 0 1px #ffffff; +} .homepage [role=main] { padding: 20px 0; min-height: 0; @@ -7711,6 +7729,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; } @@ -7728,9 +7749,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; } @@ -7981,6 +8021,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: #810606 url("../../../base/images/bg.png"); } @@ -8182,7 +8237,6 @@ iframe { .ie7 .control-custom .checkbox { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .stages { @@ -8216,7 +8270,6 @@ iframe { .ie7 .masthead nav { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .masthead .header-image { diff --git a/ckan/public/base/css/red.css b/ckan/public/base/css/red.css index 4b1a9181328..975d4d07065 100644 --- a/ckan/public/base/css/red.css +++ b/ckan/public/base/css/red.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: #c14531; /* 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; @@ -5575,6 +5547,12 @@ textarea { } .form-actions .control-required-message { float: left; + margin-left: 20px; + margin-bottom: 0; + line-height: 30px; +} +.form-actions .control-required-message:first-child { + margin-left: 0; } .form-actions { background: none; @@ -5692,27 +5670,24 @@ textarea { padding: 7px 5px; } .simple-input .field .btn-search { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - width: 16px; - height: 16px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - background-position: -51px -16px; position: absolute; display: block; height: 17px; width: 17px; + padding: 0; top: 50%; right: 0; - margin-top: -8px; + margin-top: -10px; background-color: transparent; border: none; - text-indent: -999em; + color: #999; + -webkit-transition: color 0.2s ease-in; + -moz-transition: color 0.2s ease-in; + -o-transition: color 0.2s ease-in; + transition: color 0.2s ease-in; +} +.simple-input .field .btn-search:hover { + color: #000; } .editor textarea { -webkit-border-radius: 3px 3px 0 0; @@ -5818,7 +5793,6 @@ textarea { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #c14531; /* 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, @@ -6097,7 +6071,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); @@ -6120,6 +6093,43 @@ 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; @@ -6414,6 +6424,10 @@ textarea { font-weight: normal; color: #000000; } +.search-form.no-bottom-border { + border-bottom-width: 0; + margin-bottom: 0; +} .tertiary .control-order-by { float: none; margin: 0; @@ -6534,7 +6548,6 @@ textarea { margin-right: 5px; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .actions li:last-of-type { @@ -7159,6 +7172,11 @@ h4 small { .context-info.editing .module-content { margin-top: 0; } +.flash-messages .alert { + -webkit-box-shadow: 0 0 0 1px #ffffff; + -moz-box-shadow: 0 0 0 1px #ffffff; + box-shadow: 0 0 0 1px #ffffff; +} .homepage [role=main] { padding: 20px 0; min-height: 0; @@ -7711,6 +7729,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; } @@ -7728,9 +7749,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; } @@ -7981,6 +8021,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: #c14531 url("../../../base/images/bg.png"); } @@ -8182,7 +8237,6 @@ iframe { .ie7 .control-custom .checkbox { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .stages { @@ -8216,7 +8270,6 @@ iframe { .ie7 .masthead nav { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .masthead .header-image { diff --git a/ckan/public/base/javascript/modules/image-upload.js b/ckan/public/base/javascript/modules/image-upload.js index 96308ed5764..47f38fb240e 100644 --- a/ckan/public/base/javascript/modules/image-upload.js +++ b/ckan/public/base/javascript/modules/image-upload.js @@ -6,16 +6,18 @@ this.ckan.module('image-upload', function($, _) { /* 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"]', + is_upload: false, + field_upload: 'image_upload', + field_url: 'image_url', + field_clear: 'clear_upload', + upload_label: '', i18n: { - upload: _('From computer'), - url: _('From web'), + upload: _('Upload'), + url: _('Link'), remove: _('Remove'), - label: _('Upload image'), - label_url: _('Image URL'), + upload_label: _('Image'), + upload_tooltip: _('Upload a file on your computer'), + url_tooltip: _('Link to a URL on the internet (you can also link to an API)'), remove_tooltip: _('Reset this') }, template: [ @@ -38,14 +40,18 @@ this.ckan.module('image-upload', function($, _) { 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'); + var field_upload = 'input[name="' + options.field_upload + '"]'; + var field_url = 'input[name="' + options.field_url + '"]'; + var field_clear = 'input[name="' + options.field_clear + '"]'; + + this.input = $(field_upload, this.el); + this.field_url = $(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); + var checkbox = $(field_clear, this.el); if (checkbox.length > 0) { - options.has_image = true; + options.is_upload = true; checkbox.parents('.control-group').remove(); } @@ -55,6 +61,7 @@ this.ckan.module('image-upload', function($, _) { // Button to set the field to be a URL this.button_url = $(' '+this.i18n('url')+'') + .prop('title', this.i18n('url_tooltip')) .on('click', this._onFromWeb) .insertAfter(this.input); @@ -75,13 +82,14 @@ this.ckan.module('image-upload', function($, _) { .insertBefore($('input', this.field_url)); // Update the main label - $('label[for="field-image-upload"]').text(this.i18n('label')); + $('label[for="field-image-upload"]').text(options.upload_label || this.i18n('upload_label')); // Setup the file input this.input .on('mouseover', this._onInputMouseOver) .on('mouseout', this._onInputMouseOut) .on('change', this._onInputChange) + .prop('title', this.i18n('upload_tooltip')) .css('width', this.button_upload.outerWidth()); // Fields storage. Used in this.changeState @@ -96,7 +104,7 @@ this.ckan.module('image-upload', function($, _) { // Setup the initial state if (options.is_url) { this.changeState(this.state.web); - } else if (options.has_image) { + } else if (options.is_upload) { this.changeState(this.state.attached); } else { this.changeState(this.state.blank); @@ -139,7 +147,7 @@ this.ckan.module('image-upload', function($, _) { _onFromWeb: function() { this.changeState(this.state.web); $('input', this.field_url).focus(); - if (this.options.has_image) { + if (this.options.is_upload) { this.field_clear.val('true'); } }, diff --git a/ckan/public/base/javascript/modules/resource-reorder.js b/ckan/public/base/javascript/modules/resource-reorder.js new file mode 100644 index 00000000000..f836d7f2fa5 --- /dev/null +++ b/ckan/public/base/javascript/modules/resource-reorder.js @@ -0,0 +1,144 @@ +/* Module for reordering resources + */ +this.ckan.module('resource-reorder', function($, _) { + return { + options: { + id: false, + i18n: { + label: _('Reorder resources'), + save: _('Save order'), + saving: _('Saving...'), + cancel: _('Cancel') + } + }, + template: { + title: '

', + button: [ + '', + '', + '', + '' + ].join('\n'), + form_actions: [ + '
', + '', + '', + '
' + ].join('\n'), + handle: [ + '', + '', + '' + ].join('\n'), + saving: [ + '', + '', + '', + '' + ].join('\n') + }, + is_reordering: false, + cache: false, + + initialize: function() { + jQuery.proxyAll(this, /_on/); + + this.html_title = $(this.template.title) + .text(this.i18n('label')) + .insertBefore(this.el) + .hide(); + var button = $(this.template.button) + .on('click', this._onHandleStartReorder) + .appendTo('.page_primary_action'); + $('span', button).text(this.i18n('label')); + + this.html_form_actions = $(this.template.form_actions) + .hide() + .insertAfter(this.el); + $('.save', this.html_form_actions) + .text(this.i18n('save')) + .on('click', this._onHandleSave); + $('.cancel', this.html_form_actions) + .text(this.i18n('cancel')) + .on('click', this._onHandleCancel); + + this.html_handles = $(this.template.handle) + .hide() + .appendTo($('.resource-item', this.el)); + + this.html_saving = $(this.template.saving) + .hide() + .insertBefore($('.save', this.html_form_actions)); + $('span', this.html_saving).text(this.i18n('saving')); + + this.cache = this.el.html(); + + this.el + .sortable() + .sortable('disable'); + + }, + + _onHandleStartReorder: function() { + if (!this.is_reordering) { + this.html_form_actions + .add(this.html_handles) + .add(this.html_title) + .show(); + this.el + .addClass('reordering') + .sortable('enable'); + $('.page_primary_action').hide(); + this.is_reordering = true; + } + }, + + _onHandleCancel: function() { + if ( + this.is_reordering + && !$('.cancel', this.html_form_actions).hasClass('disabled') + ) { + this.reset(); + this.is_reordering = false; + this.el.html(this.cache) + .sortable() + .sortable('disable'); + this.html_handles = $('.handle', this.el); + } + }, + + _onHandleSave: function() { + if (!$('.save', this.html_form_actions).hasClass('disabled')) { + var module = this; + module.html_saving.show(); + $('.save, .cancel', module.html_form_actions).addClass('disabled'); + var order = []; + $('.resource-item', module.el).each(function() { + order.push($(this).data('id')); + }); + module.sandbox.client.call('POST', 'package_resource_reorder', { + id: module.options.id, + order: order + }, function() { + module.html_saving.hide(); + $('.save, .cancel', module.html_form_actions).removeClass('disabled'); + module.cache = module.el.html(); + module.reset(); + module.is_reordering = false; + }); + } + }, + + reset: function() { + this.html_form_actions + .add(this.html_handles) + .add(this.html_title) + .hide(); + this.el + .removeClass('reordering') + .sortable('disable'); + $('.page_primary_action').show(); + } + + }; +}); diff --git a/ckan/public/base/javascript/modules/slug-preview.js b/ckan/public/base/javascript/modules/slug-preview.js index e622401ee09..63fb08d94e7 100644 --- a/ckan/public/base/javascript/modules/slug-preview.js +++ b/ckan/public/base/javascript/modules/slug-preview.js @@ -9,17 +9,20 @@ this.ckan.module('slug-preview-target', { el.after(preview); }); - // Once the preview box is modified stop watching it. - sandbox.subscribe('slug-preview-modified', function () { - el.off('.slug-preview'); - }); + // Make sure there isn't a value in the field already... + if (el.val() == '') { + // Once the preview box is modified stop watching it. + sandbox.subscribe('slug-preview-modified', function () { + el.off('.slug-preview'); + }); - // Watch for updates to the target field and update the hidden slug field - // triggering the "change" event manually. - el.on('keyup.slug-preview', function (event) { - sandbox.publish('slug-target-changed', this.value); - //slug.val(this.value).trigger('change'); - }); + // Watch for updates to the target field and update the hidden slug field + // triggering the "change" event manually. + el.on('keyup.slug-preview', function (event) { + sandbox.publish('slug-target-changed', this.value); + //slug.val(this.value).trigger('change'); + }); + } } }); diff --git a/ckan/public/base/javascript/resource.config b/ckan/public/base/javascript/resource.config index 564da811a6e..0dee42a831f 100644 --- a/ckan/public/base/javascript/resource.config +++ b/ckan/public/base/javascript/resource.config @@ -32,6 +32,7 @@ ckan = modules/table-selectable-rows.js modules/resource-form.js modules/resource-upload-field.js + modules/resource-reorder.js modules/follow.js modules/activity-stream.js modules/dashboard.js diff --git a/ckan/public/base/less/dataset.less b/ckan/public/base/less/dataset.less index 29b07d6b8a1..0089e991b5c 100644 --- a/ckan/public/base/less/dataset.less +++ b/ckan/public/base/less/dataset.less @@ -93,46 +93,50 @@ right: 10px; } -// Dataset Forms - -.dataset-resource-form .dataset-form-resource-types { - margin-bottom: 5px; -} - -.dataset-form-resource-types .ckan-icon { - position: relative; - top: 3px; -} - -.dataset-form-resource-types .radio { - font-weight: normal; - padding-left: 0; - padding-right: 18px; -} - -.dataset-form-resource-types label { - position: relative; -} - -.dataset-form-resource-types input[type=radio]:checked+label { - font-weight: bold; -} - -.dataset-form-resource-types input[type=radio]:checked+label:after { - .ckan-icon; - .ckan-icon-callout; - display: block; - content: ""; - position: absolute; - top: auto; - left: 0; - bottom: -12px; +.resource-list.reordering { + .resource-item { + border: 1px solid @moduleHeadingBorderColor; + margin-bottom: 10px; + cursor: move; + .handle { + display: block; + position: absolute; + color: @moduleHeadingActionTextColor; + left: -31px; + top: 50%; + margin-top: -15px; + width: 30px; + height: 30px; + line-height: 30px; + text-align: center; + border: 1px solid @moduleHeadingBorderColor; + border-width: 1px 0 1px 1px; + background-color: @moduleBackgroundColor; + .border-radius(20px 0 0 20px); + &:hover { + text-decoration: none; + } + } + &:hover .handle { + background-color: @layoutBackgroundColor; + } + &.ui-sortable-helper { + background-color: @layoutBackgroundColor; + border: 1px solid @layoutLinkColor; + .handle { + background-color: @layoutBackgroundColor; + border-color: @layoutLinkColor; + color: @navLinkColor; + } + } + } } - -.dataset-form-resource-types input[type=radio] { +.resource-item .handle { display: none; } +// Dataset Forms + // Tag List .tag-list { diff --git a/ckan/public/base/less/forms.less b/ckan/public/base/less/forms.less index ba02fb6b49e..bf45350340f 100644 --- a/ckan/public/base/less/forms.less +++ b/ckan/public/base/less/forms.less @@ -85,6 +85,12 @@ textarea { .form-actions .control-required-message { float: left; + margin-left: 20px; + margin-bottom: 0; + line-height: 30px; + &:first-child { + margin-left: 0; + } } .form-actions { @@ -237,18 +243,21 @@ textarea { } .simple-input .field .btn-search { - .ckan-icon; - .ckan-icon-search; position: absolute; display: block; height: 17px; width: 17px; + padding: 0; top: 50%; right: 0; - margin-top: -8px; + margin-top: -10px; background-color: transparent; border: none; - text-indent: -999em; + color: #999; + .transition(color 0.2s ease-in); + &:hover { + color: #000; + } } .editor textarea { diff --git a/ckan/public/base/less/layout.less b/ckan/public/base/less/layout.less index 78905c89eee..0a74dfbe311 100644 --- a/ckan/public/base/less/layout.less +++ b/ckan/public/base/less/layout.less @@ -165,3 +165,10 @@ } } } + +.flash-messages { + .alert { + .box-shadow(0 0 0 1px white); + } +} + diff --git a/ckan/public/base/less/search.less b/ckan/public/base/less/search.less index a9e4193eb83..fffe049ff29 100644 --- a/ckan/public/base/less/search.less +++ b/ckan/public/base/less/search.less @@ -84,6 +84,10 @@ color: @layoutBoldColor; } } + &.no-bottom-border { + border-bottom-width: 0; + margin-bottom: 0; + } } .tertiary { diff --git a/ckan/public/base/vendor/jquery.ui.core.js b/ckan/public/base/vendor/jquery.ui.core.js new file mode 100755 index 00000000000..91ca5ff5056 --- /dev/null +++ b/ckan/public/base/vendor/jquery.ui.core.js @@ -0,0 +1,320 @@ +/*! + * jQuery UI Core 1.10.3 + * http://jqueryui.com + * + * Copyright 2013 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/category/ui-core/ + */ +(function( $, undefined ) { + +var uuid = 0, + runiqueId = /^ui-id-\d+$/; + +// $.ui might exist from components with no dependencies, e.g., $.ui.position +$.ui = $.ui || {}; + +$.extend( $.ui, { + version: "1.10.3", + + keyCode: { + BACKSPACE: 8, + COMMA: 188, + DELETE: 46, + DOWN: 40, + END: 35, + ENTER: 13, + ESCAPE: 27, + HOME: 36, + LEFT: 37, + NUMPAD_ADD: 107, + NUMPAD_DECIMAL: 110, + NUMPAD_DIVIDE: 111, + NUMPAD_ENTER: 108, + NUMPAD_MULTIPLY: 106, + NUMPAD_SUBTRACT: 109, + PAGE_DOWN: 34, + PAGE_UP: 33, + PERIOD: 190, + RIGHT: 39, + SPACE: 32, + TAB: 9, + UP: 38 + } +}); + +// plugins +$.fn.extend({ + focus: (function( orig ) { + return function( delay, fn ) { + return typeof delay === "number" ? + this.each(function() { + var elem = this; + setTimeout(function() { + $( elem ).focus(); + if ( fn ) { + fn.call( elem ); + } + }, delay ); + }) : + orig.apply( this, arguments ); + }; + })( $.fn.focus ), + + scrollParent: function() { + var scrollParent; + if (($.ui.ie && (/(static|relative)/).test(this.css("position"))) || (/absolute/).test(this.css("position"))) { + scrollParent = this.parents().filter(function() { + return (/(relative|absolute|fixed)/).test($.css(this,"position")) && (/(auto|scroll)/).test($.css(this,"overflow")+$.css(this,"overflow-y")+$.css(this,"overflow-x")); + }).eq(0); + } else { + scrollParent = this.parents().filter(function() { + return (/(auto|scroll)/).test($.css(this,"overflow")+$.css(this,"overflow-y")+$.css(this,"overflow-x")); + }).eq(0); + } + + return (/fixed/).test(this.css("position")) || !scrollParent.length ? $(document) : scrollParent; + }, + + zIndex: function( zIndex ) { + if ( zIndex !== undefined ) { + return this.css( "zIndex", zIndex ); + } + + if ( this.length ) { + var elem = $( this[ 0 ] ), position, value; + while ( elem.length && elem[ 0 ] !== document ) { + // Ignore z-index if position is set to a value where z-index is ignored by the browser + // This makes behavior of this function consistent across browsers + // WebKit always returns auto if the element is positioned + position = elem.css( "position" ); + if ( position === "absolute" || position === "relative" || position === "fixed" ) { + // IE returns 0 when zIndex is not specified + // other browsers return a string + // we ignore the case of nested elements with an explicit value of 0 + //
+ value = parseInt( elem.css( "zIndex" ), 10 ); + if ( !isNaN( value ) && value !== 0 ) { + return value; + } + } + elem = elem.parent(); + } + } + + return 0; + }, + + uniqueId: function() { + return this.each(function() { + if ( !this.id ) { + this.id = "ui-id-" + (++uuid); + } + }); + }, + + removeUniqueId: function() { + return this.each(function() { + if ( runiqueId.test( this.id ) ) { + $( this ).removeAttr( "id" ); + } + }); + } +}); + +// selectors +function focusable( element, isTabIndexNotNaN ) { + var map, mapName, img, + nodeName = element.nodeName.toLowerCase(); + if ( "area" === nodeName ) { + map = element.parentNode; + mapName = map.name; + if ( !element.href || !mapName || map.nodeName.toLowerCase() !== "map" ) { + return false; + } + img = $( "img[usemap=#" + mapName + "]" )[0]; + return !!img && visible( img ); + } + return ( /input|select|textarea|button|object/.test( nodeName ) ? + !element.disabled : + "a" === nodeName ? + element.href || isTabIndexNotNaN : + isTabIndexNotNaN) && + // the element and all of its ancestors must be visible + visible( element ); +} + +function visible( element ) { + return $.expr.filters.visible( element ) && + !$( element ).parents().addBack().filter(function() { + return $.css( this, "visibility" ) === "hidden"; + }).length; +} + +$.extend( $.expr[ ":" ], { + data: $.expr.createPseudo ? + $.expr.createPseudo(function( dataName ) { + return function( elem ) { + return !!$.data( elem, dataName ); + }; + }) : + // support: jQuery <1.8 + function( elem, i, match ) { + return !!$.data( elem, match[ 3 ] ); + }, + + focusable: function( element ) { + return focusable( element, !isNaN( $.attr( element, "tabindex" ) ) ); + }, + + tabbable: function( element ) { + var tabIndex = $.attr( element, "tabindex" ), + isTabIndexNaN = isNaN( tabIndex ); + return ( isTabIndexNaN || tabIndex >= 0 ) && focusable( element, !isTabIndexNaN ); + } +}); + +// support: jQuery <1.8 +if ( !$( "" ).outerWidth( 1 ).jquery ) { + $.each( [ "Width", "Height" ], function( i, name ) { + var side = name === "Width" ? [ "Left", "Right" ] : [ "Top", "Bottom" ], + type = name.toLowerCase(), + orig = { + innerWidth: $.fn.innerWidth, + innerHeight: $.fn.innerHeight, + outerWidth: $.fn.outerWidth, + outerHeight: $.fn.outerHeight + }; + + function reduce( elem, size, border, margin ) { + $.each( side, function() { + size -= parseFloat( $.css( elem, "padding" + this ) ) || 0; + if ( border ) { + size -= parseFloat( $.css( elem, "border" + this + "Width" ) ) || 0; + } + if ( margin ) { + size -= parseFloat( $.css( elem, "margin" + this ) ) || 0; + } + }); + return size; + } + + $.fn[ "inner" + name ] = function( size ) { + if ( size === undefined ) { + return orig[ "inner" + name ].call( this ); + } + + return this.each(function() { + $( this ).css( type, reduce( this, size ) + "px" ); + }); + }; + + $.fn[ "outer" + name] = function( size, margin ) { + if ( typeof size !== "number" ) { + return orig[ "outer" + name ].call( this, size ); + } + + return this.each(function() { + $( this).css( type, reduce( this, size, true, margin ) + "px" ); + }); + }; + }); +} + +// support: jQuery <1.8 +if ( !$.fn.addBack ) { + $.fn.addBack = function( selector ) { + return this.add( selector == null ? + this.prevObject : this.prevObject.filter( selector ) + ); + }; +} + +// support: jQuery 1.6.1, 1.6.2 (http://bugs.jquery.com/ticket/9413) +if ( $( "" ).data( "a-b", "a" ).removeData( "a-b" ).data( "a-b" ) ) { + $.fn.removeData = (function( removeData ) { + return function( key ) { + if ( arguments.length ) { + return removeData.call( this, $.camelCase( key ) ); + } else { + return removeData.call( this ); + } + }; + })( $.fn.removeData ); +} + + + + + +// deprecated +$.ui.ie = !!/msie [\w.]+/.exec( navigator.userAgent.toLowerCase() ); + +$.support.selectstart = "onselectstart" in document.createElement( "div" ); +$.fn.extend({ + disableSelection: function() { + return this.bind( ( $.support.selectstart ? "selectstart" : "mousedown" ) + + ".ui-disableSelection", function( event ) { + event.preventDefault(); + }); + }, + + enableSelection: function() { + return this.unbind( ".ui-disableSelection" ); + } +}); + +$.extend( $.ui, { + // $.ui.plugin is deprecated. Use $.widget() extensions instead. + plugin: { + add: function( module, option, set ) { + var i, + proto = $.ui[ module ].prototype; + for ( i in set ) { + proto.plugins[ i ] = proto.plugins[ i ] || []; + proto.plugins[ i ].push( [ option, set[ i ] ] ); + } + }, + call: function( instance, name, args ) { + var i, + set = instance.plugins[ name ]; + if ( !set || !instance.element[ 0 ].parentNode || instance.element[ 0 ].parentNode.nodeType === 11 ) { + return; + } + + for ( i = 0; i < set.length; i++ ) { + if ( instance.options[ set[ i ][ 0 ] ] ) { + set[ i ][ 1 ].apply( instance.element, args ); + } + } + } + }, + + // only used by resizable + hasScroll: function( el, a ) { + + //If overflow is hidden, the element might have extra content, but the user wants to hide it + if ( $( el ).css( "overflow" ) === "hidden") { + return false; + } + + var scroll = ( a && a === "left" ) ? "scrollLeft" : "scrollTop", + has = false; + + if ( el[ scroll ] > 0 ) { + return true; + } + + // TODO: determine which cases actually cause this to happen + // if the element doesn't have the scroll set, see if it's possible to + // set the scroll + el[ scroll ] = 1; + has = ( el[ scroll ] > 0 ); + el[ scroll ] = 0; + return has; + } +}); + +})( jQuery ); diff --git a/ckan/public/base/vendor/jquery.ui.mouse.js b/ckan/public/base/vendor/jquery.ui.mouse.js new file mode 100755 index 00000000000..62022cedf02 --- /dev/null +++ b/ckan/public/base/vendor/jquery.ui.mouse.js @@ -0,0 +1,169 @@ +/*! + * jQuery UI Mouse 1.10.3 + * http://jqueryui.com + * + * Copyright 2013 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/mouse/ + * + * Depends: + * jquery.ui.widget.js + */ +(function( $, undefined ) { + +var mouseHandled = false; +$( document ).mouseup( function() { + mouseHandled = false; +}); + +$.widget("ui.mouse", { + version: "1.10.3", + options: { + cancel: "input,textarea,button,select,option", + distance: 1, + delay: 0 + }, + _mouseInit: function() { + var that = this; + + this.element + .bind("mousedown."+this.widgetName, function(event) { + return that._mouseDown(event); + }) + .bind("click."+this.widgetName, function(event) { + if (true === $.data(event.target, that.widgetName + ".preventClickEvent")) { + $.removeData(event.target, that.widgetName + ".preventClickEvent"); + event.stopImmediatePropagation(); + return false; + } + }); + + this.started = false; + }, + + // TODO: make sure destroying one instance of mouse doesn't mess with + // other instances of mouse + _mouseDestroy: function() { + this.element.unbind("."+this.widgetName); + if ( this._mouseMoveDelegate ) { + $(document) + .unbind("mousemove."+this.widgetName, this._mouseMoveDelegate) + .unbind("mouseup."+this.widgetName, this._mouseUpDelegate); + } + }, + + _mouseDown: function(event) { + // don't let more than one widget handle mouseStart + if( mouseHandled ) { return; } + + // we may have missed mouseup (out of window) + (this._mouseStarted && this._mouseUp(event)); + + this._mouseDownEvent = event; + + var that = this, + btnIsLeft = (event.which === 1), + // event.target.nodeName works around a bug in IE 8 with + // disabled inputs (#7620) + elIsCancel = (typeof this.options.cancel === "string" && event.target.nodeName ? $(event.target).closest(this.options.cancel).length : false); + if (!btnIsLeft || elIsCancel || !this._mouseCapture(event)) { + return true; + } + + this.mouseDelayMet = !this.options.delay; + if (!this.mouseDelayMet) { + this._mouseDelayTimer = setTimeout(function() { + that.mouseDelayMet = true; + }, this.options.delay); + } + + if (this._mouseDistanceMet(event) && this._mouseDelayMet(event)) { + this._mouseStarted = (this._mouseStart(event) !== false); + if (!this._mouseStarted) { + event.preventDefault(); + return true; + } + } + + // Click event may never have fired (Gecko & Opera) + if (true === $.data(event.target, this.widgetName + ".preventClickEvent")) { + $.removeData(event.target, this.widgetName + ".preventClickEvent"); + } + + // these delegates are required to keep context + this._mouseMoveDelegate = function(event) { + return that._mouseMove(event); + }; + this._mouseUpDelegate = function(event) { + return that._mouseUp(event); + }; + $(document) + .bind("mousemove."+this.widgetName, this._mouseMoveDelegate) + .bind("mouseup."+this.widgetName, this._mouseUpDelegate); + + event.preventDefault(); + + mouseHandled = true; + return true; + }, + + _mouseMove: function(event) { + // IE mouseup check - mouseup happened when mouse was out of window + if ($.ui.ie && ( !document.documentMode || document.documentMode < 9 ) && !event.button) { + return this._mouseUp(event); + } + + if (this._mouseStarted) { + this._mouseDrag(event); + return event.preventDefault(); + } + + if (this._mouseDistanceMet(event) && this._mouseDelayMet(event)) { + this._mouseStarted = + (this._mouseStart(this._mouseDownEvent, event) !== false); + (this._mouseStarted ? this._mouseDrag(event) : this._mouseUp(event)); + } + + return !this._mouseStarted; + }, + + _mouseUp: function(event) { + $(document) + .unbind("mousemove."+this.widgetName, this._mouseMoveDelegate) + .unbind("mouseup."+this.widgetName, this._mouseUpDelegate); + + if (this._mouseStarted) { + this._mouseStarted = false; + + if (event.target === this._mouseDownEvent.target) { + $.data(event.target, this.widgetName + ".preventClickEvent", true); + } + + this._mouseStop(event); + } + + return false; + }, + + _mouseDistanceMet: function(event) { + return (Math.max( + Math.abs(this._mouseDownEvent.pageX - event.pageX), + Math.abs(this._mouseDownEvent.pageY - event.pageY) + ) >= this.options.distance + ); + }, + + _mouseDelayMet: function(/* event */) { + return this.mouseDelayMet; + }, + + // These are placeholder methods, to be overriden by extending plugin + _mouseStart: function(/* event */) {}, + _mouseDrag: function(/* event */) {}, + _mouseStop: function(/* event */) {}, + _mouseCapture: function(/* event */) { return true; } +}); + +})(jQuery); diff --git a/ckan/public/base/vendor/jquery.ui.sortable.js b/ckan/public/base/vendor/jquery.ui.sortable.js new file mode 100755 index 00000000000..c9f2c9021cb --- /dev/null +++ b/ckan/public/base/vendor/jquery.ui.sortable.js @@ -0,0 +1,1285 @@ +/*! + * jQuery UI Sortable 1.10.3 + * http://jqueryui.com + * + * Copyright 2013 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/sortable/ + * + * Depends: + * jquery.ui.core.js + * jquery.ui.mouse.js + * jquery.ui.widget.js + */ +(function( $, undefined ) { + +/*jshint loopfunc: true */ + +function isOverAxis( x, reference, size ) { + return ( x > reference ) && ( x < ( reference + size ) ); +} + +function isFloating(item) { + return (/left|right/).test(item.css("float")) || (/inline|table-cell/).test(item.css("display")); +} + +$.widget("ui.sortable", $.ui.mouse, { + version: "1.10.3", + widgetEventPrefix: "sort", + ready: false, + options: { + appendTo: "parent", + axis: false, + connectWith: false, + containment: false, + cursor: "auto", + cursorAt: false, + dropOnEmpty: true, + forcePlaceholderSize: false, + forceHelperSize: false, + grid: false, + handle: false, + helper: "original", + items: "> *", + opacity: false, + placeholder: false, + revert: false, + scroll: true, + scrollSensitivity: 20, + scrollSpeed: 20, + scope: "default", + tolerance: "intersect", + zIndex: 1000, + + // callbacks + activate: null, + beforeStop: null, + change: null, + deactivate: null, + out: null, + over: null, + receive: null, + remove: null, + sort: null, + start: null, + stop: null, + update: null + }, + _create: function() { + + var o = this.options; + this.containerCache = {}; + this.element.addClass("ui-sortable"); + + //Get the items + this.refresh(); + + //Let's determine if the items are being displayed horizontally + this.floating = this.items.length ? o.axis === "x" || isFloating(this.items[0].item) : false; + + //Let's determine the parent's offset + this.offset = this.element.offset(); + + //Initialize mouse events for interaction + this._mouseInit(); + + //We're ready to go + this.ready = true; + + }, + + _destroy: function() { + this.element + .removeClass("ui-sortable ui-sortable-disabled"); + this._mouseDestroy(); + + for ( var i = this.items.length - 1; i >= 0; i-- ) { + this.items[i].item.removeData(this.widgetName + "-item"); + } + + return this; + }, + + _setOption: function(key, value){ + if ( key === "disabled" ) { + this.options[ key ] = value; + + this.widget().toggleClass( "ui-sortable-disabled", !!value ); + } else { + // Don't call widget base _setOption for disable as it adds ui-state-disabled class + $.Widget.prototype._setOption.apply(this, arguments); + } + }, + + _mouseCapture: function(event, overrideHandle) { + var currentItem = null, + validHandle = false, + that = this; + + if (this.reverting) { + return false; + } + + if(this.options.disabled || this.options.type === "static") { + return false; + } + + //We have to refresh the items data once first + this._refreshItems(event); + + //Find out if the clicked node (or one of its parents) is a actual item in this.items + $(event.target).parents().each(function() { + if($.data(this, that.widgetName + "-item") === that) { + currentItem = $(this); + return false; + } + }); + if($.data(event.target, that.widgetName + "-item") === that) { + currentItem = $(event.target); + } + + if(!currentItem) { + return false; + } + if(this.options.handle && !overrideHandle) { + $(this.options.handle, currentItem).find("*").addBack().each(function() { + if(this === event.target) { + validHandle = true; + } + }); + if(!validHandle) { + return false; + } + } + + this.currentItem = currentItem; + this._removeCurrentsFromItems(); + return true; + + }, + + _mouseStart: function(event, overrideHandle, noActivation) { + + var i, body, + o = this.options; + + this.currentContainer = this; + + //We only need to call refreshPositions, because the refreshItems call has been moved to mouseCapture + this.refreshPositions(); + + //Create and append the visible helper + this.helper = this._createHelper(event); + + //Cache the helper size + this._cacheHelperProportions(); + + /* + * - Position generation - + * This block generates everything position related - it's the core of draggables. + */ + + //Cache the margins of the original element + this._cacheMargins(); + + //Get the next scrolling parent + this.scrollParent = this.helper.scrollParent(); + + //The element's absolute position on the page minus margins + this.offset = this.currentItem.offset(); + this.offset = { + top: this.offset.top - this.margins.top, + left: this.offset.left - this.margins.left + }; + + $.extend(this.offset, { + click: { //Where the click happened, relative to the element + left: event.pageX - this.offset.left, + top: event.pageY - this.offset.top + }, + parent: this._getParentOffset(), + relative: this._getRelativeOffset() //This is a relative to absolute position minus the actual position calculation - only used for relative positioned helper + }); + + // Only after we got the offset, we can change the helper's position to absolute + // TODO: Still need to figure out a way to make relative sorting possible + this.helper.css("position", "absolute"); + this.cssPosition = this.helper.css("position"); + + //Generate the original position + this.originalPosition = this._generatePosition(event); + this.originalPageX = event.pageX; + this.originalPageY = event.pageY; + + //Adjust the mouse offset relative to the helper if "cursorAt" is supplied + (o.cursorAt && this._adjustOffsetFromHelper(o.cursorAt)); + + //Cache the former DOM position + this.domPosition = { prev: this.currentItem.prev()[0], parent: this.currentItem.parent()[0] }; + + //If the helper is not the original, hide the original so it's not playing any role during the drag, won't cause anything bad this way + if(this.helper[0] !== this.currentItem[0]) { + this.currentItem.hide(); + } + + //Create the placeholder + this._createPlaceholder(); + + //Set a containment if given in the options + if(o.containment) { + this._setContainment(); + } + + if( o.cursor && o.cursor !== "auto" ) { // cursor option + body = this.document.find( "body" ); + + // support: IE + this.storedCursor = body.css( "cursor" ); + body.css( "cursor", o.cursor ); + + this.storedStylesheet = $( "" ).appendTo( body ); + } + + if(o.opacity) { // opacity option + if (this.helper.css("opacity")) { + this._storedOpacity = this.helper.css("opacity"); + } + this.helper.css("opacity", o.opacity); + } + + if(o.zIndex) { // zIndex option + if (this.helper.css("zIndex")) { + this._storedZIndex = this.helper.css("zIndex"); + } + this.helper.css("zIndex", o.zIndex); + } + + //Prepare scrolling + if(this.scrollParent[0] !== document && this.scrollParent[0].tagName !== "HTML") { + this.overflowOffset = this.scrollParent.offset(); + } + + //Call callbacks + this._trigger("start", event, this._uiHash()); + + //Recache the helper size + if(!this._preserveHelperProportions) { + this._cacheHelperProportions(); + } + + + //Post "activate" events to possible containers + if( !noActivation ) { + for ( i = this.containers.length - 1; i >= 0; i-- ) { + this.containers[ i ]._trigger( "activate", event, this._uiHash( this ) ); + } + } + + //Prepare possible droppables + if($.ui.ddmanager) { + $.ui.ddmanager.current = this; + } + + if ($.ui.ddmanager && !o.dropBehaviour) { + $.ui.ddmanager.prepareOffsets(this, event); + } + + this.dragging = true; + + this.helper.addClass("ui-sortable-helper"); + this._mouseDrag(event); //Execute the drag once - this causes the helper not to be visible before getting its correct position + return true; + + }, + + _mouseDrag: function(event) { + var i, item, itemElement, intersection, + o = this.options, + scrolled = false; + + //Compute the helpers position + this.position = this._generatePosition(event); + this.positionAbs = this._convertPositionTo("absolute"); + + if (!this.lastPositionAbs) { + this.lastPositionAbs = this.positionAbs; + } + + //Do scrolling + if(this.options.scroll) { + if(this.scrollParent[0] !== document && this.scrollParent[0].tagName !== "HTML") { + + if((this.overflowOffset.top + this.scrollParent[0].offsetHeight) - event.pageY < o.scrollSensitivity) { + this.scrollParent[0].scrollTop = scrolled = this.scrollParent[0].scrollTop + o.scrollSpeed; + } else if(event.pageY - this.overflowOffset.top < o.scrollSensitivity) { + this.scrollParent[0].scrollTop = scrolled = this.scrollParent[0].scrollTop - o.scrollSpeed; + } + + if((this.overflowOffset.left + this.scrollParent[0].offsetWidth) - event.pageX < o.scrollSensitivity) { + this.scrollParent[0].scrollLeft = scrolled = this.scrollParent[0].scrollLeft + o.scrollSpeed; + } else if(event.pageX - this.overflowOffset.left < o.scrollSensitivity) { + this.scrollParent[0].scrollLeft = scrolled = this.scrollParent[0].scrollLeft - o.scrollSpeed; + } + + } else { + + if(event.pageY - $(document).scrollTop() < o.scrollSensitivity) { + scrolled = $(document).scrollTop($(document).scrollTop() - o.scrollSpeed); + } else if($(window).height() - (event.pageY - $(document).scrollTop()) < o.scrollSensitivity) { + scrolled = $(document).scrollTop($(document).scrollTop() + o.scrollSpeed); + } + + if(event.pageX - $(document).scrollLeft() < o.scrollSensitivity) { + scrolled = $(document).scrollLeft($(document).scrollLeft() - o.scrollSpeed); + } else if($(window).width() - (event.pageX - $(document).scrollLeft()) < o.scrollSensitivity) { + scrolled = $(document).scrollLeft($(document).scrollLeft() + o.scrollSpeed); + } + + } + + if(scrolled !== false && $.ui.ddmanager && !o.dropBehaviour) { + $.ui.ddmanager.prepareOffsets(this, event); + } + } + + //Regenerate the absolute position used for position checks + this.positionAbs = this._convertPositionTo("absolute"); + + //Set the helper position + if(!this.options.axis || this.options.axis !== "y") { + this.helper[0].style.left = this.position.left+"px"; + } + if(!this.options.axis || this.options.axis !== "x") { + this.helper[0].style.top = this.position.top+"px"; + } + + //Rearrange + for (i = this.items.length - 1; i >= 0; i--) { + + //Cache variables and intersection, continue if no intersection + item = this.items[i]; + itemElement = item.item[0]; + intersection = this._intersectsWithPointer(item); + if (!intersection) { + continue; + } + + // Only put the placeholder inside the current Container, skip all + // items form other containers. This works because when moving + // an item from one container to another the + // currentContainer is switched before the placeholder is moved. + // + // Without this moving items in "sub-sortables" can cause the placeholder to jitter + // beetween the outer and inner container. + if (item.instance !== this.currentContainer) { + continue; + } + + // cannot intersect with itself + // no useless actions that have been done before + // no action if the item moved is the parent of the item checked + if (itemElement !== this.currentItem[0] && + this.placeholder[intersection === 1 ? "next" : "prev"]()[0] !== itemElement && + !$.contains(this.placeholder[0], itemElement) && + (this.options.type === "semi-dynamic" ? !$.contains(this.element[0], itemElement) : true) + ) { + + this.direction = intersection === 1 ? "down" : "up"; + + if (this.options.tolerance === "pointer" || this._intersectsWithSides(item)) { + this._rearrange(event, item); + } else { + break; + } + + this._trigger("change", event, this._uiHash()); + break; + } + } + + //Post events to containers + this._contactContainers(event); + + //Interconnect with droppables + if($.ui.ddmanager) { + $.ui.ddmanager.drag(this, event); + } + + //Call callbacks + this._trigger("sort", event, this._uiHash()); + + this.lastPositionAbs = this.positionAbs; + return false; + + }, + + _mouseStop: function(event, noPropagation) { + + if(!event) { + return; + } + + //If we are using droppables, inform the manager about the drop + if ($.ui.ddmanager && !this.options.dropBehaviour) { + $.ui.ddmanager.drop(this, event); + } + + if(this.options.revert) { + var that = this, + cur = this.placeholder.offset(), + axis = this.options.axis, + animation = {}; + + if ( !axis || axis === "x" ) { + animation.left = cur.left - this.offset.parent.left - this.margins.left + (this.offsetParent[0] === document.body ? 0 : this.offsetParent[0].scrollLeft); + } + if ( !axis || axis === "y" ) { + animation.top = cur.top - this.offset.parent.top - this.margins.top + (this.offsetParent[0] === document.body ? 0 : this.offsetParent[0].scrollTop); + } + this.reverting = true; + $(this.helper).animate( animation, parseInt(this.options.revert, 10) || 500, function() { + that._clear(event); + }); + } else { + this._clear(event, noPropagation); + } + + return false; + + }, + + cancel: function() { + + if(this.dragging) { + + this._mouseUp({ target: null }); + + if(this.options.helper === "original") { + this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper"); + } else { + this.currentItem.show(); + } + + //Post deactivating events to containers + for (var i = this.containers.length - 1; i >= 0; i--){ + this.containers[i]._trigger("deactivate", null, this._uiHash(this)); + if(this.containers[i].containerCache.over) { + this.containers[i]._trigger("out", null, this._uiHash(this)); + this.containers[i].containerCache.over = 0; + } + } + + } + + if (this.placeholder) { + //$(this.placeholder[0]).remove(); would have been the jQuery way - unfortunately, it unbinds ALL events from the original node! + if(this.placeholder[0].parentNode) { + this.placeholder[0].parentNode.removeChild(this.placeholder[0]); + } + if(this.options.helper !== "original" && this.helper && this.helper[0].parentNode) { + this.helper.remove(); + } + + $.extend(this, { + helper: null, + dragging: false, + reverting: false, + _noFinalSort: null + }); + + if(this.domPosition.prev) { + $(this.domPosition.prev).after(this.currentItem); + } else { + $(this.domPosition.parent).prepend(this.currentItem); + } + } + + return this; + + }, + + serialize: function(o) { + + var items = this._getItemsAsjQuery(o && o.connected), + str = []; + o = o || {}; + + $(items).each(function() { + var res = ($(o.item || this).attr(o.attribute || "id") || "").match(o.expression || (/(.+)[\-=_](.+)/)); + if (res) { + str.push((o.key || res[1]+"[]")+"="+(o.key && o.expression ? res[1] : res[2])); + } + }); + + if(!str.length && o.key) { + str.push(o.key + "="); + } + + return str.join("&"); + + }, + + toArray: function(o) { + + var items = this._getItemsAsjQuery(o && o.connected), + ret = []; + + o = o || {}; + + items.each(function() { ret.push($(o.item || this).attr(o.attribute || "id") || ""); }); + return ret; + + }, + + /* Be careful with the following core functions */ + _intersectsWith: function(item) { + + var x1 = this.positionAbs.left, + x2 = x1 + this.helperProportions.width, + y1 = this.positionAbs.top, + y2 = y1 + this.helperProportions.height, + l = item.left, + r = l + item.width, + t = item.top, + b = t + item.height, + dyClick = this.offset.click.top, + dxClick = this.offset.click.left, + isOverElementHeight = ( this.options.axis === "x" ) || ( ( y1 + dyClick ) > t && ( y1 + dyClick ) < b ), + isOverElementWidth = ( this.options.axis === "y" ) || ( ( x1 + dxClick ) > l && ( x1 + dxClick ) < r ), + isOverElement = isOverElementHeight && isOverElementWidth; + + if ( this.options.tolerance === "pointer" || + this.options.forcePointerForContainers || + (this.options.tolerance !== "pointer" && this.helperProportions[this.floating ? "width" : "height"] > item[this.floating ? "width" : "height"]) + ) { + return isOverElement; + } else { + + return (l < x1 + (this.helperProportions.width / 2) && // Right Half + x2 - (this.helperProportions.width / 2) < r && // Left Half + t < y1 + (this.helperProportions.height / 2) && // Bottom Half + y2 - (this.helperProportions.height / 2) < b ); // Top Half + + } + }, + + _intersectsWithPointer: function(item) { + + var isOverElementHeight = (this.options.axis === "x") || isOverAxis(this.positionAbs.top + this.offset.click.top, item.top, item.height), + isOverElementWidth = (this.options.axis === "y") || isOverAxis(this.positionAbs.left + this.offset.click.left, item.left, item.width), + isOverElement = isOverElementHeight && isOverElementWidth, + verticalDirection = this._getDragVerticalDirection(), + horizontalDirection = this._getDragHorizontalDirection(); + + if (!isOverElement) { + return false; + } + + return this.floating ? + ( ((horizontalDirection && horizontalDirection === "right") || verticalDirection === "down") ? 2 : 1 ) + : ( verticalDirection && (verticalDirection === "down" ? 2 : 1) ); + + }, + + _intersectsWithSides: function(item) { + + var isOverBottomHalf = isOverAxis(this.positionAbs.top + this.offset.click.top, item.top + (item.height/2), item.height), + isOverRightHalf = isOverAxis(this.positionAbs.left + this.offset.click.left, item.left + (item.width/2), item.width), + verticalDirection = this._getDragVerticalDirection(), + horizontalDirection = this._getDragHorizontalDirection(); + + if (this.floating && horizontalDirection) { + return ((horizontalDirection === "right" && isOverRightHalf) || (horizontalDirection === "left" && !isOverRightHalf)); + } else { + return verticalDirection && ((verticalDirection === "down" && isOverBottomHalf) || (verticalDirection === "up" && !isOverBottomHalf)); + } + + }, + + _getDragVerticalDirection: function() { + var delta = this.positionAbs.top - this.lastPositionAbs.top; + return delta !== 0 && (delta > 0 ? "down" : "up"); + }, + + _getDragHorizontalDirection: function() { + var delta = this.positionAbs.left - this.lastPositionAbs.left; + return delta !== 0 && (delta > 0 ? "right" : "left"); + }, + + refresh: function(event) { + this._refreshItems(event); + this.refreshPositions(); + return this; + }, + + _connectWith: function() { + var options = this.options; + return options.connectWith.constructor === String ? [options.connectWith] : options.connectWith; + }, + + _getItemsAsjQuery: function(connected) { + + var i, j, cur, inst, + items = [], + queries = [], + connectWith = this._connectWith(); + + if(connectWith && connected) { + for (i = connectWith.length - 1; i >= 0; i--){ + cur = $(connectWith[i]); + for ( j = cur.length - 1; j >= 0; j--){ + inst = $.data(cur[j], this.widgetFullName); + if(inst && inst !== this && !inst.options.disabled) { + queries.push([$.isFunction(inst.options.items) ? inst.options.items.call(inst.element) : $(inst.options.items, inst.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"), inst]); + } + } + } + } + + queries.push([$.isFunction(this.options.items) ? this.options.items.call(this.element, null, { options: this.options, item: this.currentItem }) : $(this.options.items, this.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"), this]); + + for (i = queries.length - 1; i >= 0; i--){ + queries[i][0].each(function() { + items.push(this); + }); + } + + return $(items); + + }, + + _removeCurrentsFromItems: function() { + + var list = this.currentItem.find(":data(" + this.widgetName + "-item)"); + + this.items = $.grep(this.items, function (item) { + for (var j=0; j < list.length; j++) { + if(list[j] === item.item[0]) { + return false; + } + } + return true; + }); + + }, + + _refreshItems: function(event) { + + this.items = []; + this.containers = [this]; + + var i, j, cur, inst, targetData, _queries, item, queriesLength, + items = this.items, + queries = [[$.isFunction(this.options.items) ? this.options.items.call(this.element[0], event, { item: this.currentItem }) : $(this.options.items, this.element), this]], + connectWith = this._connectWith(); + + if(connectWith && this.ready) { //Shouldn't be run the first time through due to massive slow-down + for (i = connectWith.length - 1; i >= 0; i--){ + cur = $(connectWith[i]); + for (j = cur.length - 1; j >= 0; j--){ + inst = $.data(cur[j], this.widgetFullName); + if(inst && inst !== this && !inst.options.disabled) { + queries.push([$.isFunction(inst.options.items) ? inst.options.items.call(inst.element[0], event, { item: this.currentItem }) : $(inst.options.items, inst.element), inst]); + this.containers.push(inst); + } + } + } + } + + for (i = queries.length - 1; i >= 0; i--) { + targetData = queries[i][1]; + _queries = queries[i][0]; + + for (j=0, queriesLength = _queries.length; j < queriesLength; j++) { + item = $(_queries[j]); + + item.data(this.widgetName + "-item", targetData); // Data for target checking (mouse manager) + + items.push({ + item: item, + instance: targetData, + width: 0, height: 0, + left: 0, top: 0 + }); + } + } + + }, + + refreshPositions: function(fast) { + + //This has to be redone because due to the item being moved out/into the offsetParent, the offsetParent's position will change + if(this.offsetParent && this.helper) { + this.offset.parent = this._getParentOffset(); + } + + var i, item, t, p; + + for (i = this.items.length - 1; i >= 0; i--){ + item = this.items[i]; + + //We ignore calculating positions of all connected containers when we're not over them + if(item.instance !== this.currentContainer && this.currentContainer && item.item[0] !== this.currentItem[0]) { + continue; + } + + t = this.options.toleranceElement ? $(this.options.toleranceElement, item.item) : item.item; + + if (!fast) { + item.width = t.outerWidth(); + item.height = t.outerHeight(); + } + + p = t.offset(); + item.left = p.left; + item.top = p.top; + } + + if(this.options.custom && this.options.custom.refreshContainers) { + this.options.custom.refreshContainers.call(this); + } else { + for (i = this.containers.length - 1; i >= 0; i--){ + p = this.containers[i].element.offset(); + this.containers[i].containerCache.left = p.left; + this.containers[i].containerCache.top = p.top; + this.containers[i].containerCache.width = this.containers[i].element.outerWidth(); + this.containers[i].containerCache.height = this.containers[i].element.outerHeight(); + } + } + + return this; + }, + + _createPlaceholder: function(that) { + that = that || this; + var className, + o = that.options; + + if(!o.placeholder || o.placeholder.constructor === String) { + className = o.placeholder; + o.placeholder = { + element: function() { + + var nodeName = that.currentItem[0].nodeName.toLowerCase(), + element = $( "<" + nodeName + ">", that.document[0] ) + .addClass(className || that.currentItem[0].className+" ui-sortable-placeholder") + .removeClass("ui-sortable-helper"); + + if ( nodeName === "tr" ) { + that.currentItem.children().each(function() { + $( " ", that.document[0] ) + .attr( "colspan", $( this ).attr( "colspan" ) || 1 ) + .appendTo( element ); + }); + } else if ( nodeName === "img" ) { + element.attr( "src", that.currentItem.attr( "src" ) ); + } + + if ( !className ) { + element.css( "visibility", "hidden" ); + } + + return element; + }, + update: function(container, p) { + + // 1. If a className is set as 'placeholder option, we don't force sizes - the class is responsible for that + // 2. The option 'forcePlaceholderSize can be enabled to force it even if a class name is specified + if(className && !o.forcePlaceholderSize) { + return; + } + + //If the element doesn't have a actual height by itself (without styles coming from a stylesheet), it receives the inline height from the dragged item + if(!p.height()) { p.height(that.currentItem.innerHeight() - parseInt(that.currentItem.css("paddingTop")||0, 10) - parseInt(that.currentItem.css("paddingBottom")||0, 10)); } + if(!p.width()) { p.width(that.currentItem.innerWidth() - parseInt(that.currentItem.css("paddingLeft")||0, 10) - parseInt(that.currentItem.css("paddingRight")||0, 10)); } + } + }; + } + + //Create the placeholder + that.placeholder = $(o.placeholder.element.call(that.element, that.currentItem)); + + //Append it after the actual current item + that.currentItem.after(that.placeholder); + + //Update the size of the placeholder (TODO: Logic to fuzzy, see line 316/317) + o.placeholder.update(that, that.placeholder); + + }, + + _contactContainers: function(event) { + var i, j, dist, itemWithLeastDistance, posProperty, sizeProperty, base, cur, nearBottom, floating, + innermostContainer = null, + innermostIndex = null; + + // get innermost container that intersects with item + for (i = this.containers.length - 1; i >= 0; i--) { + + // never consider a container that's located within the item itself + if($.contains(this.currentItem[0], this.containers[i].element[0])) { + continue; + } + + if(this._intersectsWith(this.containers[i].containerCache)) { + + // if we've already found a container and it's more "inner" than this, then continue + if(innermostContainer && $.contains(this.containers[i].element[0], innermostContainer.element[0])) { + continue; + } + + innermostContainer = this.containers[i]; + innermostIndex = i; + + } else { + // container doesn't intersect. trigger "out" event if necessary + if(this.containers[i].containerCache.over) { + this.containers[i]._trigger("out", event, this._uiHash(this)); + this.containers[i].containerCache.over = 0; + } + } + + } + + // if no intersecting containers found, return + if(!innermostContainer) { + return; + } + + // move the item into the container if it's not there already + if(this.containers.length === 1) { + if (!this.containers[innermostIndex].containerCache.over) { + this.containers[innermostIndex]._trigger("over", event, this._uiHash(this)); + this.containers[innermostIndex].containerCache.over = 1; + } + } else { + + //When entering a new container, we will find the item with the least distance and append our item near it + dist = 10000; + itemWithLeastDistance = null; + floating = innermostContainer.floating || isFloating(this.currentItem); + posProperty = floating ? "left" : "top"; + sizeProperty = floating ? "width" : "height"; + base = this.positionAbs[posProperty] + this.offset.click[posProperty]; + for (j = this.items.length - 1; j >= 0; j--) { + if(!$.contains(this.containers[innermostIndex].element[0], this.items[j].item[0])) { + continue; + } + if(this.items[j].item[0] === this.currentItem[0]) { + continue; + } + if (floating && !isOverAxis(this.positionAbs.top + this.offset.click.top, this.items[j].top, this.items[j].height)) { + continue; + } + cur = this.items[j].item.offset()[posProperty]; + nearBottom = false; + if(Math.abs(cur - base) > Math.abs(cur + this.items[j][sizeProperty] - base)){ + nearBottom = true; + cur += this.items[j][sizeProperty]; + } + + if(Math.abs(cur - base) < dist) { + dist = Math.abs(cur - base); itemWithLeastDistance = this.items[j]; + this.direction = nearBottom ? "up": "down"; + } + } + + //Check if dropOnEmpty is enabled + if(!itemWithLeastDistance && !this.options.dropOnEmpty) { + return; + } + + if(this.currentContainer === this.containers[innermostIndex]) { + return; + } + + itemWithLeastDistance ? this._rearrange(event, itemWithLeastDistance, null, true) : this._rearrange(event, null, this.containers[innermostIndex].element, true); + this._trigger("change", event, this._uiHash()); + this.containers[innermostIndex]._trigger("change", event, this._uiHash(this)); + this.currentContainer = this.containers[innermostIndex]; + + //Update the placeholder + this.options.placeholder.update(this.currentContainer, this.placeholder); + + this.containers[innermostIndex]._trigger("over", event, this._uiHash(this)); + this.containers[innermostIndex].containerCache.over = 1; + } + + + }, + + _createHelper: function(event) { + + var o = this.options, + helper = $.isFunction(o.helper) ? $(o.helper.apply(this.element[0], [event, this.currentItem])) : (o.helper === "clone" ? this.currentItem.clone() : this.currentItem); + + //Add the helper to the DOM if that didn't happen already + if(!helper.parents("body").length) { + $(o.appendTo !== "parent" ? o.appendTo : this.currentItem[0].parentNode)[0].appendChild(helper[0]); + } + + if(helper[0] === this.currentItem[0]) { + this._storedCSS = { width: this.currentItem[0].style.width, height: this.currentItem[0].style.height, position: this.currentItem.css("position"), top: this.currentItem.css("top"), left: this.currentItem.css("left") }; + } + + if(!helper[0].style.width || o.forceHelperSize) { + helper.width(this.currentItem.width()); + } + if(!helper[0].style.height || o.forceHelperSize) { + helper.height(this.currentItem.height()); + } + + return helper; + + }, + + _adjustOffsetFromHelper: function(obj) { + if (typeof obj === "string") { + obj = obj.split(" "); + } + if ($.isArray(obj)) { + obj = {left: +obj[0], top: +obj[1] || 0}; + } + if ("left" in obj) { + this.offset.click.left = obj.left + this.margins.left; + } + if ("right" in obj) { + this.offset.click.left = this.helperProportions.width - obj.right + this.margins.left; + } + if ("top" in obj) { + this.offset.click.top = obj.top + this.margins.top; + } + if ("bottom" in obj) { + this.offset.click.top = this.helperProportions.height - obj.bottom + this.margins.top; + } + }, + + _getParentOffset: function() { + + + //Get the offsetParent and cache its position + this.offsetParent = this.helper.offsetParent(); + var po = this.offsetParent.offset(); + + // This is a special case where we need to modify a offset calculated on start, since the following happened: + // 1. The position of the helper is absolute, so it's position is calculated based on the next positioned parent + // 2. The actual offset parent is a child of the scroll parent, and the scroll parent isn't the document, which means that + // the scroll is included in the initial calculation of the offset of the parent, and never recalculated upon drag + if(this.cssPosition === "absolute" && this.scrollParent[0] !== document && $.contains(this.scrollParent[0], this.offsetParent[0])) { + po.left += this.scrollParent.scrollLeft(); + po.top += this.scrollParent.scrollTop(); + } + + // This needs to be actually done for all browsers, since pageX/pageY includes this information + // with an ugly IE fix + if( this.offsetParent[0] === document.body || (this.offsetParent[0].tagName && this.offsetParent[0].tagName.toLowerCase() === "html" && $.ui.ie)) { + po = { top: 0, left: 0 }; + } + + return { + top: po.top + (parseInt(this.offsetParent.css("borderTopWidth"),10) || 0), + left: po.left + (parseInt(this.offsetParent.css("borderLeftWidth"),10) || 0) + }; + + }, + + _getRelativeOffset: function() { + + if(this.cssPosition === "relative") { + var p = this.currentItem.position(); + return { + top: p.top - (parseInt(this.helper.css("top"),10) || 0) + this.scrollParent.scrollTop(), + left: p.left - (parseInt(this.helper.css("left"),10) || 0) + this.scrollParent.scrollLeft() + }; + } else { + return { top: 0, left: 0 }; + } + + }, + + _cacheMargins: function() { + this.margins = { + left: (parseInt(this.currentItem.css("marginLeft"),10) || 0), + top: (parseInt(this.currentItem.css("marginTop"),10) || 0) + }; + }, + + _cacheHelperProportions: function() { + this.helperProportions = { + width: this.helper.outerWidth(), + height: this.helper.outerHeight() + }; + }, + + _setContainment: function() { + + var ce, co, over, + o = this.options; + if(o.containment === "parent") { + o.containment = this.helper[0].parentNode; + } + if(o.containment === "document" || o.containment === "window") { + this.containment = [ + 0 - this.offset.relative.left - this.offset.parent.left, + 0 - this.offset.relative.top - this.offset.parent.top, + $(o.containment === "document" ? document : window).width() - this.helperProportions.width - this.margins.left, + ($(o.containment === "document" ? document : window).height() || document.body.parentNode.scrollHeight) - this.helperProportions.height - this.margins.top + ]; + } + + if(!(/^(document|window|parent)$/).test(o.containment)) { + ce = $(o.containment)[0]; + co = $(o.containment).offset(); + over = ($(ce).css("overflow") !== "hidden"); + + this.containment = [ + co.left + (parseInt($(ce).css("borderLeftWidth"),10) || 0) + (parseInt($(ce).css("paddingLeft"),10) || 0) - this.margins.left, + co.top + (parseInt($(ce).css("borderTopWidth"),10) || 0) + (parseInt($(ce).css("paddingTop"),10) || 0) - this.margins.top, + co.left+(over ? Math.max(ce.scrollWidth,ce.offsetWidth) : ce.offsetWidth) - (parseInt($(ce).css("borderLeftWidth"),10) || 0) - (parseInt($(ce).css("paddingRight"),10) || 0) - this.helperProportions.width - this.margins.left, + co.top+(over ? Math.max(ce.scrollHeight,ce.offsetHeight) : ce.offsetHeight) - (parseInt($(ce).css("borderTopWidth"),10) || 0) - (parseInt($(ce).css("paddingBottom"),10) || 0) - this.helperProportions.height - this.margins.top + ]; + } + + }, + + _convertPositionTo: function(d, pos) { + + if(!pos) { + pos = this.position; + } + var mod = d === "absolute" ? 1 : -1, + scroll = this.cssPosition === "absolute" && !(this.scrollParent[0] !== document && $.contains(this.scrollParent[0], this.offsetParent[0])) ? this.offsetParent : this.scrollParent, + scrollIsRootNode = (/(html|body)/i).test(scroll[0].tagName); + + return { + top: ( + pos.top + // The absolute mouse position + this.offset.relative.top * mod + // Only for relative positioned nodes: Relative offset from element to offset parent + this.offset.parent.top * mod - // The offsetParent's offset without borders (offset + border) + ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollTop() : ( scrollIsRootNode ? 0 : scroll.scrollTop() ) ) * mod) + ), + left: ( + pos.left + // The absolute mouse position + this.offset.relative.left * mod + // Only for relative positioned nodes: Relative offset from element to offset parent + this.offset.parent.left * mod - // The offsetParent's offset without borders (offset + border) + ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollLeft() : scrollIsRootNode ? 0 : scroll.scrollLeft() ) * mod) + ) + }; + + }, + + _generatePosition: function(event) { + + var top, left, + o = this.options, + pageX = event.pageX, + pageY = event.pageY, + scroll = this.cssPosition === "absolute" && !(this.scrollParent[0] !== document && $.contains(this.scrollParent[0], this.offsetParent[0])) ? this.offsetParent : this.scrollParent, scrollIsRootNode = (/(html|body)/i).test(scroll[0].tagName); + + // This is another very weird special case that only happens for relative elements: + // 1. If the css position is relative + // 2. and the scroll parent is the document or similar to the offset parent + // we have to refresh the relative offset during the scroll so there are no jumps + if(this.cssPosition === "relative" && !(this.scrollParent[0] !== document && this.scrollParent[0] !== this.offsetParent[0])) { + this.offset.relative = this._getRelativeOffset(); + } + + /* + * - Position constraining - + * Constrain the position to a mix of grid, containment. + */ + + if(this.originalPosition) { //If we are not dragging yet, we won't check for options + + if(this.containment) { + if(event.pageX - this.offset.click.left < this.containment[0]) { + pageX = this.containment[0] + this.offset.click.left; + } + if(event.pageY - this.offset.click.top < this.containment[1]) { + pageY = this.containment[1] + this.offset.click.top; + } + if(event.pageX - this.offset.click.left > this.containment[2]) { + pageX = this.containment[2] + this.offset.click.left; + } + if(event.pageY - this.offset.click.top > this.containment[3]) { + pageY = this.containment[3] + this.offset.click.top; + } + } + + if(o.grid) { + top = this.originalPageY + Math.round((pageY - this.originalPageY) / o.grid[1]) * o.grid[1]; + pageY = this.containment ? ( (top - this.offset.click.top >= this.containment[1] && top - this.offset.click.top <= this.containment[3]) ? top : ((top - this.offset.click.top >= this.containment[1]) ? top - o.grid[1] : top + o.grid[1])) : top; + + left = this.originalPageX + Math.round((pageX - this.originalPageX) / o.grid[0]) * o.grid[0]; + pageX = this.containment ? ( (left - this.offset.click.left >= this.containment[0] && left - this.offset.click.left <= this.containment[2]) ? left : ((left - this.offset.click.left >= this.containment[0]) ? left - o.grid[0] : left + o.grid[0])) : left; + } + + } + + return { + top: ( + pageY - // The absolute mouse position + this.offset.click.top - // Click offset (relative to the element) + this.offset.relative.top - // Only for relative positioned nodes: Relative offset from element to offset parent + this.offset.parent.top + // The offsetParent's offset without borders (offset + border) + ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollTop() : ( scrollIsRootNode ? 0 : scroll.scrollTop() ) )) + ), + left: ( + pageX - // The absolute mouse position + this.offset.click.left - // Click offset (relative to the element) + this.offset.relative.left - // Only for relative positioned nodes: Relative offset from element to offset parent + this.offset.parent.left + // The offsetParent's offset without borders (offset + border) + ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollLeft() : scrollIsRootNode ? 0 : scroll.scrollLeft() )) + ) + }; + + }, + + _rearrange: function(event, i, a, hardRefresh) { + + a ? a[0].appendChild(this.placeholder[0]) : i.item[0].parentNode.insertBefore(this.placeholder[0], (this.direction === "down" ? i.item[0] : i.item[0].nextSibling)); + + //Various things done here to improve the performance: + // 1. we create a setTimeout, that calls refreshPositions + // 2. on the instance, we have a counter variable, that get's higher after every append + // 3. on the local scope, we copy the counter variable, and check in the timeout, if it's still the same + // 4. this lets only the last addition to the timeout stack through + this.counter = this.counter ? ++this.counter : 1; + var counter = this.counter; + + this._delay(function() { + if(counter === this.counter) { + this.refreshPositions(!hardRefresh); //Precompute after each DOM insertion, NOT on mousemove + } + }); + + }, + + _clear: function(event, noPropagation) { + + this.reverting = false; + // We delay all events that have to be triggered to after the point where the placeholder has been removed and + // everything else normalized again + var i, + delayedTriggers = []; + + // We first have to update the dom position of the actual currentItem + // Note: don't do it if the current item is already removed (by a user), or it gets reappended (see #4088) + if(!this._noFinalSort && this.currentItem.parent().length) { + this.placeholder.before(this.currentItem); + } + this._noFinalSort = null; + + if(this.helper[0] === this.currentItem[0]) { + for(i in this._storedCSS) { + if(this._storedCSS[i] === "auto" || this._storedCSS[i] === "static") { + this._storedCSS[i] = ""; + } + } + this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper"); + } else { + this.currentItem.show(); + } + + if(this.fromOutside && !noPropagation) { + delayedTriggers.push(function(event) { this._trigger("receive", event, this._uiHash(this.fromOutside)); }); + } + if((this.fromOutside || this.domPosition.prev !== this.currentItem.prev().not(".ui-sortable-helper")[0] || this.domPosition.parent !== this.currentItem.parent()[0]) && !noPropagation) { + delayedTriggers.push(function(event) { this._trigger("update", event, this._uiHash()); }); //Trigger update callback if the DOM position has changed + } + + // Check if the items Container has Changed and trigger appropriate + // events. + if (this !== this.currentContainer) { + if(!noPropagation) { + delayedTriggers.push(function(event) { this._trigger("remove", event, this._uiHash()); }); + delayedTriggers.push((function(c) { return function(event) { c._trigger("receive", event, this._uiHash(this)); }; }).call(this, this.currentContainer)); + delayedTriggers.push((function(c) { return function(event) { c._trigger("update", event, this._uiHash(this)); }; }).call(this, this.currentContainer)); + } + } + + + //Post events to containers + for (i = this.containers.length - 1; i >= 0; i--){ + if(!noPropagation) { + delayedTriggers.push((function(c) { return function(event) { c._trigger("deactivate", event, this._uiHash(this)); }; }).call(this, this.containers[i])); + } + if(this.containers[i].containerCache.over) { + delayedTriggers.push((function(c) { return function(event) { c._trigger("out", event, this._uiHash(this)); }; }).call(this, this.containers[i])); + this.containers[i].containerCache.over = 0; + } + } + + //Do what was originally in plugins + if ( this.storedCursor ) { + this.document.find( "body" ).css( "cursor", this.storedCursor ); + this.storedStylesheet.remove(); + } + if(this._storedOpacity) { + this.helper.css("opacity", this._storedOpacity); + } + if(this._storedZIndex) { + this.helper.css("zIndex", this._storedZIndex === "auto" ? "" : this._storedZIndex); + } + + this.dragging = false; + if(this.cancelHelperRemoval) { + if(!noPropagation) { + this._trigger("beforeStop", event, this._uiHash()); + for (i=0; i < delayedTriggers.length; i++) { + delayedTriggers[i].call(this, event); + } //Trigger all delayed events + this._trigger("stop", event, this._uiHash()); + } + + this.fromOutside = false; + return false; + } + + if(!noPropagation) { + this._trigger("beforeStop", event, this._uiHash()); + } + + //$(this.placeholder[0]).remove(); would have been the jQuery way - unfortunately, it unbinds ALL events from the original node! + this.placeholder[0].parentNode.removeChild(this.placeholder[0]); + + if(this.helper[0] !== this.currentItem[0]) { + this.helper.remove(); + } + this.helper = null; + + if(!noPropagation) { + for (i=0; i < delayedTriggers.length; i++) { + delayedTriggers[i].call(this, event); + } //Trigger all delayed events + this._trigger("stop", event, this._uiHash()); + } + + this.fromOutside = false; + return true; + + }, + + _trigger: function() { + if ($.Widget.prototype._trigger.apply(this, arguments) === false) { + this.cancel(); + } + }, + + _uiHash: function(_inst) { + var inst = _inst || this; + return { + helper: inst.helper, + placeholder: inst.placeholder || $([]), + position: inst.position, + originalPosition: inst.originalPosition, + offset: inst.positionAbs, + item: inst.currentItem, + sender: _inst ? _inst.element : null + }; + } + +}); + +})(jQuery); diff --git a/ckan/public/base/vendor/jquery.ui.widget.js b/ckan/public/base/vendor/jquery.ui.widget.js old mode 100644 new mode 100755 index 9da8673a58b..916a6ad73c1 --- a/ckan/public/base/vendor/jquery.ui.widget.js +++ b/ckan/public/base/vendor/jquery.ui.widget.js @@ -1,58 +1,35 @@ -/* - * jQuery UI Widget 1.8.18+amd - * https://github.com/blueimp/jQuery-File-Upload +/*! + * jQuery UI Widget 1.10.3 + * http://jqueryui.com * - * Copyright 2011, AUTHORS.txt (http://jqueryui.com/about) - * Dual licensed under the MIT or GPL Version 2 licenses. + * Copyright 2013 jQuery Foundation and other contributors + * Released under the MIT license. * http://jquery.org/license * - * http://docs.jquery.com/UI/Widget + * http://api.jqueryui.com/jQuery.widget/ */ - -(function (factory) { - if (typeof define === "function" && define.amd) { - // Register as an anonymous AMD module: - define(["jquery"], factory); - } else { - // Browser globals: - factory(jQuery); - } -}(function( $, undefined ) { - -// jQuery 1.4+ -if ( $.cleanData ) { - var _cleanData = $.cleanData; - $.cleanData = function( elems ) { - for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { - try { - $( elem ).triggerHandler( "remove" ); - // http://bugs.jquery.com/ticket/8235 - } catch( e ) {} - } - _cleanData( elems ); - }; -} else { - var _remove = $.fn.remove; - $.fn.remove = function( selector, keepData ) { - return this.each(function() { - if ( !keepData ) { - if ( !selector || $.filter( selector, [ this ] ).length ) { - $( "*", this ).add( [ this ] ).each(function() { - try { - $( this ).triggerHandler( "remove" ); - // http://bugs.jquery.com/ticket/8235 - } catch( e ) {} - }); - } - } - return _remove.call( $(this), selector, keepData ); - }); - }; -} +(function( $, undefined ) { + +var uuid = 0, + slice = Array.prototype.slice, + _cleanData = $.cleanData; +$.cleanData = function( elems ) { + for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { + try { + $( elem ).triggerHandler( "remove" ); + // http://bugs.jquery.com/ticket/8235 + } catch( e ) {} + } + _cleanData( elems ); +}; $.widget = function( name, base, prototype ) { - var namespace = name.split( "." )[ 0 ], - fullName; + var fullName, existingConstructor, constructor, basePrototype, + // proxiedPrototype allows the provided prototype to remain unmodified + // so that it can be used as a mixin for multiple widgets (#8876) + proxiedPrototype = {}, + namespace = name.split( "." )[ 0 ]; + name = name.split( "." )[ 1 ]; fullName = namespace + "-" + name; @@ -62,81 +39,167 @@ $.widget = function( name, base, prototype ) { } // create selector for plugin - $.expr[ ":" ][ fullName ] = function( elem ) { - return !!$.data( elem, name ); + $.expr[ ":" ][ fullName.toLowerCase() ] = function( elem ) { + return !!$.data( elem, fullName ); }; $[ namespace ] = $[ namespace ] || {}; - $[ namespace ][ name ] = function( options, element ) { + existingConstructor = $[ namespace ][ name ]; + constructor = $[ namespace ][ name ] = function( options, element ) { + // allow instantiation without "new" keyword + if ( !this._createWidget ) { + return new constructor( options, element ); + } + // allow instantiation without initializing for simple inheritance + // must use "new" keyword (the code above always passes args) if ( arguments.length ) { this._createWidget( options, element ); } }; - - var basePrototype = new base(); + // extend with the existing constructor to carry over any static properties + $.extend( constructor, existingConstructor, { + version: prototype.version, + // copy the object used to create the prototype in case we need to + // redefine the widget later + _proto: $.extend( {}, prototype ), + // track widgets that inherit from this widget in case this widget is + // redefined after a widget inherits from it + _childConstructors: [] + }); + + basePrototype = new base(); // we need to make the options hash a property directly on the new instance // otherwise we'll modify the options hash on the prototype that we're // inheriting from -// $.each( basePrototype, function( key, val ) { -// if ( $.isPlainObject(val) ) { -// basePrototype[ key ] = $.extend( {}, val ); -// } -// }); - basePrototype.options = $.extend( true, {}, basePrototype.options ); - $[ namespace ][ name ].prototype = $.extend( true, basePrototype, { + basePrototype.options = $.widget.extend( {}, basePrototype.options ); + $.each( prototype, function( prop, value ) { + if ( !$.isFunction( value ) ) { + proxiedPrototype[ prop ] = value; + return; + } + proxiedPrototype[ prop ] = (function() { + var _super = function() { + return base.prototype[ prop ].apply( this, arguments ); + }, + _superApply = function( args ) { + return base.prototype[ prop ].apply( this, args ); + }; + return function() { + var __super = this._super, + __superApply = this._superApply, + returnValue; + + this._super = _super; + this._superApply = _superApply; + + returnValue = value.apply( this, arguments ); + + this._super = __super; + this._superApply = __superApply; + + return returnValue; + }; + })(); + }); + constructor.prototype = $.widget.extend( basePrototype, { + // TODO: remove support for widgetEventPrefix + // always use the name + a colon as the prefix, e.g., draggable:start + // don't prefix for widgets that aren't DOM-based + widgetEventPrefix: existingConstructor ? basePrototype.widgetEventPrefix : name + }, proxiedPrototype, { + constructor: constructor, namespace: namespace, widgetName: name, - widgetEventPrefix: $[ namespace ][ name ].prototype.widgetEventPrefix || name, - widgetBaseClass: fullName - }, prototype ); + widgetFullName: fullName + }); + + // If this widget is being redefined then we need to find all widgets that + // are inheriting from it and redefine all of them so that they inherit from + // the new version of this widget. We're essentially trying to replace one + // level in the prototype chain. + if ( existingConstructor ) { + $.each( existingConstructor._childConstructors, function( i, child ) { + var childPrototype = child.prototype; + + // redefine the child widget using the same prototype that was + // originally used, but inherit from the new version of the base + $.widget( childPrototype.namespace + "." + childPrototype.widgetName, constructor, child._proto ); + }); + // remove the list of existing child constructors from the old constructor + // so the old child constructors can be garbage collected + delete existingConstructor._childConstructors; + } else { + base._childConstructors.push( constructor ); + } - $.widget.bridge( name, $[ namespace ][ name ] ); + $.widget.bridge( name, constructor ); +}; + +$.widget.extend = function( target ) { + var input = slice.call( arguments, 1 ), + inputIndex = 0, + inputLength = input.length, + key, + value; + for ( ; inputIndex < inputLength; inputIndex++ ) { + for ( key in input[ inputIndex ] ) { + value = input[ inputIndex ][ key ]; + if ( input[ inputIndex ].hasOwnProperty( key ) && value !== undefined ) { + // Clone objects + if ( $.isPlainObject( value ) ) { + target[ key ] = $.isPlainObject( target[ key ] ) ? + $.widget.extend( {}, target[ key ], value ) : + // Don't extend strings, arrays, etc. with objects + $.widget.extend( {}, value ); + // Copy everything else by reference + } else { + target[ key ] = value; + } + } + } + } + return target; }; $.widget.bridge = function( name, object ) { + var fullName = object.prototype.widgetFullName || name; $.fn[ name ] = function( options ) { var isMethodCall = typeof options === "string", - args = Array.prototype.slice.call( arguments, 1 ), + args = slice.call( arguments, 1 ), returnValue = this; // allow multiple hashes to be passed on init options = !isMethodCall && args.length ? - $.extend.apply( null, [ true, options ].concat(args) ) : + $.widget.extend.apply( null, [ options ].concat(args) ) : options; - // prevent calls to internal methods - if ( isMethodCall && options.charAt( 0 ) === "_" ) { - return returnValue; - } - if ( isMethodCall ) { this.each(function() { - var instance = $.data( this, name ), - methodValue = instance && $.isFunction( instance[options] ) ? - instance[ options ].apply( instance, args ) : - instance; - // TODO: add this back in 1.9 and use $.error() (see #5972) -// if ( !instance ) { -// throw "cannot call methods on " + name + " prior to initialization; " + -// "attempted to call method '" + options + "'"; -// } -// if ( !$.isFunction( instance[options] ) ) { -// throw "no such method '" + options + "' for " + name + " widget instance"; -// } -// var methodValue = instance[ options ].apply( instance, args ); + var methodValue, + instance = $.data( this, fullName ); + if ( !instance ) { + return $.error( "cannot call methods on " + name + " prior to initialization; " + + "attempted to call method '" + options + "'" ); + } + if ( !$.isFunction( instance[options] ) || options.charAt( 0 ) === "_" ) { + return $.error( "no such method '" + options + "' for " + name + " widget instance" ); + } + methodValue = instance[ options ].apply( instance, args ); if ( methodValue !== instance && methodValue !== undefined ) { - returnValue = methodValue; + returnValue = methodValue && methodValue.jquery ? + returnValue.pushStack( methodValue.get() ) : + methodValue; return false; } }); } else { this.each(function() { - var instance = $.data( this, name ); + var instance = $.data( this, fullName ); if ( instance ) { instance.option( options || {} )._init(); } else { - $.data( this, name, new object( options, this ) ); + $.data( this, fullName, new object( options, this ) ); } }); } @@ -145,74 +208,123 @@ $.widget.bridge = function( name, object ) { }; }; -$.Widget = function( options, element ) { - // allow instantiation without initializing for simple inheritance - if ( arguments.length ) { - this._createWidget( options, element ); - } -}; +$.Widget = function( /* options, element */ ) {}; +$.Widget._childConstructors = []; $.Widget.prototype = { widgetName: "widget", widgetEventPrefix: "", + defaultElement: "
", options: { - disabled: false + disabled: false, + + // callbacks + create: null }, _createWidget: function( options, element ) { - // $.widget.bridge stores the plugin instance, but we do it anyway - // so that it's stored even before the _create function runs - $.data( element, this.widgetName, this ); + element = $( element || this.defaultElement || this )[ 0 ]; this.element = $( element ); - this.options = $.extend( true, {}, + this.uuid = uuid++; + this.eventNamespace = "." + this.widgetName + this.uuid; + this.options = $.widget.extend( {}, this.options, this._getCreateOptions(), options ); - var self = this; - this.element.bind( "remove." + this.widgetName, function() { - self.destroy(); - }); + this.bindings = $(); + this.hoverable = $(); + this.focusable = $(); + + if ( element !== this ) { + $.data( element, this.widgetFullName, this ); + this._on( true, this.element, { + remove: function( event ) { + if ( event.target === element ) { + this.destroy(); + } + } + }); + this.document = $( element.style ? + // element within the document + element.ownerDocument : + // element is window or document + element.document || element ); + this.window = $( this.document[0].defaultView || this.document[0].parentWindow ); + } this._create(); - this._trigger( "create" ); + this._trigger( "create", null, this._getCreateEventData() ); this._init(); }, - _getCreateOptions: function() { - return $.metadata && $.metadata.get( this.element[0] )[ this.widgetName ]; - }, - _create: function() {}, - _init: function() {}, + _getCreateOptions: $.noop, + _getCreateEventData: $.noop, + _create: $.noop, + _init: $.noop, destroy: function() { + this._destroy(); + // we can probably remove the unbind calls in 2.0 + // all event bindings should go through this._on() this.element - .unbind( "." + this.widgetName ) - .removeData( this.widgetName ); + .unbind( this.eventNamespace ) + // 1.9 BC for #7810 + // TODO remove dual storage + .removeData( this.widgetName ) + .removeData( this.widgetFullName ) + // support: jquery <1.6.3 + // http://bugs.jquery.com/ticket/9413 + .removeData( $.camelCase( this.widgetFullName ) ); this.widget() - .unbind( "." + this.widgetName ) + .unbind( this.eventNamespace ) .removeAttr( "aria-disabled" ) .removeClass( - this.widgetBaseClass + "-disabled " + + this.widgetFullName + "-disabled " + "ui-state-disabled" ); + + // clean up events and states + this.bindings.unbind( this.eventNamespace ); + this.hoverable.removeClass( "ui-state-hover" ); + this.focusable.removeClass( "ui-state-focus" ); }, + _destroy: $.noop, widget: function() { return this.element; }, option: function( key, value ) { - var options = key; + var options = key, + parts, + curOption, + i; if ( arguments.length === 0 ) { // don't return a reference to the internal hash - return $.extend( {}, this.options ); + return $.widget.extend( {}, this.options ); } - if (typeof key === "string" ) { - if ( value === undefined ) { - return this.options[ key ]; - } + if ( typeof key === "string" ) { + // handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } } options = {}; - options[ key ] = value; + parts = key.split( "." ); + key = parts.shift(); + if ( parts.length ) { + curOption = options[ key ] = $.widget.extend( {}, this.options[ key ] ); + for ( i = 0; i < parts.length - 1; i++ ) { + curOption[ parts[ i ] ] = curOption[ parts[ i ] ] || {}; + curOption = curOption[ parts[ i ] ]; + } + key = parts.pop(); + if ( value === undefined ) { + return curOption[ key ] === undefined ? null : curOption[ key ]; + } + curOption[ key ] = value; + } else { + if ( value === undefined ) { + return this.options[ key ] === undefined ? null : this.options[ key ]; + } + options[ key ] = value; + } } this._setOptions( options ); @@ -220,10 +332,11 @@ $.Widget.prototype = { return this; }, _setOptions: function( options ) { - var self = this; - $.each( options, function( key, value ) { - self._setOption( key, value ); - }); + var key; + + for ( key in options ) { + this._setOption( key, options[ key ] ); + } return this; }, @@ -232,10 +345,10 @@ $.Widget.prototype = { if ( key === "disabled" ) { this.widget() - [ value ? "addClass" : "removeClass"]( - this.widgetBaseClass + "-disabled" + " " + - "ui-state-disabled" ) + .toggleClass( this.widgetFullName + "-disabled ui-state-disabled", !!value ) .attr( "aria-disabled", value ); + this.hoverable.removeClass( "ui-state-hover" ); + this.focusable.removeClass( "ui-state-focus" ); } return this; @@ -248,6 +361,97 @@ $.Widget.prototype = { return this._setOption( "disabled", true ); }, + _on: function( suppressDisabledCheck, element, handlers ) { + var delegateElement, + instance = this; + + // no suppressDisabledCheck flag, shuffle arguments + if ( typeof suppressDisabledCheck !== "boolean" ) { + handlers = element; + element = suppressDisabledCheck; + suppressDisabledCheck = false; + } + + // no element argument, shuffle and use this.element + if ( !handlers ) { + handlers = element; + element = this.element; + delegateElement = this.widget(); + } else { + // accept selectors, DOM elements + element = delegateElement = $( element ); + this.bindings = this.bindings.add( element ); + } + + $.each( handlers, function( event, handler ) { + function handlerProxy() { + // allow widgets to customize the disabled handling + // - disabled as an array instead of boolean + // - disabled class as method for disabling individual parts + if ( !suppressDisabledCheck && + ( instance.options.disabled === true || + $( this ).hasClass( "ui-state-disabled" ) ) ) { + return; + } + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + + // copy the guid so direct unbinding works + if ( typeof handler !== "string" ) { + handlerProxy.guid = handler.guid = + handler.guid || handlerProxy.guid || $.guid++; + } + + var match = event.match( /^(\w+)\s*(.*)$/ ), + eventName = match[1] + instance.eventNamespace, + selector = match[2]; + if ( selector ) { + delegateElement.delegate( selector, eventName, handlerProxy ); + } else { + element.bind( eventName, handlerProxy ); + } + }); + }, + + _off: function( element, eventName ) { + eventName = (eventName || "").split( " " ).join( this.eventNamespace + " " ) + this.eventNamespace; + element.unbind( eventName ).undelegate( eventName ); + }, + + _delay: function( handler, delay ) { + function handlerProxy() { + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + var instance = this; + return setTimeout( handlerProxy, delay || 0 ); + }, + + _hoverable: function( element ) { + this.hoverable = this.hoverable.add( element ); + this._on( element, { + mouseenter: function( event ) { + $( event.currentTarget ).addClass( "ui-state-hover" ); + }, + mouseleave: function( event ) { + $( event.currentTarget ).removeClass( "ui-state-hover" ); + } + }); + }, + + _focusable: function( element ) { + this.focusable = this.focusable.add( element ); + this._on( element, { + focusin: function( event ) { + $( event.currentTarget ).addClass( "ui-state-focus" ); + }, + focusout: function( event ) { + $( event.currentTarget ).removeClass( "ui-state-focus" ); + } + }); + }, + _trigger: function( type, event, data ) { var prop, orig, callback = this.options[ type ]; @@ -272,11 +476,46 @@ $.Widget.prototype = { } this.element.trigger( event, data ); - - return !( $.isFunction(callback) && - callback.call( this.element[0], event, data ) === false || + return !( $.isFunction( callback ) && + callback.apply( this.element[0], [ event ].concat( data ) ) === false || event.isDefaultPrevented() ); } }; -})); +$.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) { + $.Widget.prototype[ "_" + method ] = function( element, options, callback ) { + if ( typeof options === "string" ) { + options = { effect: options }; + } + var hasOptions, + effectName = !options ? + method : + options === true || typeof options === "number" ? + defaultEffect : + options.effect || defaultEffect; + options = options || {}; + if ( typeof options === "number" ) { + options = { duration: options }; + } + hasOptions = !$.isEmptyObject( options ); + options.complete = callback; + if ( options.delay ) { + element.delay( options.delay ); + } + if ( hasOptions && $.effects && $.effects.effect[ effectName ] ) { + element[ method ]( options ); + } else if ( effectName !== method && element[ effectName ] ) { + element[ effectName ]( options.duration, options.easing, callback ); + } else { + element.queue(function( next ) { + $( this )[ method ](); + if ( callback ) { + callback.call( element[ 0 ] ); + } + next(); + }); + } + }; +}); + +})( jQuery ); diff --git a/ckan/public/base/vendor/resource.config b/ckan/public/base/vendor/resource.config index 8924b72606a..56bdf2a316a 100644 --- a/ckan/public/base/vendor/resource.config +++ b/ckan/public/base/vendor/resource.config @@ -25,6 +25,7 @@ block_html5_shim = vendor = jquery.js bootstrap = jquery.js fileupload = jquery.js +reorder = jquery.js [groups] @@ -39,8 +40,13 @@ bootstrap = font-awesome/css/font-awesome.css font-awesome/css/font-awesome-ie7.css - fileupload = jquery.ui.widget.js jquery-fileupload/jquery.iframe-transport.js jquery-fileupload/jquery.fileupload.js + +reorder = + jquery.ui.core.js + jquery.ui.widget.js + jquery.ui.mouse.js + jquery.ui.sortable.js diff --git a/ckan/templates/admin/config.html b/ckan/templates/admin/config.html index bfa9b3a2ee5..81a322b3997 100644 --- a/ckan/templates/admin/config.html +++ b/ckan/templates/admin/config.html @@ -10,7 +10,7 @@ {% endblock %} diff --git a/ckan/templates/base.html b/ckan/templates/base.html index b6290af0ad0..9ab7a69d973 100644 --- a/ckan/templates/base.html +++ b/ckan/templates/base.html @@ -102,17 +102,9 @@ {%- block page %}{% endblock -%} {# - The scripts block allows you to add additonal scripts to the page. Use the - super() function to load the default scripts before/after your own. - NOTE: Scripts should be loaded by the {% resource %} tag except - in very special circumstances DO NOT USE THIS METHOD FOR ADDING SCRIPTS. - - Example: - - {% block scripts %} - {{ super() }} - - {% endblock %} + DO NOT USE THIS BLOCK FOR ADDING SCRIPTS + Scripts should be loaded by the {% resource %} tag except in very special + circumstances #} {%- block scripts %} {% endblock -%} diff --git a/ckan/templates/group/about.html b/ckan/templates/group/about.html index 69259e22ec3..a7d3683700d 100644 --- a/ckan/templates/group/about.html +++ b/ckan/templates/group/about.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('About') }} - {{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ _('About') }} - {{ super() }}{% endblock %} {% block primary_content_inner %}

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

diff --git a/ckan/templates/group/activity_stream.html b/ckan/templates/group/activity_stream.html index 800ed24db9b..47a6d029bb4 100644 --- a/ckan/templates/group/activity_stream.html +++ b/ckan/templates/group/activity_stream.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ c.group_dict.title or c.group_dict.name }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} {% block primary_content_inner %}

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

diff --git a/ckan/templates/group/edit.html b/ckan/templates/group/edit.html index 57808056d54..ff5c0b96989 100644 --- a/ckan/templates/group/edit.html +++ b/ckan/templates/group/edit.html @@ -4,11 +4,9 @@
  • {% link_for _('Groups'), controller='group', action='index' %}
  • {% block breadcrumb_content_inner %}
  • {% link_for group.display_name|truncate(35), controller='group', action='read', id=group.name %}
  • -
  • {% link_for _('Edit'), controller='group', action='edit', id=group.name %}
  • +
  • {% link_for _('Manage'), controller='group', action='edit', id=group.name %}
  • {% endblock %} {% endblock %} -{% block subtitle %}{{ _('Edit Group') }}{% endblock %} - {% block page_heading_class %}hide-heading{% endblock %} {% block page_heading %}{{ _('Edit Group') }}{% endblock %} diff --git a/ckan/templates/group/edit_base.html b/ckan/templates/group/edit_base.html index a5f1203d529..566da231192 100644 --- a/ckan/templates/group/edit_base.html +++ b/ckan/templates/group/edit_base.html @@ -1,17 +1,19 @@ {% extends "page.html" %} +{% block subtitle %}{{ _('Manage') }} - {{ c.group_dict.display_name }} - {{ _('Groups') }}{% endblock %} + {% set group = c.group_dict %} {% block breadcrumb_content %}
  • {% link_for _('Groups'), controller='group', action='index' %}
  • {% block breadcrumb_content_inner %}
  • {% link_for group.display_name|truncate(35), controller='group', action='read', id=group.name %}
  • -
  • {% link_for _('Edit'), controller='group', action='edit', id=group.name %}
  • +
  • {% link_for _('Manage'), controller='group', action='edit', id=group.name %}
  • {% endblock %} {% endblock %} {% block content_action %} - {% link_for _('View group'), controller='group', action='read', id=c.group_dict.name, class_='btn', icon='eye-open' %} + {% link_for _('View'), controller='group', action='read', id=c.group_dict.name, class_='btn', icon='eye-open' %} {% endblock %} {% block content_primary_nav %} diff --git a/ckan/templates/group/index.html b/ckan/templates/group/index.html index 6298cb5a120..514c12bb961 100644 --- a/ckan/templates/group/index.html +++ b/ckan/templates/group/index.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ _('Groups of Datasets') }}{% endblock %} +{% block subtitle %}{{ _('Groups') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Groups'), controller='group', action='index' %}
  • @@ -17,11 +17,13 @@ {% block primary_content_inner %}

    {{ _('Groups') }}

    {% block groups_search_form %} - {% snippet 'snippets/search_form.html', type='group', query=c.q, sorting_selected=c.sort_by_selected, count=c.page.item_count, placeholder=_('Search groups...'), show_empty=request.params %} + {% snippet 'snippets/search_form.html', type='group', query=c.q, sorting_selected=c.sort_by_selected, count=c.page.item_count, placeholder=_('Search groups...'), show_empty=request.params, no_bottom_border=true if c.page.items %} {% endblock %} {% block groups_list %} {% if c.page.items or request.params %} - {% snippet "group/snippets/group_list.html", groups=c.page.items %} + {% if c.page.items %} + {% snippet "group/snippets/group_list.html", groups=c.page.items %} + {% endif %} {% else %}

    {{ _('There are currently no groups for this site') }}. diff --git a/ckan/templates/group/member_new.html b/ckan/templates/group/member_new.html index 6a8fa691c29..bead208c5ec 100644 --- a/ckan/templates/group/member_new.html +++ b/ckan/templates/group/member_new.html @@ -85,12 +85,9 @@

    {% trans %} -

    Admin: Can add/edit and delete datasets, as well as - manage group members.

    -

    Editor: Can add and edit datasets, but not manage - group members.

    -

    Member: Can view the group's private - datasets, but not add new datasets.

    +

    Admin: Can edit group information, as well as + manage organization members.

    +

    Member: Can add/remove datasets from groups

    {% endtrans %}
    diff --git a/ckan/templates/group/members.html b/ckan/templates/group/members.html index 600c6d1bf54..c758972d54b 100644 --- a/ckan/templates/group/members.html +++ b/ckan/templates/group/members.html @@ -1,6 +1,6 @@ {% extends "group/edit_base.html" %} -{% block subtitle %}{{ _('Members') }} - {{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ _('Members') }} - {{ c.group_dict.display_name }} - {{ _('Groups') }}{% endblock %} {% block primary_content_inner %} {% link_for _('Add Member'), controller='group', action='member_new', id=c.group_dict.id, class_='btn pull-right', icon='plus-sign-alt' %} diff --git a/ckan/templates/group/new.html b/ckan/templates/group/new.html index 23d66facb05..1d78ac40010 100644 --- a/ckan/templates/group/new.html +++ b/ckan/templates/group/new.html @@ -2,7 +2,7 @@ {% block subtitle %}{{ _('Create a Group') }}{% endblock %} -{% block breadcrumb_link %}{{ h.nav_link(_('Create Group'), controller='group', action='edit', id=c.group.name) }}{% endblock %} +{% block breadcrumb_link %}{{ h.nav_link(_('Create a Group'), controller='group', action='edit', id=c.group.name) }}{% endblock %} {% block page_heading %}{{ _('Create a Group') }}{% endblock %} diff --git a/ckan/templates/group/read.html b/ckan/templates/group/read.html index 8218edb4b37..81b967e53d7 100644 --- a/ckan/templates/group/read.html +++ b/ckan/templates/group/read.html @@ -2,10 +2,6 @@ {% block subtitle %}{{ c.group_dict.display_name }}{% endblock %} -{% block page_primary_action %} - {% link_for _('Add Dataset'), controller='package', action='new', group=c.group_dict.id, class_='btn btn-primary', icon='plus-sign-alt' %} -{% endblock %} - {% block primary_content_inner %} {% block groups_search_form %} {% set facets = { diff --git a/ckan/templates/group/read_base.html b/ckan/templates/group/read_base.html index afa92f2038f..9164f28ed64 100644 --- a/ckan/templates/group/read_base.html +++ b/ckan/templates/group/read_base.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ c.group_dict.display_name }} - {{ _('Groups') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Groups'), controller='group', action='index' %}
  • @@ -9,7 +9,7 @@ {% block content_action %} {% if h.check_access('group_update', {'id': c.group_dict.id}) %} - {% link_for _('Edit'), controller='group', action='edit', id=c.group_dict.name, class_='btn', icon='wrench' %} + {% link_for _('Manage'), controller='group', action='edit', id=c.group_dict.name, class_='btn', icon='wrench' %} {% endif %} {% endblock %} diff --git a/ckan/templates/group/snippets/group_form.html b/ckan/templates/group/snippets/group_form.html index cd19912d66b..a6d5fbcbf95 100644 --- a/ckan/templates/group/snippets/group_form.html +++ b/ckan/templates/group/snippets/group_form.html @@ -19,7 +19,10 @@ {{ form.markdown('description', label=_('Description'), id='field-description', placeholder=_('A little information about my group...'), value=data.description, error=errors.description) }} - {{ form.image_upload(data, errors, image_url=c.group_dict.image_display_url, is_upload_enabled=h.uploads_enabled()) }} + {% set is_upload = data.image_url and not data.image_url.startswith('http') %} + {% set is_url = data.image_url and data.image_url.startswith('http') %} + + {{ form.image_upload(data, errors, is_upload_enabled=h.uploads_enabled(), is_url=is_url, is_upload=is_upload) }} {% endblock %} @@ -48,6 +51,8 @@ ) }} {% endfor %} {% endblock %} + + {{ form.required_message() }} {# Do not update datasets here {% block dataset_fields %} {% if data.packages %} @@ -70,13 +75,13 @@ #}
    + {% block delete_button %} + {% if h.check_access('group_delete', {'id': data.id}) %} + {% set locale = h.dump_json({'content': _('Are you sure you want to delete this Group?')}) %} + {% block delete_button_text %}{{ _('Delete') }}{% endblock %} + {% endif %} + {% endblock %} {{ form.required_message() }} - {% block delete_button %} - {% if h.check_access('group_delete', {'id': data.id}) %} - {% set locale = h.dump_json({'content': _('Are you sure you want to delete this Group?')}) %} - {% block delete_button_text %}{{ _('Delete') }}{% endblock %} - {% endif %} - {% endblock %}
    diff --git a/ckan/templates/group/snippets/group_item.html b/ckan/templates/group/snippets/group_item.html index 81cfa902261..04351329f2b 100644 --- a/ckan/templates/group/snippets/group_item.html +++ b/ckan/templates/group/snippets/group_item.html @@ -11,7 +11,8 @@ {% endfor %} #} -{% set url = h.url_for(group.type ~ '_read', action='read', id=group.name) %} +{% set type = group.type or 'group' %} +{% set url = h.url_for(type ~ '_read', action='read', id=group.name) %}
  • {% block image %} {{ group.name }} @@ -29,13 +30,16 @@

    {{ group.display_name }}

    {% block datasets %} {% if group.packages %} {{ ungettext('{num} Dataset', '{num} Datasets', group.packages).format(num=group.packages) }} - {% else %} + {% elif group.packages == 0 %} {{ _('0 Datasets') }} {% endif %} {% endblock %} {{ _('View {name}').format(name=group.display_name) }} + {% if group.user_member %} + + {% endif %}
  • {% if position is divisibleby 3 %}
  • diff --git a/ckan/templates/header.html b/ckan/templates/header.html index 24a5cfa5b2b..dd0d5c07c57 100644 --- a/ckan/templates/header.html +++ b/ckan/templates/header.html @@ -105,8 +105,8 @@

    {% endblock %} diff --git a/ckan/templates/macros/form.html b/ckan/templates/macros/form.html index db93f046961..522524350a7 100644 --- a/ckan/templates/macros/form.html +++ b/ckan/templates/macros/form.html @@ -403,18 +403,23 @@ {{ 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) %} +{% macro image_upload(data, errors, field_url='image_url', field_upload='image_upload', field_clear='clear_upload', + is_url=false, is_upload=false, is_upload_enabled=false, placeholder=false, + url_label='', upload_label='') %} {% 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') %} + {% set url_label = url_label or _('Image URL') %} + {% set upload_label = upload_label or _('Image') %} - {% if is_upload_enabled %}
    {% endif %} + {% 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']) }} + {{ input(field_url, label=url_label, 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 %} + {{ input(field_upload, label=upload_label, id='field-image-upload', type='file', placeholder='', value='', error='', classes=['control-full']) }} + {% if is_upload %} {{ checkbox(field_clear, label=_('Clear Upload'), id='field-clear-upload', value='true', error='', classes=['control-full']) }} {% endif %} {% endif %} diff --git a/ckan/templates/organization/about.html b/ckan/templates/organization/about.html index 6848ed9ede7..6d3fcfe1bfd 100644 --- a/ckan/templates/organization/about.html +++ b/ckan/templates/organization/about.html @@ -1,6 +1,6 @@ {% extends "organization/read_base.html" %} -{% block subtitle %}{{ _('About') }} - {{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ _('About') }} - {{ super() }}{% endblock %} {% block primary_content_inner %}

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

    diff --git a/ckan/templates/organization/activity_stream.html b/ckan/templates/organization/activity_stream.html index dc8e711c5c8..ffa029d951b 100644 --- a/ckan/templates/organization/activity_stream.html +++ b/ckan/templates/organization/activity_stream.html @@ -1,6 +1,6 @@ {% extends "organization/read_base.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ c.group_dict.title or c.group_dict.name }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} {% block primary_content_inner %}

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

    diff --git a/ckan/templates/organization/edit.html b/ckan/templates/organization/edit.html index 06f7c7ee8ed..3fde80a2ab0 100644 --- a/ckan/templates/organization/edit.html +++ b/ckan/templates/organization/edit.html @@ -1,6 +1,6 @@ {% extends "organization/base_form_page.html" %} -{% block subtitle %}{{ _('Edit Organization') }}{% endblock %} +{% block subtitle %}{{ _('Edit') }} - {{ super() }}{% endblock %} {% block page_heading_class %}hide-heading{% endblock %} {% block page_heading %}{{ _('Edit Organization') }}{% endblock %} diff --git a/ckan/templates/organization/edit_base.html b/ckan/templates/organization/edit_base.html index 6f29a82b1f8..daaeef556e5 100644 --- a/ckan/templates/organization/edit_base.html +++ b/ckan/templates/organization/edit_base.html @@ -2,21 +2,20 @@ {% set organization = c.group_dict %} -{% block subtitle %}{{ organization.display_name }}{% endblock %} +{% block subtitle %}{{ c.group_dict.display_name }} - {{ _('Organizations') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Organizations'), controller='organization', action='index' %}
  • {% block breadcrumb_content_inner %}
  • {% link_for organization.display_name|truncate(35), controller='organization', action='read', id=organization.name %}
  • -
  • {% link_for _('Admin'), controller='organization', action='edit', id=organization.name %}
  • +
  • {% link_for _('Manage'), controller='organization', action='edit', id=organization.name %}
  • {% endblock %} {% endblock %} {% block content_action %} {% if organization and h.check_access('organization_update', {'id': organization.id}) %} - {% link_for _('View organization'), controller='organization', action='read', id=organization.name, class_='btn', icon='eye-open' %} + {% link_for _('View'), controller='organization', action='read', id=organization.name, class_='btn', icon='eye-open' %} {% endif %} - {#
  • {% link_for _('History'), controller='organization', action='history', id=organization.name, class_='btn', icon='undo' %}
  • #} {% endblock %} {% block content_primary_nav %} diff --git a/ckan/templates/organization/index.html b/ckan/templates/organization/index.html index ae2fb2bc49b..3b317ec701c 100644 --- a/ckan/templates/organization/index.html +++ b/ckan/templates/organization/index.html @@ -17,11 +17,13 @@ {% block primary_content_inner %}

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

    {% block organizations_search_form %} - {% snippet 'snippets/search_form.html', type='organization', query=c.q, sorting_selected=c.sort_by_selected, count=c.page.item_count, placeholder=_('Search organizations...'), show_empty=request.params %} + {% snippet 'snippets/search_form.html', type='organization', query=c.q, sorting_selected=c.sort_by_selected, count=c.page.item_count, placeholder=_('Search organizations...'), show_empty=request.params, no_bottom_border=true if c.page.items %} {% endblock %} {% block organizations_list %} {% if c.page.items or request.params %} - {% snippet "organization/snippets/organization_list.html", organizations=c.page.items %} + {% if c.page.items %} + {% snippet "organization/snippets/organization_list.html", organizations=c.page.items %} + {% endif %} {% else %}

    {{ _('There are currently no organizations for this site') }}. diff --git a/ckan/templates/organization/member_new.html b/ckan/templates/organization/member_new.html index a121549dab4..cc4ac8c7dd4 100644 --- a/ckan/templates/organization/member_new.html +++ b/ckan/templates/organization/member_new.html @@ -4,7 +4,7 @@ {% set user = c.user_dict %} -{% block subtitle %}{{ _('Members') }} - {{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ _('Edit Member') if user else _('Add Member') }} - {{ super() }}{% endblock %} {% block primary_content_inner %} {% link_for _('Back to all members'), controller='organization', action='members', id=organization.name, class_='btn pull-right', icon='arrow-left' %} @@ -55,11 +55,11 @@

    {% set locale = h.dump_json({'content': _('Are you sure you want to delete this member?')}) %} {{ _('Delete') }} {% else %} {% endif %}

    diff --git a/ckan/templates/organization/members.html b/ckan/templates/organization/members.html index c5dcd0ebdae..6e3f4446b9f 100644 --- a/ckan/templates/organization/members.html +++ b/ckan/templates/organization/members.html @@ -1,6 +1,6 @@ {% extends "organization/edit_base.html" %} -{% block subtitle %}{{ _('Members') }} - {{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ _('Members') }} - {{ super() }}{% endblock %} {% block page_primary_action %} {% link_for _('Add Member'), controller='organization', action='member_new', id=c.group_dict.id, class_='btn btn-primary', icon='plus-sign-alt' %} diff --git a/ckan/templates/organization/read.html b/ckan/templates/organization/read.html index a702541fd66..aa3a0388a4d 100644 --- a/ckan/templates/organization/read.html +++ b/ckan/templates/organization/read.html @@ -1,7 +1,7 @@ {% extends "organization/read_base.html" %} {% block page_primary_action %} - {% if h.check_access('package_create', {'organization_id': c.group_dict.id}) %} + {% if h.check_access('package_create', {'owner_org': c.group_dict.id}) %} {% link_for _('Add Dataset'), controller='package', action='new', group=c.group_dict.id, class_='btn btn-primary', icon='plus-sign-alt' %} {% endif %} {% endblock %} diff --git a/ckan/templates/organization/read_base.html b/ckan/templates/organization/read_base.html index d5a0f8120d5..1c9454f4729 100644 --- a/ckan/templates/organization/read_base.html +++ b/ckan/templates/organization/read_base.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ c.group_dict.display_name }} - {{ _('Organizations') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Organizations'), controller='organization', action='index' %}
  • @@ -9,7 +9,7 @@ {% block content_action %} {% if h.check_access('organization_update', {'id': c.group_dict.id}) %} - {% link_for _('Admin'), controller='organization', action='edit', id=c.group_dict.name, class_='btn', icon='wrench' %} + {% link_for _('Manage'), controller='organization', action='edit', id=c.group_dict.name, class_='btn', icon='wrench' %} {% endif %} {% endblock %} diff --git a/ckan/templates/organization/snippets/organization_form.html b/ckan/templates/organization/snippets/organization_form.html index 0d62e36cac1..f98cb3d9b09 100644 --- a/ckan/templates/organization/snippets/organization_form.html +++ b/ckan/templates/organization/snippets/organization_form.html @@ -19,7 +19,10 @@ {{ form.markdown('description', label=_('Description'), id='field-description', placeholder=_('A little information about my organization...'), value=data.description, error=errors.description) }} - {{ form.image_upload(data, errors, image_url=c.group_dict.image_display_url, is_upload_enabled=h.uploads_enabled()) }} + {% set is_upload = data.image_url and not data.image_url.startswith('http') %} + {% set is_url = data.image_url and data.image_url.startswith('http') %} + + {{ form.image_upload(data, errors, is_upload_enabled=h.uploads_enabled(), is_url=is_url, is_upload=is_upload) }} {% endblock %} @@ -73,12 +76,12 @@ {{ 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.')}) %} - {% block delete_button_text %}{{ _('Delete') }}{% endblock %} - {% endif %} - {% endblock %} + {% 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.')}) %} + {% block delete_button_text %}{{ _('Delete') }}{% endblock %} + {% endif %} + {% endblock %}
    diff --git a/ckan/templates/package/activity.html b/ckan/templates/package/activity.html index 52c0f8eade7..3941a6dda02 100644 --- a/ckan/templates/package/activity.html +++ b/ckan/templates/package/activity.html @@ -1,6 +1,6 @@ {% extends "package/read_base.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ c.pkg_dict.title or c.pkg_dict.name }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} {% block primary_content_inner %}

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

    diff --git a/ckan/templates/package/activity_stream.html b/ckan/templates/package/activity_stream.html deleted file mode 100644 index af67b063a7b..00000000000 --- a/ckan/templates/package/activity_stream.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "package/read.html" %} - -{% block subtitle %}{{ _('Activity Stream') }}{% endblock %} - -{% block package_content %} -

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

    - {% block activity_stream %} - {{ c.package_activity_stream | safe }} - {% endblock %} -{% endblock %} diff --git a/ckan/templates/package/base.html b/ckan/templates/package/base.html index b51f78faaa6..20dd0b1db27 100644 --- a/ckan/templates/package/base.html +++ b/ckan/templates/package/base.html @@ -4,6 +4,8 @@ {% block breadcrumb_content_selected %} class="active"{% endblock %} +{% block subtitle %}{{ _('Datasets') }}{% endblock %} + {% block breadcrumb_content %} {% if pkg %} {% set dataset = pkg.title or pkg.name %} diff --git a/ckan/templates/package/base_form_page.html b/ckan/templates/package/base_form_page.html index b4d7dc440c0..832fbbb2554 100644 --- a/ckan/templates/package/base_form_page.html +++ b/ckan/templates/package/base_form_page.html @@ -1,4 +1,4 @@ -{% extends "package/base.html" %} +{% extends "package/edit_base.html" %} {% block primary_content %}
    diff --git a/ckan/templates/package/group_list.html b/ckan/templates/package/group_list.html new file mode 100644 index 00000000000..34b29496c11 --- /dev/null +++ b/ckan/templates/package/group_list.html @@ -0,0 +1,26 @@ +{% extends "package/read_base.html" %} +{% import 'macros/form.html' as form %} + +{% block primary_content_inner %} +

    {{ _('Groups') }}

    + + {% if c.group_dropdown %} +
    + + +
    + {% endif %} + + {% if c.pkg_dict.groups %} +
    + {% snippet 'group/snippets/group_list.html', groups=c.pkg_dict.groups %} +
    + {% else %} +

    {{ _('There are no groups associated with this dataset') }}

    + {% endif %} + +{% endblock %} diff --git a/ckan/templates/package/new.html b/ckan/templates/package/new.html index a3e7bae8e1c..a8eea92ca48 100644 --- a/ckan/templates/package/new.html +++ b/ckan/templates/package/new.html @@ -1,3 +1,3 @@ {% extends "package/base_form_page.html" %} -{% block subtitle %}{{ _('Create dataset') }}{% endblock %} +{% block subtitle %}{{ _('Create Dataset') }}{% endblock %} diff --git a/ckan/templates/package/read.html b/ckan/templates/package/read.html index d70cc3e5bf2..9f178b198ec 100644 --- a/ckan/templates/package/read.html +++ b/ckan/templates/package/read.html @@ -2,8 +2,6 @@ {% set pkg = c.pkg_dict %} -{% block subtitle %}{{ pkg.title or pkg.name }}{% endblock %} - {% block primary_content_inner %} {{ super() }} {% block package_description %} diff --git a/ckan/templates/package/read_base.html b/ckan/templates/package/read_base.html index f0a58a21018..8cd14492cd9 100644 --- a/ckan/templates/package/read_base.html +++ b/ckan/templates/package/read_base.html @@ -1,6 +1,6 @@ {% extends "package/base.html" %} -{% block subtitle %}{{ pkg.title or pkg.name }}{% endblock %} +{% block subtitle %}{{ pkg.title or pkg.name }} - {{ super() }}{% endblock %} {% block links -%} {{ super() }} @@ -16,12 +16,13 @@ {% block content_action %} {% if h.check_access('package_update', {'id':pkg.id }) %} - {% link_for _('Edit'), controller='package', action='edit', id=pkg.name, class_='btn', icon='wrench' %} + {% link_for _('Manage'), controller='package', action='edit', id=pkg.name, class_='btn', icon='wrench' %} {% endif %} {% endblock %} {% block content_primary_nav %} {{ h.build_nav_icon('dataset_read', _('Dataset'), id=pkg.name) }} + {{ h.build_nav_icon('dataset_groups', _('Groups'), id=pkg.name) }} {{ h.build_nav_icon('dataset_activity', _('Activity Stream'), id=pkg.name) }} {{ h.build_nav_icon('related_list', _('Related'), id=pkg.name) }} {% endblock %} @@ -59,23 +60,6 @@ {% endif %} {% endblock %} - {% block package_groups %} - {% if pkg.groups %} -
    -

    {{ _('Groups') }}

    - -
    - {% endif %} - {% endblock %} - {% block package_social %} {% snippet "snippets/social.html" %} {% endblock %} diff --git a/ckan/templates/package/related_list.html b/ckan/templates/package/related_list.html index 0f31cb0e790..affdd974372 100644 --- a/ckan/templates/package/related_list.html +++ b/ckan/templates/package/related_list.html @@ -2,6 +2,8 @@ {% set pkg = c.pkg %} +{% block subtitle %}{{ _('Related') }} - {{ super() }}{% endblock %} + {% block primary_content_inner %}

    {% block page_heading %}{{ _('Related Media for {dataset}').format(dataset=h.dataset_display_name(c.pkg)) }}{% endblock %}

    {% block related_list %} diff --git a/ckan/templates/package/resource_data.html b/ckan/templates/package/resource_data.html index 05bd66fdfee..531d9575c44 100644 --- a/ckan/templates/package/resource_data.html +++ b/ckan/templates/package/resource_data.html @@ -9,7 +9,7 @@
    @@ -32,11 +32,15 @@ {{ _('Status') }} - {{ status.status.capitalize() if status.status else _('Not Uploaded Yet') }} + {{ h.datapusher_status_description(status) }} {{ _('Last updated') }} - {{ h.time_ago_from_timestamp(status.last_updated) if status.status else _('Never') }} + {% if status.status %} + {{ h.time_ago_from_timestamp(status.last_updated) }} + {% else %} + {{ _('Never') }} + {% endif %} @@ -51,9 +55,9 @@

    {{ _('Upload Log') }}

    {{ item.message | urlize }}
    - + {{ h.time_ago_from_timestamp(item.timestamp) }} - more info + {{ _('Details') }}

    diff --git a/ckan/templates/package/resource_edit_base.html b/ckan/templates/package/resource_edit_base.html index 0682cbbf014..4ace42b6dea 100644 --- a/ckan/templates/package/resource_edit_base.html +++ b/ckan/templates/package/resource_edit_base.html @@ -21,7 +21,7 @@ {% 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) }} + {{ h.build_nav_icon('resource_data', _('DataStore'), id=pkg.name, resource_id=res.id) }} {% endif %} {% endblock %} diff --git a/ckan/templates/package/resource_read.html b/ckan/templates/package/resource_read.html index 46406746a6f..dee83c32e0d 100644 --- a/ckan/templates/package/resource_read.html +++ b/ckan/templates/package/resource_read.html @@ -27,7 +27,7 @@
      {% block resource_actions_inner %} {% if h.check_access('package_update', {'id':pkg.id }) %} -
    • {% link_for _('Edit'), controller='package', action='resource_edit', id=pkg.name, resource_id=res.id, class_='btn', icon='wrench' %}
    • +
    • {% link_for _('Manage'), controller='package', action='resource_edit', id=pkg.name, resource_id=res.id, class_='btn', icon='wrench' %}
    • {% endif %} {% if res.url %}
    • diff --git a/ckan/templates/package/resources.html b/ckan/templates/package/resources.html index e353d03f10b..0e02b3846dd 100644 --- a/ckan/templates/package/resources.html +++ b/ckan/templates/package/resources.html @@ -1,5 +1,7 @@ {% extends "package/edit_base.html" %} +{% set has_reorder = c.pkg_dict and c.pkg_dict.resources and c.pkg_dict.resources|length > 0 %} + {% block subtitle %}{{ _('Resources') }} - {{ h.dataset_display_name(pkg) }}{% endblock %} {% block page_primary_action %} @@ -8,7 +10,7 @@ {% 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 %} @@ -19,3 +21,10 @@ {% endtrans %} {% endif %} {% endblock %} + +{% block scripts %} + {{ super() }} + {% if has_reorder %} + {% resource 'vendor/reorder' %} + {% endif %} +{% endblock %} diff --git a/ckan/templates/package/search.html b/ckan/templates/package/search.html index 6274ecc0798..6c8173eb772 100644 --- a/ckan/templates/package/search.html +++ b/ckan/templates/package/search.html @@ -1,7 +1,7 @@ {% extends "page.html" %} {% import 'macros/form.html' as form %} -{% block subtitle %}{{ _("Search for a Dataset") }}{% endblock %} +{% block subtitle %}{{ _("Datasets") }}{% endblock %} {% block breadcrumb_content %}
        • {{ h.nav_link(_('Datasets'), controller='package', action='search', highlight_actions = 'new index') }}
        • diff --git a/ckan/templates/package/snippets/package_basic_fields.html b/ckan/templates/package/snippets/package_basic_fields.html index 7cb56ec7e51..8db5763da95 100644 --- a/ckan/templates/package/snippets/package_basic_fields.html +++ b/ckan/templates/package/snippets/package_basic_fields.html @@ -64,7 +64,7 @@ {% endif %} {% if show_organizations_selector %} - {% set existing_org = data.owner_org %} + {% set existing_org = data.owner_org or data.group_id %}
          @@ -99,6 +99,17 @@
          {% endif %} - {{ form.required_message() }} + + {% if data.id and h.check_access('package_delete', {'id': data.id}) and data.state != 'active' %} +
          + +
          + +
          +
          + {% endif %} {% endblock %} diff --git a/ckan/templates/package/snippets/package_form.html b/ckan/templates/package/snippets/package_form.html index 33d726639f1..0c3d2872fbb 100644 --- a/ckan/templates/package/snippets/package_form.html +++ b/ckan/templates/package/snippets/package_form.html @@ -33,15 +33,13 @@

          {% endblock %} {% block delete_button %} - {% if h.check_access('package_delete', {'id': data.id}) %} + {% if h.check_access('package_delete', {'id': data.id}) and not data.state == 'deleted' %} {% set locale = h.dump_json({'content': _('Are you sure you want to delete this dataset?')}) %} {% block delete_button_text %}{{ _('Delete') }}{% endblock %} {% endif %} {% endblock %} - {% block cancel_button %} - {% block cancel_button_text %}{{ _('Cancel') }}{% endblock %} - {% endblock %} + {{ form.required_message() }}
          {% endblock %} diff --git a/ckan/templates/package/snippets/package_metadata_fields.html b/ckan/templates/package/snippets/package_metadata_fields.html index 4805e6637f9..b398818b297 100644 --- a/ckan/templates/package/snippets/package_metadata_fields.html +++ b/ckan/templates/package/snippets/package_metadata_fields.html @@ -29,22 +29,6 @@ {% endblock %} {% block dataset_fields %} - {% if data.groups %} -
          - -
          - {% for group in data.groups %} - - {% endfor %} -
          -
          - {% endif %} - {% set group_name = 'groups__%s__id' % data.groups|length %} - {% set group_attrs = {'data-module': 'autocomplete', 'data-module-source': '/api/2/util/group/autocomplete?q=?', 'data-module-key': 'id', 'data-module-label': 'title'} %} - {{ form.input(group_name, label=_('Add Group'), id="field-group", value=data[group_name], classes=['control-medium'], attrs=group_attrs) }} {% endblock %} {% endblock %} diff --git a/ckan/templates/package/snippets/resource_form.html b/ckan/templates/package/snippets/resource_form.html index f3bb2bddc91..3c5cb06da28 100644 --- a/ckan/templates/package/snippets/resource_form.html +++ b/ckan/templates/package/snippets/resource_form.html @@ -4,7 +4,7 @@ {% set errors = errors or {} %} {% set action = form_action or h.url_for(controller='package', action='new_resource', id=pkg_name) %} -
          + {% block stages %} {# An empty stages variable will not show the stages #} {% if stage %} @@ -16,29 +16,12 @@ -
          - {% block basic_fields %} - - {% block basic_fields_data %} -
          - {# - This block uses a slightly odd pattern. Unlike the rest of the radio - buttons which are wrapped _inside_ the labels here we place the label - after the input. This enables us to style the label based on the state - of the radio using css. eg. input[type=radio]+label {} - #} - - - - - - -
          -
          - {% endblock %} {% block basic_fields_url %} - {{ form.input('url', id='field-url', label=_('Resource'), placeholder=_('eg. http://example.com/gold-prices-jan-2011.json'), value=data.url, error=errors.url, classes=['control-full', 'control-large'], is_required=true) }} + {% set is_upload = (data.url_type == 'upload') %} + {{ form.image_upload(data, errors, field_url='url', field_upload='upload', field_clear='clear_upload', + is_upload_enabled=h.uploads_enabled(), is_url=data.url and not is_upload, is_upload=is_upload, + upload_label=_('File'), url_label=_('URL')) }} {% endblock %} {% block basic_fields_name %} @@ -52,16 +35,9 @@ {% block basic_fields_format %} {% set format_attrs = {'data-module': 'autocomplete', 'data-module-source': '/api/2/util/resource/format_autocomplete?incomplete=?'} %} {% call form.input('format', id='field-format', label=_('Format'), placeholder=_('eg. CSV, XML or JSON'), value=data.format, error=errors.format, classes=['control-medium'], attrs=format_attrs) %} - - - {{ _('This is generated automatically. You can edit if you wish') }} - {% endcall %} {% endblock %} - {{ form.required_message() }} - - {% endblock %} {% block metadata_fields %} {% if include_metadata %} diff --git a/ckan/templates/package/snippets/resource_item.html b/ckan/templates/package/snippets/resource_item.html index c5b22521d64..da03ec496f9 100644 --- a/ckan/templates/package/snippets/resource_item.html +++ b/ckan/templates/package/snippets/resource_item.html @@ -2,7 +2,7 @@ {% 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 %} {{ h.resource_display_name(res) | truncate(50) }}{{ res.format }} diff --git a/ckan/templates/snippets/organization.html b/ckan/templates/snippets/organization.html index e0f5417024a..ec191850c73 100644 --- a/ckan/templates/snippets/organization.html +++ b/ckan/templates/snippets/organization.html @@ -42,10 +42,6 @@

          {{ organization.title or organization.name }}

          {{ _('Followers') }}
          {{ h.SI_number_span(organization.num_followers) }}
          -
          -
          {{ _('Members') }}
          -
          {{ h.SI_number_span(organization.members|length) }}
          -
          {{ _('Datasets') }}
          {{ h.SI_number_span(organization.package_count) }}
          diff --git a/ckan/templates/snippets/package_list.html b/ckan/templates/snippets/package_list.html index 99aed748242..75213d535a3 100644 --- a/ckan/templates/snippets/package_list.html +++ b/ckan/templates/snippets/package_list.html @@ -15,7 +15,7 @@ #} {% if packages %} -
            +
              {% for package in packages %} {% snippet 'snippets/package_item.html', package=package, item_class=item_class, hide_resources=hide_resources, banner=banner, truncate=truncate, truncate_title=truncate_title %} {% endfor %} diff --git a/ckan/templates/snippets/search_form.html b/ckan/templates/snippets/search_form.html index 6fdd9e1638e..d0c6bd95887 100644 --- a/ckan/templates/snippets/search_form.html +++ b/ckan/templates/snippets/search_form.html @@ -1,10 +1,11 @@ {% import 'macros/form.html' as form %} -{% set placeholder = placeholder if placeholder else _('Search...') %} +{% set placeholder = placeholder if placeholder else _('Search datasets...') %} {% set sorting = sorting if sorting else [(_('Name Ascending'), 'name asc'), (_('Name Descending'), 'name desc')] %} {% set search_class = search_class if search_class else 'search-giant' %} +{% set no_bottom_border = no_bottom_border if no_bottom_border else false %} - + {% block search_input %}
              diff --git a/ckan/templates/snippets/search_result_text.html b/ckan/templates/snippets/search_result_text.html index 0cd48bf2d06..2cdc1f0859a 100644 --- a/ckan/templates/snippets/search_result_text.html +++ b/ckan/templates/snippets/search_result_text.html @@ -13,21 +13,21 @@ #} {% if type == 'dataset' %} {% set text_query = ungettext('{number} dataset found for "{query}"', '{number} datasets found for "{query}"', count) %} - {% set text_query_none = _('Sorry no datasets found for "{query}"') %} + {% set text_query_none = _('No datasets found for "{query}"') %} {% set text_no_query = ungettext('{number} dataset found', '{number} datasets found', count) %} - {% set text_no_query_none = _('Sorry no datasets found') %} + {% set text_no_query_none = _('No datasets found') %} {% elif type == 'group' %} {% set text_query = ungettext('{number} group found for "{query}"', '{number} groups found for "{query}"', count) %} - {% set text_query_none = _('Sorry no groups found for "{query}"') %} + {% set text_query_none = _('No groups found for "{query}"') %} {% set text_no_query = ungettext('{number} group found', '{number} groups found', count) %} - {% set text_no_query_none = _('Sorry no groups found') %} + {% set text_no_query_none = _('No groups found') %} {% elif type == 'organization' %} {% set text_query = ungettext('{number} organization found for "{query}"', '{number} organizations found for "{query}"', count) %} - {% set text_query_none = _('Sorry no organizations found for "{query}"') %} + {% set text_query_none = _('No organizations found for "{query}"') %} {% set text_no_query = ungettext('{number} organization found', '{number} organizations found', count) %} - {% set text_no_query_none = _('Sorry no organizations found') %} + {% set text_no_query_none = _('No organizations found') %} {%- endif -%} {% if query %} diff --git a/ckan/templates/user/activity_stream.html b/ckan/templates/user/activity_stream.html index 092e6ea8485..290d34d5fa6 100644 --- a/ckan/templates/user/activity_stream.html +++ b/ckan/templates/user/activity_stream.html @@ -1,6 +1,6 @@ {% extends "user/read.html" %} -{% block subtitle %}{{ _('Activity Stream') }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} {% block primary_content_inner %}

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

              diff --git a/ckan/templates/user/dashboard.html b/ckan/templates/user/dashboard.html index 3109a632036..dd6c346e6ec 100644 --- a/ckan/templates/user/dashboard.html +++ b/ckan/templates/user/dashboard.html @@ -1,5 +1,7 @@ {% extends "user/edit_base.html" %} +{% set user = c.userobj %} + {% block breadcrumb_content %}
            • {{ _('Dashboard') }}
            • {% endblock %} diff --git a/ckan/templates/user/edit.html b/ckan/templates/user/edit.html index 3845684f78d..1f5b7251b7a 100644 --- a/ckan/templates/user/edit.html +++ b/ckan/templates/user/edit.html @@ -3,14 +3,9 @@ {% block actions_content %}{% endblock %} {% block breadcrumb_content %} - {% if c.userobj.name == c.user_dict.name %} -
            • {{ _('Dashboard') }}
            • -
            • {{ _('Edit settings') }}
            • - {% else %} -
            • {{ _('Users') }}
            • -
            • {{ c.user_dict.display_name }}
            • -
            • {{ _('Edit') }}
            • - {% endif %} +
            • {{ _('Users') }}
            • +
            • {{ c.user_dict.display_name }}
            • +
            • {{ _('Manage') }}
            • {% endblock %} {% block primary_content_inner %} diff --git a/ckan/templates/user/edit_base.html b/ckan/templates/user/edit_base.html index 7e29720a60d..5ca813fe31d 100644 --- a/ckan/templates/user/edit_base.html +++ b/ckan/templates/user/edit_base.html @@ -1,8 +1,6 @@ {% extends "page.html" %} -{% set user = c.userobj %} - -{% block subtitle %}{{ _('Dashboard') }}{% endblock %} +{% block subtitle %}{{ _('Manage') }} - {{ c.user_dict.display_name }} - {{ _('Users') }}{% endblock %} {% block primary_content %}
              diff --git a/ckan/templates/user/edit_user_form.html b/ckan/templates/user/edit_user_form.html index ce31e34b583..b62f5fd9f08 100644 --- a/ckan/templates/user/edit_user_form.html +++ b/ckan/templates/user/edit_user_form.html @@ -4,7 +4,7 @@ {{ form.errors(error_summary) }}
              - {{ _('Change your details') }} + {{ _('Change details') }} {{ form.input('name', label=_('Username'), id='field-username', value=data.name, error=errors.name, classes=['control-medium'], is_required=true) }} @@ -20,12 +20,10 @@ {% endcall %} {% endif %} - {{ form.required_message() }} -
              - {{ _('Change your password') }} + {{ _('Change password') }} {{ form.input('password1', type='password', label=_('Password'), id='field-password', value=data.password1, error=errors.password1, classes=['control-medium'], attrs={'autocomplete': 'off'} ) }} @@ -39,6 +37,7 @@ {% block delete_button_text %}{{ _('Delete') }}{% endblock %} {% endif %} {% endblock %} + {{ form.required_message() }}
              diff --git a/ckan/templates/user/read.html b/ckan/templates/user/read.html index 13f58fc3054..2ca0a920f41 100644 --- a/ckan/templates/user/read.html +++ b/ckan/templates/user/read.html @@ -1,7 +1,5 @@ {% extends "user/read_base.html" %} -{% block subtitle %}{{ user.display_name }}{% endblock %} - {% block primary_content_inner %}

              {% block page_heading %}{{ _('Datasets') }}{% endblock %} diff --git a/ckan/templates/user/read_base.html b/ckan/templates/user/read_base.html index 3a24c21c5a1..9fb39fd2998 100644 --- a/ckan/templates/user/read_base.html +++ b/ckan/templates/user/read_base.html @@ -2,7 +2,7 @@ {% set user = c.user_dict %} -{% block subtitle %}{{ user.display_name }}{% endblock %} +{% block subtitle %}{{ user.display_name }} - {{ _('Users') }}{% endblock %} {% block breadcrumb_content %} {{ h.build_nav('user_index', _('Users')) }} @@ -10,9 +10,9 @@ {% endblock %} {% block content_action %} -{% if h.check_access('user_update', user) %} - {% link_for _('Edit'), controller='user', action='edit', id=user.name, class_='btn', icon='wrench' %} -{% endif %} + {% if h.check_access('user_update', user) %} + {% link_for _('Manage'), controller='user', action='edit', id=user.name, class_='btn', icon='wrench' %} + {% endif %} {% endblock %} {% block content_primary_nav %} diff --git a/ckan/templates/user/snippets/back_to_user_action.html b/ckan/templates/user/snippets/back_to_user_action.html deleted file mode 100644 index 8ca7494916f..00000000000 --- a/ckan/templates/user/snippets/back_to_user_action.html +++ /dev/null @@ -1 +0,0 @@ -
            • {% link_for _('View my profile'), controller='user', action='read', id=user.name, class_='btn', icon='eye-open' %}
            • diff --git a/ckan/templates/user/snippets/login_form.html b/ckan/templates/user/snippets/login_form.html index d1b54251137..e6a88d95902 100644 --- a/ckan/templates/user/snippets/login_form.html +++ b/ckan/templates/user/snippets/login_form.html @@ -24,6 +24,6 @@ {{ form.checkbox('remember', label=_("Remember me"), id='field-remember', checked=true, value="63072000") }}
              - +
              diff --git a/ckan/tests/functional/api/test_activity.py b/ckan/tests/functional/api/test_activity.py index bd42409edd6..f093a200a7c 100644 --- a/ckan/tests/functional/api/test_activity.py +++ b/ckan/tests/functional/api/test_activity.py @@ -573,17 +573,17 @@ def _update_extra(self, package_dict, user): [group['name'] for group in package_dict['groups']], apikey=apikey) - extras_before = package_dict['extras'] + import copy + extras_before = copy.deepcopy(package_dict['extras']) assert len(extras_before) > 0, ( "Can't update an extra if the package doesn't have any") # Update the package's first extra. - extras = list(extras_before) - if extras[0]['value'] != '"edited"': - extras[0]['value'] = '"edited"' + if package_dict['extras'][0]['value'] != '"edited"': + package_dict['extras'][0]['value'] = '"edited"' else: - assert extras[0]['value'] != '"edited again"' - extras[0]['value'] = '"edited again"' + assert package_dict['extras'][0]['value'] != '"edited again"' + package_dict['extras'][0]['value'] = '"edited again"' updated_package = package_update(self.app, package_dict, user['apikey']) diff --git a/ckan/tests/functional/test_group.py b/ckan/tests/functional/test_group.py index c5dc63b1097..f8a968f09cd 100644 --- a/ckan/tests/functional/test_group.py +++ b/ckan/tests/functional/test_group.py @@ -3,7 +3,6 @@ from nose.tools import assert_equal import mock -import ckan.tests.test_plugins as test_plugins import ckan.model as model import ckan.lib.search as search @@ -16,7 +15,6 @@ from ckan.tests import is_search_supported - class TestGroup(FunctionalTestCase): @classmethod @@ -25,6 +23,10 @@ def setup_class(self): model.Session.remove() CreateTestData.create() + # reduce extraneous logging + from ckan.lib import activity_streams_session_extension + activity_streams_session_extension.logger.level = 100 + @classmethod def teardown_class(self): model.repo.rebuild_db() @@ -56,14 +58,14 @@ def test_atom_feed_page_negative(self): assert '"page" parameter must be a positive integer' in res, res def test_children(self): - if model.engine_is_sqlite() : + if model.engine_is_sqlite(): from nose import SkipTest raise SkipTest("Can't use CTE for sqlite") group_name = 'deletetest' CreateTestData.create_groups([{'name': group_name, 'packages': []}, - {'name': "parent_group", + {'name': "parent_group", 'packages': []}], admin_user_name='testsysadmin') @@ -73,7 +75,7 @@ def test_children(self): rev = model.repo.new_revision() rev.author = "none" - member = model.Member(group_id=parent.id, table_id=group.id, + member = model.Member(group_id=group.id, table_id=parent.id, table_name='group', capacity='member') model.Session.add(member) model.repo.commit_and_remove() @@ -103,17 +105,21 @@ def test_children(self): def test_sorting(self): model.repo.rebuild_db() + testsysadmin = model.User(name=u'testsysadmin') + testsysadmin.sysadmin = True + model.Session.add(testsysadmin) + pkg1 = model.Package(name="pkg1") pkg2 = model.Package(name="pkg2") model.Session.add(pkg1) model.Session.add(pkg2) CreateTestData.create_groups([{'name': "alpha", 'packages': []}, - {'name': "beta", - 'packages': ["pkg1", "pkg2"]}, - {'name': "delta", - 'packages': ["pkg1"]}, - {'name': "gamma", 'packages': []}], + {'name': "beta", + 'packages': ["pkg1", "pkg2"]}, + {'name': "delta", + 'packages': ["pkg1"]}, + {'name': "gamma", 'packages': []}], admin_user_name='testsysadmin') context = {'model': model, 'session': model.Session, @@ -148,7 +154,6 @@ def test_sorting(self): assert results[0]['name'] == u'beta', results[0]['name'] assert results[1]['name'] == u'delta', results[1]['name'] - def test_mainmenu(self): # the home page does a package search so have to skip this test if # search is not supported @@ -198,14 +203,16 @@ def test_read_and_authorized_to_edit(self): title = u'Dave\'s books' pkgname = u'warandpeace' offset = url_for(controller='group', action='read', id=name) - res = self.app.get(offset, extra_environ={'REMOTE_USER': 'testsysadmin'}) + res = self.app.get(offset, + extra_environ={'REMOTE_USER': 'testsysadmin'}) assert title in res, res assert 'edit' in res assert name in res def test_new_page(self): offset = url_for(controller='group', action='new') - res = self.app.get(offset, extra_environ={'REMOTE_USER': 'testsysadmin'}) + res = self.app.get(offset, + extra_environ={'REMOTE_USER': 'testsysadmin'}) assert 'Add A Group' in res, res @@ -258,7 +265,6 @@ def setup_class(self): model.Session.add(model.Package(name=self.packagename)) model.repo.commit_and_remove() - @classmethod def teardown_class(self): model.Session.remove() @@ -662,6 +668,7 @@ def test_2_atom_feed(self): assert 'xmlns="http://www.w3.org/2005/Atom"' in res, res assert '' in res, res + class TestMemberInvite(FunctionalTestCase): @classmethod def setup_class(self): diff --git a/ckan/tests/functional/test_package.py b/ckan/tests/functional/test_package.py index 662ad0c2458..23a8bb48247 100644 --- a/ckan/tests/functional/test_package.py +++ b/ckan/tests/functional/test_package.py @@ -6,6 +6,7 @@ from nose.tools import assert_equal from ckan.tests import * +import ckan.tests as tests from ckan.tests.html_check import HtmlCheckMethods from ckan.tests.pylons_controller import PylonsTestCase from base import FunctionalTestCase @@ -1505,3 +1506,43 @@ def test_package_autocomplete(self): expected = ['A Wonderful Story (warandpeace)|warandpeace','annakarenina|annakarenina'] received = sorted(res.body.split('\n')) assert expected == received + +class TestResourceListing(TestPackageBase): + @classmethod + def setup_class(cls): + + CreateTestData.create() + cls.tester_user = model.User.by_name(u'tester') + cls.extra_environ_admin = {'REMOTE_USER': 'testsysadmin'} + cls.extra_environ_tester = {'REMOTE_USER': 'tester'} + cls.extra_environ_someone_else = {'REMOTE_USER': 'someone_else'} + + tests.call_action_api(cls.app, 'organization_create', + name='test_org_2', + apikey=cls.tester_user.apikey) + + tests.call_action_api(cls.app, 'package_create', + name='crimeandpunishment', + owner_org='test_org_2', + apikey=cls.tester_user.apikey) + + @classmethod + def teardown_class(cls): + model.repo.rebuild_db() + + def test_resource_listing_premissions_sysadmin(self): + # sysadmin 200 + self.app.get('/dataset/resources/crimeandpunishment', extra_environ=self.extra_environ_admin, status=200) + + def test_resource_listing_premissions_auth_user(self): + # auth user 200 + self.app.get('/dataset/resources/crimeandpunishment', extra_environ=self.extra_environ_tester, status=200) + + def test_resource_listing_premissions_non_auth_user(self): + # non auth user 401 + self.app.get('/dataset/resources/crimeandpunishment', extra_environ=self.extra_environ_someone_else, status=[302,401]) + + def test_resource_listing_premissions_not_logged_in(self): + # not logged in 401 + self.app.get('/dataset/resources/crimeandpunishment', status=[302,401]) + diff --git a/ckan/tests/lib/test_accept.py b/ckan/tests/lib/test_accept.py index 8234ab9a087..13c065e4b8c 100644 --- a/ckan/tests/lib/test_accept.py +++ b/ckan/tests/lib/test_accept.py @@ -56,10 +56,3 @@ def test_accept_valid6(self): assert_equal( ct, "application/rdf+xml; charset=utf-8") assert_equal( markup, True) assert_equal( ext, "rdf") - - def test_accept_valid7(self): - a = "text/turtle,application/turtle,application/rdf+xml;q=0.8,text/plain;q=0.9,*/*;q=.5" - ct, markup, ext = accept.parse_header(a) - assert_equal( ct, "text/plain; charset=utf-8") - assert_equal( markup, False) - assert_equal( ext, "txt") diff --git a/ckan/tests/lib/test_dictization.py b/ckan/tests/lib/test_dictization.py index a1a94fc2b21..92b75db00db 100644 --- a/ckan/tests/lib/test_dictization.py +++ b/ckan/tests/lib/test_dictization.py @@ -48,6 +48,8 @@ def setup_class(cls): 'name': u'david', 'capacity': 'public', 'image_url': u'', + 'image_display_url': u'', + 'display_name': u"Dave's books", 'type': u'group', 'state': u'active', 'is_organization': False, @@ -57,6 +59,8 @@ def setup_class(cls): 'name': u'roger', 'capacity': 'public', 'image_url': u'', + 'image_display_url': u'', + 'display_name': u"Roger's books", 'type': u'group', 'state': u'active', 'is_organization': False, @@ -877,8 +881,6 @@ def test_16_group_dictized(self): context = {"model": model, "session": model.Session} - pkg = model.Session.query(model.Package).filter_by(name='annakarenina3').first() - simple_group_dict = {'name': 'simple', 'title': 'simple', 'type': 'organization', @@ -896,7 +898,7 @@ def test_16_group_dictized(self): 'approval_status': 'approved', 'extras': [{'key': 'genre', 'value': u'"horror"'}, {'key': 'media', 'value': u'"dvd"'}], - 'packages':[{'name': 'annakarenina2'}, {'id': pkg.id, 'capacity': 'in'}], + 'packages':[{'name': 'annakarenina2'}], 'users':[{'name': 'annafan'}], 'groups':[{'name': 'simple'}], 'tags':[{'name': 'russian'}] @@ -947,41 +949,46 @@ def test_16_group_dictized(self): 'display_name': u'help', 'image_url': u'', 'image_display_url': u'', - 'package_count': 2, + 'package_count': 1, 'is_organization': False, - 'packages': [{'author': None, - 'author_email': None, - 'license_id': u'other-open', - 'maintainer': None, - 'maintainer_email': None, - 'type': u'dataset', - 'name': u'annakarenina3', - 'notes': u'Some test notes\n\n### A 3rd level heading\n\n**Some bolded text.**\n\n*Some italicized text.*\n\nForeign characters:\nu with umlaut \xfc\n66-style quote \u201c\nforeign word: th\xfcmb\n\nNeeds escaping:\nleft arrow <\n\n\n\n', - 'state': u'active', - 'capacity' : 'in', - 'title': u'A Novel By Tolstoy', - 'private': False, - 'creator_user_id': None, - 'owner_org': None, - 'url': u'http://www.annakarenina.com', - 'version': u'0.7a'}, - {'author': None, - 'author_email': None, - 'capacity' : 'public', - 'title': u'A Novel By Tolstoy', - 'license_id': u'other-open', - 'maintainer': None, - 'maintainer_email': None, - 'type': u'dataset', - 'name': u'annakarenina2', - 'notes': u'Some test notes\n\n### A 3rd level heading\n\n**Some bolded text.**\n\n*Some italicized text.*\n\nForeign characters:\nu with umlaut \xfc\n66-style quote \u201c\nforeign word: th\xfcmb\n\nNeeds escaping:\nleft arrow <\n\n\n\n', - 'state': u'active', - 'title': u'A Novel By Tolstoy', - 'private': False, - 'creator_user_id': None, - 'owner_org': None, - 'url': u'http://www.annakarenina.com', - 'version': u'0.7a'}], + 'packages': [{u'author': None, + u'author_email': None, + u'creator_user_id': None, + u'extras': [], + u'groups':[{u'approval_status': u'approved', + u'capacity': u'public', + u'description': u'', + u'display_name': u'help', + u'image_display_url': u'', + u'image_url': u'', + u'is_organization': False, + u'name': u'help', + u'state': u'active', + u'title': u'help', + u'type': u'group'}] , + u'isopen': True, + u'license_id': u'other-open', + u'license_title': u'Other (Open)', + u'maintainer': None, + u'maintainer_email': None, + u'name': u'annakarenina2', + u'notes': u'Some test notes\n\n### A 3rd level heading\n\n**Some bolded text.**\n\n*Some italicized text.*\n\nForeign characters:\nu with umlaut \xfc\n66-style quote \u201c\nforeign word: th\xfcmb\n\nNeeds escaping:\nleft arrow <\n\n\n\n', + u'num_resources': 0, + u'num_tags': 0, + u'organization': None, + u'owner_org': None, + u'private': False, + u'relationships_as_object': [], + u'relationships_as_subject': [], + u'resources': [], + u'state': u'active', + u'tags': [], + u'title': u'A Novel By Tolstoy', + u'tracking_summary': {u'recent': 0, u'total': 0}, + u'type': u'dataset', + u'url': u'http://www.annakarenina.com', + u'version': u'0.7a'}, + ], 'state': u'active', 'approval_status': u'approved', 'title': u'help', diff --git a/ckan/tests/lib/test_solr_schema_version.py b/ckan/tests/lib/test_solr_schema_version.py index 2dbc06f5bc7..344603274c4 100644 --- a/ckan/tests/lib/test_solr_schema_version.py +++ b/ckan/tests/lib/test_solr_schema_version.py @@ -1,5 +1,4 @@ import os -from pylons import config from ckan.tests import TestController class TestSolrSchemaVersionCheck(TestController): @@ -11,11 +10,7 @@ def setup_class(cls): def _get_current_schema(self): - from ckan.lib.search import SUPPORTED_SCHEMA_VERSIONS - - current_version = sorted(SUPPORTED_SCHEMA_VERSIONS).pop() - - current_schema = os.path.join(self.root_dir,'..','..','config','solr','schema-%s.xml' % current_version) + current_schema = os.path.join(self.root_dir,'..','..','config','solr','schema.xml') return current_schema diff --git a/ckan/tests/logic/test_action.py b/ckan/tests/logic/test_action.py index 70857c5eda2..316d6533a5e 100644 --- a/ckan/tests/logic/test_action.py +++ b/ckan/tests/logic/test_action.py @@ -832,10 +832,10 @@ def test_19_update_resource(self): resource_updated.pop('url') resource_updated.pop('revision_id') - resource_updated.pop('revision_timestamp') + resource_updated.pop('revision_timestamp', None) resource_created.pop('url') resource_created.pop('revision_id') - resource_created.pop('revision_timestamp') + resource_created.pop('revision_timestamp', None) assert_equal(resource_updated, resource_created) def test_20_task_status_update(self): diff --git a/ckan/tests/logic/test_auth.py b/ckan/tests/logic/test_auth.py index 45caddd02d4..e44cf60efd3 100644 --- a/ckan/tests/logic/test_auth.py +++ b/ckan/tests/logic/test_auth.py @@ -4,6 +4,7 @@ from ckan.logic import get_action import ckan.model as model import ckan.new_authz as new_authz +from ckan.lib.create_test_data import CreateTestData import json INITIAL_TEST_CONFIG_PERMISSIONS = { @@ -16,6 +17,7 @@ 'create_unowned_dataset': False, 'create_user_via_api': False, 'create_user_via_web': True, + 'roles_that_cascade_to_sub_groups': ['admin'], } @@ -36,19 +38,26 @@ def teardown_class(cls): new_authz.CONFIG_PERMISSIONS.update(cls.old_perm) model.repo.rebuild_db() - def _call_api(self, action, data, user, status=None): + @classmethod + def _call_api(cls, action, data, user, status=None): params = '%s=1' % json.dumps(data) - return self.app.post('/api/action/%s' % action, - params=params, - extra_environ={'Authorization': self.apikeys[user]}, - status=status) + res = cls.app.post('/api/action/%s' % action, + params=params, + extra_environ={'Authorization': cls.apikeys[user]}, + status=[200, 403, 409]) + if res.status != (status or 200): + error = json.loads(res.body)['error'] + raise AssertionError('Status was %s but should be %s. Error: %s' % + (res.status, status, error)) + return res - def create_user(self, name): + @classmethod + def create_user(cls, name): user = {'name': name, 'password': 'pass', 'email': 'moo@moo.com'} - res = self._call_api('user_create', user, 'sysadmin', 200) - self.apikeys[name] = str(json.loads(res.body)['result']['apikey']) + res = cls._call_api('user_create', user, 'sysadmin', 200) + cls.apikeys[name] = str(json.loads(res.body)['result']['apikey']) class TestAuthUsers(TestAuth): @@ -81,9 +90,7 @@ def test_auth_deleted_users_are_always_unauthorized(self): 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'] @@ -117,6 +124,7 @@ def test_02_create_orgs(self): def test_03_create_dataset_no_org(self): + # no owner_org supplied dataset = {'name': 'admin_create_no_org'} self._call_api('package_create', dataset, 'sysadmin', 409) @@ -135,10 +143,8 @@ def test_04_create_dataset_with_org(self): dataset = {'name': 'sysadmin_create_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.json['result']['id']} - self._call_api('package_create', dataset, 'no_org', 403) def test_05_add_users_to_org(self): @@ -231,6 +237,214 @@ def test_11_delete_org(self): self._call_api('organization_delete', org, 'org_editor', 403) self._call_api('organization_delete', org, 'org_admin', 403) +ORG_HIERARCHY_PERMISSIONS = { + 'roles_that_cascade_to_sub_groups': ['admin'], + } + +class TestAuthOrgHierarchy(TestAuth): + # Tests are in the same vein as TestAuthOrgs, testing the cases where the + # group hierarchy provides extra permissions through cascading + + @classmethod + def setup_class(cls): + TestAuth.setup_class() + CreateTestData.create_group_hierarchy_test_data() + for user in model.Session.query(model.User): + cls.apikeys[user.name] = str(user.apikey) + new_authz.CONFIG_PERMISSIONS.update(ORG_HIERARCHY_PERMISSIONS) + CreateTestData.create_arbitrary( + package_dicts= [{'name': 'adataset', + 'groups': ['national-health-service']}], + extra_user_names=['john']) + + def _reset_a_datasets_owner_org(self): + rev = model.repo.new_revision() + get_action('package_owner_org_update')( + {'model': model, 'ignore_auth': True}, + {'id': 'adataset', + 'organization_id': 'national-health-service'}) + + def _undelete_package_if_needed(self, package_name): + pkg = model.Package.by_name(package_name) + if pkg and pkg.state == 'deleted': + rev = model.repo.new_revision() + pkg.state = 'active' + model.repo.commit_and_remove() + + def test_05_add_users_to_org_1(self): + member = {'username': 'john', 'role': 'admin', + 'id': 'department-of-health'} + self._call_api('organization_member_create', member, 'nhsadmin', 403) + def test_05_add_users_to_org_2(self): + member = {'username': 'john', 'role': 'editor', + 'id': 'department-of-health'} + self._call_api('organization_member_create', member, 'nhsadmin', 403) + def test_05_add_users_to_org_3(self): + member = {'username': 'john', 'role': 'admin', + 'id': 'national-health-service'} + self._call_api('organization_member_create', member, 'nhsadmin', 200) + def test_05_add_users_to_org_4(self): + member = {'username': 'john', 'role': 'editor', + 'id': 'national-health-service'} + self._call_api('organization_member_create', member, 'nhsadmin', 200) + def test_05_add_users_to_org_5(self): + member = {'username': 'john', 'role': 'admin', + 'id': 'nhs-wirral-ccg'} + self._call_api('organization_member_create', member, 'nhsadmin', 200) + def test_05_add_users_to_org_6(self): + member = {'username': 'john', 'role': 'editor', + 'id': 'nhs-wirral-ccg'} + self._call_api('organization_member_create', member, 'nhsadmin', 200) + def test_05_add_users_to_org_7(self): + member = {'username': 'john', 'role': 'editor', + 'id': 'national-health-service'} + self._call_api('organization_member_create', member, 'nhseditor', 403) + + def test_07_add_datasets_1(self): + dataset = {'name': 't1', 'owner_org': 'department-of-health'} + self._call_api('package_create', dataset, 'nhsadmin', 403) + + def test_07_add_datasets_2(self): + dataset = {'name': 't2', 'owner_org': 'national-health-service'} + self._call_api('package_create', dataset, 'nhsadmin', 200) + + def test_07_add_datasets_3(self): + dataset = {'name': 't3', 'owner_org': 'nhs-wirral-ccg'} + self._call_api('package_create', dataset, 'nhsadmin', 200) + + def test_07_add_datasets_4(self): + dataset = {'name': 't4', 'owner_org': 'department-of-health'} + self._call_api('package_create', dataset, 'nhseditor', 403) + + def test_07_add_datasets_5(self): + dataset = {'name': 't5', 'owner_org': 'national-health-service'} + self._call_api('package_create', dataset, 'nhseditor', 200) + + def test_07_add_datasets_6(self): + dataset = {'name': 't6', 'owner_org': 'nhs-wirral-ccg'} + self._call_api('package_create', dataset, 'nhseditor', 403) + + def test_08_update_datasets_1(self): + dataset = {'name': 'adataset', 'owner_org': 'department-of-health'} + self._call_api('package_update', dataset, 'nhsadmin', 409) + + def test_08_update_datasets_2(self): + dataset = {'name': 'adataset', 'owner_org': 'national-health-service'} + self._call_api('package_update', dataset, 'nhsadmin', 200) + + def test_08_update_datasets_3(self): + dataset = {'name': 'adataset', 'owner_org': 'nhs-wirral-ccg'} + try: + self._call_api('package_update', dataset, 'nhsadmin', 200) + finally: + self._reset_a_datasets_owner_org() + + def test_08_update_datasets_4(self): + dataset = {'name': 'adataset', 'owner_org': 'department-of-health'} + self._call_api('package_update', dataset, 'nhseditor', 409) + + def test_08_update_datasets_5(self): + dataset = {'name': 'adataset', 'owner_org': 'national-health-service'} + try: + self._call_api('package_update', dataset, 'nhseditor', 200) + finally: + self._reset_a_datasets_owner_org() + + def test_08_update_datasets_6(self): + dataset = {'name': 'adataset', 'owner_org': 'nhs-wirral-ccg'} + self._call_api('package_update', dataset, 'nhseditor', 409) + + def test_09_delete_datasets_1(self): + dataset = {'id': 'doh-spend'} + try: + self._call_api('package_delete', dataset, 'nhsadmin', 403) + finally: + self._undelete_package_if_needed(dataset['id']) + + def test_09_delete_datasets_2(self): + dataset = {'id': 'nhs-spend'} + try: + self._call_api('package_delete', dataset, 'nhsadmin', 200) + finally: + self._undelete_package_if_needed(dataset['id']) + + def test_09_delete_datasets_3(self): + dataset = {'id': 'wirral-spend'} + try: + self._call_api('package_delete', dataset, 'nhsadmin', 200) + finally: + self._undelete_package_if_needed(dataset['id']) + + def test_09_delete_datasets_4(self): + dataset = {'id': 'nhs-spend'} + try: + self._call_api('package_delete', dataset, 'nhseditor', 200) + finally: + self._undelete_package_if_needed(dataset['id']) + + def test_09_delete_datasets_5(self): + dataset = {'id': 'wirral-spend'} + try: + self._call_api('package_delete', dataset, 'nhseditor', 403) + finally: + self._undelete_package_if_needed(dataset['id']) + + def _flesh_out_organization(self, org): + # When calling organization_update, unless you include the list of + # editor and admin users and parent groups, it will remove them. So + # get the current list + existing_org = get_action('organization_show')( + {'model': model, 'ignore_auth': True}, {'id': org['id']}) + org.update(existing_org) + + def test_10_edit_org_1(self): + org = {'id': 'department-of-health', 'title': 'test'} + self._flesh_out_organization(org) + self._call_api('organization_update', org, 'nhsadmin', 403) + + def test_10_edit_org_2(self): + org = {'id': 'national-health-service', 'title': 'test'} + self._flesh_out_organization(org) + import pprint; pprint.pprint(org) + print model.Session.query(model.Member).filter_by(state='deleted').all() + self._call_api('organization_update', org, 'nhsadmin', 200) + print model.Session.query(model.Member).filter_by(state='deleted').all() + + def test_10_edit_org_3(self): + org = {'id': 'nhs-wirral-ccg', 'title': 'test'} + self._flesh_out_organization(org) + self._call_api('organization_update', org, 'nhsadmin', 200) + + def test_10_edit_org_4(self): + org = {'id': 'department-of-health', 'title': 'test'} + self._flesh_out_organization(org) + self._call_api('organization_update', org, 'nhseditor', 403) + + def test_10_edit_org_5(self): + org = {'id': 'national-health-service', 'title': 'test'} + self._flesh_out_organization(org) + self._call_api('organization_update', org, 'nhseditor', 403) + + def test_10_edit_org_6(self): + org = {'id': 'nhs-wirral-ccg', 'title': 'test'} + self._flesh_out_organization(org) + self._call_api('organization_update', org, 'nhseditor', 403) + + def test_11_delete_org_1(self): + org = {'id': 'department-of-health'} + self._call_api('organization_delete', org, 'nhsadmin', 403) + self._call_api('organization_delete', org, 'nhseditor', 403) + + def test_11_delete_org_2(self): + org = {'id': 'national-health-service'} + self._call_api('organization_delete', org, 'nhsadmin', 403) + self._call_api('organization_delete', org, 'nhseditor', 403) + + def test_11_delete_org_3(self): + org = {'id': 'nhs-wirral-ccg'} + self._call_api('organization_delete', org, 'nhsadmin', 403) + self._call_api('organization_delete', org, 'nhseditor', 403) + class TestAuthGroups(TestAuth): diff --git a/ckan/tests/logic/test_member.py b/ckan/tests/logic/test_member.py index 39f67edd2db..70487181cb5 100644 --- a/ckan/tests/logic/test_member.py +++ b/ckan/tests/logic/test_member.py @@ -11,10 +11,18 @@ def setup_class(cls): model.repo.new_revision() create_test_data.CreateTestData.create() cls.user = model.User.get('testsysadmin') + cls.tester = model.User.get('tester') cls.group = model.Group.get('david') + cls.roger = model.Group.get('roger') cls.pkgs = [model.Package.by_name('warandpeace'), model.Package.by_name('annakarenina')] + # 'Tester' becomes an admin for the 'roger' group + model.repo.new_revision() + model.Member(group=cls.roger, table_id=cls.tester.id, + table_name='user', capacity='admin') + model.repo.commit_and_remove() + @classmethod def teardown_class(cls): model.repo.rebuild_db() @@ -37,6 +45,20 @@ def test_member_create_raises_if_user_unauthorized_to_update_group(self): assert_raises(logic.NotAuthorized, logic.get_action('member_create'), ctx, dd) + def test_member_create_with_child_group_permission(self): + # 'tester' has admin priviledge for roger, so anyone can make it + # a child group. + self._member_create_group_hierarchy(parent_group=self.group, + child_group=self.roger, + user=self.tester) + + def test_member_create_raises_when_only_have_parent_group_permission(self): + assert_raises(logic.NotAuthorized, + self._member_create_group_hierarchy, + self.roger, # parent + self.group, # child + self.tester) + def test_member_create_accepts_group_name_or_id(self): by_name = self._member_create_in_group(self.pkgs[0].id, 'package', 'public', self.group.name) @@ -139,14 +161,18 @@ def test_member_delete_raises_if_object_type_is_invalid(self): self._member_delete, 'obj_id', 'invalid_obj_type') def _member_create(self, obj, obj_type, capacity): + '''Makes the given object a member of cls.group.''' ctx, dd = self._build_context(obj, obj_type, capacity) return logic.get_action('member_create')(ctx, dd) def _member_create_in_group(self, obj, obj_type, capacity, group_id): + '''Makes the given object a member of the given group.''' ctx, dd = self._build_context(obj, obj_type, capacity, group_id) return logic.get_action('member_create')(ctx, dd) def _member_create_as_user(self, obj, obj_type, capacity, user): + '''Makes the given object a member of cls.group using privileges of + the given user.''' ctx, dd = self._build_context(obj, obj_type, capacity, user=user) return logic.get_action('member_create')(ctx, dd) @@ -162,11 +188,17 @@ def _member_delete_in_group(self, obj, obj_type, group_id): ctx, dd = self._build_context(obj, obj_type, group_id=group_id) return logic.get_action('member_delete')(ctx, dd) + def _member_create_group_hierarchy(self, parent_group, child_group, user): + ctx, dd = self._build_context(parent_group.name, 'group', 'parent', + group_id=child_group.name, user=user.id) + return logic.get_action('member_create')(ctx, dd) + def _build_context(self, obj, obj_type, capacity='public', group_id=None, user=None): ctx = {'model': model, 'session': model.Session, 'user': user or self.user.id} + ctx['auth_user_obj'] = model.User.get(ctx['user']) dd = {'id': group_id or self.group.name, 'object': obj, 'object_type': obj_type, diff --git a/ckan/tests/models/test_group.py b/ckan/tests/models/test_group.py index 88d1e63d9be..c59d6ae8048 100644 --- a/ckan/tests/models/test_group.py +++ b/ckan/tests/models/test_group.py @@ -1,7 +1,6 @@ -from nose.tools import assert_equal +from ckan.tests import assert_equal, assert_in, assert_not_in, CreateTestData import ckan.model as model -from ckan.tests import * class TestGroup(object): @@ -92,6 +91,103 @@ def _search_results(self, query, is_org=False): results = model.Group.search_by_name_or_title(query,is_org=is_org) return set([group.name for group in results]) +name_set_from_dicts = lambda groups: set([group['name'] for group in groups]) +name_set_from_group_tuple = lambda tuples: set([t[1] for t in tuples]) +name_set_from_groups = lambda groups: set([group.name for group in groups]) +names_from_groups = lambda groups: [group.name for group in groups] + +group_type = 'organization' + +class TestHierarchy: + @classmethod + def setup_class(self): + CreateTestData.create_group_hierarchy_test_data() + + def test_get_children_groups(self): + res = model.Group.by_name(u'department-of-health').\ + get_children_groups(type=group_type) + # check groups + assert_equal(name_set_from_groups(res), + set(('national-health-service', + 'food-standards-agency'))) + # check each group is a Group + assert isinstance(res[0], model.Group) + assert_in(res[0].name, ('national-health-service', 'food-standards-agency')) + assert_in(res[0].title, ('National Health Service', 'Food Standards Agency')) + + def test_get_children_group_hierarchy__from_top_2(self): + groups = model.Group.by_name(u'department-of-health').\ + get_children_group_hierarchy(type=group_type) + # the first group must be NHS or Food Standards Agency - i.e. on the + # first level down + nhs = groups[0] + assert_in(nhs[1], ('national-health-service', 'food-standards-agency')) + assert_equal(model.Group.get(nhs[3]).name, 'department-of-health') + + def test_get_children_group_hierarchy__from_top(self): + assert_equal(name_set_from_group_tuple(model.Group.by_name(u'department-of-health').\ + get_children_group_hierarchy(type=group_type)), + set(('national-health-service', 'food-standards-agency', + 'nhs-wirral-ccg', 'nhs-southwark-ccg'))) + # i.e. not cabinet-office + + def test_get_children_group_hierarchy__from_tier_two(self): + assert_equal(name_set_from_group_tuple(model.Group.by_name(u'national-health-service').\ + get_children_group_hierarchy(type=group_type)), + set(('nhs-wirral-ccg', + 'nhs-southwark-ccg'))) + # i.e. not department-of-health or food-standards-agency + + def test_get_children_group_hierarchy__from_bottom_tier(self): + assert_equal(name_set_from_group_tuple(model.Group.by_name(u'nhs-wirral-ccg').\ + get_children_group_hierarchy(type=group_type)), + set()) + + def test_get_parents__top(self): + assert_equal(names_from_groups(model.Group.by_name(u'department-of-health').\ + get_parent_groups(type=group_type)), + []) + + def test_get_parents__tier_two(self): + assert_equal(names_from_groups(model.Group.by_name(u'national-health-service').\ + get_parent_groups(type=group_type)), + ['department-of-health']) + + def test_get_parents__tier_three(self): + assert_equal(names_from_groups(model.Group.by_name(u'nhs-wirral-ccg').\ + get_parent_groups(type=group_type)), + ['national-health-service']) + + def test_get_parent_groups_up_hierarchy__from_top(self): + assert_equal(names_from_groups(model.Group.by_name(u'department-of-health').\ + get_parent_group_hierarchy(type=group_type)), + []) + + def test_get_parent_groups_up_hierarchy__from_tier_two(self): + assert_equal(names_from_groups(model.Group.by_name(u'national-health-service').\ + get_parent_group_hierarchy(type=group_type)), + ['department-of-health']) + + def test_get_parent_groups_up_hierarchy__from_tier_three(self): + assert_equal(names_from_groups(model.Group.by_name(u'nhs-wirral-ccg').\ + get_parent_group_hierarchy(type=group_type)), + ['department-of-health', + 'national-health-service']) + + def test_get_top_level_groups(self): + assert_equal(names_from_groups(model.Group.by_name(u'nhs-wirral-ccg').\ + get_top_level_groups(type=group_type)), + ['cabinet-office', 'department-of-health']) + + def test_groups_allowed_to_be_its_parent(self): + groups = model.Group.by_name(u'national-health-service').\ + groups_allowed_to_be_its_parent(type=group_type) + names = names_from_groups(groups) + assert_in('department-of-health', names) + assert_in('cabinet-office', names) + assert_not_in('natonal-health-service', names) + assert_not_in('nhs-wirral-ccg', names) + class TestGroupRevisions: @classmethod def setup_class(self): diff --git a/ckan/tests/test_coding_standards.py b/ckan/tests/test_coding_standards.py index 349f4785f6a..a3fb044c7f2 100644 --- a/ckan/tests/test_coding_standards.py +++ b/ckan/tests/test_coding_standards.py @@ -472,7 +472,6 @@ class TestImportStar(object): 'ckan/tests/lib/test_tag_search.py', 'ckan/tests/misc/test_sync.py', 'ckan/tests/models/test_extras.py', - 'ckan/tests/models/test_group.py', 'ckan/tests/models/test_misc.py', 'ckan/tests/models/test_package.py', 'ckan/tests/models/test_package_relationships.py', @@ -751,7 +750,6 @@ class TestPep8(object): 'ckan/tests/functional/test_cors.py', 'ckan/tests/functional/test_error.py', 'ckan/tests/functional/test_follow.py', - 'ckan/tests/functional/test_group.py', 'ckan/tests/functional/test_home.py', 'ckan/tests/functional/test_package.py', 'ckan/tests/functional/test_package_relationships.py', @@ -909,7 +907,6 @@ class TestActionAuth(object): 'create: follow_dataset', 'create: follow_group', 'create: follow_user', - 'create: member_create', 'create: package_relationship_create_rest', 'delete: member_delete', 'delete: package_relationship_delete_rest', diff --git a/ckanext/datapusher/helpers.py b/ckanext/datapusher/helpers.py index d5291425b28..535e6d57635 100644 --- a/ckanext/datapusher/helpers.py +++ b/ckanext/datapusher/helpers.py @@ -9,3 +9,19 @@ def datapusher_status(resource_id): return { 'status': 'unknown' } + + +def datapusher_status_description(status): + _ = toolkit._ + + if status.get('status'): + captions = { + 'complete': _('Complete'), + 'pending': _('Pending'), + 'submitting': _('Submitting'), + 'error': _('Error'), + } + + return captions.get(status['status'], status['status'].capitalize()) + else: + return _('Not Uploaded Yet') diff --git a/ckanext/datapusher/logic/action.py b/ckanext/datapusher/logic/action.py index 1872a1ef265..359fcb3d21f 100644 --- a/ckanext/datapusher/logic/action.py +++ b/ckanext/datapusher/logic/action.py @@ -153,6 +153,7 @@ def datapusher_hook(context, data_dict): task['state'] = status task['last_updated'] = str(datetime.datetime.now()) + context['ignore_auth'] = True p.toolkit.get_action('task_status_update')(context, task) diff --git a/ckanext/datapusher/plugin.py b/ckanext/datapusher/plugin.py index c53dd14790d..50c9cd864f5 100644 --- a/ckanext/datapusher/plugin.py +++ b/ckanext/datapusher/plugin.py @@ -128,4 +128,7 @@ def get_auth_functions(self): def get_helpers(self): return { - 'datapusher_status': helpers.datapusher_status} + 'datapusher_status': helpers.datapusher_status, + 'datapusher_status_description': + helpers.datapusher_status_description, + } diff --git a/ckanext/datapusher/tests/test.py b/ckanext/datapusher/tests/test.py index 2ec2df42d21..af7488736f6 100644 --- a/ckanext/datapusher/tests/test.py +++ b/ckanext/datapusher/tests/test.py @@ -138,7 +138,7 @@ def test_send_datapusher_creates_task(self): assert task['state'] == 'pending', task - def test_datapusher_hook(self): + def _call_datapusher_hook(self, user): package = model.Package.get('annakarenina') resource = package.resources[0] @@ -163,7 +163,7 @@ def test_datapusher_hook(self): } } postparams = '%s=1' % json.dumps(data) - auth = {'Authorization': str(self.sysadmin_user.apikey)} + auth = {'Authorization': str(user.apikey)} res = self.app.post('/api/action/datapusher_hook', params=postparams, extra_environ=auth, status=200) print res.body @@ -182,3 +182,11 @@ def test_datapusher_hook(self): task_type='datapusher', key='datapusher') assert task['state'] == 'success', task + + def test_datapusher_hook_sysadmin(self): + + self._call_datapusher_hook(self.sysadmin_user) + + def test_datapusher_hook_normal_user(self): + + self._call_datapusher_hook(self.normal_user) diff --git a/ckanext/datastore/db.py b/ckanext/datastore/db.py index 4dc41ff974d..82353c9e550 100644 --- a/ckanext/datastore/db.py +++ b/ckanext/datastore/db.py @@ -292,6 +292,8 @@ def convert(data, type_name): if type_name.startswith('_'): sub_type = type_name[1:] return [convert(item, sub_type) for item in data] + if type_name == 'tsvector': + return unicode(data, 'utf-8') if isinstance(data, datetime.datetime): return data.isoformat() if isinstance(data, (int, float)): @@ -834,6 +836,10 @@ def _insert_links(data_dict, limit, offset): and the resource page.''' data_dict['_links'] = {} + # no links required for local actions + if not toolkit.request.environ: + return + # get the url from the request urlstring = toolkit.request.environ['CKAN_CURRENT_URL'] diff --git a/ckanext/datastore/logic/action.py b/ckanext/datastore/logic/action.py index 804fec1a197..1b90193a1bb 100644 --- a/ckanext/datastore/logic/action.py +++ b/ckanext/datastore/logic/action.py @@ -380,7 +380,7 @@ def datastore_search_sql(context, data_dict): '(; equals chr(59)) and string concatenation (||). ')] }) - p.toolkit.check_access('datastore_search', context, data_dict) + p.toolkit.check_access('datastore_search_sql', context, data_dict) data_dict['connection_url'] = pylons.config['ckan.datastore.read_url'] diff --git a/ckanext/datastore/logic/auth.py b/ckanext/datastore/logic/auth.py index f99d7f45ae3..d002d106fda 100644 --- a/ckanext/datastore/logic/auth.py +++ b/ckanext/datastore/logic/auth.py @@ -35,5 +35,10 @@ def datastore_search(context, data_dict): return datastore_auth(context, data_dict, 'resource_show') +@p.toolkit.auth_allow_anonymous_access +def datastore_search_sql(context, data_dict): + return {'success': True} + + def datastore_change_permissions(context, data_dict): return datastore_auth(context, data_dict) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index f91310ecdec..73103e67121 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -221,6 +221,7 @@ def get_auth_functions(self): 'datastore_upsert': auth.datastore_upsert, 'datastore_delete': auth.datastore_delete, 'datastore_search': auth.datastore_search, + 'datastore_search_sql': auth.datastore_search_sql, 'datastore_change_permissions': auth.datastore_change_permissions} def before_map(self, m): diff --git a/doc/appendices/solr-multicore.rst b/doc/appendices/solr-multicore.rst index 92ebcb6fb28..0d81ecd06fa 100644 --- a/doc/appendices/solr-multicore.rst +++ b/doc/appendices/solr-multicore.rst @@ -9,7 +9,7 @@ same machine. Each configuration is called a Solr *core*. Having multiple cores is useful when you want different applications or different versions of CKAN to share the same Solr instance, each application can have its own Solr core so each can use a different ``schema.xml`` file. This is necessary, for example, -if you want to CKAN instances to share the same |solr| server and those two +if you want two CKAN instances to share the same |solr| server and those two instances are running different versions of CKAN that require differemt ``schema.xml`` files, or if the two instances have different |solr| schema customizations. @@ -57,7 +57,7 @@ core for it, see :ref:`creating another solr core`. CKAN's ``schema.xml`` file:: sudo mv /etc/solr/ckan_default/conf/schema.xml /etc/solr/ckan_default/conf/schema.xml.bak - sudo ln -s /usr/lib/ckan/default/src/ckan/ckan/config/solr/schema-2.0.xml /etc/solr/ckan_default/conf/schema.xml + sudo ln -s /usr/lib/ckan/default/src/ckan/ckan/config/solr/schema.xml /etc/solr/ckan_default/conf/schema.xml #. Edit ``/etc/solr/ckan_default/conf/solrconfig.xml`` and change the ```` tag to this:: @@ -116,7 +116,7 @@ In this example we'll assume that: #. You've installed a second instance of CKAN in a second virtual environment at /usr/lib/ckan/|ckan|, and now want to setup a second Solr core for it. -You can ofcourse follow these instructions again to setup further Solr cores. +You can of course follow these instructions again to setup further Solr cores. #. Add the core to ``/usr/share/solr/solr.xml``. This file should now list two cores. For example: @@ -154,7 +154,7 @@ You can ofcourse follow these instructions again to setup further Solr cores. .. parsed-literal:: sudo rm /etc/solr/|core|/conf/schema.xml - sudo ln -s /usr/lib/ckan/|ckan|/src/ckan/ckan/config/solr/schema-2.0.xml /etc/solr/|core|/conf/schema.xml + sudo ln -s /usr/lib/ckan/|ckan|/src/ckan/ckan/config/solr/schema.xml /etc/solr/|core|/conf/schema.xml #. Create the /usr/share/solr/|core| directory and put a symlink to the ``conf`` directory in it: diff --git a/doc/conf.py b/doc/conf.py index bc1452faa3e..e5c33312cfc 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -47,6 +47,7 @@ .. |sstore| replace:: |config_dir|/sstore .. |storage_parent_dir| replace:: /var/lib/ckan .. |storage_dir| replace:: |storage_parent_dir|/default +.. |storage_path| replace:: |storage_parent_dir|/default .. |reload_apache| replace:: sudo service apache2 reload .. |restart_apache| replace:: sudo service apache2 restart .. |restart_solr| replace:: sudo service jetty restart diff --git a/doc/configuration.rst b/doc/configuration.rst index 8cf90b9ab2d..40c88b6f7db 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -351,6 +351,22 @@ Default value: ``True`` Allow new user accounts to be created via the Web. +.. _ckan.auth.roles_that_cascade_to_sub_groups: + +ckan.auth.roles_that_cascade_to_sub_groups +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Example:: + + ckan.auth.roles_that_cascade_to_sub_groups = admin editor + +Default value: ``admin`` + + +Makes role permissions apply to all the groups down the hierarchy from the groups that the role is applied to. + +e.g. a particular user has the 'admin' role for group 'Department of Health'. If you set the value of this option to 'admin' then the user will automatically have the same admin permissions for the child groups of 'Department of Health' such as 'Cancer Research' (and its children too and so on). + .. end_config-authorization @@ -971,31 +987,41 @@ For more information on theming, see :doc:`theming`. Storage Settings ---------------- -.. _ckan.storage.bucket: +.. _ckan.storage_path: -ckan.storage.bucket -^^^^^^^^^^^^^^^^^^^ +ckan.storage_path +^^^^^^^^^^^^^^^^^ Example:: - - ckan.storage.bucket = ckan + ckan.storage_path = /var/lib/ckan Default value: ``None`` -This changes the bucket name for the uploaded files. +This defines the location of where CKAN will store all uploaded data. -.. _ckan.storage.max_content_length: +.. _ckan.max_resource_size: -ckan.storage.max_content_length -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +ckan.max_resource_size +^^^^^^^^^^^^^^^^^^^^^^ Example:: + ckan.max_resource_size = 100 + +Default value: ``10`` + +The maximum in megabytes a resources upload can be. - ckan.storage.max_content_length = 500000 +.. _ckan.max_image_size: -Default value: ``50000000`` +ckan.max_image_size +^^^^^^^^^^^^^^^^^^^^ + +Example:: + ckan.max_image_size = 10 + +Default value: ``2`` -This defines the maximum content size, in bytes, for uploads. +The maximum in megabytes an image upload can be. .. _ofs.impl: @@ -1010,6 +1036,9 @@ Default value: ``None`` Defines the storage backend used by CKAN: ``pairtree`` for local storage, ``s3`` for Amazon S3 Cloud Storage or ``google`` for Google Cloud Storage. Note that each of these must be accompanied by the relevant settings for each backend described below. +Deprecated, only available option is now pairtree. This must be used nonetheless if upgrading for CKAN 2.1 in order to keep access to your old pairtree files. + + .. _ofs.storage_dir: ofs.storage_dir @@ -1023,72 +1052,9 @@ Default value: ``None`` Only used with the local storage backend. Use this to specify where uploaded files should be stored, and also to turn on the handling of file storage. The folder should exist, and will automatically be turned into a valid pairtree repository if it is not already. -.. _ckan.storage.key_prefix: - -ckan.storage.key_prefix -^^^^^^^^^^^^^^^^^^^^^^^ - -Example:: - - ckan.storage.key_prefix = ckan-file/ - -Default value: ``file/`` - -Only used with the local storage backend. This changes the prefix for the uploaded files. - -.. _ofs.aws_access_key_id: - -ofs.aws_access_key_id -^^^^^^^^^^^^^^^^^^^^^ +Deprecated, please use ckan.storage_path. This must be used nonetheless if upgrading for CKAN 2.1 in order to keep access to your old pairtree files. -Example:: - - ofs.aws_access_key_id = 022QF06E7MXBSH9DHM02 - -Default value: ``None`` - -Only used with the Amazon S3 storage backend. Configure with your AWS Access Key ID. - -.. _ofs.aws_secret_access_key: - -ofs.aws_secret_access_key -^^^^^^^^^^^^^^^^^^^^^^^^^ - -Example:: - - ofs.aws_secret_access_key = kWcrlUX5JEDGM/LtmEENI/aVmYvHNif5zB+d9+ct - -Default value: ``None`` - -Only used with the Amazon S3 storage backend. Configure with your AWS Secret Access Key. - -.. _ofs.gs_access_key_id: - -ofs.gs_access_key_id -^^^^^^^^^^^^^^^^^^^^^ - -Example:: - - ofs.gs_access_key_id = GOOGTS7C7FUP3AIRVJTE - -Default value: ``None`` - -Only used with the Google storage backend. Configure with your Google Storage -Access Key ID. - -.. _ofs.gs_secret_access_key: - -ofs.gs_secret_access_key -^^^^^^^^^^^^^^^^^^^^^^^^^ - -Example:: - - ofs.gs_secret_access_key = bGoa+V7g/yqDXvKRqq+JTFn4uQZbPiQJo4pf9RzJ - -Default value: ``None`` -Only used with the Google storage backend. Configure with your Google Storage -Secret Access Key. DataPusher Settings diff --git a/doc/contents.rst b/doc/contents.rst index 071ecd42625..b49c030c1ab 100644 --- a/doc/contents.rst +++ b/doc/contents.rst @@ -5,6 +5,8 @@ Full table of contents .. toctree:: index + user-guide + sysadmin-guide installing upgrading getting-started diff --git a/doc/documentation-guidelines.rst b/doc/documentation-guidelines.rst index 6014c645016..ddfcc23b19f 100644 --- a/doc/documentation-guidelines.rst +++ b/doc/documentation-guidelines.rst @@ -161,6 +161,12 @@ customize, initialize, color, etc. There's a list here: https://wiki.ubuntu.com/EnglishTranslation/WordSubstitution +Spellcheck +========== + +Please spellcheck documentation before merging it into master! + + Commonly used terms =================== @@ -342,13 +348,33 @@ As with Python code, try to limit all lines to a maximum of 79 characters. Cross-references and links ========================== +Whenever mentioning another page or section in the docs, an external website, a +configuration setting, or a class, exception or function, etc. try to +cross-reference it. Using proper Sphinx cross-references is better than just +typing things like "see above/below" or "see section foo" because Sphinx +cross-refs are hyperlinked, and because if the thing you're referencing to gets +moved or deleted Sphinx will update the cross-reference or print a warning. + + +Cross-referencing to another file +--------------------------------- + Use ``:doc:`` to cross-reference to other files by filename:: - See :doc:`theming` + See :doc:`configuration` + +If the file you're editing is in a subdir within the ``doc`` dir, you may need +to use an absolute reference (starting with a ``/``):: -see `Cross-referencing documents `_ + See :doc:`/configuration` + +See `Cross-referencing documents `_ for details. + +Cross-referencing a section within a file +----------------------------------------- + Use ``:ref:`` to cross-reference to particular sections within the same or another file. First you have to add a label before the section you want to cross-reference to:: @@ -365,13 +391,59 @@ then from elsewhere cross-reference to the section like this:: see `Cross-referencing arbitrary locations `_. -With both ``:doc:`` and ``:ref:`` if you want the link text to be different -from the title of the section you're referencing, do this:: + +Cross-referencing to CKAN config settings +----------------------------------------- + +Whenever you mention a CKAN config setting, make it link to the docs for that +setting in :doc:`/configuration` by using ``:ref:`` and the name of the config +setting:: + + :ref:`ckan.site_title` + +This works because all CKAN config settings are documented in +:doc:`/configuration`, and every setting has a Sphinx label that is exactly +the same as the name of the setting, for example:: + + .. _ckan.site_title: + + ckan.site_title + ^^^^^^^^^^^^^^^ + + Example:: + + ckan.site_title = Open Data Scotland + + Default value: ``CKAN`` + + This sets the name of the site, as displayed in the CKAN web interface. + +If you add a new config setting to CKAN, make sure to document like this it in +:doc:`configuration`. + + +Cross-referencing to a Python object +------------------------------------ + +Whenever you mention a Python function, method, object, class, exception, etc. +cross-reference it using a Sphinx domain object cross-reference. +See :ref:`Referencing other code objects`. + + +Changing the link text of a cross-reference +------------------------------------------- + +With ``:doc:`` ``:ref:`` and other kinds of link, if you want the link text to +be different from the title of the thing you're referencing, do this:: :doc:`the theming document ` :ref:`the getting started section ` + +Cross-referencing to an external page +------------------------------------- + The syntax for linking to external URLs is slightly different from cross-referencing, you have to add a trailing underscore:: @@ -386,9 +458,6 @@ or to define a URL once and then link to it in multiple places, do:: see `Hyperlinks `_ for details. -Use ``:py:`` to reference other Python or JavaScript functions, modules, -classes, etc. See :ref:`Referencing other code objects`. - .. _sphinx substitutions: diff --git a/doc/filestore.rst b/doc/filestore.rst index eadc93ae408..f57057ebd97 100644 --- a/doc/filestore.rst +++ b/doc/filestore.rst @@ -1,172 +1,139 @@ ========================== -FileStore and File Uploads +FileStore and file uploads ========================== -CKAN allows users to upload files directly to file storage either on the local -file system or to online 'cloud' storage like Amazon S3 or Google Storage. The -uploaded files will be stored in the configured location. +When enabled, CKAN's FileStore allows users to upload data files to CKAN +resources, and to upload logo images for groups and organizations. Users will +see an upload button when creating or updating a resource, group or +organization. -------------------------------------------- -Setup the FileStore with Local File Storage -------------------------------------------- +.. versionadded:: 2.2 + Uploading logo images for groups and organizations was added in CKAN 2.2. -To setup CKAN's FileStore with local file storage: +.. versionchanged:: 2.2 + Previous versions of CKAN used to allow uploads to remote cloud hosting but + we have simplified this to only allow local file uploads (see + :ref:`filestore_21_to_22_migration` for details on how to migrate). This is + to give CKAN more control over the files and make access control possible. -1. Create the directory where CKAN will store uploaded files: +.. seealso:: - .. parsed-literal:: + :ref:`datapusher` - sudo mkdir -p |storage_dir| + Resource files linked-to from CKAN or uploaded to CKAN's FileStore can + also be pushed into CKAN's DataStore, which then enables data previews and + a data API for the resources. -2. Add the following lines to your CKAN config file, after the ``[app:main]`` - line: - .. parsed-literal:: +------------------ +Setup file uploads +------------------ - ofs.impl = pairtree - ofs.storage_dir = |storage_dir| +To setup CKAN's FileStore with local file storage: -3. Set the permissions of the ``storage_dir``. For example if you're running - CKAN with Apache, then Apache's user (``www-data`` on Ubuntu) must have - read, write and execute permissions for the ``storage_dir``: +1. Create the directory where CKAN will store uploaded files: .. parsed-literal:: - sudo chown www-data |storage_dir| - sudo chmod u+rwx |storage_dir| + sudo mkdir -p |storage_path| -4. Make sure you've set :ref:`ckan.site_url` in your config file. - -5. Restart your web server, for example to restart Apache: +2. Add the following line to your CKAN config file, after the ``[app:main]`` + line: .. parsed-literal:: - |reload_apache| - - --------------------------------------- -Setup the FileStore with Cloud Storage --------------------------------------- - -Important: you must install boto library for cloud storage to function:: + ckan.storage_path = |storage_path| - pip install boto +3. Set the permissions of your :ref:`ckan.storage_path` directory. + For example if you're running CKAN with Apache, then Apache's user + (``www-data`` on Ubuntu) must have read, write and execute permissions for + the :ref:`ckan.storage_path`: -In your config for google:: - - ## OFS configuration - ofs.impl = google - ofs.gs_access_key_id = GOOG.... - ofs.gs_secret_access_key = .... + .. parsed-literal:: -For S3:: + sudo chown www-data |storage_path| + sudo chmod u+rwx |storage_path| - ## OFS configuration - ofs.impl = s3 - ofs.aws_access_key_id = .... - ofs.aws_secret_access_key = .... +4. Restart your web server, for example to restart Apache: + .. parsed-literal:: ------------------------ -FileStore Web Interface ------------------------ + |reload_apache| -Upload of files to storage is integrated directly into the the Dataset creation -and editing system with files being associated to Resources. ------------- FileStore API ------------- -CKAN's FileStore API lets you upload files to CKAN's -:doc:`FileStore `. If you're looking for an example, -`ckanclient `_ contains -`Python code for uploading a file to CKAN using the FileStore API `_. - - -FileStore Metadata API -====================== - -The API is located at:: - - /api/storage/metadata/{label} - -It supports the following methods: - -* GET will return the metadata -* POST will add/update metadata -* PUT will replace metadata - -Metadata is a json dict of key values which for POST and PUT should be send in body of request. - -A standard response looks like:: - - { - "_bucket": "ckannet-storage", - _content_length: 1074 - _format: "text/plain" - _label: "/file/8630a664-0ae4-485f-99c2-126dae95653a" - _last_modified: "Fri, 29 Apr 2011 19:27:31 GMT" - _location: "some-location" - _owner: null - uploaded-by: "bff737ef-b84c-4519-914c-b4285144d8e6" - } +.. versionchanged:: 2.2 + The FileStore API was redesigned for CKAN 2.2. + The previous API has been deprecated. -Note that values with '_' are standard OFS metadata and are mostly read-only -- _format i.e. content-type can be set). +Files can be uploaded to the FileStore using the +:py:func:`~ckan.logic.action.create.resource_create` and +:py:func:`~ckan.logic.action.update.resource_update` action API +functions. You can post multipart/form-data to the API and the key, value +pairs will treated as as if they are a JSON object. +The extra key ``upload`` is used to actually post the binary data. +For example, to create a new CKAN resource and upload a file to it using +`curl `_: -FileStore Form Authentication API -================================= +.. parsed-literal:: -Provides credentials for doing operations on storage directly from a client -(using web form style POSTs). + curl -H'Authorization: your-api-key' 'http://yourhost/api/action/resource_create' --form upload=@filetoupload --form package_id=my_dataset -The API is located at:: +(Curl automatically sends a ``multipart-form-data`` heading with you use the +``--form`` option.) - /api/storage/auth/form/{label} +To create a new resource and upload a file to it using the Python library +`requests `_: -Provide fields for a form upload to storage including authentication:: +.. parsed-literal:: - :param label: label. - :return: json-encoded dictionary with action parameter and fields list. + import requests + requests.post('http://0.0.0.0:5000/api/action/resource_create', + data={"package_id":"my_dataset}", + headers={"X-CKAN-API-Key": "21a47217-6d7b-49c5-88f9-72ebd5a4d4bb"}, + files=[('upload', file('/path/to/file/to/upload.csv'))]) +(Requests automatically sends a ``multipart-form-data`` heading when you use the +``files=`` parameter.) -FileStore Request Authentication API -==================================== +To overwrite an uploaded file with a new version of the file, post to the +:py:func:`~ckan.logic.action.update.resource_update` action and use the +``upload`` field:: -Provides credentials for doing operations on storage directly from a client. + curl -H'Authorization: your-api-key' 'http://yourhost/api/action/resource_update' --form upload=@newfiletoupload --form id=resourceid -.. warning:: This API is currently disabled and will likely be deprecated. - Use the form authentication instead. +To replace an uploaded file with a link to a file at a remote URL, use the +``clear_upload`` field:: -The API is at:: + curl -H'Authorization: your-api-key' 'http://yourhost/api/action/resource_update' --form url=http://expample.com --form clear_upload=true --form id=resourceid - /api/storage/auth/request/{label} -Provide authentication information for a request so a client can -interact with backend storage directly:: +.. _filestore_21_to_22_migration: - :param label: label. - :param kwargs: sent either via query string for GET or json-encoded - dict for POST). Interpreted as http headers for request plus an - (optional) method parameter (being the HTTP method). +-------------------------- +Migration from 2.1 to 2.2 +-------------------------- - Examples of headers are: +If you are using pairtree local file storage then you can keep your current settings +without issue. The pairtree and new storage can live side by side but you are still +encouraged to migrate. If you change your config options to the ones specified in +this docs you will need to run the migration below. - Content-Type - Content-Encoding (optional) - Content-Length - Content-MD5 - Expect (should be '100-Continue') +If you are running remote storage then all previous links will still be accessible +but if you want to move the remote storage documents to the local storage you will +run the migration also. - :return: is a json hash containing various attributes including a - headers dictionary containing an Authorization field which is good for - 15m. +In order to migrate make sure your CKAN instance is running as the script will +request the data from the instance using APIs. You need to run the following +on the command line to do the migration:: ---------------------- -DataStore Integration ---------------------- + paster db migrate-filestore -It is also possible to have uploaded files (if of a suitable format) stored in -the DataStore which will then provides an API to the data. See :ref:`datapusher` for more details. +This may take a long time especially if you have a lot of files remotely. +If the remote hosting goes down or the job is interrupted it is save to run it again +and it will try all the unsuccessful ones again. diff --git a/doc/install-from-source.rst b/doc/install-from-source.rst index e66d49515a3..f34449f9105 100644 --- a/doc/install-from-source.rst +++ b/doc/install-from-source.rst @@ -253,18 +253,13 @@ installed, we need to install and configure Solr. JAVA_HOME=/usr/lib/jvm/java-6-openjdk-i386/ -#. Replace the default ``schema.xml`` file with a symlink to your CKAN schema - file. - There are multiple versions of CKAN's schema file in - |virtualenv|/src/ckan/ckan/config/solr/. Use the newest schema file whose version - number is the same as or older than the version of CKAN you're using. For - example, if you're using CKAN 2.1.1 then you would use the ``schema-2.0.xml`` - file (because there is no ``schema-2.1.xml`` file): +#. Replace the default ``schema.xml`` file with a symlink to the CKAN schema + file included in the sources. .. parsed-literal:: sudo mv /etc/solr/conf/schema.xml /etc/solr/conf/schema.xml.bak - sudo ln -s |virtualenv|/src/ckan/ckan/config/solr/schema-2.0.xml /etc/solr/conf/schema.xml + sudo ln -s |virtualenv|/src/ckan/ckan/config/solr/schema.xml /etc/solr/conf/schema.xml Now restart Solr: diff --git a/doc/release-process.rst b/doc/release-process.rst index 2d0046fbd05..4b427d2cf03 100644 --- a/doc/release-process.rst +++ b/doc/release-process.rst @@ -12,7 +12,7 @@ Doing a Beta Release Beta releases are branched off a certain point in master and will eventually become stable releases. -1. Create a new release branch:: +#. Create a new release branch:: git checkout -b release-v1.8 @@ -26,11 +26,22 @@ become stable releases. You will probably need to update the same file on master to increase the version number, in this case ending with an *a* (for alpha). -2. During the beta process, all changes to the release branch must be +#. Check if there have been changes in the |solr| schema at + ``ckan/config/solr/schema.xml``, and if so: + + * Update the ``version`` attribute of the ``schema`` with the current CKAN + version:: + + + + * Update the ``SUPPORTED_SCHEMA_VERSIONS`` list in + ``ckan/lib/search/__init__.py`` + +#. During the beta process, all changes to the release branch must be cherry-picked from master (or merged from special branches based on the release branch if the original branch was not compatible). -3. As in the master branch, if some commits involving CSS changes are +#. As in the master branch, if some commits involving CSS changes are cherry-picked from master, the less compiling command needs to be run on the release branch. This will update the ``main.css`` file:: @@ -40,10 +51,10 @@ become stable releases. There will be a final front-end build before the actual release. -4. The beta staging site (http://beta.ckan.org, currently on s084) should be +#. The beta staging site (http://beta.ckan.org, currently on s084) should be updated regularly to allow user testing. -5. Once the translation freeze is in place (ie no changes to the translatable +#. Once the translation freeze is in place (ie no changes to the translatable strings are allowed), strings need to be extracted and uploaded to Transifex_: diff --git a/doc/upgrade-package-to-minor-release.rst b/doc/upgrade-package-to-minor-release.rst index cf2f5fe7335..c7754352589 100644 --- a/doc/upgrade-package-to-minor-release.rst +++ b/doc/upgrade-package-to-minor-release.rst @@ -74,22 +74,7 @@ respectively. command. #. If there have been changes in the Solr schema (check the :doc:`changelog` - to find out) you need to update your Solr schema symlink. - - When :ref:`setting up solr` you created a symlink - ``/etc/solr/conf/schema.xml`` linking to a CKAN Solr schema file such as - |virtualenv|/src/ckan/ckan/config/solr/schema-2.0.xml. This symlink - should be updated to point to the latest schema file in - |virtualenv|/src/ckan/ckan/config/solr/, if it doesn't already. - - For example, to update the symlink: - - .. parsed-literal:: - - sudo rm /etc/solr/conf/schema.xml - sudo ln -s |virtualenv|/src/ckan/ckan/config/solr/schema-2.0.xml /etc/solr/conf/schema.xml - - You will need to restart Jetty for the changes to take effect: + to find out) you need to restart Jetty for the changes to take effect: .. parsed-literal:: diff --git a/doc/upgrade-source.rst b/doc/upgrade-source.rst index 539b96dcb62..82fac09ed5f 100644 --- a/doc/upgrade-source.rst +++ b/doc/upgrade-source.rst @@ -43,23 +43,8 @@ CKAN release you're upgrading to: python setup.py develop -#. If you are upgrading to a new :ref:`major release ` you need to - update your Solr schema symlink. - - When :ref:`setting up solr` you created a symlink - ``/etc/solr/conf/schema.xml`` linking to a CKAN Solr schema file such as - |virtualenv|/src/ckan/ckan/config/solr/schema-2.0.xml. This symlink - should be updated to point to the latest schema file in - |virtualenv|/src/ckan/ckan/config/solr/, if it doesn't already. - - For example, to update the symlink: - - .. parsed-literal:: - - sudo rm /etc/solr/conf/schema.xml - sudo ln -s |virtualenv|/src/ckan/ckan/config/solr/schema-2.0.xml /etc/solr/conf/schema.xml - - You will need to restart Jetty for the changes to take effect: +#. If there have been changes in the Solr schema (check the :doc:`changelog` + to find out) you need to restart Jetty for the changes to take effect: .. parsed-literal:: diff --git a/requirements.txt b/requirements.txt index e16309daa8a..07320c945d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ FormAlchemy==1.4.3 FormEncode==1.2.6 Genshi==0.6 Jinja2==2.6 -Mako==0.8.1 +Mako==0.9.0 MarkupSafe==0.18 Pairtree==0.7.1-T Paste==1.7.5.1 @@ -22,7 +22,6 @@ WebTest==1.4.3 apachemiddleware==0.1.1 argparse==1.2.1 decorator==3.4.0 -distribute==0.6.34 fanstatic==0.12 nose==1.3.0 ofs==0.4.1 @@ -35,7 +34,7 @@ repoze.who==1.0.19 repoze.who-friendlyform==1.0.8 repoze.who.plugins.openid==0.5.3 requests==1.1.0 -simplejson==3.3.0 +simplejson==3.3.1 solrpy==0.9.5 sqlalchemy-migrate==0.7.2 unicodecsv==0.9.4