diff --git a/ckan/config/routing.py b/ckan/config/routing.py index a42db28c023..fdb44386038 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -257,9 +257,9 @@ def make_map(): ) map.connect('group_read', '/group/{id}', controller='group', action='read') - register_package_behaviour(map) register_group_behaviour(map) + # authz group map.redirect("/authorizationgroups", "/authorizationgroup") diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index 3689020a4c5..7e6c12ac6a0 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -1,4 +1,5 @@ import logging +import cgi from paste.util.multidict import MultiDict from webob.multidict import UnicodeMultiDict @@ -70,7 +71,8 @@ def _finish(self, status_int, response_data=None, if status_int==200 and request.params.has_key('callback') and \ (request.method == 'GET' or \ c.logic_function and request.method == 'POST'): - callback = request.params['callback'] + # escape callback to remove '<', '&', '>' chars + callback = cgi.escape(request.params['callback']) response_msg = self._wrap_jsonp(callback, response_msg) return response_msg diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index 8490c00bc5b..02f8bc306be 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -54,13 +54,9 @@ def register_pluggable_behaviour(map): # Our version of routes doesn't allow the environ to be passed into the match call # and so we have to set it on the map instead. This looks like a threading problem # waiting to happen but it is executed sequentially from instead the routing setup - e = map.environ - map.environ = {'REQUEST_METHOD': 'GET'} - match = map.match('/%s/new' % (group_type,)) - map.environ = e - if match: - raise Exception, "Plugin %r would overwrite existing urls" % plugin - + + map.connect('%s_index' % (group_type,), + '/%s' % (group_type,), controller='group', action='index') map.connect('%s_new' % (group_type,), '/%s/new' % (group_type,), controller='group', action='new') map.connect('%s_read' % (group_type,), @@ -145,7 +141,7 @@ def check_data_dict(self, data_dict): def setup_template_variables(self, context, data_dict): c.is_sysadmin = Authorizer().is_sysadmin(c.user) - + ## This is messy as auths take domain object not data_dict context_group = context.get('group',None) group = context_group or c.group @@ -315,7 +311,6 @@ def pager_url(q=None, page=None): return render('group/read.html') def new(self, data=None, errors=None, error_summary=None): - group_type = request.path.strip('/').split('/')[0] if group_type == 'group': group_type = None @@ -372,7 +367,6 @@ def edit(self, id, data=None, errors=None, error_summary=None): group = context.get("group") c.group = group - try: check_access('group_update',context) except NotAuthorized, e: @@ -416,6 +410,7 @@ def _save_new(self, context, group_type=None): tuplize_dict(parse_params(request.params)))) data_dict['type'] = group_type or 'group' context['message'] = data_dict.get('log_message', '') + data_dict['users'] = [{'name': c.user, 'capacity': 'admin'}] group = get_action('group_create')(context, data_dict) # Redirect to the appropriate _read route for the type of group @@ -432,17 +427,17 @@ def _save_new(self, context, group_type=None): return self.new(data_dict, errors, error_summary) def _save_edit(self, id, context): - try: + try: data_dict = clean_dict(unflatten( tuplize_dict(parse_params(request.params)))) context['message'] = data_dict.get('log_message', '') data_dict['id'] = id group = get_action('group_update')(context, data_dict) - h.redirect_to(controller='group', action='read', id=group['name']) + h.redirect_to('%s_read' % str(group['type']), id=group['name']) except NotAuthorized: abort(401, _('Unauthorized to read group %s') % id) except NotFound, e: - abort(404, _('Package not found')) + abort(404, _('Group not found')) except DataError: abort(400, _(u'Integrity Error')) except ValidationError, e: diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py index 89f7beb10c6..961661d1a7b 100644 --- a/ckan/controllers/package.py +++ b/ckan/controllers/package.py @@ -67,13 +67,6 @@ def register_pluggable_behaviour(map): """ global _default_controller_behaviour - # Check this method hasn't been invoked already. - # TODO: This method seems to be being invoked more than once during running of - # the tests. So I've disbabled this check until I figure out why. - #if _default_controller_behaviour is not None: - #raise ValueError, "Pluggable package controller behaviour is already defined "\ - #"'%s'" % _default_controller_behaviour - # Create the mappings and register the fallback behaviour if one is found. for plugin in PluginImplementations(IDatasetForm): if plugin.is_fallback(): @@ -84,7 +77,6 @@ def register_pluggable_behaviour(map): for package_type in plugin.package_types(): # Create a connection between the newly named type and the package controller - # but first we need to make sure we are not clobbering an existing domain map.connect('/%s/new' % (package_type,), controller='package', action='new') map.connect('%s_read' % (package_type,), '/%s/{id}' % (package_type,), controller='package', action='read') map.connect('%s_action' % (package_type,), @@ -448,6 +440,8 @@ def new(self, data=None, errors=None, error_summary=None): 'save': 'save' in request.params, 'schema': self._form_to_db_schema(package_type=package_type)} + # Package needs to have a publisher group in the call to check_access + # and also to save it try: check_access('package_create',context) except NotAuthorized: diff --git a/ckan/lib/alphabet_paginate.py b/ckan/lib/alphabet_paginate.py index c467ee57158..321c9b6d861 100644 --- a/ckan/lib/alphabet_paginate.py +++ b/ckan/lib/alphabet_paginate.py @@ -21,7 +21,8 @@ from routes import url_for class AlphaPage(object): - def __init__(self, collection, alpha_attribute, page, other_text, paging_threshold=50): + def __init__(self, collection, alpha_attribute, page, other_text, paging_threshold=50, + controller_name='tag'): ''' @param collection - sqlalchemy query of all the items to paginate @param alpha_attribute - name of the attribute (on each item of the @@ -31,12 +32,22 @@ def __init__(self, collection, alpha_attribute, page, other_text, paging_thresho non-alphabetic first character. @param paging_threshold - the minimum number of items required to start paginating them. + @param controller_name - The name of the controller that will be linked to, + which defaults to tag. The controller name should be the + same as the route so for some this will be the full + controller name such as 'A.B.controllers.C:ClassName' ''' self.collection = collection self.alpha_attribute = alpha_attribute self.page = page self.other_text = other_text self.paging_threshold = paging_threshold + self.controller_name = controller_name + self.available = dict( (c,0,) for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" ) + for c in self.collection: + x = c if isinstance( c, unicode ) else getattr(c, self.alpha_attribute)[0] + self.available[x] = self.available.get(x, 0) + 1 + def pager(self, q=None): @@ -58,10 +69,13 @@ def pager(self, q=None): letters = [char for char in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'] + [self.other_text] for letter in letters: if letter != page: - page = HTML.a(class_='pager_link', href=url_for(controller='tag', action='index', page=letter), c=letter) + if self.available.get(letter, 0): + page_element = HTML.a(class_='pager_link', href=url_for(controller=self.controller_name, action='index', page=letter),c=letter) + else: + page_element = HTML.span(class_="pager_empty", c=letter) else: - page = HTML.span(class_='pager_curpage', c=letter) - pages.append(page) + page_element = HTML.span(class_='pager_curpage', c=letter) + pages.append(page_element) div = HTML.tag('div', class_='pager', *pages) return div @@ -96,10 +110,16 @@ def items(self): elif isinstance(self.collection,list): if self.item_count >= self.paging_threshold: if self.page != self.other_text: - items = [x for x in self.collection if x[0:1].lower() == self.page.lower()] + if isinstance(self.collection[0], dict): + items = [x for x in self.collection if x[self.alpha_attribute][0:1].lower() == self.page.lower()] + else: + items = [x for x in self.collection if getattr(x,self.alpha_attribute)[0:1].lower() == self.page.lower()] else: # regexp search - items = [x for x in self.collection if re.match('^[^a-zA-Z].*',x)] + if isinstance(self.collection[0], dict): + items = [x for x in self.collection if re.match('^[^a-zA-Z].*',x[self.alpha_attribute])] + else: + items = [x for x in self.collection if re.match('^[^a-zA-Z].*',x)] items.sort() else: items = self.collection diff --git a/ckan/lib/create_test_data.py b/ckan/lib/create_test_data.py index 6554cf8371a..d2b06cd6c30 100644 --- a/ckan/lib/create_test_data.py +++ b/ckan/lib/create_test_data.py @@ -273,7 +273,7 @@ def pkg(pkg_name): @classmethod - def create_groups(cls, group_dicts, admin_user_name=None): + 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.''' @@ -289,6 +289,7 @@ def create_groups(cls, group_dicts, admin_user_name=None): group_attributes = set(('name', 'title', 'description', 'parent_id')) for group_dict in group_dicts: group = model.Group(name=unicode(group_dict['name'])) + group.type = auth_profile or 'group' for key in group_dict: if key in group_attributes: setattr(group, key, group_dict[key]) @@ -307,7 +308,7 @@ def create_groups(cls, group_dicts, admin_user_name=None): model.repo.commit_and_remove() @classmethod - def create(cls): + def create(cls, auth_profile=""): import ckan.model as model model.Session.remove() rev = model.repo.new_revision() @@ -318,8 +319,13 @@ def create(cls): * Package: warandpeace * Associated tags, etc etc ''' + if auth_profile == "publisher": + publisher_group = model.Group(name=u"publisher_group", type="publisher") + cls.pkg_names = [u'annakarenina', u'warandpeace'] pkg1 = model.Package(name=cls.pkg_names[0]) + if auth_profile == "publisher": + pkg1.group = publisher_group model.Session.add(pkg1) pkg1.title = u'A Novel By Tolstoy' pkg1.version = u'0.7a' @@ -373,6 +379,9 @@ def create(cls): tag1 = model.Tag(name=u'russian') tag2 = model.Tag(name=u'tolstoy') + if auth_profile == "publisher": + pkg2.group = publisher_group + # Flexible tag, allows spaces, upper-case, # and all punctuation except commas tag3 = model.Tag(name=u'Flexible \u30a1') @@ -390,10 +399,12 @@ def create(cls): # group david = model.Group(name=u'david', title=u'Dave\'s books', - description=u'These are books that David likes.') + description=u'These are books that David likes.', + type=auth_profile or 'group') roger = model.Group(name=u'roger', title=u'Roger\'s books', - description=u'Roger likes these books.') + description=u'Roger likes these books.', + type=auth_profile or 'group') for obj in [david, roger]: model.Session.add(obj) diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py index ab62f8db6ae..47a46b8b5a4 100644 --- a/ckan/lib/dictization/model_save.py +++ b/ckan/lib/dictization/model_save.py @@ -219,6 +219,7 @@ def package_membership_list_save(group_dicts, package, context): member_obj = model.Member(table_id = package.id, table_name = 'package', group = group, + group_id=group.id, state = 'active') session.add(member_obj) @@ -262,7 +263,8 @@ def relationship_list_save(relationship_dicts, package, attr, context): relationship_list.append(relationship) def package_dict_save(pkg_dict, context): - + import uuid + model = context["model"] package = context.get("package") allow_partial_update = context.get("allow_partial_update", False) @@ -277,6 +279,9 @@ def package_dict_save(pkg_dict, context): pkg = table_dict_save(pkg_dict, Package, context) + if not pkg.id: + pkg.id = str(uuid.uuid4()) + package_resource_list_save(pkg_dict.get("resources", []), pkg, context) package_tag_list_save(pkg_dict.get("tags", []), pkg, context) package_membership_list_save(pkg_dict.get("groups", []), pkg, context) @@ -295,7 +300,6 @@ def package_dict_save(pkg_dict, context): return pkg def group_member_save(context, group_dict, member_table_name): - model = context["model"] session = context["session"] group = context['group'] @@ -327,14 +331,15 @@ def group_member_save(context, group_dict, member_table_name): session.add(entity_member[entity_id]) for entity_id in set(entities.keys()) - set(entity_member.keys()): - member = Member(group=group, table_id=entity_id[0], + member = Member(group=group, group_id=group.id, table_id=entity_id[0], table_name=member_table_name[:-1], capacity=entity_id[1]) session.add(member) def group_dict_save(group_dict, context): - + import uuid + model = context["model"] session = context["session"] group = context.get("group") @@ -345,6 +350,9 @@ def group_dict_save(group_dict, context): group_dict["id"] = group.id group = table_dict_save(group_dict, Group, context) + if not group.id: + group.id = str(uuid.uuid4()) + context['group'] = group group_member_save(context, group_dict, 'packages') diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index d8141278773..80926f47e17 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -148,7 +148,7 @@ def subnav_named_route(c, text, routename,**kwargs): """ Generate a subnav element based on a named route """ return link_to( text, - url_for(routename, **kwargs), + url_for(str(routename), **kwargs), class_=('active' if c.action == kwargs['action'] else '') ) diff --git a/ckan/logic/__init__.py b/ckan/logic/__init__.py index 02edca95183..f24e07d80f2 100644 --- a/ckan/logic/__init__.py +++ b/ckan/logic/__init__.py @@ -1,7 +1,7 @@ import logging from ckan.lib.base import _ import ckan.authz -import ckan.new_authz as new_authz +from ckan.new_authz import is_authorized from ckan.lib.navl.dictization_functions import flatten_dict, DataError from ckan.plugins import PluginImplementations from ckan.plugins.interfaces import IActions @@ -129,7 +129,7 @@ def check_access(action, context, data_dict=None): # # TODO Check the API key is valid at some point too! # log.debug('Valid API key needed to make changes') # raise NotAuthorized - logic_authorization = new_authz.is_authorized(action, context, data_dict) + logic_authorization = is_authorized(action, context, data_dict) if not logic_authorization['success']: msg = logic_authorization.get('msg','') raise NotAuthorized(msg) diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index f4d331be91b..4c6b6da5e58 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -180,7 +180,7 @@ def group_create(context, data_dict): rev.message = _(u'REST API: Create object %s') % data.get("name") group = group_dict_save(data, context) - + if user: admins = [model.User.by_name(user.decode('utf8'))] else: @@ -188,6 +188,7 @@ def group_create(context, data_dict): model.setup_default_user_roles(group, admins) # Needed to let extensions know the group id session.flush() + for item in PluginImplementations(IGroupController): item.create(group) diff --git a/ckan/logic/auth/create.py b/ckan/logic/auth/create.py index 6484b71af37..fc0672138ef 100644 --- a/ckan/logic/auth/create.py +++ b/ckan/logic/auth/create.py @@ -6,14 +6,13 @@ def package_create(context, data_dict=None): model = context['model'] user = context['user'] - check1 = check_access_old(model.System(), model.Action.PACKAGE_CREATE, context) if not check1: return {'success': False, 'msg': _('User %s not authorized to create packages') % str(user)} else: - check2 = check_group_auth(context,data_dict) + check2 = _check_group_auth(context,data_dict) if not check2: return {'success': False, 'msg': _('User %s not authorized to edit these groups') % str(user)} @@ -74,7 +73,8 @@ def user_create(context, data_dict=None): else: return {'success': True} -def check_group_auth(context, data_dict): + +def _check_group_auth(context, data_dict): if not data_dict: return True diff --git a/ckan/logic/auth/publisher/__init__.py b/ckan/logic/auth/publisher/__init__.py new file mode 100644 index 00000000000..8b2c572eb46 --- /dev/null +++ b/ckan/logic/auth/publisher/__init__.py @@ -0,0 +1,10 @@ + + +def _groups_intersect( groups_A, groups_B ): + """ Return true if any of the groups in A are also in B (or size + of intersection > 0). If both are empty for now we will allow it """ + ga = set(groups_A) + gb = set(groups_B) + + return len( ga.intersection( gb ) ) > 0 + \ No newline at end of file diff --git a/ckan/logic/auth/publisher/create.py b/ckan/logic/auth/publisher/create.py new file mode 100644 index 00000000000..c87f8637243 --- /dev/null +++ b/ckan/logic/auth/publisher/create.py @@ -0,0 +1,110 @@ +from ckan.logic.auth import get_package_object, get_group_object, \ + get_user_object, get_resource_object +from ckan.logic.auth.publisher import _groups_intersect +from ckan.logic import NotFound +from ckan.authz import Authorizer +from ckan.lib.base import _ + + +def package_create(context, data_dict=None): + model = context['model'] + user = context['user'] + userobj = model.User.get( user ) + + if userobj: + return {'success': True} + + return {'success': False, 'msg': 'You must be logged in to create a package'} + +def resource_create(context, data_dict): + return {'success': False, 'msg': 'Not implemented yet in the auth refactor'} + +def package_relationship_create(context, data_dict): + """ + Permission for users to create a new package relationship requires that the + user share a group with both packages. + """ + model = context['model'] + user = context['user'] + + id = data_dict.get('id', '') + id2 = data_dict.get('id2', '') + + pkg1 = model.Package.get(id) + pkg2 = model.Package.get(id2) + + if not pkg1 or not pkg2: + return {'success': False, 'msg': _('Two package IDs are required')} + + pkg1grps = pkg1.get_groups('publisher') + pkg2grps = pkg2.get_groups('publisher') + + usergrps = model.User.get( user ).get_groups('publisher') + authorized = _groups_intersect( usergrps, pkg1grps ) and _groups_intersect( usergrps, pkg2grps ) + if not authorized: + return {'success': False, 'msg': _('User %s not authorized to edit these packages') % str(user)} + else: + return {'success': True} + +def group_create(context, data_dict=None): + """ + Group create permission. If a group is provided, within which we want to create a group + then we check that the user is within that group. If not then we just say Yes for now + although there may be some approval issues elsewhere. + """ + model = context['model'] + user = context['user'] + + if not user: + return {'success': False, 'msg': _('User is not authorized to create groups') } + + try: + # If the user is doing this within another group then we need to make sure that + # the user has permissions for this group. + group = get_group_object( context ) + except NotFound: + return { 'success' : True } + + userobj = model.User.get( user ) + if not userobj: + return {'success': False, 'msg': _('User %s not authorized to create groups') % str(user)} + + authorized = _groups_intersect( userobj.get_groups('publisher'), [group] ) + if not authorized: + return {'success': False, 'msg': _('User %s not authorized to create groups') % str(user)} + else: + return {'success': True} + +def authorization_group_create(context, data_dict=None): + return {'success': False, 'msg': _('Authorization groups not implemented in this profile') % str(user)} + + +def rating_create(context, data_dict): + # No authz check in the logic function + return {'success': True} + +def user_create(context, data_dict=None): + return {'success': True} + + +## Modifications for rest api + +def package_create_rest(context, data_dict): + model = context['model'] + user = context['user'] + if user in (model.PSEUDO_USER__VISITOR, ''): + return {'success': False, 'msg': _('Valid API key needed to create a package')} + + return package_create(context, data_dict) + +def group_create_rest(context, data_dict): + model = context['model'] + user = context['user'] + if user in (model.PSEUDO_USER__VISITOR, ''): + return {'success': False, 'msg': _('Valid API key needed to create a group')} + + return group_create(context, data_dict) + +def activity_create(context, data_dict): + user = context['user'] + return {'success': Authorizer.is_sysadmin(user)} diff --git a/ckan/logic/auth/publisher/delete.py b/ckan/logic/auth/publisher/delete.py new file mode 100644 index 00000000000..57c9ee84260 --- /dev/null +++ b/ckan/logic/auth/publisher/delete.py @@ -0,0 +1,64 @@ +from ckan.logic.auth import get_package_object, get_group_object, \ + get_user_object, get_resource_object +from ckan.logic.auth import get_package_object, get_group_object +from ckan.logic.auth.publisher import _groups_intersect +from ckan.logic.auth.publisher.create import package_relationship_create +from ckan.authz import Authorizer +from ckan.lib.base import _ + +def package_delete(context, data_dict): + """ + Delete a package permission. User must be in at least one group that that + package is also in. + """ + model = context['model'] + user = context['user'] + package = get_package_object(context, data_dict) + userobj = model.User.get( user ) + + if not userobj or \ + not _groups_intersect( userobj.get_groups('publisher'), package.get_groups('publisher') ): + return {'success': False, + 'msg': _('User %s not authorized to delete packages in these group') % str(user)} + return {'success': True} + +def package_relationship_delete(context, data_dict): + return package_relationship_create(context, data_dict) + +def group_delete(context, data_dict): + """ + Group delete permission. Checks that the user specified is within the group to be deleted + and also have 'admin' capacity. + """ + model = context['model'] + user = context['user'] + + if not user: + return {'success': False, 'msg': _('Only members of this group are authorized to delete this group')} + + group = get_group_object(context, data_dict) + userobj = model.User.get( user ) + if not userobj: + return {'success': False, 'msg': _('Only members of this group are authorized to delete this group')} + + authorized = _groups_intersect( userobj.get_groups('publisher', 'admin'), [group] ) + if not authorized: + return {'success': False, 'msg': _('User %s not authorized to delete group %s') % (str(user),group.id)} + else: + return {'success': True} + +def revision_undelete(context, data_dict): + return {'success': False, 'msg': 'Not implemented yet in the auth refactor'} + +def revision_delete(context, data_dict): + return {'success': False, 'msg': 'Not implemented yet in the auth refactor'} + +def task_status_delete(context, data_dict): + model = context['model'] + user = context['user'] + + authorized = Authorizer().is_sysadmin(unicode(user)) + if not authorized: + return {'success': False, 'msg': _('User %s not authorized to delete task_status') % str(user)} + else: + return {'success': True} diff --git a/ckan/logic/auth/publisher/get.py b/ckan/logic/auth/publisher/get.py new file mode 100644 index 00000000000..7766b1ae1fa --- /dev/null +++ b/ckan/logic/auth/publisher/get.py @@ -0,0 +1,165 @@ +from ckan.logic.auth import get_package_object, get_group_object, \ + get_user_object, get_resource_object +from ckan.lib.base import _ +from ckan.logic.auth.publisher import _groups_intersect +from ckan.authz import Authorizer +from ckan.logic.auth import get_package_object, get_group_object, get_resource_object + + +def site_read(context, data_dict): + """\ + This function should be deprecated. It is only here because we couldn't + get hold of Friedrich to ask what it was for. + + ./ckan/controllers/api.py + """ + return {'success': True} + +def package_search(context, data_dict): + # Everyone can search by default + return {'success': True} + +def package_list(context, data_dict): + # List of all active packages are visible by default + return {'success': True} + +def current_package_list_with_resources(context, data_dict): + return package_list(context, data_dict) + +def revision_list(context, data_dict): + # In our new model everyone can read the revison list + return {'success': True} + +def group_revision_list(context, data_dict): + return group_show(context, data_dict) + +def package_revision_list(context, data_dict): + return package_show(context, data_dict) + +def group_list(context, data_dict): + # List of all active groups is visible by default + return {'success': True} + +def group_list_authz(context, data_dict): + return group_list(context, data_dict) + +def group_list_available(context, data_dict): + return group_list(context, data_dict) + +def licence_list(context, data_dict): + # Licences list is visible by default + return {'success': True} + +def tag_list(context, data_dict): + # Tags list is visible by default + return {'success': True} + +def user_list(context, data_dict): + # Users list is visible by default + return {'success': True} + +def package_relationships_list(context, data_dict): + return {'success': True} + +def package_show(context, data_dict): + """ Package show permission checks the user group if the state is deleted """ + model = context['model'] + package = get_package_object(context, data_dict) + + if package.state == 'deleted': + if 'ignore_auth' in context and context['ignore_auth']: + return {'success': True} + + user = context.get('user') + + if not user: + return {'success': False, 'msg': _('User not authorized to read package %s') % (package.id)} + + userobj = model.User.get( user ) + if not userobj: + return {'success': False, 'msg': _('User %s not authorized to read package %s') % (str(user),package.id)} + + if not _groups_intersect( userobj.get_groups('publisher'), package.get_groups('publisher') ): + return {'success': False, 'msg': _('User %s not authorized to read package %s') % (str(user),package.id)} + + return {'success': True} + +def resource_show(context, data_dict): + """ Resource show permission checks the user group if the package state is deleted """ + model = context['model'] + user = context.get('user') + resource = get_resource_object(context, data_dict) + package = resource.revision_group.package + + if package.state == 'deleted': + userobj = model.User.get( user ) + if not userobj: + return {'success': False, 'msg': _('User %s not authorized to read resource %s') % (str(user),package.id)} + if not _groups_intersect( userobj.get_groups('publisher'), package.get_groups('publisher') ): + return {'success': False, 'msg': _('User %s not authorized to read package %s') % (str(user),package.id)} + + pkg_dict = {'id': pkg.id} + return package_show(context, pkg_dict) + + +def revision_show(context, data_dict): + # No authz check in the logic function + return {'success': True} + +def group_show(context, data_dict): + """ Group show permission checks the user group if the state is deleted """ + model = context['model'] + user = context.get('user') + group = get_group_object(context, data_dict) + userobj = model.User.get( user ) + + if group.state == 'deleted': + if not user or \ + not _groups_intersect( userobj.get_groups('publisher'), group.get_groups('publisher') ): + return {'success': False, 'msg': _('User %s not authorized to show group %s') % (str(user),group.id)} + + return {'success': True} + +def tag_show(context, data_dict): + # No authz check in the logic function + return {'success': True} + +def user_show(context, data_dict): + # By default, user details can be read by anyone, but some properties like + # the API key are stripped at the action level if not not logged in. + return {'success': True} + +def package_autocomplete(context, data_dict): + return package_list(context, data_dict) + +def group_autocomplete(context, data_dict): + return group_list(context, data_dict) + +def tag_autocomplete(context, data_dict): + return tag_list(context, data_dict) + +def user_autocomplete(context, data_dict): + return user_list(context, data_dict) + +def format_autocomplete(context, data_dict): + return {'success': True} + +def task_status_show(context, data_dict): + return {'success': True} + +## Modifications for rest api + +def package_show_rest(context, data_dict): + return package_show(context, data_dict) + +def group_show_rest(context, data_dict): + return group_show(context, data_dict) + +def tag_show_rest(context, data_dict): + return tag_show(context, data_dict) + +def get_site_user(context, data_dict): + if not context.get('ignore_auth'): + return {'success': False, 'msg': 'Only internal services allowed to use this action'} + else: + return {'success': True} diff --git a/ckan/logic/auth/publisher/update.py b/ckan/logic/auth/publisher/update.py new file mode 100644 index 00000000000..cc8ca288f9b --- /dev/null +++ b/ckan/logic/auth/publisher/update.py @@ -0,0 +1,144 @@ +from ckan.logic.auth import get_package_object, get_group_object, \ + get_user_object, get_resource_object +from ckan.logic.auth.publisher import _groups_intersect +from ckan.logic.auth.publisher.create import package_relationship_create +from ckan.authz import Authorizer +from ckan.lib.base import _ + +def make_latest_pending_package_active(context, data_dict): + return package_update(context, data_dict) + +def package_update(context, data_dict): + model = context['model'] + user = context.get('user') + package = get_package_object(context, data_dict) + + if Authorizer().is_sysadmin(unicode(user)): + return { 'success': True } + + userobj = model.User.get( user ) + if not userobj or \ + not _groups_intersect( userobj.get_groups('publisher'), package.get_groups('publisher') ): + return {'success': False, + 'msg': _('User %s not authorized to edit packages in these groups') % str(user)} + + return {'success': True} + +def resource_update(context, data_dict): + """ + Update resource permission checks the user is in a group that the resource's + package is also a member of. + """ + model = context['model'] + user = context.get('user') + resource = get_resource_object(context, data_dict) + userobj = model.User.get( user ) + + if not userobj: + return {'success': False, 'msg': _('User %s not authorized to edit resources in this package') % str(user)} + + if not _groups_intersect( userobj.get_groups('publisher'), resource.resource_group.package.get_groups('publisher') ): + return {'success': False, 'msg': _('User %s not authorized to edit resources in this package') % str(user)} + + return {'success': True} + +def package_relationship_update(context, data_dict): + return package_relationship_create(context, data_dict) + +def package_change_state(context, data_dict): + return package_update( context, data_dict ) + +def package_edit_permissions(context, data_dict): + return {'success': False, + 'msg': _('Package edit permissions is not available')} + +def group_update(context, data_dict): + """ + Group edit permission. Checks that a valid user is supplied and that the user is + a member of the group currently with any capacity. + """ + model = context['model'] + user = context.get('user','') + group = get_group_object(context, data_dict) + + if not user: + return {'success': False, 'msg': _('Only members of this group are authorized to edit this group')} + + # Sys admins should be allowed to update groups + if Authorizer().is_sysadmin(unicode(user)): + return { 'success': True } + + # Only allow package update if the user and package groups intersect + userobj = model.User.get( user ) + if not userobj: + return { 'success' : False, 'msg': _('Could not find user %s') % str(user) } + + # Only admins of this group should be able to update this group + if not _groups_intersect( userobj.get_groups( 'publisher', 'admin' ), [group] ): + return { 'success': False, 'msg': _('User %s not authorized to edit this group') % str(user) } + + return { 'success': True } + +def group_change_state(context, data_dict): + return group_update(context, data_dict) + +def group_edit_permissions(context, data_dict): + return {'success': False, 'msg': _('Group edit permissions is not implemented')} + +def authorization_group_update(context, data_dict): + return {'success': False, 'msg': _('Authorization group update not implemented')} + + +def authorization_group_edit_permissions(context, data_dict): + return {'success': False, 'msg': _('Authorization group update not implemented')} + +def user_update(context, data_dict): + model = context['model'] + user = context['user'] + user_obj = get_user_object(context, data_dict) + + if not (Authorizer().is_sysadmin(unicode(user)) or user == user_obj.name) and \ + not ('reset_key' in data_dict and data_dict['reset_key'] == user_obj.reset_key): + return {'success': False, 'msg': _('User %s not authorized to edit user %s') % (str(user), user_obj.id)} + + return {'success': True} + +def revision_change_state(context, data_dict): + model = context['model'] + user = context['user'] + + authorized = Authorizer().is_sysadmin(unicode(user)) + if not authorized: + return {'success': False, 'msg': _('User %s not authorized to change state of revision' ) % str(user)} + else: + return {'success': True} + +def task_status_update(context, data_dict): + model = context['model'] + user = context['user'] + + authorized = Authorizer().is_sysadmin(unicode(user)) + if not authorized: + return {'success': False, 'msg': _('User %s not authorized to update task_status table') % str(user)} + else: + return {'success': True} + +## Modifications for rest api + +def package_update_rest(context, data_dict): + model = context['model'] + user = context['user'] + + if user in (model.PSEUDO_USER__VISITOR, ''): + return {'success': False, 'msg': _('Valid API key needed to edit a package')} + + return package_update(context, data_dict) + +def group_update_rest(context, data_dict): + model = context['model'] + user = context['user'] + if user in (model.PSEUDO_USER__VISITOR, ''): + return {'success': False, 'msg': _('Valid API key needed to edit a group')} + + return group_update(context, data_dict) + diff --git a/ckan/logic/auth/update.py b/ckan/logic/auth/update.py index b2b15fc6831..7b84b430f2e 100644 --- a/ckan/logic/auth/update.py +++ b/ckan/logic/auth/update.py @@ -1,7 +1,7 @@ from ckan.logic import check_access_old, NotFound from ckan.logic.auth import get_package_object, get_resource_object, get_group_object, get_authorization_group_object, \ get_user_object, get_resource_object -from ckan.logic.auth.create import check_group_auth, package_relationship_create +from ckan.logic.auth.create import _check_group_auth, package_relationship_create from ckan.authz import Authorizer from ckan.lib.base import _ @@ -17,7 +17,7 @@ def package_update(context, data_dict): if not check1: return {'success': False, 'msg': _('User %s not authorized to edit package %s') % (str(user), package.id)} else: - check2 = check_group_auth(context,data_dict) + check2 = _check_group_auth(context,data_dict) if not check2: return {'success': False, 'msg': _('User %s not authorized to edit these groups') % str(user)} @@ -74,7 +74,7 @@ def group_update(context, data_dict): model = context['model'] user = context['user'] group = get_group_object(context, data_dict) - + authorized = check_access_old(group, model.Action.EDIT, context) if not authorized: return {'success': False, 'msg': _('User %s not authorized to edit group %s') % (str(user),group.id)} diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index 5e054c9d59c..aa2610309bb 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -162,9 +162,10 @@ def default_group_schema(): 'name': [not_empty, unicode, name_validator, group_name_validator], 'title': [ignore_missing, unicode], 'description': [ignore_missing, unicode], - 'type': [ignore_missing, unicode], + 'type': [ignore_missing, unicode], 'state': [ignore_not_group_admin, ignore_missing], 'created': [ignore], + 'approval_status': [ignore_missing, unicode], 'extras': default_extras_schema(), '__extras': [ignore], 'packages': { @@ -181,6 +182,11 @@ def group_form_schema(): "name": [not_empty, unicode], "__extras": [ignore] } + schema['users'] = { + "name": [not_empty, unicode], + "capacity": [ignore_missing], + "__extras": [ignore] + } return schema diff --git a/ckan/migration/versions/0049_add_group_approval_status.py b/ckan/migration/versions/0049_add_group_approval_status.py new file mode 100644 index 00000000000..bbc6d1d09ff --- /dev/null +++ b/ckan/migration/versions/0049_add_group_approval_status.py @@ -0,0 +1,18 @@ +from migrate import * + +def upgrade(migrate_engine): + migrate_engine.execute(''' +BEGIN; +ALTER TABLE "group" + ADD COLUMN approval_status text; + +ALTER TABLE group_revision + ADD COLUMN approval_status text; + + +update "group" set approval_status = 'approved'; +update group_revision set approval_status = 'approved'; + +COMMIT; + ''' + ) diff --git a/ckan/model/group.py b/ckan/model/group.py index 2fc52d3ac9d..def774fe164 100644 --- a/ckan/model/group.py +++ b/ckan/model/group.py @@ -7,7 +7,7 @@ from package import * from types import make_uuid import vdm.sqlalchemy -from ckan.model import extension +from ckan.model import extension, User from sqlalchemy.ext.associationproxy import association_proxy __all__ = ['group_table', 'Group', 'package_revision_table', @@ -21,7 +21,7 @@ Column('capacity', UnicodeText, nullable=False), Column('group_id', UnicodeText, ForeignKey('group.id')), ) - + vdm.sqlalchemy.make_table_stateful(member_table) member_revision_table = make_revisioned_table(member_table) @@ -32,6 +32,7 @@ Column('type', UnicodeText, nullable=False), Column('description', UnicodeText), Column('created', DateTime, default=datetime.datetime.now), + Column('approval_status', UnicodeText, default=u"approved"), ) vdm.sqlalchemy.make_table_stateful(group_table) @@ -49,7 +50,26 @@ def __init__(self, group=None, table_id=None, group_id=None, self.table_name = table_name self.capacity = capacity self.state = state - + + @classmethod + def get(cls, reference): + '''Returns a group object referenced by its id or name.''' + query = Session.query(cls).filter(cls.id==reference) + member = query.first() + if member == None: + member = cls.by_name(reference) + return member + + + def get_related(self, type): + """ TODO: Determine if this is useful + Get all objects that are members of the group of the specified type. + + Should the type be used to get table_name or should we use the one in + the constructor + """ + pass + def related_packages(self): # TODO do we want to return all related packages or certain ones? return Session.query(Package).filter_by(id=self.table_id).all() @@ -57,11 +77,14 @@ def related_packages(self): class Group(vdm.sqlalchemy.RevisionedObjectMixin, vdm.sqlalchemy.StatefulObjectMixin, DomainObject): - def __init__(self, name=u'', title=u'', description=u'', type=u'group'): + + def __init__(self, name=u'', title=u'', description=u'', + type=u'group', approval_status=u'approved' ): self.name = name self.title = title self.description = description self.type = type + self.approval_status= approval_status @property def display_name(self): @@ -80,6 +103,40 @@ def get(cls, reference): return group # Todo: Make sure group names can't be changed to look like group IDs? + def set_approval_status(self, status): + """ + Aproval status can be set on a group, where currently it does + nothing other than act as an indication of whether it was + approved or not. It may be that we want to tie the object + status to the approval status + """ + assert status in ["approved", "pending", "denied"] + self.approval_status = status + if status == "denied": + pass + + def members_of_type(self, object_type, capacity=None): + object_type_string = object_type.__name__.lower() + query = Session.query(object_type).\ + filter(group_table.c.id == self.id).\ + filter(member_table.c.state == 'active').\ + filter(member_table.c.table_name == object_type_string) + + if capacity: + query = query.filter(member_table.c.capacity == capacity) + + query = query.join(member_table, member_table.c.table_id == getattr(object_type,'id') ).\ + join(group_table, group_table.c.id == member_table.c.group_id) + + return query + + def add_child(self, object_instance): + object_type_string = object_instance.__class__.__name__.lower() + if not object_instance in self.members_of_type(object_instance.__class__).all(): + member = Member(group=self, table_id=getattr(object_instance,'id'), table_name=object_type_string) + Session.add(member) + + def active_packages(self, load_eager=True): query = Session.query(Package).\ filter_by(state=vdm.sqlalchemy.State.ACTIVE).\ @@ -98,6 +155,8 @@ def as_dict(self, ref_package_by='name'): _dict = DomainObject.as_dict(self) _dict['packages'] = [getattr(package, ref_package_by) for package in self.packages] _dict['extras'] = dict([(key, value) for key, value in self.extras.items()]) + if ( self.type == 'publisher' ): + _dict['users'] = [getattr(user, "name") for user in self.members_of_type(User)] return _dict def add_package_by_name(self, package_name): @@ -105,10 +164,28 @@ def add_package_by_name(self, package_name): return package = Package.by_name(package_name) assert package - if not package in self.active_packages().all(): + if not package in self.members_of_type( package.__class__ ).all(): member = Member(group=self, table_id=package.id, table_name='package') 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 = model.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): @@ -137,7 +214,7 @@ def __repr__(self): return '' % self.name -mapper(Group, group_table, +mapper(Group, group_table, extension=[vdm.sqlalchemy.Revisioner(group_revision_table),], ) @@ -161,4 +238,3 @@ def __repr__(self): #TODO MemberRevision.related_packages = lambda self: [self.continuity.package] - diff --git a/ckan/model/package.py b/ckan/model/package.py index b2e2f97b496..7cff7b5e258 100644 --- a/ckan/model/package.py +++ b/ckan/model/package.py @@ -505,15 +505,25 @@ def metadata_modified(self): timestamp_float = timegm(timestamp_without_usecs) + usecs return datetime.datetime.utcfromtimestamp(timestamp_float) - def get_groups(self): + def is_in_group(self, group): + return group in self.get_groups() + + def get_groups(self, group_type=None, capacity=None): import ckan.model as model if '_groups' not in self.__dict__: self._groups = model.Session.query(model.Group).\ - join(model.Member, model.Member.group_id == model.Group.id).\ + join(model.Member, model.Member.group_id == model.Group.id and \ + model.Member.table_name == 'package' ).\ join(model.Package, model.Package.id == model.Member.table_id).\ filter(model.Member.state == 'active').\ - filter(model.Package.id == self.id).all() - return self._groups + 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 metadata_created(self): diff --git a/ckan/model/user.py b/ckan/model/user.py index bf3e33e74b8..ed07c0c5e37 100644 --- a/ckan/model/user.py +++ b/ckan/model/user.py @@ -145,6 +145,31 @@ def number_administered_packages(self): q = q.filter_by(user=self, role=model.Role.ADMIN) return q.count() + def is_in_group(self, group): + return group in self.get_groups() + + def get_groups(self, group_type=None, capacity=None): + import ckan.model as model + + q = model.Session.query(model.Group)\ + .join(model.Member, model.Member.group_id == model.Group.id and \ + model.Member.table_name == 'user' ).\ + join(model.User, model.User.id == model.Member.table_id).\ + filter(model.Member.state == 'active').\ + filter(model.Member.table_id == self.id) + if capacity: + q = q.filter( model.Member.capacity == capacity ) + return q.all() + + if '_groups' not in self.__dict__: + self._groups = q.all() + + groups = self._groups + if group_type: + groups = [g for g in groups if g.type == group_type] + return groups + + @classmethod def search(cls, querystr, sqlalchemy_query=None): '''Search name, fullname, email and openid. diff --git a/ckan/new_authz.py b/ckan/new_authz.py index 46c6b8b4c36..8934e7bffe5 100644 --- a/ckan/new_authz.py +++ b/ckan/new_authz.py @@ -8,7 +8,12 @@ # This is a private cache used by get_auth_function() and should never # be accessed directly -_auth_functions = {} +class AuthFunctions: + _functions = {} + +def reset_auth_functions(type=''): + AuthFunctions._functions.clear() + _get_auth_function('resource_create', type) def is_authorized(action, context,data_dict=None): auth_function = _get_auth_function(action) @@ -17,16 +22,32 @@ def is_authorized(action, context,data_dict=None): else: raise ValueError(_('Authorization function not found: %s' % action)) -def _get_auth_function(action): - if _auth_functions: - return _auth_functions.get(action) +def _get_auth_function(action, profile=None): + from pylons import config + + if AuthFunctions._functions: + return AuthFunctions._functions.get(action) + # Otherwise look in all the plugins to resolve all possible # First get the default ones in the ckan/logic/auth directory # Rather than writing them out in full will use __import__ # to load anything from ckan.auth that looks like it might # be an authorisation function + + # We will load the auth profile from settings + module_root = 'ckan.logic.auth' + if profile is not None: + auth_profile = profile + else: + auth_profile = config.get('ckan.auth.profile', '') + + if auth_profile: + module_root = '%s.%s' % (module_root, auth_profile) + + log.info('Using auth profile at %s' % module_root) + for auth_module_name in ['get', 'create', 'update','delete']: - module_path = 'ckan.logic.auth.'+auth_module_name + module_path = '%s.%s' % (module_root, auth_module_name,) try: module = __import__(module_path) except ImportError,e: @@ -35,9 +56,10 @@ def _get_auth_function(action): for part in module_path.split('.')[1:]: module = getattr(module, part) - for k, v in module.__dict__.items(): - if not k.startswith('_'): - _auth_functions[k] = v + + for key, v in module.__dict__.items(): + if not key.startswith('_'): + AuthFunctions._functions[key] = v # Then overwrite them with any specific ones in the plugins: resolved_auth_function_plugins = {} @@ -55,6 +77,6 @@ def _get_auth_function(action): resolved_auth_function_plugins[name] = plugin.name fetched_auth_functions[name] = auth_function # Use the updated ones in preference to the originals. - _auth_functions.update(fetched_auth_functions) - return _auth_functions.get(action) + AuthFunctions._functions.update(fetched_auth_functions) + return AuthFunctions._functions.get(action) diff --git a/ckan/public/css/chosen.css b/ckan/public/css/chosen.css new file mode 100644 index 00000000000..1405ebbfc5a --- /dev/null +++ b/ckan/public/css/chosen.css @@ -0,0 +1,390 @@ +/* @group Base */ +.chzn-container { + font-size: 13px; + position: relative; + display: inline-block; + zoom: 1; + *display: inline; +} +.chzn-container .chzn-drop { + background: #fff; + border: 1px solid #aaa; + border-top: 0; + position: absolute; + top: 29px; + left: 0; + -webkit-box-shadow: 0 4px 5px rgba(0,0,0,.15); + -moz-box-shadow : 0 4px 5px rgba(0,0,0,.15); + -o-box-shadow : 0 4px 5px rgba(0,0,0,.15); + box-shadow : 0 4px 5px rgba(0,0,0,.15); + z-index: 999; +} +/* @end */ + +/* @group Single Chosen */ +.chzn-container-single .chzn-single { + background-color: #ffffff; + background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #f4f4f4), color-stop(0.48, #eeeeee), color-stop(0.5, #f6f6f6), color-stop(0.8, #ffffff)); + background-image: -webkit-linear-gradient(center bottom, #f4f4f4 0%, #eeeeee 48%, #f6f6f6 50%, #ffffff 80%); + background-image: -moz-linear-gradient(center bottom, #f4f4f4 0%, #eeeeee 48%, #f6f6f6 50%, #ffffff 80%); + background-image: -o-linear-gradient(top, #f4f4f4 0%, #eeeeee 48%, #f6f6f6 50%, #ffffff 80%); + background-image: -ms-linear-gradient(top, #f4f4f4 0%, #eeeeee 48%, #f6f6f6 50%, #ffffff 80%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#eeeeee', endColorstr='#ffffff',GradientType=0 ); + background-image: linear-gradient(top, #f4f4f4 0%, #eeeeee 48%, #f6f6f6 50%, #ffffff 80%); + -webkit-border-radius: 5px; + -moz-border-radius : 5px; + border-radius : 5px; + -moz-background-clip : padding; + -webkit-background-clip: padding-box; + background-clip : padding-box; + border: 1px solid #aaaaaa; + -webkit-box-shadow: 0 0 3px #ffffff inset, 0 1px 1px rgba(0,0,0,0.1); + -moz-box-shadow : 0 0 3px #ffffff inset, 0 1px 1px rgba(0,0,0,0.1); + box-shadow : 0 0 3px #ffffff inset, 0 1px 1px rgba(0,0,0,0.1); + display: block; + overflow: hidden; + white-space: nowrap; + position: relative; + height: 23px; + line-height: 24px; + padding: 0 0 0 8px; + color: #444444; + text-decoration: none; +} +.chzn-container-single .chzn-single span { + margin-right: 26px; + display: block; + overflow: hidden; + white-space: nowrap; + -o-text-overflow: ellipsis; + -ms-text-overflow: ellipsis; + text-overflow: ellipsis; +} +.chzn-container-single .chzn-single abbr { + display: block; + position: absolute; + right: 26px; + top: 6px; + width: 12px; + height: 13px; + font-size: 1px; + background: url('../images/chosen-sprite.png') right top no-repeat; +} +.chzn-container-single .chzn-single abbr:hover { + background-position: right -11px; +} +.chzn-container-single .chzn-single div { + position: absolute; + right: 0; + top: 0; + display: block; + height: 100%; + width: 18px; +} +.chzn-container-single .chzn-single div b { + background: url('../images/chosen-sprite.png') no-repeat 0 0; + display: block; + width: 100%; + height: 100%; +} +.chzn-container-single .chzn-search { + padding: 3px 4px; + position: relative; + margin: 0; + white-space: nowrap; + z-index: 1010; +} +.chzn-container-single .chzn-search input { + background: #fff url('../images/chosen-sprite.png') no-repeat 100% -22px; + background: url('../images/chosen-sprite.png') no-repeat 100% -22px, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee)); + background: url('../images/chosen-sprite.png') no-repeat 100% -22px, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%); + background: url('../images/chosen-sprite.png') no-repeat 100% -22px, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%); + background: url('../images/chosen-sprite.png') no-repeat 100% -22px, -o-linear-gradient(bottom, white 85%, #eeeeee 99%); + background: url('../images/chosen-sprite.png') no-repeat 100% -22px, -ms-linear-gradient(top, #ffffff 85%,#eeeeee 99%); + background: url('../images/chosen-sprite.png') no-repeat 100% -22px, linear-gradient(top, #ffffff 85%,#eeeeee 99%); + margin: 1px 0; + padding: 4px 20px 4px 5px; + outline: 0; + border: 1px solid #aaa; + font-family: sans-serif; + font-size: 1em; +} +.chzn-container-single .chzn-drop { + -webkit-border-radius: 0 0 4px 4px; + -moz-border-radius : 0 0 4px 4px; + border-radius : 0 0 4px 4px; + -moz-background-clip : padding; + -webkit-background-clip: padding-box; + background-clip : padding-box; +} +/* @end */ + +.chzn-container-single-nosearch .chzn-search input { + position: absolute; + left: -9000px; +} + +/* @group Multi Chosen */ +.chzn-container-multi .chzn-choices { + background-color: #fff; + background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee)); + background-image: -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%); + background-image: -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%); + background-image: -o-linear-gradient(bottom, white 85%, #eeeeee 99%); + background-image: -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffff', endColorstr='#eeeeee',GradientType=0 ); + background-image: linear-gradient(top, #ffffff 85%, #eeeeee 99%); + border: 1px solid #aaa; + margin: 0; + padding: 0; + cursor: text; + overflow: hidden; + height: auto !important; + height: 1%; + position: relative; +} +.chzn-container-multi .chzn-choices li { + float: left; + list-style: none; +} +.chzn-container-multi .chzn-choices .search-field { + white-space: nowrap; + margin: 0; + padding: 0; +} +.chzn-container-multi .chzn-choices .search-field input { + color: #666; + background: transparent !important; + border: 0 !important; + font-family: sans-serif; + font-size: 100%; + height: 15px; + padding: 5px; + margin: 1px 0; + outline: 0; + -webkit-box-shadow: none; + -moz-box-shadow : none; + -o-box-shadow : none; + box-shadow : none; +} +.chzn-container-multi .chzn-choices .search-field .default { + color: #999; +} +.chzn-container-multi .chzn-choices .search-choice { + -webkit-border-radius: 3px; + -moz-border-radius : 3px; + border-radius : 3px; + -moz-background-clip : padding; + -webkit-background-clip: padding-box; + background-clip : padding-box; + background-color: #e4e4e4; + background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eeeeee), color-stop(0.48, #e8e8e8), color-stop(0.5, #f0f0f0), color-stop(0.8, #f4f4f4)); + background-image: -webkit-linear-gradient(center bottom, #eeeeee 0%, #e8e8e8 48%, #f0f0f0 50%, #f4f4f4 80%); + background-image: -moz-linear-gradient(center bottom, #eeeeee 0%, #e8e8e8 48%, #f0f0f0 50%, #f4f4f4 80%); + background-image: -o-linear-gradient(top, #eeeeee 0%, #e8e8e8 48%, #f0f0f0 50%, #f4f4f4 80%); + background-image: -ms-linear-gradient(top, #eeeeee 0%, #e8e8e8 48%, #f0f0f0 50%, #f4f4f4 80%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#eeeeee', endColorstr='#f4f4f4',GradientType=0 ); + background-image: linear-gradient(top, #eeeeee 0%, #e8e8e8 48%, #f0f0f0 50%, #f4f4f4 80%); + -webkit-box-shadow: 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); + -moz-box-shadow : 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); + box-shadow : 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); + color: #333; + border: 1px solid #aaaaaa; + line-height: 13px; + padding: 3px 20px 3px 5px; + margin: 3px 0 3px 5px; + position: relative; +} +.chzn-container-multi .chzn-choices .search-choice span { + cursor: default; +} +.chzn-container-multi .chzn-choices .search-choice-focus { + background: #d4d4d4; +} +.chzn-container-multi .chzn-choices .search-choice .search-choice-close { + display: block; + position: absolute; + right: 3px; + top: 4px; + width: 12px; + height: 13px; + font-size: 1px; + background: url('../images/chosen-sprite.png') right top no-repeat; +} +.chzn-container-multi .chzn-choices .search-choice .search-choice-close:hover { + background-position: right -11px; +} +.chzn-container-multi .chzn-choices .search-choice-focus .search-choice-close { + background-position: right -11px; +} +/* @end */ + +/* @group Results */ +.chzn-container .chzn-results { + margin: 0 4px 4px 0; + max-height: 240px; + padding: 0 0 0 4px; + position: relative; + overflow-x: hidden; + overflow-y: auto; +} +.chzn-container-multi .chzn-results { + margin: -1px 0 0; + padding: 0; +} +.chzn-container .chzn-results li { + display: none; + line-height: 15px; + padding: 5px 6px; + margin: 0; + list-style: none; +} +.chzn-container .chzn-results .active-result { + cursor: pointer; + display: list-item; +} +.chzn-container .chzn-results .highlighted { + background-color: #3875d7; + background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.1, #2a62bc), color-stop(0.8, #3875d7)); + background-image: -webkit-linear-gradient(center bottom, #2a62bc 10%, #3875d7 80%); + background-image: -moz-linear-gradient(center bottom, #2a62bc 10%, #3875d7 80%); + background-image: -o-linear-gradient(bottom, #2a62bc 10%, #3875d7 80%); + background-image: -ms-linear-gradient(top, #2a62bc 10%, #3875d7 80%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#2a62bc', endColorstr='#3875d7',GradientType=0 ); + background-image: linear-gradient(top, #2a62bc 10%, #3875d7 80%); + color: #fff; +} +.chzn-container .chzn-results li em { + background: #feffde; + font-style: normal; +} +.chzn-container .chzn-results .highlighted em { + background: transparent; +} +.chzn-container .chzn-results .no-results { + background: #f4f4f4; + display: list-item; +} +.chzn-container .chzn-results .group-result { + cursor: default; + color: #999; + font-weight: bold; +} +.chzn-container .chzn-results .group-option { + padding-left: 15px; +} +.chzn-container-multi .chzn-drop .result-selected { + display: none; +} +.chzn-container .chzn-results-scroll { + background: white; + margin: 0px 4px; + position: absolute; + text-align: center; + width: 321px; /* This should by dynamic with js */ + z-index: 1; +} +.chzn-container .chzn-results-scroll span { + display: inline-block; + height: 17px; + text-indent: -5000px; + width: 9px; +} +.chzn-container .chzn-results-scroll-down { + bottom: 0; +} +.chzn-container .chzn-results-scroll-down span { + background: url('../images/chosen-sprite.png') no-repeat -4px -3px; +} +.chzn-container .chzn-results-scroll-up span { + background: url('../images/chosen-sprite.png') no-repeat -22px -3px; +} +/* @end */ + +/* @group Active */ +.chzn-container-active .chzn-single { + -webkit-box-shadow: 0 0 5px rgba(0,0,0,.3); + -moz-box-shadow : 0 0 5px rgba(0,0,0,.3); + -o-box-shadow : 0 0 5px rgba(0,0,0,.3); + box-shadow : 0 0 5px rgba(0,0,0,.3); + border: 1px solid #5897fb; +} +.chzn-container-active .chzn-single-with-drop { + border: 1px solid #aaa; + -webkit-box-shadow: 0 1px 0 #fff inset; + -moz-box-shadow : 0 1px 0 #fff inset; + -o-box-shadow : 0 1px 0 #fff inset; + box-shadow : 0 1px 0 #fff inset; + background-color: #eee; + background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, white), color-stop(0.8, #eeeeee)); + background-image: -webkit-linear-gradient(center bottom, white 20%, #eeeeee 80%); + background-image: -moz-linear-gradient(center bottom, white 20%, #eeeeee 80%); + background-image: -o-linear-gradient(bottom, white 20%, #eeeeee 80%); + background-image: -ms-linear-gradient(top, #ffffff 20%,#eeeeee 80%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffff', endColorstr='#eeeeee',GradientType=0 ); + background-image: linear-gradient(top, #ffffff 20%,#eeeeee 80%); + -webkit-border-bottom-left-radius : 0; + -webkit-border-bottom-right-radius: 0; + -moz-border-radius-bottomleft : 0; + -moz-border-radius-bottomright: 0; + border-bottom-left-radius : 0; + border-bottom-right-radius: 0; +} +.chzn-container-active .chzn-single-with-drop div { + background: transparent; + border-left: none; +} +.chzn-container-active .chzn-single-with-drop div b { + background-position: -18px 1px; +} +.chzn-container-active .chzn-choices { + -webkit-box-shadow: 0 0 5px rgba(0,0,0,.3); + -moz-box-shadow : 0 0 5px rgba(0,0,0,.3); + -o-box-shadow : 0 0 5px rgba(0,0,0,.3); + box-shadow : 0 0 5px rgba(0,0,0,.3); + border: 1px solid #5897fb; +} +.chzn-container-active .chzn-choices .search-field input { + color: #111 !important; +} +/* @end */ + +/* @group Disabled Support */ +.chzn-disabled { + cursor: default; + opacity:0.5 !important; +} +.chzn-disabled .chzn-single { + cursor: default; +} +.chzn-disabled .chzn-choices .search-choice .search-choice-close { + cursor: default; +} + +/* @group Right to Left */ +.chzn-rtl { direction:rtl;text-align: right; } +.chzn-rtl .chzn-single { padding-left: 0; padding-right: 8px; } +.chzn-rtl .chzn-single span { margin-left: 26px; margin-right: 0; } + +.chzn-rtl .chzn-single div { left: 3px; right: auto; } +.chzn-rtl .chzn-single abbr { + left: 26px; + right: auto; +} +.chzn-rtl .chzn-choices li { float: right; } +.chzn-rtl .chzn-choices .search-choice { padding: 3px 5px 3px 19px; margin: 3px 5px 3px 0; } +.chzn-rtl .chzn-choices .search-choice .search-choice-close { left: 4px; right: auto; background-position: right top;} +.chzn-rtl.chzn-container-single .chzn-results { margin-left: 4px; margin-right: 0; padding-left: 0; padding-right: 4px; } +.chzn-rtl .chzn-results .group-option { padding-left: 0; padding-right: 20px; } +.chzn-rtl.chzn-container-active .chzn-single-with-drop div { border-right: none; } +.chzn-rtl .chzn-search input { + background: url('../images/chosen-sprite.png') no-repeat -38px -22px, #ffffff; + background: url('../images/chosen-sprite.png') no-repeat -38px -22px, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee)); + background: url('../images/chosen-sprite.png') no-repeat -38px -22px, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%); + background: url('../images/chosen-sprite.png') no-repeat -38px -22px, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%); + background: url('../images/chosen-sprite.png') no-repeat -38px -22px, -o-linear-gradient(bottom, white 85%, #eeeeee 99%); + background: url('../images/chosen-sprite.png') no-repeat -38px -22px, -ms-linear-gradient(top, #ffffff 85%,#eeeeee 99%); + background: url('../images/chosen-sprite.png') no-repeat -38px -22px, linear-gradient(top, #ffffff 85%,#eeeeee 99%); + padding: 4px 5px 4px 20px; +} +/* @end */ \ No newline at end of file diff --git a/ckan/public/css/style.css b/ckan/public/css/style.css index f5d935ed300..5fb2a633c74 100644 --- a/ckan/public/css/style.css +++ b/ckan/public/css/style.css @@ -429,7 +429,6 @@ img.gravatar { border: 1px solid #ddd; } - /* ====== */ /* Facets */ /* ====== */ @@ -1350,7 +1349,8 @@ body.package.resource_read #resource-explore { margin-bottom: 2em; } /* = Add Group Page = */ /* ================== */ .group-create-form fieldset#extras, -.group-create-form fieldset#datasets { +.group-create-form fieldset#datasets, +.group-create-form fieldset#users { display: none; } .group-create-form .description-label, diff --git a/ckan/public/images/chosen-sprite.png b/ckan/public/images/chosen-sprite.png new file mode 100644 index 00000000000..231fe905534 Binary files /dev/null and b/ckan/public/images/chosen-sprite.png differ diff --git a/ckan/public/scripts/application.js b/ckan/public/scripts/application.js index 6bb6fc1d826..61c03ec5f67 100644 --- a/ckan/public/scripts/application.js +++ b/ckan/public/scripts/application.js @@ -1,6 +1,7 @@ (function ($) { $(document).ready(function () { CKAN.Utils.setupUserAutocomplete($('input.autocomplete-user')); + CKAN.Utils.setupPublisherUserAutocomplete($('input.autocomplete-publisher-user')); CKAN.Utils.setupAuthzGroupAutocomplete($('input.autocomplete-authzgroup')); CKAN.Utils.setupPackageAutocomplete($('input.autocomplete-dataset')); CKAN.Utils.setupTagAutocomplete($('input.autocomplete-tag')); @@ -364,6 +365,48 @@ CKAN.Utils = function($, my) { }); }; + my.setupPublisherUserAutocomplete = function(elements) { + elements.autocomplete({ + minLength: 2, + source: function(request, callback) { + var url = '/api/2/util/user/autocomplete?q=' + request.term; + $.getJSON(url, function(data) { + $.each(data, function(idx, userobj) { + var label = userobj.name; + if (userobj.fullname) { + label += ' [' + userobj.fullname + ']'; + } + userobj.label = label; + userobj.value = userobj.name; + }); + callback(data); + }); + }, + select: function(event, ui) { + var input_box = $(this); + input_box.val(''); + var parent_dd = input_box.parent('dd'); + var old_name = input_box.attr('name'); + var field_name_regex = /^(\S+)__(\d+)__(\S+)$/; + var split = old_name.match(field_name_regex); + + var new_name = split[1] + '__' + (parseInt(split[2]) + 1) + '__' + split[3] + input_box.attr('name', new_name) + input_box.attr('id', new_name) + + var capacity = $("input:radio[name=add-user-capacity]:checked").val(); + parent_dd.before( + '' + + '' + + '
' + ui.item.label + '
' + ); + + return false; // to cancel the event ;) + } + }); + }; + + // Attach user autocompletion to provided elements // // Requires: jquery-ui autocomplete diff --git a/ckan/public/scripts/vendor/jquery.chosen/0.9.7/chosen.js b/ckan/public/scripts/vendor/jquery.chosen/0.9.7/chosen.js new file mode 100644 index 00000000000..ef07b3edc58 --- /dev/null +++ b/ckan/public/scripts/vendor/jquery.chosen/0.9.7/chosen.js @@ -0,0 +1,902 @@ +// Chosen, a Select Box Enhancer for jQuery and Protoype +// by Patrick Filler for Harvest, http://getharvest.com +// +// Version 0.9.6 +// Full source at https://github.com/harvesthq/chosen +// Copyright (c) 2011 Harvest http://getharvest.com + +// MIT License, https://github.com/harvesthq/chosen/blob/master/LICENSE.md +// This file is generated by `cake build`, do not edit it by hand. +(function() { + var SelectParser; + SelectParser = (function() { + function SelectParser() { + this.options_index = 0; + this.parsed = []; + } + SelectParser.prototype.add_node = function(child) { + if (child.nodeName === "OPTGROUP") { + return this.add_group(child); + } else { + return this.add_option(child); + } + }; + SelectParser.prototype.add_group = function(group) { + var group_position, option, _i, _len, _ref, _results; + group_position = this.parsed.length; + this.parsed.push({ + array_index: group_position, + group: true, + label: group.label, + children: 0, + disabled: group.disabled + }); + _ref = group.childNodes; + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + option = _ref[_i]; + _results.push(this.add_option(option, group_position, group.disabled)); + } + return _results; + }; + SelectParser.prototype.add_option = function(option, group_position, group_disabled) { + if (option.nodeName === "OPTION") { + if (option.text !== "") { + if (group_position != null) { + this.parsed[group_position].children += 1; + } + this.parsed.push({ + array_index: this.parsed.length, + options_index: this.options_index, + value: option.value, + text: option.text, + html: option.innerHTML, + selected: option.selected, + disabled: group_disabled === true ? group_disabled : option.disabled, + group_array_index: group_position, + classes: option.className, + style: option.style.cssText + }); + } else { + this.parsed.push({ + array_index: this.parsed.length, + options_index: this.options_index, + empty: true + }); + } + return this.options_index += 1; + } + }; + return SelectParser; + })(); + SelectParser.select_to_array = function(select) { + var child, parser, _i, _len, _ref; + parser = new SelectParser(); + _ref = select.childNodes; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + child = _ref[_i]; + parser.add_node(child); + } + return parser.parsed; + }; + this.SelectParser = SelectParser; +}).call(this); +(function() { + /* + Chosen source: generate output using 'cake build' + Copyright (c) 2011 by Harvest + */ + var AbstractChosen, root; + var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + root = this; + AbstractChosen = (function() { + function AbstractChosen(form_field, options) { + this.form_field = form_field; + this.options = options != null ? options : {}; + this.set_default_values(); + this.is_multiple = this.form_field.multiple; + this.default_text_default = this.is_multiple ? "Select Some Options" : "Select an Option"; + this.setup(); + this.set_up_html(); + this.register_observers(); + this.finish_setup(); + } + AbstractChosen.prototype.set_default_values = function() { + this.click_test_action = __bind(function(evt) { + return this.test_active_click(evt); + }, this); + this.activate_action = __bind(function(evt) { + return this.activate_field(evt); + }, this); + this.active_field = false; + this.mouse_on_container = false; + this.results_showing = false; + this.result_highlighted = null; + this.result_single_selected = null; + this.allow_single_deselect = (this.options.allow_single_deselect != null) && (this.form_field.options[0] != null) && this.form_field.options[0].text === "" ? this.options.allow_single_deselect : false; + this.disable_search_threshold = this.options.disable_search_threshold || 0; + this.choices = 0; + return this.results_none_found = this.options.no_results_text || "No results match"; + }; + AbstractChosen.prototype.mouse_enter = function() { + return this.mouse_on_container = true; + }; + AbstractChosen.prototype.mouse_leave = function() { + return this.mouse_on_container = false; + }; + AbstractChosen.prototype.input_focus = function(evt) { + if (!this.active_field) { + return setTimeout((__bind(function() { + return this.container_mousedown(); + }, this)), 50); + } + }; + AbstractChosen.prototype.input_blur = function(evt) { + if (!this.mouse_on_container) { + this.active_field = false; + return setTimeout((__bind(function() { + return this.blur_test(); + }, this)), 100); + } + }; + AbstractChosen.prototype.result_add_option = function(option) { + var classes, style; + if (!option.disabled) { + option.dom_id = this.container_id + "_o_" + option.array_index; + classes = option.selected && this.is_multiple ? [] : ["active-result"]; + if (option.selected) { + classes.push("result-selected"); + } + if (option.group_array_index != null) { + classes.push("group-option"); + } + if (option.classes !== "") { + classes.push(option.classes); + } + style = option.style.cssText !== "" ? " style=\"" + option.style + "\"" : ""; + return '
  • ' + option.html + '
  • '; + } else { + return ""; + } + }; + AbstractChosen.prototype.results_update_field = function() { + this.result_clear_highlight(); + this.result_single_selected = null; + return this.results_build(); + }; + AbstractChosen.prototype.results_toggle = function() { + if (this.results_showing) { + return this.results_hide(); + } else { + return this.results_show(); + } + }; + AbstractChosen.prototype.results_search = function(evt) { + if (this.results_showing) { + return this.winnow_results(); + } else { + return this.results_show(); + } + }; + AbstractChosen.prototype.keyup_checker = function(evt) { + var stroke, _ref; + stroke = (_ref = evt.which) != null ? _ref : evt.keyCode; + this.search_field_scale(); + switch (stroke) { + case 8: + if (this.is_multiple && this.backstroke_length < 1 && this.choices > 0) { + return this.keydown_backstroke(); + } else if (!this.pending_backstroke) { + this.result_clear_highlight(); + return this.results_search(); + } + break; + case 13: + evt.preventDefault(); + if (this.results_showing) { + return this.result_select(evt); + } + break; + case 27: + if (this.results_showing) { + this.results_hide(); + } + return true; + case 9: + case 38: + case 40: + case 16: + case 91: + case 17: + break; + default: + return this.results_search(); + } + }; + AbstractChosen.prototype.generate_field_id = function() { + var new_id; + new_id = this.generate_random_id(); + this.form_field.id = new_id; + return new_id; + }; + AbstractChosen.prototype.generate_random_char = function() { + var chars, newchar, rand; + chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZ"; + rand = Math.floor(Math.random() * chars.length); + return newchar = chars.substring(rand, rand + 1); + }; + return AbstractChosen; + })(); + root.AbstractChosen = AbstractChosen; +}).call(this); +(function() { + /* + Chosen source: generate output using 'cake build' + Copyright (c) 2011 by Harvest + */ + var $, Chosen, get_side_border_padding, root; + var __hasProp = Object.prototype.hasOwnProperty, __extends = function(child, parent) { + for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } + function ctor() { this.constructor = child; } + ctor.prototype = parent.prototype; + child.prototype = new ctor; + child.__super__ = parent.prototype; + return child; + }, __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; + root = this; + $ = jQuery; + $.fn.extend({ + chosen: function(options) { + if ($.browser.msie && ($.browser.version === "6.0" || $.browser.version === "7.0")) { + return this; + } + return $(this).each(function(input_field) { + if (!($(this)).hasClass("chzn-done")) { + return new Chosen(this, options); + } + }); + } + }); + Chosen = (function() { + __extends(Chosen, AbstractChosen); + function Chosen() { + Chosen.__super__.constructor.apply(this, arguments); + } + Chosen.prototype.setup = function() { + this.form_field_jq = $(this.form_field); + return this.is_rtl = this.form_field_jq.hasClass("chzn-rtl"); + }; + Chosen.prototype.finish_setup = function() { + return this.form_field_jq.addClass("chzn-done"); + }; + Chosen.prototype.set_up_html = function() { + var container_div, dd_top, dd_width, sf_width; + this.container_id = this.form_field.id.length ? this.form_field.id.replace(/(:|\.)/g, '_') : this.generate_field_id(); + this.container_id += "_chzn"; + this.f_width = this.form_field_jq.outerWidth(); + this.default_text = this.form_field_jq.data('placeholder') ? this.form_field_jq.data('placeholder') : this.default_text_default; + container_div = $("
    ", { + id: this.container_id, + "class": "chzn-container" + (this.is_rtl ? ' chzn-rtl' : ''), + style: 'width: ' + this.f_width + 'px;' + }); + if (this.is_multiple) { + container_div.html('
      '); + } else { + container_div.html('' + this.default_text + '
        '); + } + this.form_field_jq.hide().after(container_div); + this.container = $('#' + this.container_id); + this.container.addClass("chzn-container-" + (this.is_multiple ? "multi" : "single")); + this.dropdown = this.container.find('div.chzn-drop').first(); + dd_top = this.container.height(); + dd_width = this.f_width - get_side_border_padding(this.dropdown); + this.dropdown.css({ + "width": dd_width + "px", + "top": dd_top + "px" + }); + this.search_field = this.container.find('input').first(); + this.search_results = this.container.find('ul.chzn-results').first(); + this.search_field_scale(); + this.search_no_results = this.container.find('li.no-results').first(); + if (this.is_multiple) { + this.search_choices = this.container.find('ul.chzn-choices').first(); + this.search_container = this.container.find('li.search-field').first(); + } else { + this.search_container = this.container.find('div.chzn-search').first(); + this.selected_item = this.container.find('.chzn-single').first(); + sf_width = dd_width - get_side_border_padding(this.search_container) - get_side_border_padding(this.search_field); + this.search_field.css({ + "width": sf_width + "px" + }); + } + this.results_build(); + this.set_tab_index(); + return this.form_field_jq.trigger("liszt:ready", { + chosen: this + }); + }; + Chosen.prototype.register_observers = function() { + this.container.mousedown(__bind(function(evt) { + return this.container_mousedown(evt); + }, this)); + this.container.mouseup(__bind(function(evt) { + return this.container_mouseup(evt); + }, this)); + this.container.mouseenter(__bind(function(evt) { + return this.mouse_enter(evt); + }, this)); + this.container.mouseleave(__bind(function(evt) { + return this.mouse_leave(evt); + }, this)); + this.search_results.mouseup(__bind(function(evt) { + return this.search_results_mouseup(evt); + }, this)); + this.search_results.mouseover(__bind(function(evt) { + return this.search_results_mouseover(evt); + }, this)); + this.search_results.mouseout(__bind(function(evt) { + return this.search_results_mouseout(evt); + }, this)); + this.form_field_jq.bind("liszt:updated", __bind(function(evt) { + return this.results_update_field(evt); + }, this)); + this.search_field.blur(__bind(function(evt) { + return this.input_blur(evt); + }, this)); + this.search_field.keyup(__bind(function(evt) { + return this.keyup_checker(evt); + }, this)); + this.search_field.keydown(__bind(function(evt) { + return this.keydown_checker(evt); + }, this)); + if (this.is_multiple) { + this.search_choices.click(__bind(function(evt) { + return this.choices_click(evt); + }, this)); + return this.search_field.focus(__bind(function(evt) { + return this.input_focus(evt); + }, this)); + } else { + return this.container.click(__bind(function(evt) { + return evt.preventDefault(); + }, this)); + } + }; + Chosen.prototype.search_field_disabled = function() { + this.is_disabled = this.form_field_jq[0].disabled; + if (this.is_disabled) { + this.container.addClass('chzn-disabled'); + this.search_field[0].disabled = true; + if (!this.is_multiple) { + this.selected_item.unbind("focus", this.activate_action); + } + return this.close_field(); + } else { + this.container.removeClass('chzn-disabled'); + this.search_field[0].disabled = false; + if (!this.is_multiple) { + return this.selected_item.bind("focus", this.activate_action); + } + } + }; + Chosen.prototype.container_mousedown = function(evt) { + var target_closelink; + if (!this.is_disabled) { + target_closelink = evt != null ? ($(evt.target)).hasClass("search-choice-close") : false; + if (evt && evt.type === "mousedown") { + evt.stopPropagation(); + } + if (!this.pending_destroy_click && !target_closelink) { + if (!this.active_field) { + if (this.is_multiple) { + this.search_field.val(""); + } + $(document).click(this.click_test_action); + this.results_show(); + } else if (!this.is_multiple && evt && (($(evt.target)[0] === this.selected_item[0]) || $(evt.target).parents("a.chzn-single").length)) { + evt.preventDefault(); + this.results_toggle(); + } + return this.activate_field(); + } else { + return this.pending_destroy_click = false; + } + } + }; + Chosen.prototype.container_mouseup = function(evt) { + if (evt.target.nodeName === "ABBR") { + return this.results_reset(evt); + } + }; + Chosen.prototype.blur_test = function(evt) { + if (!this.active_field && this.container.hasClass("chzn-container-active")) { + return this.close_field(); + } + }; + Chosen.prototype.close_field = function() { + $(document).unbind("click", this.click_test_action); + if (!this.is_multiple) { + this.selected_item.attr("tabindex", this.search_field.attr("tabindex")); + this.search_field.attr("tabindex", -1); + } + this.active_field = false; + this.results_hide(); + this.container.removeClass("chzn-container-active"); + this.winnow_results_clear(); + this.clear_backstroke(); + this.show_search_field_default(); + return this.search_field_scale(); + }; + Chosen.prototype.activate_field = function() { + if (!this.is_multiple && !this.active_field) { + this.search_field.attr("tabindex", this.selected_item.attr("tabindex")); + this.selected_item.attr("tabindex", -1); + } + this.container.addClass("chzn-container-active"); + this.active_field = true; + this.search_field.val(this.search_field.val()); + return this.search_field.focus(); + }; + Chosen.prototype.test_active_click = function(evt) { + if ($(evt.target).parents('#' + this.container_id).length) { + return this.active_field = true; + } else { + return this.close_field(); + } + }; + Chosen.prototype.results_build = function() { + var content, data, _i, _len, _ref; + this.parsing = true; + this.results_data = root.SelectParser.select_to_array(this.form_field); + if (this.is_multiple && this.choices > 0) { + this.search_choices.find("li.search-choice").remove(); + this.choices = 0; + } else if (!this.is_multiple) { + this.selected_item.find("span").text(this.default_text); + if (this.form_field.options.length <= this.disable_search_threshold) { + this.container.addClass("chzn-container-single-nosearch"); + } else { + this.container.removeClass("chzn-container-single-nosearch"); + } + } + content = ''; + _ref = this.results_data; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + data = _ref[_i]; + if (data.group) { + content += this.result_add_group(data); + } else if (!data.empty) { + content += this.result_add_option(data); + if (data.selected && this.is_multiple) { + this.choice_build(data); + } else if (data.selected && !this.is_multiple) { + this.selected_item.find("span").text(data.text); + if (this.allow_single_deselect) { + this.single_deselect_control_build(); + } + } + } + } + this.search_field_disabled(); + this.show_search_field_default(); + this.search_field_scale(); + this.search_results.html(content); + return this.parsing = false; + }; + Chosen.prototype.result_add_group = function(group) { + if (!group.disabled) { + group.dom_id = this.container_id + "_g_" + group.array_index; + return '
      • ' + $("
        ").text(group.label).html() + '
      • '; + } else { + return ""; + } + }; + Chosen.prototype.result_do_highlight = function(el) { + var high_bottom, high_top, maxHeight, visible_bottom, visible_top; + if (el.length) { + this.result_clear_highlight(); + this.result_highlight = el; + this.result_highlight.addClass("highlighted"); + maxHeight = parseInt(this.search_results.css("maxHeight"), 10); + visible_top = this.search_results.scrollTop(); + visible_bottom = maxHeight + visible_top; + high_top = this.result_highlight.position().top + this.search_results.scrollTop(); + high_bottom = high_top + this.result_highlight.outerHeight(); + if (high_bottom >= visible_bottom) { + return this.search_results.scrollTop((high_bottom - maxHeight) > 0 ? high_bottom - maxHeight : 0); + } else if (high_top < visible_top) { + return this.search_results.scrollTop(high_top); + } + } + }; + Chosen.prototype.result_clear_highlight = function() { + if (this.result_highlight) { + this.result_highlight.removeClass("highlighted"); + } + return this.result_highlight = null; + }; + Chosen.prototype.results_show = function() { + var dd_top; + if (!this.is_multiple) { + this.selected_item.addClass("chzn-single-with-drop"); + if (this.result_single_selected) { + this.result_do_highlight(this.result_single_selected); + } + } + dd_top = this.is_multiple ? this.container.height() : this.container.height() - 1; + this.dropdown.css({ + "top": dd_top + "px", + "left": 0 + }); + this.results_showing = true; + this.search_field.focus(); + this.search_field.val(this.search_field.val()); + return this.winnow_results(); + }; + Chosen.prototype.results_hide = function() { + if (!this.is_multiple) { + this.selected_item.removeClass("chzn-single-with-drop"); + } + this.result_clear_highlight(); + this.dropdown.css({ + "left": "-9000px" + }); + return this.results_showing = false; + }; + Chosen.prototype.set_tab_index = function(el) { + var ti; + if (this.form_field_jq.attr("tabindex")) { + ti = this.form_field_jq.attr("tabindex"); + this.form_field_jq.attr("tabindex", -1); + if (this.is_multiple) { + return this.search_field.attr("tabindex", ti); + } else { + this.selected_item.attr("tabindex", ti); + return this.search_field.attr("tabindex", -1); + } + } + }; + Chosen.prototype.show_search_field_default = function() { + if (this.is_multiple && this.choices < 1 && !this.active_field) { + this.search_field.val(this.default_text); + return this.search_field.addClass("default"); + } else { + this.search_field.val(""); + return this.search_field.removeClass("default"); + } + }; + Chosen.prototype.search_results_mouseup = function(evt) { + var target; + target = $(evt.target).hasClass("active-result") ? $(evt.target) : $(evt.target).parents(".active-result").first(); + if (target.length) { + this.result_highlight = target; + return this.result_select(evt); + } + }; + Chosen.prototype.search_results_mouseover = function(evt) { + var target; + target = $(evt.target).hasClass("active-result") ? $(evt.target) : $(evt.target).parents(".active-result").first(); + if (target) { + return this.result_do_highlight(target); + } + }; + Chosen.prototype.search_results_mouseout = function(evt) { + if ($(evt.target).hasClass("active-result" || $(evt.target).parents('.active-result').first())) { + return this.result_clear_highlight(); + } + }; + Chosen.prototype.choices_click = function(evt) { + evt.preventDefault(); + if (this.active_field && !($(evt.target).hasClass("search-choice" || $(evt.target).parents('.search-choice').first)) && !this.results_showing) { + return this.results_show(); + } + }; + Chosen.prototype.choice_build = function(item) { + var choice_id, link; + choice_id = this.container_id + "_c_" + item.array_index; + this.choices += 1; + this.search_container.before('
      • ' + item.html + '
      • '); + link = $('#' + choice_id).find("a").first(); + return link.click(__bind(function(evt) { + return this.choice_destroy_link_click(evt); + }, this)); + }; + Chosen.prototype.choice_destroy_link_click = function(evt) { + evt.preventDefault(); + if (!this.is_disabled) { + this.pending_destroy_click = true; + return this.choice_destroy($(evt.target)); + } else { + return evt.stopPropagation; + } + }; + Chosen.prototype.choice_destroy = function(link) { + this.choices -= 1; + this.show_search_field_default(); + if (this.is_multiple && this.choices > 0 && this.search_field.val().length < 1) { + this.results_hide(); + } + this.result_deselect(link.attr("rel")); + return link.parents('li').first().remove(); + }; + Chosen.prototype.results_reset = function(evt) { + this.form_field.options[0].selected = true; + this.selected_item.find("span").text(this.default_text); + this.show_search_field_default(); + $(evt.target).remove(); + this.form_field_jq.trigger("change"); + if (this.active_field) { + return this.results_hide(); + } + }; + Chosen.prototype.result_select = function(evt) { + var high, high_id, item, position; + if (this.result_highlight) { + high = this.result_highlight; + high_id = high.attr("id"); + this.result_clear_highlight(); + if (this.is_multiple) { + this.result_deactivate(high); + } else { + this.search_results.find(".result-selected").removeClass("result-selected"); + this.result_single_selected = high; + } + high.addClass("result-selected"); + position = high_id.substr(high_id.lastIndexOf("_") + 1); + item = this.results_data[position]; + item.selected = true; + this.form_field.options[item.options_index].selected = true; + if (this.is_multiple) { + this.choice_build(item); + } else { + this.selected_item.find("span").first().text(item.text); + if (this.allow_single_deselect) { + this.single_deselect_control_build(); + } + } + if (!(evt.metaKey && this.is_multiple)) { + this.results_hide(); + } + this.search_field.val(""); + this.form_field_jq.trigger("change"); + return this.search_field_scale(); + } + }; + Chosen.prototype.result_activate = function(el) { + return el.addClass("active-result"); + }; + Chosen.prototype.result_deactivate = function(el) { + return el.removeClass("active-result"); + }; + Chosen.prototype.result_deselect = function(pos) { + var result, result_data; + result_data = this.results_data[pos]; + result_data.selected = false; + this.form_field.options[result_data.options_index].selected = false; + result = $("#" + this.container_id + "_o_" + pos); + result.removeClass("result-selected").addClass("active-result").show(); + this.result_clear_highlight(); + this.winnow_results(); + this.form_field_jq.trigger("change"); + return this.search_field_scale(); + }; + Chosen.prototype.single_deselect_control_build = function() { + if (this.allow_single_deselect && this.selected_item.find("abbr").length < 1) { + return this.selected_item.find("span").first().after(""); + } + }; + Chosen.prototype.winnow_results = function() { + var found, option, part, parts, regex, result, result_id, results, searchText, startpos, text, zregex, _i, _j, _len, _len2, _ref; + this.no_results_clear(); + results = 0; + searchText = this.search_field.val() === this.default_text ? "" : $('
        ').text($.trim(this.search_field.val())).html(); + regex = new RegExp('^' + searchText.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"), 'i'); + zregex = new RegExp(searchText.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"), 'i'); + _ref = this.results_data; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + option = _ref[_i]; + if (!option.disabled && !option.empty) { + if (option.group) { + $('#' + option.dom_id).css('display', 'none'); + } else if (!(this.is_multiple && option.selected)) { + found = false; + result_id = option.dom_id; + result = $("#" + result_id); + if (regex.test(option.html)) { + found = true; + results += 1; + } else if (option.html.indexOf(" ") >= 0 || option.html.indexOf("[") === 0) { + parts = option.html.replace(/\[|\]/g, "").split(" "); + if (parts.length) { + for (_j = 0, _len2 = parts.length; _j < _len2; _j++) { + part = parts[_j]; + if (regex.test(part)) { + found = true; + results += 1; + } + } + } + } + if (found) { + if (searchText.length) { + startpos = option.html.search(zregex); + text = option.html.substr(0, startpos + searchText.length) + '' + option.html.substr(startpos + searchText.length); + text = text.substr(0, startpos) + '' + text.substr(startpos); + } else { + text = option.html; + } + result.html(text); + this.result_activate(result); + if (option.group_array_index != null) { + $("#" + this.results_data[option.group_array_index].dom_id).css('display', 'list-item'); + } + } else { + if (this.result_highlight && result_id === this.result_highlight.attr('id')) { + this.result_clear_highlight(); + } + this.result_deactivate(result); + } + } + } + } + if (results < 1 && searchText.length) { + return this.no_results(searchText); + } else { + return this.winnow_results_set_highlight(); + } + }; + Chosen.prototype.winnow_results_clear = function() { + var li, lis, _i, _len, _results; + this.search_field.val(""); + lis = this.search_results.find("li"); + _results = []; + for (_i = 0, _len = lis.length; _i < _len; _i++) { + li = lis[_i]; + li = $(li); + _results.push(li.hasClass("group-result") ? li.css('display', 'auto') : !this.is_multiple || !li.hasClass("result-selected") ? this.result_activate(li) : void 0); + } + return _results; + }; + Chosen.prototype.winnow_results_set_highlight = function() { + var do_high, selected_results; + if (!this.result_highlight) { + selected_results = !this.is_multiple ? this.search_results.find(".result-selected.active-result") : []; + do_high = selected_results.length ? selected_results.first() : this.search_results.find(".active-result").first(); + if (do_high != null) { + return this.result_do_highlight(do_high); + } + } + }; + Chosen.prototype.no_results = function(terms) { + var no_results_html; + no_results_html = $('
      • ' + this.results_none_found + ' ""
      • '); + no_results_html.find("span").first().html(terms); + return this.search_results.append(no_results_html); + }; + Chosen.prototype.no_results_clear = function() { + return this.search_results.find(".no-results").remove(); + }; + Chosen.prototype.keydown_arrow = function() { + var first_active, next_sib; + if (!this.result_highlight) { + first_active = this.search_results.find("li.active-result").first(); + if (first_active) { + this.result_do_highlight($(first_active)); + } + } else if (this.results_showing) { + next_sib = this.result_highlight.nextAll("li.active-result").first(); + if (next_sib) { + this.result_do_highlight(next_sib); + } + } + if (!this.results_showing) { + return this.results_show(); + } + }; + Chosen.prototype.keyup_arrow = function() { + var prev_sibs; + if (!this.results_showing && !this.is_multiple) { + return this.results_show(); + } else if (this.result_highlight) { + prev_sibs = this.result_highlight.prevAll("li.active-result"); + if (prev_sibs.length) { + return this.result_do_highlight(prev_sibs.first()); + } else { + if (this.choices > 0) { + this.results_hide(); + } + return this.result_clear_highlight(); + } + } + }; + Chosen.prototype.keydown_backstroke = function() { + if (this.pending_backstroke) { + this.choice_destroy(this.pending_backstroke.find("a").first()); + return this.clear_backstroke(); + } else { + this.pending_backstroke = this.search_container.siblings("li.search-choice").last(); + return this.pending_backstroke.addClass("search-choice-focus"); + } + }; + Chosen.prototype.clear_backstroke = function() { + if (this.pending_backstroke) { + this.pending_backstroke.removeClass("search-choice-focus"); + } + return this.pending_backstroke = null; + }; + Chosen.prototype.keydown_checker = function(evt) { + var stroke, _ref; + stroke = (_ref = evt.which) != null ? _ref : evt.keyCode; + this.search_field_scale(); + if (stroke !== 8 && this.pending_backstroke) { + this.clear_backstroke(); + } + switch (stroke) { + case 8: + this.backstroke_length = this.search_field.val().length; + break; + case 9: + if (this.results_showing && !this.is_multiple) { + this.result_select(evt); + } + this.mouse_on_container = false; + break; + case 13: + evt.preventDefault(); + break; + case 38: + evt.preventDefault(); + this.keyup_arrow(); + break; + case 40: + this.keydown_arrow(); + break; + } + }; + Chosen.prototype.search_field_scale = function() { + var dd_top, div, h, style, style_block, styles, w, _i, _len; + if (this.is_multiple) { + h = 0; + w = 0; + style_block = "position:absolute; left: -1000px; top: -1000px; display:none;"; + styles = ['font-size', 'font-style', 'font-weight', 'font-family', 'line-height', 'text-transform', 'letter-spacing']; + for (_i = 0, _len = styles.length; _i < _len; _i++) { + style = styles[_i]; + style_block += style + ":" + this.search_field.css(style) + ";"; + } + div = $('
        ', { + 'style': style_block + }); + div.text(this.search_field.val()); + $('body').append(div); + w = div.width() + 25; + div.remove(); + if (w > this.f_width - 10) { + w = this.f_width - 10; + } + this.search_field.css({ + 'width': w + 'px' + }); + dd_top = this.container.height(); + return this.dropdown.css({ + "top": dd_top + "px" + }); + } + }; + Chosen.prototype.generate_random_id = function() { + var string; + string = "sel" + this.generate_random_char() + this.generate_random_char() + this.generate_random_char(); + while ($("#" + string).length > 0) { + string += this.generate_random_char(); + } + return string; + }; + return Chosen; + })(); + get_side_border_padding = function(elmt) { + var side_border_padding; + return side_border_padding = elmt.outerWidth() - elmt.width(); + }; + root.get_side_border_padding = get_side_border_padding; +}).call(this); \ No newline at end of file diff --git a/ckan/templates/group/layout.html b/ckan/templates/group/layout.html index 6775ae31b09..403d6a5582d 100644 --- a/ckan/templates/group/layout.html +++ b/ckan/templates/group/layout.html @@ -41,8 +41,8 @@ ${h.subnav_link(c, h.icon('group') + _('List Groups'), controller='group', action='index')}
      • - - ${h.subnav_link(c, h.icon('group_add') + _('Login to Add a Group'), controller='group', action='new')} + + ${h.subnav_link(c, h.icon('group_add') + _('Login to Add a Publisher'), controller='group', action='new')}
      • diff --git a/ckan/templates/layout_base.html b/ckan/templates/layout_base.html index 69e54b8f066..408d0577f10 100644 --- a/ckan/templates/layout_base.html +++ b/ckan/templates/layout_base.html @@ -29,10 +29,12 @@ - - - - + + + + + + @@ -218,12 +220,15 @@

        Meta

        - - - - - - + + + + + + + + + @@ -253,6 +258,8 @@

        Meta

        $(".ckan-logged-in").show(); } $('input[placeholder], textarea[placeholder]').placeholder(); + + $(".chzn-select").chosen(); }); diff --git a/ckan/tests/functional/api/__init__.py b/ckan/tests/functional/api/__init__.py index f5667c286de..1c6e2442feb 100644 --- a/ckan/tests/functional/api/__init__.py +++ b/ckan/tests/functional/api/__init__.py @@ -31,4 +31,5 @@ def assert_dicts_equal_ignoring_ordering(dict1, dict2): dicts = [copy.deepcopy(dict1), copy.deepcopy(dict2)] for d in dicts: d = change_lists_to_sets(d) + #from nose.tools import set_trace; set_trace() assert_equal(dicts[0], dicts[1]) diff --git a/ckan/tests/functional/test_group.py b/ckan/tests/functional/test_group.py index 53318ac720f..59d745f2df4 100644 --- a/ckan/tests/functional/test_group.py +++ b/ckan/tests/functional/test_group.py @@ -6,6 +6,9 @@ from ckan import plugins import ckan.model as model from ckan.lib.create_test_data import CreateTestData +from ckan.logic import check_access, NotAuthorized + +from pylons import config from ckan.tests import * from ckan.tests import setup_test_search_index @@ -452,3 +455,230 @@ def test_2_atom_feed(self): assert 'xmlns="http://www.w3.org/2005/Atom"' in res, res assert '' in res, res + +class TestPublisherGroup(FunctionalTestCase): + + @classmethod + def setup_class(self): + model.Session.remove() + CreateTestData.create(auth_profile='publisher') + + @classmethod + def teardown_class(self): + model.repo.rebuild_db() + + def test_index(self): + offset = url_for(controller='group', action='index') + res = self.app.get(offset) + assert '

        Groups' in res, res + groupname = 'david' + group = model.Group.by_name(unicode(groupname)) + group_title = group.title + group_packages_count = len(group.active_packages().all()) + group_description = group.description + self.check_named_element(res, 'tr', group_title, group_packages_count, group_description) + res = res.click(group_title) + assert groupname in res + assert 'publisher' == group.type, group.type + + def test_read(self): + # Relies on the search index being available + setup_test_search_index() + name = u'david' + title = u'Dave\'s books' + pkgname = u'warandpeace' + group = model.Group.by_name(name) + assert 'publisher' == group.type + for group_ref in (group.name, group.id): + offset = url_for(controller='group', action='read', id=group_ref) + res = self.app.get(offset) + main_res = self.main_div(res) + assert title in res, res + assert 'Administrators' in res, res + assert 'russianfan' in main_res, main_res + assert name in res, res + assert '2 datasets found.' in self.strip_tags(main_res), main_res + pkg = model.Package.by_name(pkgname) + res = res.click(pkg.title) + assert '%s - Datasets' % pkg.title in res + + def test_read_and_not_authorized_to_edit(self): + name = u'david' + title = u'Dave\'s books' + pkgname = u'warandpeace' + offset = url_for(controller='group', action='edit', id=name) + res = self.app.get(offset, extra_environ={'REMOTE_USER': 'russianfan'}, status=200) + + +class TestPublisherEdit(FunctionalTestCase): + + @classmethod + def setup_class(self): + from ckan.tests.mock_publisher_auth import MockPublisherAuth + self.auth = MockPublisherAuth() + + model.Session.remove() + CreateTestData.create(auth_profile='publisher') + self.groupname = u'david' + self.packagename = u'testpkg' + model.repo.new_revision() + model.Session.add(model.Package(name=self.packagename)) + model.repo.commit_and_remove() + + @classmethod + def teardown_class(self): + model.Session.remove() + model.repo.rebuild_db() + model.Session.remove() + + + def test_0_not_authz(self): + offset = url_for(controller='group', action='edit', id=self.groupname) + # 401 gets caught by repoze.who and turned into redirect + res = self.app.get(offset, status=[302, 401]) + res = res.follow() + assert res.request.url.startswith('/user/login') + + def test_2_edit(self): + group = model.Group.by_name(self.groupname) + offset = url_for(controller='group', action='edit', id=self.groupname) + user = model.User.get('russianfan') + + res = self.app.get(offset, status=200, extra_environ={'REMOTE_USER': 'russianfan'}) + assert 'Edit: %s' % group.title in res, res + + form = res.forms['group-edit'] + titlefn = 'title' + descfn = 'description' + newtitle = 'xxxxxxx' + newdesc = '''### Lots of stuff here + +Ho ho ho +''' + + form[titlefn] = newtitle + form[descfn] = newdesc + pkg = model.Package.by_name(self.packagename) + form['packages__2__name'] = pkg.name + + + res = form.submit('save', status=302, extra_environ={'REMOTE_USER': 'russianfan'}) + # should be read page + # assert 'Groups - %s' % self.groupname in res, res + + model.Session.remove() + group = model.Group.by_name(self.groupname) + assert group.title == newtitle, group + assert group.description == newdesc, group + + # now look at datasets + assert len(group.active_packages().all()) == 3 + + def test_3_edit_form_has_new_package(self): + # check for dataset in autocomplete + offset = url_for(controller='package', action='autocomplete', q='an') + res = self.app.get(offset, status=200, extra_environ={'REMOTE_USER': 'russianfan'}) + assert 'annakarenina' in res, res + assert not 'newone' in res, res + model.repo.new_revision() + pkg = model.Package(name=u'anewone') + model.Session.add(pkg) + model.repo.commit_and_remove() + + model.repo.new_revision() + pkg = model.Package.by_name(u'anewone') + user = model.User.by_name(u'russianfan') + model.setup_default_user_roles(pkg, [user]) + model.repo.commit_and_remove() + + res = self.app.get(offset, status=200, extra_environ={'REMOTE_USER': 'russianfan'}) + assert 'annakarenina' in res, res + assert 'newone' in res + + def test_4_new_duplicate_package(self): + prefix = '' + + # Create group + group_name = u'testgrp4' + CreateTestData.create_groups([{'name': group_name, + 'packages': [self.packagename]}], + admin_user_name='russianfan') + + # Add same package again + offset = url_for(controller='group', action='edit', id=group_name) + res = self.app.get(offset, status=200, extra_environ={'REMOTE_USER': 'russianfan'}) + fv = res.forms['group-edit'] + fv['packages__1__name'] = self.packagename + res = fv.submit('save', status=302, extra_environ={'REMOTE_USER': 'russianfan'}) + res = res.follow() + assert group_name in res, res + model.Session.remove() + + # check package only added to the group once + group = model.Group.by_name(group_name) + pkg_names = [pkg.name for pkg in group.active_packages().all()] + assert_equal(pkg_names, [self.packagename]) + + def test_edit_plugin_hook(self): + plugin = MockGroupControllerPlugin() + plugins.load(plugin) + offset = url_for(controller='group', action='edit', id=self.groupname) + res = self.app.get(offset, status=200, extra_environ={'REMOTE_USER': 'russianfan'}) + form = res.forms['group-edit'] + group = model.Group.by_name(self.groupname) + form['title'] = "huhuhu" + res = form.submit('save', status=302, extra_environ={'REMOTE_USER': 'russianfan'}) + assert plugin.calls['edit'] == 1, plugin.calls + plugins.unload(plugin) + + def test_edit_non_auth(self): + offset = url_for(controller='group', action='edit', id=self.groupname) + res = self.app.get(offset, status=[302,401], extra_environ={'REMOTE_USER': 'non-existent'}) + + def test_edit_fail_auth(self): + context = { 'group': model.Group.by_name(self.groupname), 'model': model, 'user': 'russianfan' } + try: + if self.auth.check_access('group_update',context, {}): + assert False, "Check access said we were allowed but we shouldn't really" + except NotAuthorized, e: + pass # Do nothing as this is what we expected + + def test_edit_success_auth(self): + userobj = model.User.get('russianfan') + grp = model.Group.by_name(self.groupname) + + def gg(*args, **kwargs): + return [grp] + model.User.get_groups = gg + + context = { 'group': grp, 'model': model, 'user': 'russianfan' } + try: + self.auth.check_access('group_update',context, {}) + except NotAuthorized, e: + assert False, "The user should have access" + + + def test_delete(self): + group_name = 'deletetest' + CreateTestData.create_groups([{'name': group_name, + 'packages': [self.packagename]}], + admin_user_name='russianfan') + + group = model.Group.by_name(group_name) + offset = url_for(controller='group', action='edit', id=group_name) + res = self.app.get(offset, status=200, extra_environ={'REMOTE_USER': 'russianfan'}) + main_res = self.main_div(res) + assert 'Edit: %s' % group.title in main_res, main_res + assert 'value="active" selected' in main_res, main_res + + # delete + form = res.forms['group-edit'] + form['state'] = 'deleted' + res = form.submit('save', status=302, extra_environ={'REMOTE_USER': 'russianfan'}) + + group = model.Group.by_name(group_name) + assert_equal(group.state, 'deleted') + res = self.app.get(offset, status=302) + res = res.follow() + assert res.request.url.startswith('/user/login'), res.request.url + diff --git a/ckan/tests/functional/test_publisher_auth.py b/ckan/tests/functional/test_publisher_auth.py new file mode 100644 index 00000000000..7ea62157656 --- /dev/null +++ b/ckan/tests/functional/test_publisher_auth.py @@ -0,0 +1,380 @@ +import re + +from nose.tools import assert_equal + +import ckan.model as model +from ckan.lib.create_test_data import CreateTestData +from ckan.logic import NotAuthorized + + +from ckan.tests import * +from ckan.tests import setup_test_search_index +from base import FunctionalTestCase +from ckan.tests import search_related, is_search_supported + + +class TestPublisherGroups(FunctionalTestCase): + + @classmethod + def setup_class(self): + from ckan.tests.mock_publisher_auth import MockPublisherAuth + self.auth = MockPublisherAuth() + + model.Session.remove() + CreateTestData.create(auth_profile='publisher') + self.groupname = u'david' + self.packagename = u'testpkg' + model.repo.new_revision() + model.Session.add(model.Package(name=self.packagename)) + model.repo.commit_and_remove() + + @classmethod + def teardown_class(self): + model.Session.remove() + model.repo.rebuild_db() + model.Session.remove() + + def _run_fail_test( self, username, action): + grp = model.Group.by_name(self.groupname) + context = { 'group': grp, 'model': model, 'user': username } + try: + self.auth.check_access(action,context, {}) + assert False, "The user should not have access" + except NotAuthorized, e: + pass + + def _run_success_test( self, username, action): + userobj = model.User.get(username) + grp = model.Group.by_name(self.groupname) + f = model.User.get_groups + def gg(*args, **kwargs): + return [grp] + model.User.get_groups = gg + + context = { 'group': grp, 'model': model, 'user': username } + try: + self.auth.check_access(action, context, {}) + except NotAuthorized, e: + assert False, "The user should have %s access: %r." % (action, e.extra_msg) + model.User.get_groups = f + + def test_new_success(self): + self._run_success_test( 'russianfan', 'group_create' ) + + def test_new_fail(self): + self._run_fail_test( 'russianfan', 'group_create' ) + + def test_new_anon_fail(self): + self._run_fail_test( '', 'group_create' ) + + def test_new_unknown_fail(self): + self._run_fail_test( 'nosuchuser', 'group_create' ) + + def test_edit_success(self): + """ Success because user in group """ + self._run_success_test( 'russianfan', 'group_update' ) + + def test_edit_fail(self): + """ Fail because user not in group """ + self._run_fail_test( 'russianfan', 'group_update' ) + + def test_edit_anon_fail(self): + """ Fail because user is anon """ + self._run_fail_test( '', 'group_update' ) + + def test_edit_unknown_fail(self): + self._run_fail_test( 'nosuchuser', 'group_update' ) + + def test_delete_success(self): + """ Success because user in group """ + self._run_success_test( 'russianfan', 'group_delete' ) + + def test_delete_fail(self): + """ Fail because user not in group """ + self._run_fail_test( 'russianfan', 'group_delete' ) + + def test_delete_anon_fail(self): + """ Fail because user is anon """ + self._run_fail_test( '', 'group_delete' ) + + def test_delete_unknown_fail(self): + self._run_fail_test( 'nosuchuser', 'group_delete' ) + + +class TestPublisherShow(FunctionalTestCase): + + @classmethod + def setup_class(self): + from ckan.tests.mock_publisher_auth import MockPublisherAuth + self.auth = MockPublisherAuth() + + model.Session.remove() + CreateTestData.create(auth_profile='publisher') + self.groupname = u'david' + self.packagename = u'testpkg' + model.repo.new_revision() + model.Session.add(model.Package(name=self.packagename)) + model.repo.commit_and_remove() + + @classmethod + def teardown_class(self): + model.Session.remove() + model.repo.rebuild_db() + model.Session.remove() + + def test_package_show_deleted_success(self): + userobj = model.User.get('russianfan') + grp = model.Group.by_name(self.groupname) + pkg = model.Package.by_name(self.packagename) + pkg.state = 'deleted' + + f = model.User.get_groups + g = model.Package.get_groups + def gg(*args, **kwargs): + return [grp] + model.User.get_groups = gg + model.Package.get_groups = gg + + context = { 'package': pkg, 'model': model, 'user': userobj.name } + try: + self.auth.check_access('package_show', context, {}) + except NotAuthorized, e: + assert False, "The user should have %s access: %r." % (action, e.extra_msg) + model.User.get_groups = f + model.Package.get_groups = g + pkg.state = "active" + + def test_package_show_normal_success(self): + userobj = model.User.get('russianfan') + grp = model.Group.by_name(self.groupname) + pkg = model.Package.by_name(self.packagename) + pkg.state = "active" + + context = { 'package': pkg, 'model': model, 'user': userobj.name } + try: + self.auth.check_access('package_show', context, {}) + except NotAuthorized, e: + assert False, "The user should have %s access: %r." % ("package_show", e.extra_msg) + + def test_package_show_deleted_fail(self): + userobj = model.User.get('russianfan') + grp = model.Group.by_name(self.groupname) + pkg = model.Package.by_name(self.packagename) + pkg.state = 'deleted' + + g = model.Package.get_groups + def gg(*args, **kwargs): + return [grp] + model.Package.get_groups = gg + + context = { 'package': pkg, 'model': model, 'user': userobj.name } + try: + self.auth.check_access('package_show', context, {}) + assert False, "The user should not have access." + except NotAuthorized, e: + pass + model.Package.get_groups = g + pkg.state = "active" + + + + +class TestPublisherGroupPackages(FunctionalTestCase): + + @classmethod + def setup_class(self): + from ckan.tests.mock_publisher_auth import MockPublisherAuth + self.auth = MockPublisherAuth() + + model.Session.remove() + CreateTestData.create(auth_profile='publisher') + self.groupname = u'david' + self.packagename = u'testpkg' + model.repo.new_revision() + model.Session.add(model.Package(name=self.packagename)) + model.repo.commit_and_remove() + + @classmethod + def teardown_class(self): + model.Session.remove() + model.repo.rebuild_db() + model.Session.remove() + + def _run_fail_test( self, username, action): + pkg = model.Package.by_name(self.packagename) + context = { 'package': pkg, 'model': model, 'user': username } + try: + self.auth.check_access(action, context, {}) + assert False, "The user should not have access" + except NotAuthorized, e: + pass + + def _run_success_test( self, username, action): + userobj = model.User.get(username) + grp = model.Group.by_name(self.groupname) + pkg = model.Package.by_name(self.packagename) + + f = model.User.get_groups + g = model.Package.get_groups + def gg(*args, **kwargs): + return [grp] + model.User.get_groups = gg + model.Package.get_groups = gg + + context = { 'package': pkg, 'model': model, 'user': username } + try: + self.auth.check_access(action, context, {}) + except NotAuthorized, e: + assert False, "The user should have %s access: %r." % (action, e.extra_msg) + model.User.get_groups = f + model.Package.get_groups = g + + def test_new_success(self): + self._run_success_test( 'russianfan', 'package_create' ) + + # Currently valid to have any logged in user succeed + #def test_new_fail(self): + # self._run_fail_test( 'russianfan', 'package_create' ) + + def test_new_anon_fail(self): + self._run_fail_test( '', 'package_create' ) + + def test_new_unknown_fail(self): + self._run_fail_test( 'nosuchuser', 'package_create' ) + + def test_edit_success(self): + """ Success because user in group """ + self._run_success_test( 'russianfan', 'package_update' ) + + def test_edit_fail(self): + """ Fail because user not in group """ + self._run_fail_test( 'russianfan', 'package_update' ) + + def test_edit_anon_fail(self): + """ Fail because user is anon """ + self._run_fail_test( '', 'package_update' ) + + def test_edit_unknown_fail(self): + self._run_fail_test( 'nosuchuser', 'package_update' ) + + def test_delete_success(self): + """ Success because user in group """ + self._run_success_test( 'russianfan', 'package_delete' ) + + def test_delete_fail(self): + """ Fail because user not in group """ + self._run_fail_test( 'russianfan', 'package_delete' ) + + def test_delete_anon_fail(self): + """ Fail because user is anon """ + self._run_fail_test( '', 'package_delete' ) + + def test_delete_unknown_fail(self): + self._run_fail_test( 'nosuchuser', 'package_delete' ) + + +class TestPublisherPackageRelationships(FunctionalTestCase): + + @classmethod + def setup_class(self): + from ckan.tests.mock_publisher_auth import MockPublisherAuth + self.auth = MockPublisherAuth() + + model.Session.remove() + CreateTestData.create(auth_profile='publisher') + self.groupname = u'david' + self.package1name = u'testpkg' + self.package2name = u'testpkg2' + model.repo.new_revision() + pkg1 = model.Package(name=self.package1name) + pkg2 = model.Package(name=self.package2name) + model.Session.add( pkg1 ) + model.Session.add( pkg2 ) + model.Session.flush() + pkg1 = model.Package.by_name(self.package1name) + pkg2 = model.Package.by_name(self.package2name) + + self.rel = model.PackageRelationship(name="test", type='depends_on') + self.rel.subject = pkg1 + self.rel.object = pkg2 + model.Session.add( self.rel ) + model.repo.commit_and_remove() + + @classmethod + def teardown_class(self): + model.Session.remove() + model.repo.rebuild_db() + model.Session.remove() + + def test_create_fail_user( self): + p1 = model.Package.by_name( self.package1name ) + p2 = model.Package.by_name( self.package2name ) + + context = { 'model': model, 'user': 'russianfan' } + try: + self.auth.check_access('package_relationship_create', context, {'id': p1.id, 'id2': p2.id}) + assert False, "The user should not have access." + except NotAuthorized, e: + pass + + def test_create_fail_ddict( self): + p1 = model.Package.by_name( self.package1name ) + p2 = model.Package.by_name( self.package2name ) + + context = { 'model': model, 'user': 'russianfan' } + try: + self.auth.check_access('package_relationship_create', context, {'id': p1.id}) + assert False, "The user should not have access." + except NotAuthorized, e: + pass + + try: + self.auth.check_access('package_relationship_create', context, {'id2': p2.id}) + assert False, "The user should not have access." + except NotAuthorized, e: + pass + + def test_create_success(self): + userobj = model.User.get('russianfan') + + f = model.User.get_groups + g = model.Package.get_groups + def gg(*args, **kwargs): + return ['test_group'] + model.User.get_groups = gg + model.Package.get_groups = gg + + p1 = model.Package.by_name( self.package1name ) + p2 = model.Package.by_name( self.package2name ) + + context = { 'model': model, 'user': 'russianfan' } + try: + self.auth.check_access('package_relationship_create', context, {'id': p1.id, 'id2': p2.id}) + except NotAuthorized, e: + assert False, "The user should have %s access: %r." % (action, e.extra_msg) + model.User.get_groups = f + model.Package.get_groups = g + + def test_delete_success(self): + userobj = model.User.get('russianfan') + + f = model.User.get_groups + g = model.Package.get_groups + def gg(*args, **kwargs): + return ['test_group'] + model.User.get_groups = gg + model.Package.get_groups = gg + + p1 = model.Package.by_name( self.package1name ) + p2 = model.Package.by_name( self.package2name ) + + context = { 'model': model, 'user': 'russianfan', 'relationship': self.rel } + try: + self.auth.check_access('package_relationship_delete', context, {'id': p1.id, 'id2': p2.id }) + except NotAuthorized, e: + assert False, "The user should have %s access: %r." % ('package_relationship_delete', e.extra_msg) + + model.User.get_groups = f + model.Package.get_groups = g + + diff --git a/ckan/tests/lib/test_alphabet_pagination.py b/ckan/tests/lib/test_alphabet_pagination.py index 8af1e7f604b..a1bf6ae4749 100644 --- a/ckan/tests/lib/test_alphabet_pagination.py +++ b/ckan/tests/lib/test_alphabet_pagination.py @@ -40,8 +40,8 @@ def test_01_package_page(self): assert pager.startswith('
        '), pager assert 'A' in pager, pager url_base = '/packages' - assert re.search('\B\<\/a\>', pager), pager - assert re.search('\Other\<\/a\>', pager), pager + assert re.search('\B\<\/span\>', pager), pager + assert re.search('\Other\<\/span\>', pager), pager def test_02_package_items(self): diff --git a/ckan/tests/lib/test_dictization.py b/ckan/tests/lib/test_dictization.py index d92fc5bdffd..8d83a6f43dd 100644 --- a/ckan/tests/lib/test_dictization.py +++ b/ckan/tests/lib/test_dictization.py @@ -42,12 +42,14 @@ def setup_class(cls): 'name': u'david', 'type': u'group', 'state': u'active', - 'title': u"Dave's books"}, + 'title': u"Dave's books", + "approval_status": u"approved"}, {'description': u'Roger likes these books.', 'name': u'roger', 'type': u'group', 'state': u'active', - 'title': u"Roger's books"}], + 'title': u"Roger's books", + "approval_status": u"approved"}], 'isopen': True, 'license_id': u'other-open', 'maintainer': None, @@ -834,6 +836,7 @@ def test_16_group_dictized(self): group_dict = {'name': 'help', 'title': 'help', + 'approval_status': 'approved', 'extras': [{'key': 'genre', 'value': u'"horror"'}, {'key': 'media', 'value': u'"dvd"'}], 'packages':[{'name': 'annakarenina2'}, {'id': pkg.id, 'capacity': 'in'}], @@ -865,7 +868,8 @@ def test_16_group_dictized(self): 'packages': 0, 'state': u'active', 'title': u'simple', - 'type': u'publisher'}], + 'type': u'publisher', + 'approval_status': u'approved'}], 'users': [{'about': u'I love reading Annakarenina. My site: anna.com', 'display_name': u'annafan', 'capacity' : 'member', @@ -906,13 +910,12 @@ def test_16_group_dictized(self): 'url': u'http://www.annakarenina.com', 'version': u'0.7a'}], 'state': u'active', + 'approval_status': u'approved', 'title': u'help', 'type': u'group'} expected['packages'] = sorted(expected['packages'], key=lambda x: x['name']) - result = self.remove_changable_columns(group_dictized) - result['packages'] = sorted(result['packages'], key=lambda x: x['name']) assert result == expected, pformat(result) diff --git a/ckan/tests/lib/test_dictization_schema.py b/ckan/tests/lib/test_dictization_schema.py index 2cac490b366..1245689f302 100644 --- a/ckan/tests/lib/test_dictization_schema.py +++ b/ckan/tests/lib/test_dictization_schema.py @@ -145,7 +145,8 @@ def test_2_group_schema(self): 'packages': sorted([{'id': group_pack[0].id}, {'id': group_pack[1].id, }], key=lambda x:x["id"]), - 'title': u"Dave's books"} + 'title': u"Dave's books", + 'approval_status': u'approved'} assert not errors diff --git a/ckan/tests/logic/test_action.py b/ckan/tests/logic/test_action.py index 8e74da110df..82fbeef5ad4 100644 --- a/ckan/tests/logic/test_action.py +++ b/ckan/tests/logic/test_action.py @@ -253,7 +253,7 @@ def test_05_user_show_edits(self): assert 'timestamp' in edit assert_equal(edit['state'], 'active') assert_equal(edit['approved_timestamp'], None) - assert_equal(set(edit['groups']), set(('roger', 'david'))) + assert_equal(set(edit['groups']), set(( 'roger', 'david'))) assert_equal(edit['state'], 'active') assert edit['message'].startswith('Creating test data.') assert_equal(set(edit['packages']), set(('warandpeace', 'annakarenina'))) @@ -557,7 +557,7 @@ def test_13_group_list(self): { 'result': [ 'david', - 'roger' + 'roger', ], 'help': 'Returns a list of groups', 'success': True @@ -572,7 +572,7 @@ def test_13_group_list(self): assert res_obj['result'][0]['name'] == 'david' assert res_obj['result'][0]['display_name'] == 'Dave\'s books' assert res_obj['result'][0]['packages'] == 2 - assert res_obj['result'][1]['name'] == 'roger' + assert res_obj['result'][1]['name'] == 'roger', res_obj['result'][1] assert res_obj['result'][1]['packages'] == 1 assert 'id' in res_obj['result'][0] assert 'revision_id' in res_obj['result'][0] @@ -583,8 +583,7 @@ def test_13_group_list_by_size(self): res = self.app.post('/api/action/group_list', params=postparams) res_obj = json.loads(res.body) - assert_equal(sorted(res_obj['result']), ['david', - 'roger']) + assert_equal(sorted(res_obj['result']), ['david','roger']) def test_13_group_list_by_size_all_fields(self): postparams = '%s=1' % json.dumps({'order_by': 'packages', diff --git a/ckan/tests/misc/test_auth_profiles.py b/ckan/tests/misc/test_auth_profiles.py new file mode 100644 index 00000000000..a1d4f59f06f --- /dev/null +++ b/ckan/tests/misc/test_auth_profiles.py @@ -0,0 +1,64 @@ +from ckan.tests import * +import ckan.forms +import ckan.model as model +from ckan.tests.pylons_controller import PylonsTestCase + +from pylons import config + +class TestAuthProfiles(PylonsTestCase): + + @classmethod + def setup_class(self): + model.repo.init_db() + + @classmethod + def teardown_class(self): + model.repo.rebuild_db() + + def test_load_publisher_profile(self): + """ Ensure that the relevant config settings result in the appropriate + functions being loaded from the correct module """ + from new_authz import is_authorized, _get_auth_function + + config['ckan.auth.profile'] = 'publisher' + _ = is_authorized('site_read', {'model': model, 'user': '127.0.0.1','reset_auth_profile':True}) + s = str(_get_auth_function('site_read').__module__) + assert s == 'ckan.logic.auth.publisher.get', s + + def test_authorizer_count(self): + """ Ensure that we have the same number of auth functions in the + core auth profile as in the publisher auth profile """ + + modules = { + 'ckan.logic.auth': 0, + 'ckan.logic.auth.publisher': 0 + } + + module_items = { + 'ckan.logic.auth': [], + 'ckan.logic.auth.publisher': [] + } + + for module_root in modules.keys(): + for auth_module_name in ['get', 'create', 'update','delete']: + module_path = '%s.%s' % (module_root, auth_module_name,) + module = __import__(module_path) + + for part in module_path.split('.')[1:]: + module = getattr(module, part) + + for key, v in module.__dict__.items(): + if not key.startswith('_'): + modules[module_root] = modules[module_root] + 1 + module_items[module_root].append( key ) + + err = [] + if modules['ckan.logic.auth'] != modules['ckan.logic.auth.publisher']: + oldauth = module_items['ckan.logic.auth'] + pubauth = module_items['ckan.logic.auth.publisher'] + for e in [n for n in oldauth if not n in pubauth]: + err.append( '%s is in auth but not publisher auth ' % e ) + for e in [n for n in pubauth if not n in oldauth]: + err.append( '%s is in publisher auth but not auth ' % e ) + assert modules['ckan.logic.auth'] == modules['ckan.logic.auth.publisher'], err + diff --git a/ckan/tests/mock_publisher_auth.py b/ckan/tests/mock_publisher_auth.py new file mode 100644 index 00000000000..749588a4fd9 --- /dev/null +++ b/ckan/tests/mock_publisher_auth.py @@ -0,0 +1,34 @@ +from ckan.new_authz import is_authorized +from ckan.logic import NotAuthorized + +class MockPublisherAuth(object): + """ + MockPublisherAuth + """ + + def __init__(self): + self.functions = {} + self._load() + + def _load(self): + for auth_module_name in ['get', 'create', 'update','delete']: + module_path = 'ckan.logic.auth.publisher.%s' % (auth_module_name,) + try: + module = __import__(module_path) + except ImportError,e: + log.debug('No auth module for action "%s"' % auth_module_name) + continue + + for part in module_path.split('.')[1:]: + module = getattr(module, part) + + for key, v in module.__dict__.items(): + if not key.startswith('_'): + self.functions[key] = v + + + def check_access(self,action, context, data_dict): + logic_authorization = self.functions[action](context, data_dict) + if not logic_authorization['success']: + msg = logic_authorization.get('msg','') + raise NotAuthorized(msg) diff --git a/ckanext/publisher_form/__init__.py b/ckanext/publisher_form/__init__.py new file mode 100644 index 00000000000..37a0ca0ec88 --- /dev/null +++ b/ckanext/publisher_form/__init__.py @@ -0,0 +1,6 @@ +'''Publisher form + +Provides a form for publisher creation +''' +__version__ = '0.1' + diff --git a/ckanext/publisher_form/forms.py b/ckanext/publisher_form/forms.py new file mode 100644 index 00000000000..216d9f6f2ca --- /dev/null +++ b/ckanext/publisher_form/forms.py @@ -0,0 +1,203 @@ +import os, logging +from ckan.authz import Authorizer +import ckan.logic.action.create as create +import ckan.logic.action.update as update +import ckan.logic.action.get as get +from ckan.logic.converters import date_to_db, date_to_form, convert_to_extras, convert_from_extras +from ckan.logic import NotFound, NotAuthorized, ValidationError +from ckan.logic import tuplize_dict, clean_dict, parse_params +import ckan.logic.schema as default_schema +from ckan.logic.schema import group_form_schema +from ckan.logic.schema import package_form_schema +import ckan.logic.validators as val +from ckan.lib.base import BaseController, render, c, model, abort, request +from ckan.lib.base import redirect, _, config, h +from ckan.lib.package_saver import PackageSaver +from ckan.lib.field_types import DateType, DateConvertError +from ckan.lib.navl.dictization_functions import Invalid +from ckan.lib.navl.dictization_functions import validate, missing +from ckan.lib.navl.dictization_functions import DataError, flatten_dict, unflatten +from ckan.plugins import IDatasetForm, IGroupForm, IConfigurer +from ckan.plugins import implements, SingletonPlugin + +from ckan.lib.navl.validators import (ignore_missing, + not_empty, + empty, + ignore, + keep_extras, + ) + +log = logging.getLogger(__name__) + +class PublisherForm(SingletonPlugin): + """ + This plugin implements an IGroupForm for form associated with a + publisher group. ``IConfigurer`` is used to add the local template + path and the IGroupForm supplies the custom form. + """ + implements(IGroupForm, inherit=True) + implements(IConfigurer, inherit=True) + + def update_config(self, config): + """ + This IConfigurer implementation causes CKAN to look in the + ```templates``` directory when looking for the group_form() + """ + here = os.path.dirname(__file__) + rootdir = os.path.dirname(os.path.dirname(here)) + template_dir = os.path.join(rootdir, 'ckanext', + 'publisher_form', 'templates') + config['extra_template_paths'] = ','.join([template_dir, + config.get('extra_template_paths', '')]) + + def group_form(self): + """ + Returns a string representing the location of the template to be + rendered. e.g. "forms/group_form.html". + """ + return 'publisher_form.html' + + def group_types(self): + """ + Returns an iterable of group type strings. + + If a request involving a group of one of those types is made, then + this plugin instance will be delegated to. + + There must only be one plugin registered to each group type. Any + attempts to register more than one plugin instance to a given group + type will raise an exception at startup. + """ + return ["publisher"] + + def is_fallback(self): + """ + Returns true iff this provides the fallback behaviour, when no other + plugin instance matches a group's type. + + As this is not the fallback controller we should return False. If + we were wanting to act as the fallback, we'd return True + """ + return False + + def form_to_db_schema(self): + """ + Returns the schema for mapping group data from a form to a format + suitable for the database. + """ + return group_form_schema() + + def db_to_form_schema(self): + """ + Returns the schema for mapping group data from the database into a + format suitable for the form (optional) + """ + return {} + + def check_data_dict(self, data_dict): + """ + Check if the return data is correct. + + raise a DataError if not. + """ + + def setup_template_variables(self, context, data_dict): + """ + Add variables to c just prior to the template being rendered. We should + use the available groups for the current user, but should be optional + in case this is a top level group + """ + #c.user_groups = c.userobj.get_groups('publisher') + c.user_groups = ['One', 'Two', 'Three'] + + +class PublisherDatasetForm(SingletonPlugin): + """ + This plugin implements a new publisher form for cases where we + want to enforce group (type=publisher) membership on a dataset. + + """ + implements(IDatasetForm, inherit=True) + implements(IConfigurer, inherit=True) + + def update_config(self, config): + """ + This IConfigurer implementation causes CKAN to look in the + ```templates``` directory when looking for the package_form() + """ + here = os.path.dirname(__file__) + rootdir = os.path.dirname(os.path.dirname(here)) + template_dir = os.path.join(rootdir, 'ckanext', + 'publisher_form', 'templates') + config['extra_template_paths'] = ','.join([template_dir, + config.get('extra_template_paths', '')]) + + def package_form(self): + """ + Returns a string representing the location of the template to be + rendered. e.g. "package/new_package_form.html". + """ + return 'dataset_form.html' + + def is_fallback(self): + """ + Returns true iff this provides the fallback behaviour, when no other + plugin instance matches a package's type. + + As this is not the fallback controller we should return False. If + we were wanting to act as the fallback, we'd return True + """ + return True + + def package_types(self): + """ + Returns an iterable of package type strings. + + If a request involving a package of one of those types is made, then + this plugin instance will be delegated to. + + There must only be one plugin registered to each package type. Any + attempts to register more than one plugin instance to a given package + type will raise an exception at startup. + """ + return ["dataset"] + + def setup_template_variables(self, context, data_dict=None): + """ + Adds variables to c just prior to the template being rendered that can + then be used within the form + """ + c.licences = [('', '')] + model.Package.get_license_options() + c.publishers = [('Example publisher', 'Example publisher 2')] + c.is_sysadmin = Authorizer().is_sysadmin(c.user) + c.resource_columns = model.Resource.get_columns() + c.groups_available = c.userobj.get_groups('publisher') if c.userobj else [] + + + ## This is messy as auths take domain object not data_dict + pkg = context.get('package') or c.pkg + if pkg: + c.auth_for_change_state = Authorizer().am_authorized( + c, model.Action.CHANGE_STATE, pkg) + + def form_to_db_schema(self): + """ + Returns the schema for mapping package data from a form to a format + suitable for the database. + """ + return package_form_schema() + + def db_to_form_schema(data): + """ + Returns the schema for mapping package data from the database into a + format suitable for the form (optional) + """ + return {} + + def check_data_dict(self, data_dict): + """ + Check if the return data is correct and raises a DataError if not. + """ + pass + + \ No newline at end of file diff --git a/ckanext/publisher_form/templates/dataset_form.html b/ckanext/publisher_form/templates/dataset_form.html new file mode 100644 index 00000000000..9a52ac73c94 --- /dev/null +++ b/ckanext/publisher_form/templates/dataset_form.html @@ -0,0 +1,231 @@ +
        + + +
        +

        Errors in form

        +

        The form contains invalid entries:

        +
          +
        • ${"%s: %s" % (key, error)} + +
            + +
          • + Resource ${idx}: +
              +
            • ${thiskey}: ${errorinfo};
            • +
            +
          • +
            +
          +
          +
        • +
        +
        + +
        +
        +
        +
        + +
        +
        ${errors.get('title', '')}
        + +
        +
        + ${url(controller='package', action='index')+'/'}  + +

         

        +
        + +
        ${errors.get('name', '')}
        + +
        +
        +
        The URL for the web page describing the data (not the data itself).
        +
        e.g. http://www.example.com/growth-figures.html
        +
        ${errors.get('url', '')}
        + +
        +
        + +
        +
        The licence under which the dataset is released.
        + +
        +
        +
          +
        • +
        • +
        + + + You can use Markdown formatting here. + +
        +
        +
        + +
        +

        Resources: the files and APIs associated with this dataset

        + + + + + + + + + +
        Resource
        + + +
        +
          +
        • Add a resource:

        • +
        • +
        • + +
        +
        +
        + +
        +

        Publishers

        +
        + + + +
        + + +
        +
        +
        + +
        Publisher
        +
        + +
        +
        Cannot add any groups.
        +
        +

        Tags

        +
        +
        +
        + +
        +
        Comma-separated terms that may link this dataset to similar ones. For more information on conventions, see this wiki page.
        +
        e.g. pollution, rivers, water quality
        +
        ${errors.get('tag_string', '')}
        +
        +
        +
        +
        +
        +
        +
        The name of the main contact, for enquiries about this particular dataset, using the e-mail address in the following field.
        + +
        +
        + +
        +
        +
        If there is another important contact person (in addition to the person in the Author field) then provide details here.
        + +
        +
        + +
        +
        +
        A number representing the version (if applicable)
        +
        e.g. 1.2.0
        + +
        +
        + +
        + +
        +
        + +
        +
        + + +
        +
        + + + Delete +
        +
        + + +
        +
        + + with value + +
        +
        +
        +
        +
        + + + + + +
        + +

        + Since you have not signed in this will just be your IP address. + Click here to sign in before saving (opens in new window). +

        +
        + +
        + + + + +

        + Important: By submitting content, you agree to release your contributions under the Open Database License. Please refrain from editing this page if you are not happy to do this. +

        +
        +
        + + + +
        diff --git a/ckanext/publisher_form/templates/publisher_form.html b/ckanext/publisher_form/templates/publisher_form.html new file mode 100644 index 00000000000..45cb350b6fb --- /dev/null +++ b/ckanext/publisher_form/templates/publisher_form.html @@ -0,0 +1,142 @@ + +
        + +
        +

        Errors in form

        +

        The form contains invalid entries:

        +
          +
        • ${"%s: %s" % (key, error)}
        • +
        +
        + + + + +
        +
        +
        +
        + + +
        +
        + ${g.site_url+h.url_for('publisher_index')+'/'}  + +

         

        +
        + +
        ${errors.get('name', '')}
        + +
        +
        +
          +
        • +
        • +
        + + + You can use Markdown formatting here. +
        + + +
        +
        + +
        +
        +
        + +
        +

        Extras

        +
        + + +
        +
        + + + Delete +
        +
        + + +
        +
        + + with value + +
        +
        +
        +
        +
        + +
        +

        Users

        +
        + +
        +
        +
        + +
        +
        + Admin + Editor +
        +
        +
        +

        There are no users currently in this publisher.

        + + +

        Add users

        +
        +
        +
        + Admin + Editor +
        +
        +
        +
        +
        + + +
        +

        Datasets

        +
        + +
        +
        + +
        +
        +
        +

        There are no datasets currently in this publisher.

        + +

        Add datasets

        +
        +
        +
        +
        +
        +
        + Add a new dataset +
        +
        +
        + +
        + + + + +
        +
        diff --git a/doc/configuration.rst b/doc/configuration.rst index e087f20764b..a573af30382 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -578,7 +578,7 @@ Authorization Settings ---------------------- .. index:: - single: default_roles + single: default_roles, auth_profile default_roles ^^^^^^^^^^^^^ @@ -597,6 +597,19 @@ With this example setting, visitors and logged-in users can only read datasets t Defaults: see in ``ckan/model/authz.py`` for: ``default_default_user_roles`` +auth_profile +^^^^^^^^^^^^ + +This allows you to specify the auth profile to use for this installation. By default this is empty and uses the default authorisation code, if set to publisher it will use the publisher profile in ckan/logic/auth/publisher. + +Example:: + auth.profile = publisher + +With this example setting the publisher auth profile will be used. + +Defaults: The default authorisation from ``ckan/logic/auth/*`` will be used + + Plugin Settings --------------- diff --git a/setup.py b/setup.py index a0ef5ae60cc..b020bb6eb56 100644 --- a/setup.py +++ b/setup.py @@ -86,6 +86,8 @@ [ckan.plugins] synchronous_search = ckan.lib.search:SynchronousSearchPlugin stats=ckanext.stats.plugin:StatsPlugin + publisher_form=ckanext.publisher_form.forms:PublisherForm + publisher_dataset_form=ckanext.publisher_form.forms:PublisherDatasetForm [ckan.system_plugins] domain_object_mods = ckan.model.modification:DomainObjectModificationExtension