diff --git a/ckanext/ed/actions.py b/ckanext/ed/actions.py index d0cb1ef..4cb3964 100644 --- a/ckanext/ed/actions.py +++ b/ckanext/ed/actions.py @@ -1,12 +1,14 @@ -from ckan.plugins import toolkit +from logging import getLogger +import os +import requests import uuid -from ckanext.ed import helpers import zipfile -import os + from ckan.controllers.admin import get_sysadmins -import requests -from logging import getLogger +from ckan.logic.action.get import package_show as core_package_show +from ckan.plugins import toolkit +from ckanext.ed import helpers SUPPORTED_RESOURCE_MIMETYPES = [ 'application/vnd.openxmlformats-officedocument.presentationml.presentation', @@ -136,3 +138,18 @@ def prepare_zip_resources(context, data_dict): os.remove(file_path) return {'zip_id': None} + + +@toolkit.side_effect_free +def package_show(context, data_dict): + package = core_package_show(context, data_dict) + # User with less perms then creator should not be able to access pending dataset + approval_pending = package.get('approval_state') == 'approval_pending' + try: + toolkit.check_access('package_update', context, data_dict) + can_edit = True + except toolkit.NotAuthorized: + can_edit = False + if not can_edit and approval_pending: + raise toolkit.ObjectNotFound + return package diff --git a/ckanext/ed/helpers.py b/ckanext/ed/helpers.py index 0193dc1..d6bffdb 100644 --- a/ckanext/ed/helpers.py +++ b/ckanext/ed/helpers.py @@ -29,38 +29,39 @@ def get_recently_updated_datasets(limit=5): Returns recent created or updated datasets. :param limit: Limit of the datasets to be returned. Default is 5. :type limit: integer + :param user: user name + :type user: string + :returns: a list of recently created or updated datasets :rtype: list ''' try: - pkg_search_results = toolkit.get_action('package_search')(data_dict={ - 'sort': 'metadata_modified desc', - 'rows': limit, - })['results'] + pkg_search_results = toolkit.get_action('package_search')( + data_dict={ + 'sort': 'metadata_modified desc', + 'rows': limit + })['results'] + return pkg_search_results except toolkit.ValidationError, search.SearchError: return [] else: - pkgs = [] - for pkg in pkg_search_results: - package = toolkit.get_action('package_show')( - data_dict={'id': pkg['id']}) - modified = datetime.strptime( - package['metadata_modified'].split('T')[0], '%Y-%m-%d') - package['days_ago_modified'] = ((datetime.now() - modified).days) - pkgs.append(package) - return pkgs - + log.warning('Unexpected Error occured while searching') + return [] def get_most_popular_datasets(limit=5): ''' Returns most popular datasets based on total views. :param limit: Limit of the datasets to be returned. Default is 5. :type limit: integer + :param user: user name + :type user: string + :returns: a list of most popular datasets :rtype: list ''' - data = pkg_search_results = toolkit.get_action('package_search')(data_dict={ + data = pkg_search_results = toolkit.get_action('package_search')( + data_dict={ 'sort': 'views_total desc', 'rows': limit, })['results'] @@ -105,3 +106,17 @@ def get_total_views_for_dataset(id): return dataset.get('tracking_summary').get('total') except Exception: return 0 + + +def is_admin(user): + """ + Returns True if user is admin of any organisation + + :param user: user name + :type user: string + + :returns: True/False + :rtype: boolean + """ + user_orgs = _get_action('organization_list_for_user', {'user': user}, {'user': user}) + return any([i.get('capacity') == 'admin' for i in user_orgs]) diff --git a/ckanext/ed/plugin.py b/ckanext/ed/plugin.py index 5fb27f0..d5c8ba1 100644 --- a/ckanext/ed/plugin.py +++ b/ckanext/ed/plugin.py @@ -1,8 +1,8 @@ +from ckan.lib.plugins import DefaultTranslation import ckan.plugins as plugins import ckan.plugins.toolkit as toolkit -from ckanext.ed import helpers -from ckanext.ed import actions -from ckan.lib.plugins import DefaultTranslation + +from ckanext.ed import actions, helpers, validators class EDPlugin(plugins.SingletonPlugin, DefaultTranslation): @@ -11,33 +11,40 @@ class EDPlugin(plugins.SingletonPlugin, DefaultTranslation): plugins.implements(plugins.ITranslation) plugins.implements(plugins.IActions) plugins.implements(plugins.IRoutes, inherit=True) + plugins.implements(plugins.IValidators) + plugins.implements(plugins.IPackageController, inherit=True) # ITemplateHelpers - def get_helpers(self): return { 'ed_get_groups': helpers.get_groups, + 'ed_is_admin': helpers.is_admin, 'ed_get_recently_updated_datasets': helpers.get_recently_updated_datasets, 'ed_get_most_popular_datasets': helpers.get_most_popular_datasets, 'ed_get_total_views_for_dataset': helpers.get_total_views_for_dataset, } # IActions - def get_actions(self): return { 'ed_prepare_zip_resources': actions.prepare_zip_resources, + 'package_show': actions.package_show } - # IConfigurer + # IPackageController + def before_search(self, search_params): + search_params.update({ + 'fq': '!(approval_state:approval_pending) ' + search_params.get('fq', '') + }) + return search_params + # IConfigurer def update_config(self, config_): toolkit.add_template_directory(config_, 'templates') toolkit.add_public_directory(config_, 'public') toolkit.add_resource('fanstatic', 'ed') # IRoutes - def before_map(self, map): map.connect( 'download_zip', @@ -47,3 +54,9 @@ def before_map(self, map): ) return map + + # IValidators + def get_validators(self): + return { + 'state_validator': validators.state_validator + } diff --git a/ckanext/ed/schemas/dataset.json b/ckanext/ed/schemas/dataset.json index b7661b8..6796945 100644 --- a/ckanext/ed/schemas/dataset.json +++ b/ckanext/ed/schemas/dataset.json @@ -117,6 +117,11 @@ "label": "Temporal", "form_placeholder": "eg. 2000-01-15T00:45:00Z/2010-01-15T00:06:00Z", "help_text": "The range of temporal applicability of a dataset (i.e., a start and end date of applicability for the data)." + }, + { + "field_name": "approval_state", + "form_snippet": null, + "validators": "state_validator" } ], "resource_fields": [ diff --git a/ckanext/ed/templates/package/new_package_form.html b/ckanext/ed/templates/package/new_package_form.html new file mode 100644 index 0000000..da5b87d --- /dev/null +++ b/ckanext/ed/templates/package/new_package_form.html @@ -0,0 +1,34 @@ +{% extends 'package/snippets/package_form.html' %} + +{% block stages %} + {% if form_style != 'edit' %} + {% if not h.ed_is_admin(c.user) %} + + {% endif %} + {{ super() }} + {% endif %} +{% endblock %} + +{% block save_button_text %} + {% if form_style != 'edit' %} + {{ super() }} + {% else %} + {{ _('Update Dataset') }} + {% endif %} +{% endblock %} + +{% block cancel_button %} + {% if form_style != 'edit' %} + {{ super() }} + {% endif %} +{% endblock %} + +{% block delete_button %} + {% if form_style == 'edit' and h.check_access('package_delete', {'id': pkg_dict.id}) %} + {{ super() }} + {% endif %} +{% endblock %} diff --git a/ckanext/ed/templates/package/snippets/stages.html b/ckanext/ed/templates/package/snippets/stages.html new file mode 100644 index 0000000..8092be5 --- /dev/null +++ b/ckanext/ed/templates/package/snippets/stages.html @@ -0,0 +1,48 @@ +{# +Inserts a stepped progress indicator for the new package form. Each stage can +have one of three states, "uncomplete", "complete" and "active". + +stages - A list of states for each of the three stages. Missing stages default + to "uncomplete". + +Example: + + {% snippet 'package/snippets/stages.html', stages=['active'] %} + {% snippet 'package/snippets/stages.html', stages=['complete', 'active'] %} + {% snippet 'package/snippets/stages.html', stages=['active', 'complete'] %} + +#} +{% set s1 = stages[0] or 'active' %} +{% set s2 = stages[1] or 'uncomplete' %} +{% if s1 != 'uncomplete' %}{% set class = 'stage-1' %}{% endif %} +{% if s2 != 'uncomplete' %}{% set class = 'stage-2' %}{% endif %} + +
    +
  1. + {% if s1 != 'complete' %} + {% if h.ed_is_admin(c.user) %} + {{ _('Create dataset') }} + {% else %} + {{ _('Request dataset') }} + {% endif %} + {% else %} + {% if h.ed_is_admin(c.user) %} + + {% else %} + + {% endif %} + {% endif %} +
  2. +
  3. + {% if s2 != 'complete' %} + {{ _('Add data') }} + {% else %} + {% if s1 == 'active' %} + {# stage 1 #} + + {% else %} + {% link_for _('Add data'), named_route='dataset.new', class_="highlight" %} + {% endif %} + {% endif %} +
  4. +
diff --git a/ckanext/ed/tests/test_helpers.py b/ckanext/ed/tests/test_helpers.py index 40cccd4..fb826d8 100644 --- a/ckanext/ed/tests/test_helpers.py +++ b/ckanext/ed/tests/test_helpers.py @@ -7,19 +7,37 @@ class TestHelpers(test_helpers.FunctionalTestBase): def test_get_recently_updated_datasets(self): - factories.Dataset() - factories.Dataset() - factories.Dataset() - dataset = factories.Dataset() + user = core_factories.User() + org = core_factories.Organization( + users=[{'name': user['name'], 'capacity': 'admin'}] + ) + factories.Dataset(owner_org=org['id']) + factories.Dataset(owner_org=org['id']) + factories.Dataset(owner_org=org['id']) + dataset = factories.Dataset(owner_org=org['id']) result = helpers.get_recently_updated_datasets() - assert len(result) == 4 + assert len(result) == 4, 'Epextec 4 but got %s' % len(result) assert result[0]['id'] == dataset['id'] result = helpers.get_recently_updated_datasets(limit=2) assert len(result) == 2 assert result[0]['id'] == dataset['id'] + def test_get_recently_updated_datasets_lists_only_approved(self): + user = core_factories.User() + org = core_factories.Organization( + users=[{'name': user['name'], 'capacity': 'admin'}] + ) + factories.Dataset(owner_org=org['id'], approval_state='approval_pending') + factories.Dataset(owner_org=org['id'], approval_state='approval_pending') + factories.Dataset(owner_org=org['id']) + dataset = factories.Dataset(owner_org=org['id']) + + result = helpers.get_recently_updated_datasets() + assert len(result) == 2, 'Epextec 2 but got %s' % len(result) + assert result[0]['id'] == dataset['id'] + def test_get_groups(self): group1 = core_factories.Group() @@ -31,3 +49,24 @@ def test_get_groups(self): result = helpers.get_groups() assert result[0]['id'] == group1['id'] assert len(result) == 4 + + def test_is_admin(self): + core_factories.User(name='george') + core_factories.User(name='john') + core_factories.User(name='paul') + core_factories.Organization( + users=[ + {'name': 'george', 'capacity': 'admin'}, + {'name': 'john', 'capacity': 'editor'}, + {'name': 'paul', 'capacity': 'reader'} + ] + ) + + result = helpers.is_admin('george') + assert result, '%s is not True' % result + result = helpers.is_admin('john') + assert not result, '%s is not False' % result + result = helpers.is_admin('paul') + assert not result, '%s is not False' % result + result = helpers.is_admin('ringo') + assert not result, '%s is not False' % result diff --git a/ckanext/ed/tests/test_validators.py b/ckanext/ed/tests/test_validators.py new file mode 100644 index 0000000..70efc7e --- /dev/null +++ b/ckanext/ed/tests/test_validators.py @@ -0,0 +1,62 @@ +from nose.tools import assert_equals + +from ckan import model +from ckan.tests import factories as core_factories +from ckan.tests.helpers import call_action, FunctionalTestBase + + +class TestValidators(FunctionalTestBase): + def test_dataset_by_sysadmin_and_admin_is_not_approval_pending(self): + core_factories.User(name='george') + core_factories.Organization( + users=[{'name': 'george', 'capacity': 'admin'}], + name='us-ed-1', + id='us-ed-1' + ) + + sysadmin = core_factories.Sysadmin() + context = _create_context(sysadmin) + data_dict = _create_dataset_dict('test-dataset-1', 'us-ed-1') + call_action('package_create', context, **data_dict) + dataset = call_action('package_show', context, id='test-dataset-1') + assert_equals(dataset.get('approval_state'), 'active') + + context = _create_context({'name': 'george'}) + data_dict = _create_dataset_dict('test-dataset-2', 'us-ed-1') + call_action('package_create', context, **data_dict) + dataset = call_action('package_show', context, id='test-dataset-2') + assert_equals(dataset.get('approval_state'), 'active') + + + def test_dataset_by_editor_is_approval_pending(self): + core_factories.User(name='john') + core_factories.Organization( + users=[{'name': 'john', 'capacity': 'editor'}], + name='us-ed-2', + id='us-ed-2' + ) + + context = _create_context({'name': 'john'}) + data_dict = _create_dataset_dict('test-dataset', 'us-ed-2') + call_action('package_create', context, **data_dict) + dataset = call_action('package_show', context, id='test-dataset') + assert_equals(dataset['approval_state'], 'approval_pending') + + +def _create_context(user): + return {'model': model, 'user': user['name']} + + +def _create_dataset_dict(package_name, office_name='us-ed'): + return { + 'name': package_name, + 'contact_name': 'Stu Shepard', + 'program_code': '321', + 'access_level': 'public', + 'bureau_code': '123', + 'contact_email': '%s@email.com' % package_name, + 'notes': 'notes', + 'owner_org': office_name, + 'title': 'Title', + 'identifier': 'identifier' + } diff --git a/ckanext/ed/tests/test_workflow.py b/ckanext/ed/tests/test_workflow.py new file mode 100644 index 0000000..d187d01 --- /dev/null +++ b/ckanext/ed/tests/test_workflow.py @@ -0,0 +1,112 @@ +from nose.tools import assert_raises, assert_equals + +from ckan.lib.search import rebuild +from ckan.tests import factories as core_factories +from ckan.tests import helpers as test_helpers +from ckan.tests.helpers import call_action, FunctionalTestBase +import ckan.plugins.toolkit as toolkit + +from ckanext.ed.tests import factories + + +class TestWorFlows(FunctionalTestBase): + # For some reasons @classmethod does not work + def setup(self): + self.pkg_1 = 'test-dataset-1' + self.pkg_2 = 'test-dataset-2' + test_helpers.reset_db() + rebuild() + core_factories.User(name='george') + core_factories.User(name='john') + core_factories.User(name='paul') + core_factories.Organization( + users=[ + {'name': 'george', 'capacity': 'admin'}, + {'name': 'john', 'capacity': 'editor'}, + {'name': 'paul', 'capacity': 'reader'} + ], + name='us-ed-1', + id='us-ed-1' + ) + # Dataset created by factories seem to use sysadmin so approval_state + # forced to be "approved". Creating packages this way to avoid that + context = {'user': 'john'} + data_dict = _create_dataset_dict(self.pkg_1, 'us-ed-1') + call_action('package_create', context, **data_dict) + + # 1 datasets above "approveal_pending" and 1 below "approved" + context = {'user': 'george'} + data_dict = _create_dataset_dict(self.pkg_2, 'us-ed-1') + call_action('package_create', context, **data_dict) + + + @classmethod + def tearDownClass(self): + pass + + + def test_dataset_not_appears_in_search_if_not_approved_admin(self): + context = {'user': 'george'} + packages = call_action('package_search', context, **{}) + assert_equals(packages['count'], 1) + + def test_dataset_not_appears_in_search_if_not_approved_editor(self): + context = {'user': 'john'} + packages = call_action('package_search', context, **{}) + assert_equals(packages['count'], 1) + + def test_dataset_not_appears_in_search_if_not_approved_reder(self): + context = {'user': 'paul'} + packages = call_action('package_search', context, **{}) + assert_equals(packages['count'], 1) + + def test_dataset_not_appears_in_search_if_not_approved_aninimous(self): + context = {'user': 'ringo'} + packages = call_action('package_search', context, **{}) + assert_equals(packages['count'], 1) + + def test_package_show_admin_can_see_both(self): + context = {'user': 'george'} + packages = call_action('package_show', context, **{'id': self.pkg_1}) + assert_equals(packages['name'], self.pkg_1) + packages = call_action('package_show', context, **{'id': self.pkg_2}) + assert_equals(packages['name'], self.pkg_2) + + def test_package_show_editor_can_see_both(self): + context = {'user': 'john'} + packages = call_action('package_show', context, **{'id': self.pkg_1}) + assert_equals(packages['name'], self.pkg_1) + packages = call_action('package_show', context, **{'id': self.pkg_2}) + assert_equals(packages['name'], self.pkg_2) + + def test_package_show_member_can_not_see_pending(self): + context = {'user': 'paul', 'ignore_auth': False} + assert_raises(toolkit.ObjectNotFound, + call_action, 'package_show', context, id=self.pkg_1) + packages = call_action('package_show', context, **{'id': self.pkg_2}) + assert_equals(packages['name'], self.pkg_2) + + def test_package_show_anonimous_can_not_see_pending(self): + context = {'user': 'ringo', 'ignore_auth': False} + assert_raises(toolkit.ObjectNotFound, + call_action, 'package_show', context, id=self.pkg_1) + packages = call_action('package_show', context, **{'id': self.pkg_2}) + assert_equals(packages['name'], self.pkg_2) + + +# Helpers + + +def _create_dataset_dict(package_name, office_name='us-ed'): + return { + 'name': package_name, + 'contact_name': 'Stu Shepard', + 'program_code': '321', + 'access_level': 'public', + 'bureau_code': '123', + 'contact_email': '%s@email.com' % package_name, + 'notes': 'notes', + 'owner_org': office_name, + 'title': 'Title', + 'identifier': 'identifier' + } diff --git a/ckanext/ed/validators.py b/ckanext/ed/validators.py new file mode 100644 index 0000000..40fd034 --- /dev/null +++ b/ckanext/ed/validators.py @@ -0,0 +1,22 @@ +from ckan.plugins import toolkit + + +def state_validator(key, data, errors, context): + user_orgs = toolkit.get_action('organization_list_for_user')( + context, {'id': context['user']}) + office_id = data.get(('owner_org',)) + + state = data.pop(key, None) + + # If the user is member of the organization but not an admin, force the + # state to be pending + for org in user_orgs: + if org.get('id') == office_id: + if org.get('capacity') == 'admin': + # If no state provided and user is an admin, default to active + state = state or 'active' + else: + # If not admin, create as pending + state = 'approval_pending' + + data[key] = state