diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index 61f6a76b20f..49317cf0b0f 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -159,6 +159,7 @@ def index(self): sort_by = c.sort_by_selected = request.params.get('sort') try: self._check_access('site_read', context) + self._check_access('group_list', context) except NotAuthorized: abort(403, _('Not authorized to see this page')) @@ -380,10 +381,6 @@ def bulk_process(self, id): group_type = self._ensure_controller_matches_group_type( id.split('@')[0]) - if group_type != 'organization': - # FIXME: better error - raise Exception('Must be an organization') - # check we are org admin context = {'model': model, 'session': model.Session, @@ -401,6 +398,10 @@ def bulk_process(self, id): except (NotFound, NotAuthorized): abort(404, _('Group not found')) + if not c.group_dict['is_organization']: + # FIXME: better error + raise Exception('Must be an organization') + #use different form names so that ie7 can be detected form_names = set(["bulk_action.public", "bulk_action.delete", "bulk_action.private"]) @@ -443,9 +444,7 @@ def bulk_process(self, id): get_action(action_functions[action])(context, data_dict) except NotAuthorized: abort(403, _('Not authorized to perform bulk update')) - base.redirect(h.url_for(controller='organization', - action='bulk_process', - id=id)) + self._redirect_to_this_controller(action='bulk_process', id=id) def new(self, data=None, errors=None, error_summary=None): if data and 'type' in data: diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py index 5f8f8086fda..d911c214bfb 100644 --- a/ckan/controllers/user.py +++ b/ckan/controllers/user.py @@ -35,6 +35,15 @@ unflatten = dictization_functions.unflatten +def set_repoze_user(user_id): + '''Set the repoze.who cookie to match a given user_id''' + if 'repoze.who.plugins' in request.environ: + rememberer = request.environ['repoze.who.plugins']['friendlyform'] + identity = {'repoze.who.userid': user_id} + response.headerlist += rememberer.remember(request.environ, + identity) + + class UserController(base.BaseController): def __before__(self, action, **env): base.BaseController.__before__(self, action, **env) @@ -245,10 +254,7 @@ def _save_new(self, context): return self.new(data_dict, errors, error_summary) if not c.user: # log the user in programatically - rememberer = request.environ['repoze.who.plugins']['friendlyform'] - identity = {'repoze.who.userid': data_dict['name']} - response.headerlist += rememberer.remember(request.environ, - identity) + set_repoze_user(data_dict['name']) h.redirect_to(controller='user', action='me', __ckan_no_root=True) else: # #1799 User has managed to register whilst logged in - warn user @@ -321,6 +327,12 @@ def edit(self, id=None, data=None, errors=None, error_summary=None): def _save_edit(self, id, context): try: + if id in (c.userobj.id, c.userobj.name): + current_user = True + else: + current_user = False + old_username = c.userobj.name + data_dict = logic.clean_dict(unflatten( logic.tuplize_dict(logic.parse_params(request.params)))) context['message'] = data_dict.get('log_message', '') @@ -343,6 +355,11 @@ def _save_edit(self, id, context): user = get_action('user_update')(context, data_dict) h.flash_success(_('Profile updated')) + + if current_user and data_dict['name'] != old_username: + # Changing currently logged in user's name. + # Update repoze.who cookie to match + set_repoze_user(data_dict['name']) h.redirect_to(controller='user', action='read', id=user['name']) except NotAuthorized: abort(403, _('Unauthorized to edit user %s') % id) diff --git a/ckan/lib/plugins.py b/ckan/lib/plugins.py index 132e5b4598f..cd22984af97 100644 --- a/ckan/lib/plugins.py +++ b/ckan/lib/plugins.py @@ -178,7 +178,7 @@ def register_group_plugins(map): '/%s/{action}/{id}' % group_type, controller=group_controller, requirements=dict(action='|'.join( - ['edit', 'authz', 'history', 'member_new', + ['edit', 'authz', 'delete', 'history', 'member_new', 'member_delete', 'followers', 'follow', 'unfollow', 'admins', 'activity']))) map.connect('%s_edit' % group_type, '/%s/edit/{id}' % group_type, @@ -193,6 +193,13 @@ def register_group_plugins(map): '/%s/activity/{id}/{offset}' % group_type, controller=group_controller, action='activity', ckan_icon='time'), + map.connect('%s_about' % group_type, '/%s/about/{id}' % group_type, + controller=group_controller, + action='about', ckan_icon='info-sign') + map.connect('%s_bulk_process' % group_type, + '/%s/bulk_process/{id}' % group_type, + controller=group_controller, + action='bulk_process', ckan_icon='sitemap') if group_type in _group_plugins: raise ValueError("An existing IGroupForm is " @@ -201,12 +208,16 @@ def register_group_plugins(map): _group_plugins[group_type] = plugin _group_controllers[group_type] = group_controller + controller_obj = None + # If using one of the default controllers, tell it that it is allowed + # to handle other group_types. + # Import them here to avoid circular imports. if group_controller == 'group': - # Tell the default group controller that it is allowed to - # handle other group_types. - # Import it here to avoid circular imports. - from ckan.controllers.group import GroupController - GroupController.add_group_type(group_type) + from ckan.controllers.group import GroupController as controller_obj + elif group_controller == 'organization': + from ckan.controllers.organization import OrganizationController as controller_obj + if controller_obj is not None: + controller_obj.add_group_type(group_type) # Setup the fallback behaviour if one hasn't been defined. if _default_group_plugin is None: diff --git a/ckan/pastertemplates/template/bin/travis-build.bash_tmpl b/ckan/pastertemplates/template/bin/travis-build.bash_tmpl index 44d0531e4d6..c58598a917f 100755 --- a/ckan/pastertemplates/template/bin/travis-build.bash_tmpl +++ b/ckan/pastertemplates/template/bin/travis-build.bash_tmpl @@ -22,6 +22,11 @@ echo "Creating the PostgreSQL user and database..." sudo -u postgres psql -c "CREATE USER ckan_default WITH PASSWORD 'pass';" sudo -u postgres psql -c 'CREATE DATABASE ckan_test WITH OWNER ckan_default;' +echo "SOLR config..." +# Solr is multicore for tests on ckan master, but it's easier to run tests on +# Travis single-core. See https://github.com/ckan/ckan/issues/2972 +sed -i -e 's/solr_url.*/solr_url = http:\/\/127.0.0.1:8983\/solr/' ckan/test-core.ini + echo "Initialising the database..." cd ckan paster db init -c test-core.ini diff --git a/ckan/templates/group/index.html b/ckan/templates/group/index.html index f9c56530267..2d04c2a467d 100644 --- a/ckan/templates/group/index.html +++ b/ckan/templates/group/index.html @@ -34,7 +34,7 @@

