diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000000..ed2c3b6bc6f --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = /ckan/migration/*, /ckan/tests/*, */tests/* +source = ckan, ckanext diff --git a/.travis.yml b/.travis.yml index af450298b6c..debf9925c02 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,3 +14,5 @@ notifications: on_failure: change template: - "%{repository} %{branch} %{commit} %{build_url} %{author}: %{message}" +after_success: + - coveralls diff --git a/README.rst b/README.rst index 93d80e668cc..2eafd662e07 100644 --- a/README.rst +++ b/README.rst @@ -5,6 +5,10 @@ CKAN: The Open Source Data Portal Software :target: http://travis-ci.org/okfn/ckan :alt: Build Status +.. image:: https://coveralls.io/repos/okfn/ckan/badge.png?branch=coveralls + :target: https://coveralls.io/r/okfn/ckan?branch=coveralls + :alt: Test coverage + **CKAN is the world’s leading open-source data portal platform**. CKAN makes it easy to publish, share and work with data. It's a data management system that provides a powerful platform for cataloging, storing and accessing diff --git a/bin/travis-build b/bin/travis-build index 51e633781b4..63b4f50e339 100755 --- a/bin/travis-build +++ b/bin/travis-build @@ -63,7 +63,7 @@ MOCHA_ERROR=$? killall paster # And finally, run the nosetests -nosetests --ckan --with-pylons=test-core.ini --nologcapture ckan ckanext +nosetests --ckan --with-pylons=test-core.ini --nologcapture ckan ckanext --with-coverage --cover-package=ckanext --cover-package=ckan # Did an error occur? NOSE_ERROR=$? diff --git a/ckan/config/environment.py b/ckan/config/environment.py index 49be9b726a6..409f77435a4 100644 --- a/ckan/config/environment.py +++ b/ckan/config/environment.py @@ -159,7 +159,7 @@ def find_controller(self, controller): ''' This code is based on Genshi code - Copyright © 2006-2012 Edgewall Software + Copyright © 2006-2012 Edgewall Software All rights reserved. Redistribution and use in source and binary forms, with or diff --git a/ckan/config/routing.py b/ckan/config/routing.py index 0fbb02bb886..8f78a6c099d 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -359,6 +359,7 @@ def make_map(): action='followers', ckan_icon='group') m.connect('user_edit', '/user/edit/{id:.*}', action='edit', ckan_icon='cog') + m.connect('user_delete', '/user/delete/{id}', action='delete') m.connect('/user/reset/{id:.*}', action='perform_reset') m.connect('register', '/user/register', action='register') m.connect('login', '/user/login', action='login') diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index ccef46bfe78..66bd533fd43 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -612,6 +612,19 @@ def member_new(self, id): data_dict = clean_dict(dict_fns.unflatten( tuplize_dict(parse_params(request.params)))) data_dict['id'] = id + + email = data_dict.get('email') + if email: + user_data_dict = { + 'email': email, + 'group_id': data_dict['id'], + 'role': data_dict['role'] + } + del data_dict['email'] + user_dict = self._action('user_invite')(context, + user_data_dict) + data_dict['username'] = user_dict['name'] + c.group_dict = self._action('group_member_create')(context, data_dict) self._redirect_to(controller='group', action='members', id=id) else: diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py index e1bd29b2b26..1c6fefff344 100644 --- a/ckan/controllers/user.py +++ b/ckan/controllers/user.py @@ -183,6 +183,22 @@ def new(self, data=None, errors=None, error_summary=None): c.form = render(self.new_user_form, extra_vars=vars) return render('user/new.html') + def delete(self, id): + '''Delete user with id passed as parameter''' + context = {'model': model, + 'session': model.Session, + 'user': c.user, + 'auth_user_obj': c.userobj} + data_dict = {'id': id} + + try: + get_action('user_delete')(context, data_dict) + user_index = h.url_for(controller='user', action='index') + h.redirect_to(user_index) + except NotAuthorized: + msg = _('Unauthorized to delete user with id "{user_id}".') + abort(401, msg.format(user_id=id)) + def _save_new(self, context): try: data_dict = logic.clean_dict(unflatten( @@ -392,6 +408,9 @@ def request_reset(self): if request.method == 'POST': id = request.params.get('user') + context = {'model': model, + 'user': c.user} + data_dict = {'id': id} user_obj = None try: @@ -435,18 +454,16 @@ def perform_reset(self, id): # FIXME We should reset the reset key when it is used to prevent # reuse of the url context = {'model': model, 'session': model.Session, - 'user': c.user, - 'auth_user_obj': c.userobj, + 'user': id, 'keep_sensitive_data': True} - data_dict = {'id': id} - try: check_access('user_reset', context) except NotAuthorized: abort(401, _('Unauthorized to reset password.')) try: + data_dict = {'id': id} user_dict = get_action('user_show')(context, data_dict) # Be a little paranoid, and get rid of sensitive data that's @@ -468,6 +485,7 @@ def perform_reset(self, id): new_password = self._get_form_password() user_dict['password'] = new_password user_dict['reset_key'] = c.reset_key + user_dict['state'] = model.State.ACTIVE user = get_action('user_update')(context, user_dict) h.flash_success(_("Your password has been reset.")) diff --git a/ckan/lib/app_globals.py b/ckan/lib/app_globals.py index dde98f0a1ed..38c777f7555 100644 --- a/ckan/lib/app_globals.py +++ b/ckan/lib/app_globals.py @@ -39,7 +39,6 @@ # has been setup in load_environment(): 'ckan.site_id': {}, 'ckan.recaptcha.publickey': {'name': 'recaptcha_publickey'}, - 'ckan.recaptcha.privatekey': {'name': 'recaptcha_publickey'}, 'ckan.template_title_deliminater': {'default': '-'}, 'ckan.template_head_end': {}, 'ckan.template_footer_end': {}, diff --git a/ckan/lib/authenticator.py b/ckan/lib/authenticator.py index 6f061caad91..cf6ed016694 100644 --- a/ckan/lib/authenticator.py +++ b/ckan/lib/authenticator.py @@ -12,9 +12,9 @@ class OpenIDAuthenticator(object): def authenticate(self, environ, identity): if 'repoze.who.plugins.openid.userid' in identity: - openid = identity.get('repoze.who.plugins.openid.userid') + openid = identity['repoze.who.plugins.openid.userid'] user = User.by_openid(openid) - if user is None: + if user is None or not user.is_active(): return None else: return user.name @@ -25,14 +25,20 @@ class UsernamePasswordAuthenticator(object): implements(IAuthenticator) def authenticate(self, environ, identity): - if not 'login' in identity or not 'password' in identity: + if not ('login' in identity and 'password' in identity): return None - user = User.by_name(identity.get('login')) + + login = identity['login'] + user = User.by_name(login) + if user is None: - log.debug('Login failed - username %r not found', identity.get('login')) - return None - if user.validate_password(identity.get('password')): + log.debug('Login failed - username %r not found', login) + elif not user.is_active(): + log.debug('Login as %r failed - user isn\'t active', login) + elif not user.validate_password(identity['password']): + log.debug('Login as %r failed - password not valid', login) + else: return user.name - log.debug('Login as %r failed - password not valid', identity.get('login')) + return None diff --git a/ckan/lib/base.py b/ckan/lib/base.py index 3260bbb7e83..92b608afbd1 100644 --- a/ckan/lib/base.py +++ b/ckan/lib/base.py @@ -314,8 +314,9 @@ def _identify_user_default(self): if c.user: c.user = c.user.decode('utf8') c.userobj = model.User.by_name(c.user) - if c.userobj is None: - # This occurs when you are logged in, clean db + if c.userobj is None or not c.userobj.is_active(): + # This occurs when a user that was still logged in is deleted, + # or when you are logged in, clean db # and then restart (or when you change your username) # There is no user object, so even though repoze thinks you # are logged in and your cookie has ckan_display_name, we diff --git a/ckan/lib/create_test_data.py b/ckan/lib/create_test_data.py index 3edf865c7c7..6c7a275c36f 100644 --- a/ckan/lib/create_test_data.py +++ b/ckan/lib/create_test_data.py @@ -519,8 +519,9 @@ def _create_user_without_commit(cls, name='', **user_dict): @classmethod def create_user(cls, name='', **kwargs): - cls._create_user_without_commit(name, **kwargs) + user = cls._create_user_without_commit(name, **kwargs) model.Session.commit() + return user @classmethod def flag_for_deletion(cls, pkg_names=[], tag_names=[], group_names=[], diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py index b3a83235cd6..34fb738b950 100644 --- a/ckan/lib/dictization/model_dictize.py +++ b/ckan/lib/dictization/model_dictize.py @@ -204,6 +204,9 @@ def package_dictize(pkg, context): if not result: raise logic.NotFound result_dict = d.table_dictize(result, context) + #strip whitespace from title + if result_dict.get('title'): + result_dict['title'] = result_dict['title'].strip() #resources res_rev = model.resource_revision_table resource_group = model.resource_group_table diff --git a/ckan/lib/mailer.py b/ckan/lib/mailer.py index f0e1978ac2c..72ebe33e2e0 100644 --- a/ckan/lib/mailer.py +++ b/ckan/lib/mailer.py @@ -100,21 +100,37 @@ def mail_user(recipient, subject, body, headers={}): mail_recipient(recipient.display_name, recipient.email, subject, body, headers=headers) +def get_reset_link_body(user): + reset_link_message = _( + '''You have requested your password on %(site_title)s to be reset. -RESET_LINK_MESSAGE = _( -'''You have requested your password on %(site_title)s to be reset. + Please click the following link to confirm this request: -Please click the following link to confirm this request: + %(reset_link)s + ''') - %(reset_link)s -''') + d = { + 'reset_link': get_reset_link(user), + 'site_title': g.site_title + } + return reset_link_message % d -def make_key(): - return uuid.uuid4().hex[:10] +def get_invite_body(user): + invite_message = _( + '''You have been invited to %(site_title)s. A user has already been created to + you with the username %(user_name)s. You can change it later. -def create_reset_key(user): - user.reset_key = unicode(make_key()) - model.repo.commit_and_remove() + To accept this invite, please reset your password at: + + %(reset_link)s + ''') + + d = { + 'reset_link': get_reset_link(user), + 'site_title': g.site_title, + 'user_name': user.name, + } + return invite_message % d def get_reset_link(user): return urljoin(g.site_url, @@ -123,17 +139,24 @@ def get_reset_link(user): id=user.id, key=user.reset_key)) -def get_reset_link_body(user): - d = { - 'reset_link': get_reset_link(user), - 'site_title': g.site_title - } - return RESET_LINK_MESSAGE % d - def send_reset_link(user): create_reset_key(user) body = get_reset_link_body(user) - mail_user(user, _('Reset your password'), body) + subject = _('Reset your password') + mail_user(user, subject, body) + +def send_invite(user): + create_reset_key(user) + body = get_invite_body(user) + subject = _('Invite for {site_title}'.format(site_title=g.site_title)) + mail_user(user, subject, body) + +def create_reset_key(user): + user.reset_key = unicode(make_key()) + model.repo.commit_and_remove() + +def make_key(): + return uuid.uuid4().hex[:10] def verify_reset_link(user, key): if not key: diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index baac5a2154c..c7fb98be30a 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -1,6 +1,8 @@ '''API functions for adding data to CKAN.''' import logging +import random +import re from pylons import config import paste.deploy.converters @@ -15,6 +17,8 @@ import ckan.lib.dictization.model_dictize as model_dictize import ckan.lib.dictization.model_save as model_save import ckan.lib.navl.dictization_functions +import ckan.lib.navl.validators as validators +import ckan.lib.mailer as mailer from ckan.common import _ @@ -837,6 +841,65 @@ def user_create(context, data_dict): log.debug('Created user {name}'.format(name=user.name)) return user_dict + +def user_invite(context, data_dict): + '''Invite a new user. + + You must be authorized to create group members. + + :param email: the email of the user to be invited to the group + :type email: string + :param group_id: the id or name of the group + :type group_id: string + :param role: role of the user in the group. One of ``member``, ``editor``, + or ``admin`` + :type role: string + + :returns: the newly created yser + :rtype: dictionary + ''' + _check_access('user_invite', context, data_dict) + + schema = context.get('schema', + ckan.logic.schema.default_user_invite_schema()) + data, errors = _validate(data_dict, schema, context) + if errors: + raise ValidationError(errors) + + name = _get_random_username_from_email(data['email']) + password = str(random.SystemRandom().random()) + data['name'] = name + data['password'] = password + data['state'] = ckan.model.State.PENDING + user_dict = _get_action('user_create')(context, data) + user = ckan.model.User.get(user_dict['id']) + member_dict = { + 'username': user.id, + 'id': data['group_id'], + 'role': data['role'] + } + _get_action('group_member_create')(context, member_dict) + mailer.send_invite(user) + return model_dictize.user_dictize(user, context) + + +def _get_random_username_from_email(email): + localpart = email.split('@')[0] + cleaned_localpart = re.sub(r'[^\w]', '-', localpart) + + # if we can't create a unique user name within this many attempts + # then something else is probably wrong and we should give up + max_name_creation_attempts = 100 + + for i in range(max_name_creation_attempts): + random_number = random.SystemRandom().random() * 10000 + name = '%s-%d' % (cleaned_localpart, random_number) + if not ckan.model.User.get(name): + return name + + return cleaned_localpart + + ## Modifications for rest api def package_create_rest(context, data_dict): diff --git a/ckan/logic/action/delete.py b/ckan/logic/action/delete.py index 60ea1a5bed8..9e3ee3244ab 100644 --- a/ckan/logic/action/delete.py +++ b/ckan/logic/action/delete.py @@ -18,6 +18,29 @@ _get_or_bust = ckan.logic.get_or_bust _get_action = ckan.logic.get_action + +def user_delete(context, data_dict): + '''Delete a user. + + Only sysadmins can delete users. + + :param id: the id or usernamename of the user to delete + :type id: string + ''' + + _check_access('user_delete', context, data_dict) + + model = context['model'] + user_id = _get_or_bust(data_dict, 'id') + user = model.User.get(user_id) + + if user is None: + raise NotFound('User "{id}" was not found.'.format(id=user_id)) + + user.delete() + model.repo.commit() + + def package_delete(context, data_dict): '''Delete a dataset (package). diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 4f4b2fb57ee..37a5ca297c0 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -15,6 +15,7 @@ import ckan.logic.schema import ckan.lib.dictization.model_dictize as model_dictize import ckan.lib.navl.dictization_functions +import ckan.model as model import ckan.model.misc as misc import ckan.plugins as plugins import ckan.lib.search as search @@ -688,6 +689,9 @@ def user_list(context, data_dict): else_=model.User.fullname) ) + # Filter deleted users + query = query.filter(model.User.state != model.State.DELETED) + ## hack for pagination if context.get('return_query'): return query @@ -1266,7 +1270,9 @@ def user_autocomplete(context, data_dict): q = data_dict['q'] limit = data_dict.get('limit', 20) - query = model.User.search(q).limit(limit) + query = model.User.search(q) + query = query.filter(model.User.state != model.State.DELETED) + query = query.limit(limit) user_list = [] for user in query.all(): @@ -1314,8 +1320,9 @@ def package_search(context, data_dict): :param facet.mincount: the minimum counts for facet fields should be included in the results. :type facet.mincount: int - :param facet.limit: the maximum number of constraint counts that should be - returned for the facet fields. A negative value means unlimited + :param facet.limit: the maximum number of values the facet fields return. + A negative value means unlimited. This can be set instance-wide with + the :ref:`search.facets.limit` config option. Default is 50. :type facet.limit: int :param facet.field: the fields to facet upon. Default empty. If empty, then the returned facet information is empty. diff --git a/ckan/logic/auth/create.py b/ckan/logic/auth/create.py index a18485c2c57..bc995eb065c 100644 --- a/ckan/logic/auth/create.py +++ b/ckan/logic/auth/create.py @@ -113,6 +113,9 @@ def user_create(context, data_dict=None): else: return {'success': True} +def user_invite(context, data_dict=None): + context['id'] = context.get('group_id') + return group_member_create(context, data_dict) def _check_group_auth(context, data_dict): # FIXME This code is shared amoung other logic.auth files and should be diff --git a/ckan/logic/auth/delete.py b/ckan/logic/auth/delete.py index 61b44697407..08974144a71 100644 --- a/ckan/logic/auth/delete.py +++ b/ckan/logic/auth/delete.py @@ -4,6 +4,12 @@ from ckan.logic.auth import get_resource_object from ckan.lib.base import _ + +def user_delete(context, data_dict): + # sysadmins only + return {'success': False} + + def package_delete(context, data_dict): user = context['user'] package = get_package_object(context, data_dict) diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index f8fe8df7fbd..41e3c724656 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -395,6 +395,7 @@ def default_user_schema(): 'apikey': [ignore], 'reset_key': [ignore], 'activity_streams_email_notifications': [ignore_missing], + 'state': [ignore_missing], } return schema @@ -423,6 +424,14 @@ def default_update_user_schema(): return schema +def default_user_invite_schema(): + schema = { + 'email': [not_empty, unicode], + 'group_id': [not_empty], + 'role': [not_empty], + } + return schema + def default_task_status_schema(): schema = { 'id': [ignore], @@ -539,7 +548,7 @@ def default_package_search_schema(): 'qf': [ignore_missing, unicode], 'facet': [ignore_missing, unicode], 'facet.mincount': [ignore_missing, natural_number_validator], - 'facet.limit': [ignore_missing, natural_number_validator], + 'facet.limit': [ignore_missing, int_validator], 'facet.field': [ignore_missing, list_of_strings], 'extras': [ignore_missing] # Not used by Solr, but useful for extensions } diff --git a/ckan/migration/versions/071_add_state_column_to_user_table.py b/ckan/migration/versions/071_add_state_column_to_user_table.py new file mode 100644 index 00000000000..828ebfb5f44 --- /dev/null +++ b/ckan/migration/versions/071_add_state_column_to_user_table.py @@ -0,0 +1,9 @@ +import ckan.model + + +def upgrade(migrate_engine): + migrate_engine.execute( + ''' + ALTER TABLE "user" ADD COLUMN "state" text NOT NULL DEFAULT '%s' + ''' % ckan.model.State.ACTIVE + ) diff --git a/ckan/model/follower.py b/ckan/model/follower.py index 6e6096ed5cc..eb54cd9ca67 100644 --- a/ckan/model/follower.py +++ b/ckan/model/follower.py @@ -1,30 +1,28 @@ -import sqlalchemy import meta import datetime +import sqlalchemy + +import core +import ckan.model import domain_object -class UserFollowingUser(domain_object.DomainObject): - '''A many-many relationship between users. - A relationship between one user (the follower) and another (the object), - that means that the follower is currently following the object. - - ''' +class ModelFollowingModel(domain_object.DomainObject): def __init__(self, follower_id, object_id): self.follower_id = follower_id self.object_id = object_id self.datetime = datetime.datetime.now() @classmethod - def get(self, follower_id, object_id): - '''Return a UserFollowingUser object for the given follower_id and + def get(cls, follower_id, object_id): + '''Return a ModelFollowingModel object for the given follower_id and object_id, or None if no such follower exists. ''' - query = meta.Session.query(UserFollowingUser) - query = query.filter(UserFollowingUser.follower_id==follower_id) - query = query.filter(UserFollowingUser.object_id==object_id) - return query.first() + query = cls._get(follower_id, object_id) + following = cls._filter_following_objects(query) + if len(following) == 1: + return following[0] @classmethod def is_following(cls, follower_id, object_id): @@ -32,33 +30,78 @@ def is_following(cls, follower_id, object_id): otherwise. ''' - return UserFollowingUser.get(follower_id, object_id) is not None - + return cls.get(follower_id, object_id) is not None @classmethod def followee_count(cls, follower_id): - '''Return the number of users followed by a user.''' - return meta.Session.query(UserFollowingUser).filter( - UserFollowingUser.follower_id == follower_id).count() + '''Return the number of objects followed by the follower.''' + return cls._get_followees(follower_id).count() @classmethod def followee_list(cls, follower_id): - '''Return a list of users followed by a user.''' - return meta.Session.query(UserFollowingUser).filter( - UserFollowingUser.follower_id == follower_id).all() + '''Return a list of objects followed by the follower.''' + query = cls._get_followees(follower_id).all() + followees = cls._filter_following_objects(query) + return followees + @classmethod + def follower_count(cls, object_id): + '''Return the number of followers of the object.''' + return cls._get_followers(object_id).count() @classmethod - def follower_count(cls, user_id): - '''Return the number of followers of a user.''' - return meta.Session.query(UserFollowingUser).filter( - UserFollowingUser.object_id == user_id).count() + def follower_list(cls, object_id): + '''Return a list of followers of the object.''' + query = cls._get_followers(object_id).all() + followers = cls._filter_following_objects(query) + return followers + + @classmethod + def _filter_following_objects(cls, query): + return [q[0] for q in query] + + @classmethod + def _get_followees(cls, follower_id): + return cls._get(follower_id) @classmethod - def follower_list(cls, user_id): - '''Return a list of followers of a user.''' - return meta.Session.query(UserFollowingUser).filter( - UserFollowingUser.object_id == user_id).all() + def _get_followers(cls, object_id): + return cls._get(None, object_id) + + @classmethod + def _get(cls, follower_id=None, object_id=None): + follower_alias = sqlalchemy.orm.aliased(cls._follower_class()) + object_alias = sqlalchemy.orm.aliased(cls._object_class()) + + follower_id = follower_id or cls.follower_id + object_id = object_id or cls.object_id + + query = meta.Session.query(cls, follower_alias, object_alias)\ + .filter(sqlalchemy.and_( + follower_alias.id == follower_id, + cls.follower_id == follower_alias.id, + cls.object_id == object_alias.id, + follower_alias.state != core.State.DELETED, + object_alias.state != core.State.DELETED, + object_alias.id == object_id)) + + return query + + +class UserFollowingUser(ModelFollowingModel): + '''A many-many relationship between users. + + A relationship between one user (the follower) and another (the object), + that means that the follower is currently following the object. + + ''' + @classmethod + def _follower_class(cls): + return ckan.model.User + + @classmethod + def _object_class(cls): + return ckan.model.User user_following_user_table = sqlalchemy.Table('user_following_user', @@ -76,62 +119,20 @@ def follower_list(cls, user_id): meta.mapper(UserFollowingUser, user_following_user_table) -class UserFollowingDataset(domain_object.DomainObject): +class UserFollowingDataset(ModelFollowingModel): '''A many-many relationship between users and datasets (packages). A relationship between a user (the follower) and a dataset (the object), that means that the user is currently following the dataset. ''' - def __init__(self, follower_id, object_id): - self.follower_id = follower_id - self.object_id = object_id - self.datetime = datetime.datetime.now() - @classmethod - def get(self, follower_id, object_id): - '''Return a UserFollowingDataset object for the given follower_id and - object_id, or None if no such follower exists. - - ''' - query = meta.Session.query(UserFollowingDataset) - query = query.filter(UserFollowingDataset.follower_id==follower_id) - query = query.filter(UserFollowingDataset.object_id==object_id) - return query.first() + def _follower_class(cls): + return ckan.model.User @classmethod - def is_following(cls, follower_id, object_id): - '''Return True if follower_id is currently following object_id, False - otherwise. - - ''' - return UserFollowingDataset.get(follower_id, object_id) is not None - - - @classmethod - def followee_count(cls, follower_id): - '''Return the number of datasets followed by a user.''' - return meta.Session.query(UserFollowingDataset).filter( - UserFollowingDataset.follower_id == follower_id).count() - - @classmethod - def followee_list(cls, follower_id): - '''Return a list of datasets followed by a user.''' - return meta.Session.query(UserFollowingDataset).filter( - UserFollowingDataset.follower_id == follower_id).all() - - - @classmethod - def follower_count(cls, dataset_id): - '''Return the number of followers of a dataset.''' - return meta.Session.query(UserFollowingDataset).filter( - UserFollowingDataset.object_id == dataset_id).count() - - @classmethod - def follower_list(cls, dataset_id): - '''Return a list of followers of a dataset.''' - return meta.Session.query(UserFollowingDataset).filter( - UserFollowingDataset.object_id == dataset_id).all() + def _object_class(cls): + return ckan.model.Package user_following_dataset_table = sqlalchemy.Table('user_following_dataset', @@ -150,60 +151,20 @@ def follower_list(cls, dataset_id): meta.mapper(UserFollowingDataset, user_following_dataset_table) -class UserFollowingGroup(domain_object.DomainObject): +class UserFollowingGroup(ModelFollowingModel): '''A many-many relationship between users and groups. A relationship between a user (the follower) and a group (the object), that means that the user is currently following the group. ''' - def __init__(self, follower_id, object_id): - self.follower_id = follower_id - self.object_id = object_id - self.datetime = datetime.datetime.now() - - @classmethod - def get(self, follower_id, object_id): - '''Return a UserFollowingGroup object for the given follower_id and - object_id, or None if no such relationship exists. - - ''' - query = meta.Session.query(UserFollowingGroup) - query = query.filter(UserFollowingGroup.follower_id == follower_id) - query = query.filter(UserFollowingGroup.object_id == object_id) - return query.first() - - @classmethod - def is_following(cls, follower_id, object_id): - '''Return True if follower_id is currently following object_id, False - otherwise. - - ''' - return UserFollowingGroup.get(follower_id, object_id) is not None - - @classmethod - def followee_count(cls, follower_id): - '''Return the number of groups followed by a user.''' - return meta.Session.query(UserFollowingGroup).filter( - UserFollowingGroup.follower_id == follower_id).count() - @classmethod - def followee_list(cls, follower_id): - '''Return a list of groups followed by a user.''' - return meta.Session.query(UserFollowingGroup).filter( - UserFollowingGroup.follower_id == follower_id).all() + def _follower_class(cls): + return ckan.model.User @classmethod - def follower_count(cls, object_id): - '''Return the number of users following a group.''' - return meta.Session.query(UserFollowingGroup).filter( - UserFollowingGroup.object_id == object_id).count() - - @classmethod - def follower_list(cls, object_id): - '''Return a list of the users following a group.''' - return meta.Session.query(UserFollowingGroup).filter( - UserFollowingGroup.object_id == object_id).all() + def _object_class(cls): + return ckan.model.Group user_following_group_table = sqlalchemy.Table('user_following_group', meta.metadata, diff --git a/ckan/model/group.py b/ckan/model/group.py index c16424affee..86963983e9a 100644 --- a/ckan/model/group.py +++ b/ckan/model/group.py @@ -195,8 +195,8 @@ def packages(self, with_private=False, limit=None, query = meta.Session.query(_package.Package).\ filter( - or_(_package.Package.state == vdm.sqlalchemy.State.ACTIVE, - _package.Package.state == vdm.sqlalchemy.State.PENDING)). \ + or_(_package.Package.state == core.State.ACTIVE, + _package.Package.state == core.State.PENDING)). \ filter(group_table.c.id == self.id).\ filter(member_table.c.state == 'active') diff --git a/ckan/model/user.py b/ckan/model/user.py index d3eb3d6f1c1..da92a57c11a 100644 --- a/ckan/model/user.py +++ b/ckan/model/user.py @@ -6,8 +6,10 @@ from sqlalchemy.sql.expression import or_ from sqlalchemy.orm import synonym from sqlalchemy import types, Column, Table +import vdm.sqlalchemy import meta +import core import types as _types import domain_object @@ -28,8 +30,11 @@ Column('sysadmin', types.Boolean, default=False), ) +vdm.sqlalchemy.make_table_stateful(user_table) -class User(domain_object.DomainObject): + +class User(vdm.sqlalchemy.StatefulObjectMixin, + domain_object.DomainObject): VALID_NAME = re.compile(r"^[a-zA-Z0-9_\-]{3,255}$") DOUBLE_SLASH = re.compile(':\/([^/])') @@ -39,6 +44,10 @@ def by_openid(cls, openid): obj = meta.Session.query(cls).autoflush(False) return obj.filter_by(openid=openid).first() + @classmethod + def by_email(cls, email): + return meta.Session.query(cls).filter_by(email=email).all() + @classmethod def get(cls, user_reference): # double slashes in an openid often get turned into single slashes @@ -162,20 +171,35 @@ def number_administered_packages(self): q = q.filter_by(user=self, role=model.Role.ADMIN) return q.count() - def is_in_group(self, group): - return group in self.get_group_ids() + def activate(self): + ''' Activate the user ''' + self.state = core.State.ACTIVE + + def set_pending(self): + ''' Set the user as pending ''' + self.state = core.State.PENDING + + def is_deleted(self): + return self.state == core.State.DELETED + + def is_pending(self): + return self.state == core.State.PENDING + + def is_in_group(self, group_id): + return group_id in self.get_group_ids() - def is_in_groups(self, groupids): + def is_in_groups(self, group_ids): ''' Given a list of group ids, returns True if this user is in any of those groups ''' guser = set(self.get_group_ids()) - gids = set(groupids) + gids = set(group_ids) return len(guser.intersection(gids)) > 0 - def get_group_ids(self, group_type=None): + def get_group_ids(self, group_type=None, capacity=None): ''' Returns a list of group ids that the current user belongs to ''' - return [g.id for g in self.get_groups(group_type=group_type)] + return [g.id for g in + self.get_groups(group_type=group_type, capacity=capacity)] def get_groups(self, group_type=None, capacity=None): import ckan.model as model diff --git a/ckan/new_authz.py b/ckan/new_authz.py index abeb8f9b57a..71025683b65 100644 --- a/ckan/new_authz.py +++ b/ckan/new_authz.py @@ -113,23 +113,24 @@ def clean_action_name(action_name): def is_sysadmin(username): - ''' returns True is username is a sysadmin ''' + ''' Returns True is username is a sysadmin ''' + user = _get_user(username) + return user and user.sysadmin + + +def _get_user(username): + ''' Try to get the user from c, if possible, and fallback to using the DB ''' if not username: - return False - # see if we can authorise without touching the database + return None + # See if we can get the user without touching the DB try: if c.userobj and c.userobj.name == username: - if c.userobj.sysadmin: - return True - return False + return c.userobj except TypeError: # c is not available pass - # get user from the database - user = model.User.get(username) - if user and user.sysadmin: - return True - return False + # Get user from the DB + return model.User.get(username) def get_group_or_org_admin_ids(group_id): @@ -158,12 +159,19 @@ def is_authorized(action, context, data_dict=None): action = clean_action_name(action) auth_function = _AuthFunctions.get(action) if auth_function: - # sysadmins can do anything unless the auth_sysadmins_check - # decorator was used in which case they are treated like all other - # users. - if is_sysadmin(context.get('user')): - if not getattr(auth_function, 'auth_sysadmins_check', False): - return {'success': True} + username = context.get('user') + user = _get_user(username) + + if user: + # deleted users are always unauthorized + if user.is_deleted(): + return {'success': False} + # sysadmins can do anything unless the auth_sysadmins_check + # decorator was used in which case they are treated like all other + # users. + elif user.sysadmin: + if not getattr(auth_function, 'auth_sysadmins_check', False): + return {'success': True} # If the auth function is flagged as not allowing anonymous access, # and an existing user object is not provided in the context, deny diff --git a/ckan/public/base/css/fuchsia.css b/ckan/public/base/css/fuchsia.css index a8a33f21105..c87cd3b10a1 100644 --- a/ckan/public/base/css/fuchsia.css +++ b/ckan/public/base/css/fuchsia.css @@ -4898,16 +4898,6 @@ a.tag:hover { .module-heading:after { clear: both; } -.module-heading .action { - float: right; - color: #888888; - font-size: 12px; - line-height: 20px; - text-decoration: underline; -} -.module-heading .action:hover { - color: #444444; -} .module-content { padding: 0 25px; margin: 20px 0; @@ -6130,6 +6120,34 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.add-member-form .control-label { + width: 100%; + text-align: left; +} +.add-member-form .controls { + margin-left: auto; +} +.add-member-or { + float: left; + margin-top: 75px; + width: 7%; + text-align: center; + text-transform: uppercase; + color: #999999; + font-weight: bold; +} +.add-member-form .row-fluid .control-group { + float: left; + width: 45%; +} +.add-member-form .row-fluid .select2-container, +.add-member-form .row-fluid input { + width: 100% !important; +} +#recaptcha_table { + table-layout: inherit; + line-height: 1; +} .dataset-item { border-bottom: 1px dotted #dddddd; padding-bottom: 20px; @@ -8246,11 +8264,6 @@ iframe { .ie7 .module-heading .media-content { position: relative; } -.ie7 .module-heading .action { - position: absolute; - top: 9px; - right: 10px; -} .ie7 .module-heading .media-image img { float: left; } diff --git a/ckan/public/base/css/green.css b/ckan/public/base/css/green.css index 83bbcd5eefa..6558ce82f8d 100644 --- a/ckan/public/base/css/green.css +++ b/ckan/public/base/css/green.css @@ -4898,16 +4898,6 @@ a.tag:hover { .module-heading:after { clear: both; } -.module-heading .action { - float: right; - color: #888888; - font-size: 12px; - line-height: 20px; - text-decoration: underline; -} -.module-heading .action:hover { - color: #444444; -} .module-content { padding: 0 25px; margin: 20px 0; @@ -6130,6 +6120,34 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.add-member-form .control-label { + width: 100%; + text-align: left; +} +.add-member-form .controls { + margin-left: auto; +} +.add-member-or { + float: left; + margin-top: 75px; + width: 7%; + text-align: center; + text-transform: uppercase; + color: #999999; + font-weight: bold; +} +.add-member-form .row-fluid .control-group { + float: left; + width: 45%; +} +.add-member-form .row-fluid .select2-container, +.add-member-form .row-fluid input { + width: 100% !important; +} +#recaptcha_table { + table-layout: inherit; + line-height: 1; +} .dataset-item { border-bottom: 1px dotted #dddddd; padding-bottom: 20px; @@ -8246,11 +8264,6 @@ iframe { .ie7 .module-heading .media-content { position: relative; } -.ie7 .module-heading .action { - position: absolute; - top: 9px; - right: 10px; -} .ie7 .module-heading .media-image img { float: left; } diff --git a/ckan/public/base/css/main.css b/ckan/public/base/css/main.css index 77b93ef3cfc..d7635468324 100644 --- a/ckan/public/base/css/main.css +++ b/ckan/public/base/css/main.css @@ -4898,16 +4898,6 @@ a.tag:hover { .module-heading:after { clear: both; } -.module-heading .action { - float: right; - color: #888888; - font-size: 12px; - line-height: 20px; - text-decoration: underline; -} -.module-heading .action:hover { - color: #444444; -} .module-content { padding: 0 25px; margin: 20px 0; @@ -6130,6 +6120,34 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.add-member-form .control-label { + width: 100%; + text-align: left; +} +.add-member-form .controls { + margin-left: auto; +} +.add-member-or { + float: left; + margin-top: 75px; + width: 7%; + text-align: center; + text-transform: uppercase; + color: #999999; + font-weight: bold; +} +.add-member-form .row-fluid .control-group { + float: left; + width: 45%; +} +.add-member-form .row-fluid .select2-container, +.add-member-form .row-fluid input { + width: 100% !important; +} +#recaptcha_table { + table-layout: inherit; + line-height: 1; +} .dataset-item { border-bottom: 1px dotted #dddddd; padding-bottom: 20px; @@ -8246,11 +8264,6 @@ iframe { .ie7 .module-heading .media-content { position: relative; } -.ie7 .module-heading .action { - position: absolute; - top: 9px; - right: 10px; -} .ie7 .module-heading .media-image img { float: left; } diff --git a/ckan/public/base/css/maroon.css b/ckan/public/base/css/maroon.css index 585ed6129c2..4de9736ff0e 100644 --- a/ckan/public/base/css/maroon.css +++ b/ckan/public/base/css/maroon.css @@ -4898,16 +4898,6 @@ a.tag:hover { .module-heading:after { clear: both; } -.module-heading .action { - float: right; - color: #888888; - font-size: 12px; - line-height: 20px; - text-decoration: underline; -} -.module-heading .action:hover { - color: #444444; -} .module-content { padding: 0 25px; margin: 20px 0; @@ -6130,6 +6120,34 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.add-member-form .control-label { + width: 100%; + text-align: left; +} +.add-member-form .controls { + margin-left: auto; +} +.add-member-or { + float: left; + margin-top: 75px; + width: 7%; + text-align: center; + text-transform: uppercase; + color: #999999; + font-weight: bold; +} +.add-member-form .row-fluid .control-group { + float: left; + width: 45%; +} +.add-member-form .row-fluid .select2-container, +.add-member-form .row-fluid input { + width: 100% !important; +} +#recaptcha_table { + table-layout: inherit; + line-height: 1; +} .dataset-item { border-bottom: 1px dotted #dddddd; padding-bottom: 20px; @@ -8246,11 +8264,6 @@ iframe { .ie7 .module-heading .media-content { position: relative; } -.ie7 .module-heading .action { - position: absolute; - top: 9px; - right: 10px; -} .ie7 .module-heading .media-image img { float: left; } diff --git a/ckan/public/base/css/red.css b/ckan/public/base/css/red.css index d24e241544a..4b1a9181328 100644 --- a/ckan/public/base/css/red.css +++ b/ckan/public/base/css/red.css @@ -4898,16 +4898,6 @@ a.tag:hover { .module-heading:after { clear: both; } -.module-heading .action { - float: right; - color: #888888; - font-size: 12px; - line-height: 20px; - text-decoration: underline; -} -.module-heading .action:hover { - color: #444444; -} .module-content { padding: 0 25px; margin: 20px 0; @@ -6130,6 +6120,34 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.add-member-form .control-label { + width: 100%; + text-align: left; +} +.add-member-form .controls { + margin-left: auto; +} +.add-member-or { + float: left; + margin-top: 75px; + width: 7%; + text-align: center; + text-transform: uppercase; + color: #999999; + font-weight: bold; +} +.add-member-form .row-fluid .control-group { + float: left; + width: 45%; +} +.add-member-form .row-fluid .select2-container, +.add-member-form .row-fluid input { + width: 100% !important; +} +#recaptcha_table { + table-layout: inherit; + line-height: 1; +} .dataset-item { border-bottom: 1px dotted #dddddd; padding-bottom: 20px; @@ -8246,11 +8264,6 @@ iframe { .ie7 .module-heading .media-content { position: relative; } -.ie7 .module-heading .action { - position: absolute; - top: 9px; - right: 10px; -} .ie7 .module-heading .media-image img { float: left; } diff --git a/ckan/public/base/javascript/modules/autocomplete.js b/ckan/public/base/javascript/modules/autocomplete.js index 768a708c9c3..648fe5960d0 100644 --- a/ckan/public/base/javascript/modules/autocomplete.js +++ b/ckan/public/base/javascript/modules/autocomplete.js @@ -86,6 +86,8 @@ this.ckan.module('autocomplete', function (jQuery, _) { $('.select2-choice', select2.container).on('click', function() { return false; }); + + this._select2 = select2; }, /* Looks up the completions for the current search term and passes them @@ -140,29 +142,28 @@ this.ckan.module('autocomplete', function (jQuery, _) { // old data. this._lastTerm = string; + // Kills previous timeout + clearTimeout(this._debounced); + + // OK, wipe the dropdown before we start ajaxing the completions + fn({results:[]}); + if (string) { - if (!this._debounced) { - // Set a timer to prevent the search lookup occurring too often. - this._debounced = setTimeout(function () { - var term = module._lastTerm; + // Set a timer to prevent the search lookup occurring too often. + this._debounced = setTimeout(function () { + var term = module._lastTerm; - delete module._debounced; + // Cancel the previous request if it hasn't yet completed. + if (module._last && typeof module._last.abort == 'function') { + module._last.abort(); + } - // Cancel the previous request if it hasn't yet completed. - if (module._last) { - module._last.abort(); - } + module._last = module.getCompletions(term, fn); + }, this.options.interval); - module._last = module.getCompletions(term, function (terms) { - fn(module._lastResults = terms); - }); - }, this.options.interval); - } else { - // Re-use the last set of terms. - fn(this._lastResults || {results: []}); - } - } else { - fn({results: []}); + // This forces the ajax throbber to appear, because we've called the + // callback already and that hides the throbber + $('.select2-search input', this._select2.dropdown).addClass('select2-active'); } }, diff --git a/ckan/public/base/less/forms.less b/ckan/public/base/less/forms.less index 86e71a05dde..6619fe2d462 100644 --- a/ckan/public/base/less/forms.less +++ b/ckan/public/base/less/forms.less @@ -679,3 +679,38 @@ textarea { .box-shadow(none); } } + +.add-member-form .control-label { + width: 100%; + text-align: left; +} + +.add-member-form .controls { + margin-left: auto; +} + +.add-member-or { + float: left; + margin-top: 75px; + width: 7%; + text-align: center; + text-transform: uppercase; + color: @grayLight; + font-weight: bold; +} + +.add-member-form .row-fluid .control-group { + float: left; + width: 45%; +} + +.add-member-form .row-fluid { + .select2-container, input { + width: 100% !important; + } +} + +#recaptcha_table { + table-layout: inherit; + line-height: 1; +} diff --git a/ckan/public/base/less/iehacks.less b/ckan/public/base/less/iehacks.less index 232ee72ee9c..7d9bc5cc274 100644 --- a/ckan/public/base/less/iehacks.less +++ b/ckan/public/base/less/iehacks.less @@ -200,11 +200,6 @@ .media-content { position: relative; } - .action { - position: absolute; - top: 9px; - right: 10px; - } .media-image img { float: left; } diff --git a/ckan/public/base/less/module.less b/ckan/public/base/less/module.less index a575abcbda4..aa964cf5ea3 100644 --- a/ckan/public/base/less/module.less +++ b/ckan/public/base/less/module.less @@ -13,17 +13,6 @@ border-bottom: 1px solid @moduleHeadingBorderColor; } -.module-heading .action { - float: right; - color: @moduleHeadingActionTextColor; - font-size: 12px; - line-height: @baseLineHeight; - text-decoration: underline; - &:hover { - color: @layoutTextColor; - } -} - .module-content { padding: 0 @gutterX; margin: 20px 0; diff --git a/ckan/public/base/test/spec/modules/autocomplete.spec.js b/ckan/public/base/test/spec/modules/autocomplete.spec.js index 2a9c63ab45c..93a8fb57a54 100644 --- a/ckan/public/base/test/spec/modules/autocomplete.spec.js +++ b/ckan/public/base/test/spec/modules/autocomplete.spec.js @@ -152,10 +152,11 @@ describe('ckan.modules.AutocompleteModule()', function () { beforeEach(function () { sinon.stub(this.module, 'getCompletions'); this.target = sinon.spy(); + this.module.setupAutoComplete(); }); it('should set the _lastTerm property', function () { - this.module.lookup('term'); + this.module.lookup('term', this.target); assert.equal(this.module._lastTerm, 'term'); }); diff --git a/ckan/templates/development/snippets/facet.html b/ckan/templates/development/snippets/facet.html index ac9b9df1a0c..7280bf9a3d2 100644 --- a/ckan/templates/development/snippets/facet.html +++ b/ckan/templates/development/snippets/facet.html @@ -1,6 +1,6 @@
{% with items=(("First", true), ("Second", false), ("Third", true), ("Fourth", false), ("Last", false)) %} -

Facet List Clear All

+

Facet List