{{ _('Groups') }}

{% endif %} {% endblock %} {% block page_pagination %} - {{ c.page.pager() }} + {{ c.page.pager(q=c.q or '', sort=c.sort_by_selected or '') }} {% endblock %} {% endblock %} diff --git a/ckan/tests/controllers/test_group.py b/ckan/tests/controllers/test_group.py index bb8edaab2ab..b80724dc5b2 100644 --- a/ckan/tests/controllers/test_group.py +++ b/ckan/tests/controllers/test_group.py @@ -23,17 +23,34 @@ def test_bulk_process_throws_404_for_nonexistent_org(self): action='bulk_process', id='does-not-exist') app.get(url=bulk_process_url, status=404) - def test_page_thru_list_of_orgs(self): - orgs = [factories.Organization() for i in range(35)] + def test_page_thru_list_of_orgs_preserves_sort_order(self): + orgs = [factories.Organization() for _ in range(35)] app = self._get_test_app() - org_url = url_for(controller='organization', action='index') + org_url = url_for(controller='organization', + action='index', + sort='name desc') response = app.get(url=org_url) - assert orgs[0]['name'] in response - assert orgs[-1]['name'] not in response + assert orgs[-1]['name'] in response + assert orgs[0]['name'] not in response response2 = response.click('2') - assert orgs[0]['name'] not in response2 - assert orgs[-1]['name'] in response2 + assert orgs[-1]['name'] not in response2 + assert orgs[0]['name'] in response2 + + def test_page_thru_list_of_groups_preserves_sort_order(self): + groups = [factories.Group() for _ in range(35)] + app = self._get_test_app() + group_url = url_for(controller='group', + action='index', + sort='title desc') + + response = app.get(url=group_url) + assert groups[-1]['title'] in response + assert groups[0]['title'] not in response + + response2 = response.click(r'^2$') + assert groups[-1]['title'] not in response2 + assert groups[0]['title'] in response2 def _get_group_new_page(app): diff --git a/ckan/tests/controllers/test_organization.py b/ckan/tests/controllers/test_organization.py index ce8a875928b..549a92aa9f1 100644 --- a/ckan/tests/controllers/test_organization.py +++ b/ckan/tests/controllers/test_organization.py @@ -1,6 +1,7 @@ from bs4 import BeautifulSoup from nose.tools import assert_equal, assert_true from routes import url_for +from mock import patch from ckan.tests import factories, helpers from ckan.tests.helpers import webtest_submit, submit_and_follow, assert_in @@ -61,6 +62,22 @@ def test_all_fields_saved(self): assert_equal(group['description'], 'Sciencey datasets') +class TestOrganizationList(helpers.FunctionalTestBase): + def setup(self): + super(TestOrganizationList, self).setup() + self.app = helpers._get_test_app() + self.user = factories.User() + self.user_env = {'REMOTE_USER': self.user['name'].encode('ascii')} + self.organization_list_url = url_for(controller='organization', + action='index') + + @patch('ckan.logic.auth.get.organization_list', return_value={'success': False}) + def test_error_message_shown_when_no_organization_list_permission(self, mock_check_access): + response = self.app.get(url=self.organization_list_url, + extra_environ=self.user_env, + status=403) + + class TestOrganizationRead(helpers.FunctionalTestBase): def setup(self): super(TestOrganizationRead, self).setup() diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py index ffefa1e0011..cf78056b79c 100644 --- a/ckan/tests/controllers/test_user.py +++ b/ckan/tests/controllers/test_user.py @@ -289,6 +289,100 @@ def test_email_change_with_password(self): response = submit_and_follow(app, form, env, 'save') assert_true('Profile updated' in response) + def test_edit_user_logged_in_username_change(self): + + user_pass = 'pass' + user = factories.User(password=user_pass) + app = self._get_test_app() + + # Have to do an actual login as this test relys on repoze cookie handling. + # get the form + response = app.get('/user/login') + # ...it's the second one + login_form = response.forms[1] + # fill it in + login_form['login'] = user['name'] + login_form['password'] = user_pass + # submit it + login_form.submit() + + # Now the cookie is set, run the test + response = app.get( + url=url_for(controller='user', action='edit'), + ) + # existing values in the form + form = response.forms['user-edit-form'] + + # new values + form['name'] = 'new-name' + response = submit_and_follow(app, form, name='save') + response = helpers.webtest_maybe_follow(response) + + expected_url = url_for(controller='user', action='read', id='new-name') + assert response.request.path == expected_url + + def test_edit_user_logged_in_username_change_by_name(self): + user_pass = 'pass' + user = factories.User(password=user_pass) + app = self._get_test_app() + + # Have to do an actual login as this test relys on repoze cookie handling. + # get the form + response = app.get('/user/login') + # ...it's the second one + login_form = response.forms[1] + # fill it in + login_form['login'] = user['name'] + login_form['password'] = user_pass + # submit it + login_form.submit() + + # Now the cookie is set, run the test + response = app.get( + url=url_for(controller='user', action='edit', id=user['name']), + ) + # existing values in the form + form = response.forms['user-edit-form'] + + # new values + form['name'] = 'new-name' + response = submit_and_follow(app, form, name='save') + response = helpers.webtest_maybe_follow(response) + + expected_url = url_for(controller='user', action='read', id='new-name') + assert response.request.path == expected_url + + def test_edit_user_logged_in_username_change_by_id(self): + user_pass = 'pass' + user = factories.User(password=user_pass) + app = self._get_test_app() + + # Have to do an actual login as this test relys on repoze cookie handling. + # get the form + response = app.get('/user/login') + # ...it's the second one + login_form = response.forms[1] + # fill it in + login_form['login'] = user['name'] + login_form['password'] = user_pass + # submit it + login_form.submit() + + # Now the cookie is set, run the test + response = app.get( + url=url_for(controller='user', action='edit', id=user['id']), + ) + # existing values in the form + form = response.forms['user-edit-form'] + + # new values + form['name'] = 'new-name' + response = submit_and_follow(app, form, name='save') + response = helpers.webtest_maybe_follow(response) + + expected_url = url_for(controller='user', action='read', id='new-name') + assert response.request.path == expected_url + def test_perform_reset_for_key_change(self): password = 'password' params = {'password1': password, 'password2': password} diff --git a/ckan/tests/helpers.py b/ckan/tests/helpers.py index 58278154d50..f41e850cf78 100644 --- a/ckan/tests/helpers.py +++ b/ckan/tests/helpers.py @@ -188,6 +188,8 @@ def _apply_config_changes(cls, cfg): def setup(self): '''Reset the database and clear the search indexes.''' reset_db() + if hasattr(self, '_test_app'): + self._test_app.reset() search.clear_all() @classmethod @@ -412,3 +414,31 @@ def wrapper(*args, **kwargs): return return_value return nose.tools.make_decorator(func)(wrapper) return decorator + + +def find_flask_app(test_app): + ''' + Helper function to recursively search the wsgi stack in `test_app` until + the flask_app is discovered. + + Relies on each layer of the stack having a reference to the app they + wrap in either a .app attribute or .apps list. + ''' + if isinstance(test_app, ckan.config.middleware.CKANFlask): + return test_app + + try: + app = test_app.apps['flask_app'].app + except (AttributeError, KeyError): + pass + else: + return find_flask_app(app) + + try: + app = test_app.app + except AttributeError: + print('No .app attribute. ' + 'Have all layers of the stack got ' + 'a reference to the app they wrap?') + else: + return find_flask_app(app) diff --git a/ckan/tests/legacy/functional/test_pagination.py b/ckan/tests/legacy/functional/test_pagination.py index f92fd72265b..59726aed2d7 100644 --- a/ckan/tests/legacy/functional/test_pagination.py +++ b/ckan/tests/legacy/functional/test_pagination.py @@ -100,12 +100,12 @@ def teardown_class(self): def test_group_index(self): res = self.app.get(url_for(controller='group', action='index')) - assert 'href="/group?page=2"' in res, res + assert 'href="/group?sort=&q=&page=2"' in res, res grp_numbers = scrape_search_results(res, 'group') assert_equal(['00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20'], grp_numbers) res = self.app.get(url_for(controller='group', action='index', page=2)) - assert 'href="/group?page=1"' in res + assert 'href="/group?sort=&q=&page=1"' in res grp_numbers = scrape_search_results(res, 'group') assert_equal(['21'], grp_numbers) diff --git a/ckanext/datapusher/logic/action.py b/ckanext/datapusher/logic/action.py index 8ce97c2bc8f..5afb4b0b33a 100644 --- a/ckanext/datapusher/logic/action.py +++ b/ckanext/datapusher/logic/action.py @@ -74,7 +74,7 @@ def datapusher_submit(context, data_dict): 'entity_id': res_id, 'entity_type': 'resource', 'task_type': 'datapusher', - 'last_updated': str(datetime.datetime.now()), + 'last_updated': str(datetime.datetime.utcnow()), 'state': 'submitting', 'key': 'datapusher', 'value': '{}', @@ -119,7 +119,7 @@ def datapusher_submit(context, data_dict): 'details': str(e)} task['error'] = json.dumps(error) task['state'] = 'error' - task['last_updated'] = str(datetime.datetime.now()), + task['last_updated'] = str(datetime.datetime.utcnow()), p.toolkit.get_action('task_status_update')(context, task) raise p.toolkit.ValidationError(error) @@ -134,7 +134,7 @@ def datapusher_submit(context, data_dict): 'status_code': r.status_code} task['error'] = json.dumps(error) task['state'] = 'error' - task['last_updated'] = str(datetime.datetime.now()), + task['last_updated'] = str(datetime.datetime.utcnow()), p.toolkit.get_action('task_status_update')(context, task) raise p.toolkit.ValidationError(error) @@ -143,7 +143,7 @@ def datapusher_submit(context, data_dict): task['value'] = value task['state'] = 'pending' - task['last_updated'] = str(datetime.datetime.now()), + task['last_updated'] = str(datetime.datetime.utcnow()), p.toolkit.get_action('task_status_update')(context, task) return True @@ -175,7 +175,7 @@ def datapusher_hook(context, data_dict): }) task['state'] = status - task['last_updated'] = str(datetime.datetime.now()) + task['last_updated'] = str(datetime.datetime.utcnow()) resubmit = False diff --git a/ckanext/datapusher/tests/test_action.py b/ckanext/datapusher/tests/test_action.py index 218406ccbaf..7c24612d9e6 100644 --- a/ckanext/datapusher/tests/test_action.py +++ b/ckanext/datapusher/tests/test_action.py @@ -30,7 +30,7 @@ def _pending_task(self, resource_id): 'entity_id': resource_id, 'entity_type': 'resource', 'task_type': 'datapusher', - 'last_updated': str(datetime.datetime.now()), + 'last_updated': str(datetime.datetime.utcnow()), 'state': 'pending', 'key': 'datapusher', 'value': '{}', @@ -163,13 +163,14 @@ def test_resubmits_if_upload_changes_in_the_meantime( **self._pending_task(resource['id'])) # Update the resource, set a new last_modified (changes on file upload) - helpers.call_action('resource_update', {}, - id=resource['id'], - package_id=dataset['id'], - url='http://example.com/file.csv', - format='CSV', - last_modified=datetime.datetime.now().isoformat() - ) + helpers.call_action( + 'resource_update', {}, + id=resource['id'], + package_id=dataset['id'], + url='http://example.com/file.csv', + format='CSV', + last_modified=datetime.datetime.utcnow().isoformat() + ) # Not called eq_(len(mock_datapusher_submit.mock_calls), 1) diff --git a/ckanext/example_flask_iroutes/tests/test_routes.py b/ckanext/example_flask_iroutes/tests/test_routes.py index 27c33b79219..4e72e704ade 100644 --- a/ckanext/example_flask_iroutes/tests/test_routes.py +++ b/ckanext/example_flask_iroutes/tests/test_routes.py @@ -1,6 +1,5 @@ from nose.tools import eq_, ok_ -from ckan.config.middleware import find_flask_app import ckan.plugins as plugins import ckan.tests.helpers as helpers @@ -9,7 +8,7 @@ class TestFlaskIRoutes(helpers.FunctionalTestBase): def setup(self): self.app = helpers._get_test_app() - flask_app = find_flask_app(self.app) + flask_app = helpers.find_flask_app(self.app) # Install plugin and register its blueprint if not plugins.plugin_loaded('example_flask_iroutes'): diff --git a/ckanext/example_igroupform/plugin.py b/ckanext/example_igroupform/plugin.py index a95a4fd1538..5c1e76ee451 100644 --- a/ckanext/example_igroupform/plugin.py +++ b/ckanext/example_igroupform/plugin.py @@ -61,3 +61,29 @@ def is_fallback(self): def group_form(self): return 'example_igroup_form/group_form.html' + + +class ExampleIGroupFormOrganizationPlugin(plugins.SingletonPlugin, + tk.DefaultOrganizationForm): + '''An example IGroupForm Organization CKAN plugin with custom group_type. + + Doesn't do much yet. + ''' + plugins.implements(plugins.IGroupForm, inherit=False) + plugins.implements(plugins.IConfigurer) + + # IConfigurer + + def update_config(self, config_): + tk.add_template_directory(config_, 'templates') + + # IGroupForm + + def group_types(self): + return (group_type,) + + def is_fallback(self): + False + + def group_controller(self): + return 'organization' diff --git a/ckanext/example_igroupform/tests/test_controllers.py b/ckanext/example_igroupform/tests/test_controllers.py index 328eeb3453d..1a930348fd6 100644 --- a/ckanext/example_igroupform/tests/test_controllers.py +++ b/ckanext/example_igroupform/tests/test_controllers.py @@ -24,6 +24,97 @@ def _get_group_new_page(app, group_type): return env, response +class TestGroupController(helpers.FunctionalTestBase): + @classmethod + def setup_class(cls): + super(TestGroupController, cls).setup_class() + plugins.load('example_igroupform') + + @classmethod + def teardown_class(cls): + plugins.unload('example_igroupform') + super(TestGroupController, cls).teardown_class() + + def test_about(self): + app = self._get_test_app() + user = factories.User() + group = factories.Group(user=user, type=custom_group_type) + group_name = group['name'] + env = {'REMOTE_USER': user['name'].encode('ascii')} + url = url_for('%s_about' % custom_group_type, + id=group_name) + response = app.get(url=url, extra_environ=env) + response.mustcontain(group_name) + + def test_bulk_process(self): + app = self._get_test_app() + user = factories.User() + group = factories.Group(user=user, type=custom_group_type) + group_name = group['name'] + env = {'REMOTE_USER': user['name'].encode('ascii')} + url = url_for('%s_bulk_process' % custom_group_type, + id=group_name) + try: + response = app.get(url=url, extra_environ=env) + except Exception as e: + assert (e.args == ('Must be an organization', )) + else: + raise Exception("Response should have raised an exception") + + def test_delete(self): + app = self._get_test_app() + user = factories.User() + group = factories.Group(user=user, type=custom_group_type) + group_name = group['name'] + env = {'REMOTE_USER': user['name'].encode('ascii')} + url = url_for('%s_action' % custom_group_type, action='delete', + id=group_name) + response = app.get(url=url, extra_environ=env) + + +class TestOrganizationController(helpers.FunctionalTestBase): + @classmethod + def setup_class(cls): + super(TestOrganizationController, cls).setup_class() + plugins.load('example_igroupform_organization') + + @classmethod + def teardown_class(cls): + plugins.unload('example_igroupform_organization') + super(TestOrganizationController, cls).teardown_class() + + def test_about(self): + app = self._get_test_app() + user = factories.User() + group = factories.Organization(user=user, type=custom_group_type) + group_name = group['name'] + env = {'REMOTE_USER': user['name'].encode('ascii')} + url = url_for('%s_about' % custom_group_type, + id=group_name) + response = app.get(url=url, extra_environ=env) + response.mustcontain(group_name) + + def test_bulk_process(self): + app = self._get_test_app() + user = factories.User() + group = factories.Organization(user=user, type=custom_group_type) + group_name = group['name'] + env = {'REMOTE_USER': user['name'].encode('ascii')} + url = url_for('%s_bulk_process' % custom_group_type, + id=group_name) + response = app.get(url=url, extra_environ=env) + + def test_delete(self): + app = self._get_test_app() + user = factories.User() + group = factories.Organization(user=user, type=custom_group_type) + group_name = group['name'] + env = {'REMOTE_USER': user['name'].encode('ascii')} + url = url_for('%s_action' % custom_group_type, action='delete', + id=group_name) + response = app.get(url=url, extra_environ=env) + + class TestGroupControllerNew(helpers.FunctionalTestBase): @classmethod def setup_class(cls): diff --git a/setup.py b/setup.py index 80836e47a69..5ec8f288038 100644 --- a/setup.py +++ b/setup.py @@ -102,6 +102,7 @@ 'example_idatasetform_v4 = ckanext.example_idatasetform.plugin_v4:ExampleIDatasetFormPlugin', 'example_igroupform = ckanext.example_igroupform.plugin:ExampleIGroupFormPlugin', 'example_igroupform_default_group_type = ckanext.example_igroupform.plugin:ExampleIGroupFormPlugin_DefaultGroupType', + 'example_igroupform_organization = ckanext.example_igroupform.plugin:ExampleIGroupFormOrganizationPlugin', 'example_iauthfunctions_v1 = ckanext.example_iauthfunctions.plugin_v1:ExampleIAuthFunctionsPlugin', 'example_iauthfunctions_v2 = ckanext.example_iauthfunctions.plugin_v2:ExampleIAuthFunctionsPlugin', 'example_iauthfunctions_v3 = ckanext.example_iauthfunctions.plugin_v3:ExampleIAuthFunctionsPlugin',