diff --git a/ckan/config/deployment.ini_tmpl b/ckan/config/deployment.ini_tmpl index 2f91158ea49..67d369af28d 100644 --- a/ckan/config/deployment.ini_tmpl +++ b/ckan/config/deployment.ini_tmpl @@ -79,6 +79,7 @@ ckan.site_id = default ## Plugins Settings # Note: Add ``datastore`` to enable the CKAN DataStore +# Add ``datapusher`` to enable DataPusher # Add ``pdf_preview`` to enable the resource preview for PDFs # Add ``resource_proxy`` to enable resorce proxying and get around the # same origin policy @@ -146,8 +147,8 @@ ckan.feeds.author_link = # Make sure you have set up the DataStore -datapusher.formats = csv -datapusher.url = http://datapusher.ckan.org/ +ckan.datapusher.formats = csv +ckan.datapusher.url = http://datapusher.ckan.org/ ## Activity Streams Settings diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index 8f63e2779ca..bcff15841a5 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -114,8 +114,10 @@ def _finish_ok(self, response_data=None, return self._finish(status_int, response_data, content_type) - def _finish_not_authz(self): + def _finish_not_authz(self, extra_msg=None): response_data = _('Access denied') + if extra_msg: + response_data = '%s - %s' % (response_data, extra_msg) return self._finish(403, response_data, 'json') def _finish_not_found(self, extra_msg=None): @@ -194,10 +196,14 @@ def action(self, logic_function, ver=None): 'data': request_data} return_dict['success'] = False return self._finish(400, return_dict, content_type='json') - except NotAuthorized: + except NotAuthorized, e: return_dict['error'] = {'__type': 'Authorization Error', 'message': _('Access denied')} return_dict['success'] = False + + if e.extra_msg: + return_dict['error']['message'] += ': %s' % e.extra_msg + return self._finish(403, return_dict, content_type='json') except NotFound, e: return_dict['error'] = {'__type': 'Not Found Error', @@ -277,8 +283,9 @@ def list(self, ver=None, register=None, subregister=None, id=None): except NotFound, e: extra_msg = e.extra_msg return self._finish_not_found(extra_msg) - except NotAuthorized: - return self._finish_not_authz() + except NotAuthorized, e: + extra_msg = e.extra_msg + return self._finish_not_authz(extra_msg) def show(self, ver=None, register=None, subregister=None, id=None, id2=None): @@ -308,8 +315,9 @@ def show(self, ver=None, register=None, subregister=None, except NotFound, e: extra_msg = e.extra_msg return self._finish_not_found(extra_msg) - except NotAuthorized: - return self._finish_not_authz() + except NotAuthorized, e: + extra_msg = e.extra_msg + return self._finish_not_authz(extra_msg) def _represent_package(self, package): return package.as_dict(ref_package_by=self.ref_package_by, @@ -354,8 +362,9 @@ def create(self, ver=None, register=None, subregister=None, data_dict.get("id"))) return self._finish_ok(response_data, resource_location=location) - except NotAuthorized: - return self._finish_not_authz() + except NotAuthorized, e: + extra_msg = e.extra_msg + return self._finish_not_authz(extra_msg) except NotFound, e: extra_msg = e.extra_msg return self._finish_not_found(extra_msg) @@ -410,8 +419,9 @@ def update(self, ver=None, register=None, subregister=None, try: response_data = action(context, data_dict) return self._finish_ok(response_data) - except NotAuthorized: - return self._finish_not_authz() + except NotAuthorized, e: + extra_msg = e.extra_msg + return self._finish_not_authz(extra_msg) except NotFound, e: extra_msg = e.extra_msg return self._finish_not_found(extra_msg) @@ -458,8 +468,9 @@ def delete(self, ver=None, register=None, subregister=None, try: response_data = action(context, data_dict) return self._finish_ok(response_data) - except NotAuthorized: - return self._finish_not_authz() + except NotAuthorized, e: + extra_msg = e.extra_msg + return self._finish_not_authz(extra_msg) except NotFound, e: extra_msg = e.extra_msg return self._finish_not_found(extra_msg) diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index 37a5ca297c0..70326e75317 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -1868,11 +1868,11 @@ def task_status_show(context, data_dict): context['task_status'] = task_status + _check_access('task_status_show', context, data_dict) + if task_status is None: raise NotFound - _check_access('task_status_show', context, data_dict) - task_status_dict = model_dictize.task_status_dictize(task_status, context) return task_status_dict diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index 9fb859fde65..9425b337245 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -85,6 +85,7 @@ def default_resource_schema(): 'cache_last_updated': [ignore_missing, isodate], 'webstore_last_updated': [ignore_missing, isodate], 'tracking_summary': [ignore_missing], + 'datastore_active': [ignore], '__extras': [ignore_missing, extras_unicode_convert, keep_extras], } diff --git a/ckan/model/meta.py b/ckan/model/meta.py index 795094965c5..2b6cb4581ba 100644 --- a/ckan/model/meta.py +++ b/ckan/model/meta.py @@ -162,5 +162,5 @@ def engine_is_sqlite(sa_engine=None): def engine_is_pg(sa_engine=None): # Returns true iff the engine is connected to a postgresql database. # According to http://docs.sqlalchemy.org/en/latest/core/engines.html#postgresql - # all Postgres driver names start with `postgresql` - return (sa_engine or engine).url.drivername.startswith('postgresql') + # all Postgres driver names start with `postgres` + return (sa_engine or engine).url.drivername.startswith('postgres') diff --git a/ckan/plugins/core.py b/ckan/plugins/core.py index 8f3752117ea..f39c8062917 100644 --- a/ckan/plugins/core.py +++ b/ckan/plugins/core.py @@ -18,7 +18,7 @@ 'PluginNotFoundException', 'Plugin', 'SingletonPlugin', 'load', 'load_all', 'unload', 'unload_all', 'get_plugin', 'plugins_update', - 'use_plugin', + 'use_plugin', 'plugin_loaded', ] log = logging.getLogger(__name__) @@ -210,6 +210,15 @@ def unload(*plugins): plugins_update() +def plugin_loaded(name): + ''' + See if a particular plugin is loaded. + ''' + if name in _PLUGINS: + return True + return False + + def find_system_plugins(): ''' Return all plugins in the ckan.system_plugins entry point group. diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index 302ed2ee09b..14d64b01146 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -443,7 +443,6 @@ def before_view(self, pkg_dict): class IResourceController(Interface): """ Hook into the resource controller. - (see IGroupController) """ def before_show(self, resource_dict): diff --git a/ckan/public/base/javascript/main.js b/ckan/public/base/javascript/main.js index e307fe8403c..42c76dfd15f 100644 --- a/ckan/public/base/javascript/main.js +++ b/ckan/public/base/javascript/main.js @@ -36,6 +36,7 @@ this.ckan = this.ckan || {}; ckan.i18n.load(data); ckan.module.initialize(); }); + jQuery('[data-target="popover"]').popover(); }; /* Returns a full url for the current site with the provided path appended. diff --git a/ckan/public/base/less/activity.less b/ckan/public/base/less/activity.less index dc75ede5e37..6071fabc5f0 100644 --- a/ckan/public/base/less/activity.less +++ b/ckan/public/base/less/activity.less @@ -56,6 +56,9 @@ .border-radius(100px); .box-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); } + &.no-avatar p { + margin-left: 40px; + } } .load-less { margin-bottom: 15px; @@ -76,11 +79,26 @@ float: right; text-decoration: none; } + .popover-content { + font-size: @baseFontSize; + line-height: @baseLineHeight; + color: @layoutTextColor; + word-break: break-all; + dl { + margin: 0; + dd { + margin-left: 0; + margin-bottom: 10px; + } + } + } } // colors .activity .item { & .icon { background-color: @activityColorBlank; } // Non defined + &.failure .icon { background-color: @activityColorDelete; } + &.success .icon { background-color: @activityColorNew; } &.added-tag .icon { background-color: spin(@activityColorNew, 60); } &.changed-group .icon { background-color: @activityColorModify; } &.changed-package .icon { background-color: spin(@activityColorModify, 20); } diff --git a/ckan/public/base/less/ckan.less b/ckan/public/base/less/ckan.less index f7c108f8734..f842bd6dbae 100644 --- a/ckan/public/base/less/ckan.less +++ b/ckan/public/base/less/ckan.less @@ -20,6 +20,7 @@ @import "activity.less"; @import "dropdown.less"; @import "dashboard.less"; +@import "datapusher.less"; body { // Using the masthead/footer gradient prevents the color from changing diff --git a/ckan/public/base/less/datapusher.less b/ckan/public/base/less/datapusher.less new file mode 100644 index 00000000000..f147c0c3f6c --- /dev/null +++ b/ckan/public/base/less/datapusher.less @@ -0,0 +1,18 @@ +.datapusher-status-link:hover { + text-decoration: none; +} + +.datapusher-status { + &.status-unknown { + color: #bbb; + } + &.status-pending { + color: #FFCC00; + } + &.status-error { + color: red; + } + &.status-complete { + color: #009900; + } +} \ No newline at end of file diff --git a/ckan/templates/package/base_form_page.html b/ckan/templates/package/base_form_page.html index a738a8716c8..b4d7dc440c0 100644 --- a/ckan/templates/package/base_form_page.html +++ b/ckan/templates/package/base_form_page.html @@ -2,8 +2,11 @@ {% block primary_content %}
+ {% block page_header %}{% endblock %}
- {% block form %}{{ c.form | safe }}{% endblock %} + {% block primary_content_inner %} + {% block form %}{{ c.form | safe }}{% endblock %} + {% endblock %}
{% endblock %} diff --git a/ckan/templates/package/edit.html b/ckan/templates/package/edit.html index 565623f7ebd..c3a7a5074e5 100644 --- a/ckan/templates/package/edit.html +++ b/ckan/templates/package/edit.html @@ -1,7 +1,5 @@ {% extends 'package/edit_base.html' %} -{% block subtitle %}{{ _('Edit') }} - {{ h.dataset_display_name(pkg) }}{% endblock %} - {% block primary_content_inner %} - {% block form %}{{ c.form | safe }}{% endblock %} + {% block form %}{{ c.form | safe }}{% endblock %} {% endblock %} diff --git a/ckan/templates/package/edit_base.html b/ckan/templates/package/edit_base.html index e8b785712b3..10c1aa977ee 100644 --- a/ckan/templates/package/edit_base.html +++ b/ckan/templates/package/edit_base.html @@ -1,6 +1,7 @@ {% extends 'package/base.html' %} {% set pkg = c.pkg_dict %} +{% set pkg_dict = c.pkg_dict %} {% block breadcrumb_content_selected %}{% endblock %} diff --git a/ckan/templates/package/new_resource.html b/ckan/templates/package/new_resource.html index 49d02387768..edd34bb5213 100644 --- a/ckan/templates/package/new_resource.html +++ b/ckan/templates/package/new_resource.html @@ -13,12 +13,7 @@ {% block form %}{% snippet 'package/snippets/resource_form.html', data=data, errors=errors, error_summary=error_summary, include_metadata=false, pkg_name=pkg_name, stage=stage, allow_upload=g.ofs_impl and logged_in %}{% endblock %} {% block secondary_content %} -
-

{{ _('What\'s a resource?') }}

-
-

{{ _('A resource can be any file or link to a file containing useful data.') }}

-
-
+ {% snippet 'package/snippets/resource_help.html' %} {% endblock %} {% block scripts %} diff --git a/ckan/templates/package/new_resource_not_draft.html b/ckan/templates/package/new_resource_not_draft.html index a24f37ce459..7e1638c51e7 100644 --- a/ckan/templates/package/new_resource_not_draft.html +++ b/ckan/templates/package/new_resource_not_draft.html @@ -3,6 +3,18 @@ {% block subtitle %}{{ _('Add resource') }} - {{ h.dataset_display_name(pkg) }}{% endblock %} {% block form_title %}{{ _('Add resource') }}{% endblock %} +{% block breadcrumb_content %} +
  • {{ _('Add New Resource') }}
  • +{% endblock %} + {% block form %} {% snippet 'package/snippets/resource_form.html', data=data, errors=errors, error_summary=error_summary, include_metadata=false, pkg_name=pkg_name, stage=stage, allow_upload=g.ofs_impl and logged_in %} {% endblock %} + +{% block content_primary_nav %} +
  • {{ _('New resource') }}
  • +{% endblock %} + +{% block secondary_content %} + {% snippet 'package/snippets/resource_help.html' %} +{% endblock %} diff --git a/ckan/templates/package/resource_data.html b/ckan/templates/package/resource_data.html new file mode 100644 index 00000000000..05bd66fdfee --- /dev/null +++ b/ckan/templates/package/resource_data.html @@ -0,0 +1,68 @@ +{% extends "package/resource_edit_base.html" %} + +{% block subtitle %}{{ h.dataset_display_name(pkg) }} - {{ h.resource_display_name(res) }}{% endblock %} + +{% block primary_content_inner %} + + {% set action = h.url_for(controller='ckanext.datapusher.plugin:ResourceDataController', action='resource_data', id=pkg.name, resource_id=res.id) %} + {% set show_table = true %} + +
    + +
    + + {% if status.error and status.error.message %} + {% set show_table = false %} +
    + {{ _('Upload error:') }} {{ status.error.message }} +
    + {% elif status.task_info and status.task_info.error %} + {% set show_table = false %} +
    + {{ _('Error:') }} {{ status.task_info.error }} +
    + {% endif %} + + + + + + + + + + + + + + +
    {{ _('Status') }}{{ status.status.capitalize() if status.status else _('Not Uploaded Yet') }}
    {{ _('Last updated') }}{{ h.time_ago_from_timestamp(status.last_updated) if status.status else _('Never') }}
    + + {% if status.status and status.task_info and show_table %} +

    {{ _('Upload Log') }}

    + + {% endif %} + +{% endblock %} diff --git a/ckan/templates/package/resource_edit.html b/ckan/templates/package/resource_edit.html index 3d761185230..5e335d9114b 100644 --- a/ckan/templates/package/resource_edit.html +++ b/ckan/templates/package/resource_edit.html @@ -1,9 +1,7 @@ {% extends "package/resource_edit_base.html" %} -{% set res = c.resource %} - {% block subtitle %}{{ _('Edit') }} - {{ h.resource_display_name(res) }} - {{ h.dataset_display_name(pkg) }}{% endblock %} {% block form %} - {{ h.snippet('package/snippets/resource_edit_form.html', data=data, errors=errors, error_summary=error_summary, pkg_name=pkg.name, form_action=c.form_action, allow_upload=g.ofs_impl and logged_in) }} + {% snippet 'package/snippets/resource_edit_form.html', data=data, errors=errors, error_summary=error_summary, pkg_name=pkg.name, form_action=c.form_action, allow_upload=g.ofs_impl and logged_in %} {% endblock %} diff --git a/ckan/templates/package/resource_edit_base.html b/ckan/templates/package/resource_edit_base.html index e6c98ad7ca3..0682cbbf014 100644 --- a/ckan/templates/package/resource_edit_base.html +++ b/ckan/templates/package/resource_edit_base.html @@ -1,26 +1,28 @@ {% extends "package/base.html" %} {% set logged_in = true if c.userobj else false %} +{% set res = c.resource %} {% block breadcrumb_content_selected %}{% endblock %} {% block breadcrumb_content %} {{ super() }}
  • {% link_for h.resource_display_name(res)|truncate(30), controller='package', action='resource_read', id=pkg.name, resource_id=res.id %}
  • -
  • Edit
  • +
  • {{ _('Edit') }}
  • {% endblock %} {% block content_action %} {% link_for _('All resources'), controller='package', action='resources', id=pkg.name, class_='btn', icon='arrow-left' %} - {% link_for _('View resource'), controller='package', action='resource_read', id=pkg.name, resource_id=res.id, class_='btn', icon='eye-open' %} + {% if res %} + {% link_for _('View resource'), controller='package', action='resource_read', id=pkg.name, resource_id=res.id, class_='btn', icon='eye-open' %} + {% endif %} {% endblock %} {% block content_primary_nav %} {{ h.build_nav_icon('resource_edit', _('Edit resource'), id=pkg.name, resource_id=res.id) }} -{% endblock %} - -{% block secondary_content %} - {% snippet 'package/snippets/resource_info.html', res=res %} + {% if 'datapusher' in g.plugins %} + {{ h.build_nav_icon('resource_data', _('Resource Data'), id=pkg.name, resource_id=res.id) }} + {% endif %} {% endblock %} {% block primary_content_inner %} @@ -28,6 +30,10 @@

    {% block form_title %}{{ _('Edit resource') }}{% endblo {% block form %}{% endblock %} {% endblock %} +{% block secondary_content %} + {% snippet 'package/snippets/resource_info.html', res=res %} +{% endblock %} + {% block scripts %} {{ super() }} {% resource 'vendor/fileupload' %} diff --git a/ckan/templates/package/snippets/info.html b/ckan/templates/package/snippets/info.html index b12ce3f6998..39ad092ff94 100644 --- a/ckan/templates/package/snippets/info.html +++ b/ckan/templates/package/snippets/info.html @@ -8,19 +8,23 @@ {% snippet "package/snippets/info.html", pkg=pkg %} #} -
    -
    -

    {{ pkg.title or pkg.name }}

    -
    -
    -
    {{ _('Followers') }}
    -
    {{ h.SI_number_span(h.get_action('dataset_follower_count', {'id': pkg.id})) }}
    -
    -
    - {% if not hide_follow_button %} - +
    + +{% endif %} diff --git a/ckan/templates/package/snippets/resource_help.html b/ckan/templates/package/snippets/resource_help.html new file mode 100644 index 00000000000..d1724956909 --- /dev/null +++ b/ckan/templates/package/snippets/resource_help.html @@ -0,0 +1,6 @@ +
    +

    {{ _('What\'s a resource?') }}

    +
    +

    {{ _('A resource can be any file or link to a file containing useful data.') }}

    +
    +
    diff --git a/ckan/templates/snippets/datapusher_status.html b/ckan/templates/snippets/datapusher_status.html new file mode 100644 index 00000000000..3aa8908aef8 --- /dev/null +++ b/ckan/templates/snippets/datapusher_status.html @@ -0,0 +1,14 @@ +{# Datapusher status indicator + +resource: the resource + +#} +{% if resource.datastore_active %} + {% set job = h.datapusher_status(resource.id) %} + {% set title = _('Datapusher status: {status}.').format(status=job.status) %} + {% if job.status == 'unknown' %} + + {% else %} + + {% endif %} +{% endif %} diff --git a/ckan/tests/functional/api/model/test_group_and_organization_purge.py b/ckan/tests/functional/api/model/test_group_and_organization_purge.py index 0770642dbe3..88a44e494f0 100644 --- a/ckan/tests/functional/api/model/test_group_and_organization_purge.py +++ b/ckan/tests/functional/api/model/test_group_and_organization_purge.py @@ -266,8 +266,7 @@ def _test_visitors_cannot_purge_groups_or_orgs(self, is_org): result = tests.call_action_api(self.app, action, id=group_or_org['id'], status=403, ) - assert result == {'__type': 'Authorization Error', - 'message': 'Access denied'} + assert result['__type'] == 'Authorization Error' def test_visitors_cannot_purge_organizations(self): '''Visitors (who aren't logged in) should not be authorized to purge diff --git a/ckan/tests/functional/api/model/test_vocabulary.py b/ckan/tests/functional/api/model/test_vocabulary.py index 5330fb09822..83bd8a57676 100644 --- a/ckan/tests/functional/api/model/test_vocabulary.py +++ b/ckan/tests/functional/api/model/test_vocabulary.py @@ -399,7 +399,7 @@ def test_vocabulary_create_not_logged_in(self): params=param_string, status=403) assert response.json['success'] is False - assert response.json['error']['message'] == 'Access denied' + assert response.json['error']['__type'] == 'Authorization Error' def test_vocabulary_create_not_authorized(self): '''Test that users who are not authorized cannot create vocabs.''' @@ -412,7 +412,7 @@ def test_vocabulary_create_not_authorized(self): str(self.normal_user.apikey)}, status=403) assert response.json['success'] is False - assert response.json['error']['message'] == 'Access denied' + assert response.json['error']['__type'] == 'Authorization Error' def test_vocabulary_update_id_only(self): self._update_vocabulary({'id': self.genre_vocab['id']}, @@ -494,7 +494,7 @@ def test_vocabulary_update_not_logged_in(self): params=param_string, status=403) assert response.json['success'] is False - assert response.json['error']['message'] == 'Access denied' + assert response.json['error']['__type'] == 'Authorization Error' def test_vocabulary_update_with_tags(self): tags = [ @@ -630,7 +630,7 @@ def test_vocabulary_delete_not_logged_in(self): params=param_string, status=403) assert response.json['success'] is False - assert response.json['error']['message'] == 'Access denied' + assert response.json['error']['__type'] == 'Authorization Error' def test_vocabulary_delete_not_authorized(self): '''Test that users who are not authorized cannot delete vocabs.''' @@ -642,7 +642,7 @@ def test_vocabulary_delete_not_authorized(self): str(self.normal_user.apikey)}, status=403) assert response.json['success'] is False - assert response.json['error']['message'] == 'Access denied' + assert response.json['error']['__type'] == 'Authorization Error' def test_add_tag_to_vocab(self): '''Test that a tag can be added to and then retrieved from a vocab.''' @@ -781,7 +781,7 @@ def test_add_tag_not_logged_in(self): params=tag_string, status=403) assert response.json['success'] is False - assert response.json['error']['message'] == 'Access denied' + assert response.json['error']['__type'] == 'Authorization Error' def test_add_tag_not_authorized(self): tag_dict = { @@ -795,7 +795,7 @@ def test_add_tag_not_authorized(self): str(self.normal_user.apikey)}, status=403) assert response.json['success'] is False - assert response.json['error']['message'] == 'Access denied' + assert response.json['error']['__type'] == 'Authorization Error' def test_add_vocab_tag_to_dataset(self): '''Test that a tag belonging to a vocab can be added to a dataset, @@ -1116,8 +1116,8 @@ def test_delete_tag_not_logged_in(self): params=helpers.json.dumps(params), status=403) assert response.json['success'] is False - msg = response.json['error']['message'] - assert msg == u"Access denied", msg + error = response.json['error']['__type'] + assert error == u"Authorization Error", error def test_delete_tag_not_authorized(self): vocab = self.genre_vocab @@ -1131,5 +1131,5 @@ def test_delete_tag_not_authorized(self): str(self.normal_user.apikey)}, status=403) assert response.json['success'] is False - msg = response.json['error']['message'] - assert msg == u"Access denied" + msg = response.json['error']['__type'] + assert msg == u"Authorization Error" diff --git a/ckan/tests/functional/api/test_follow.py b/ckan/tests/functional/api/test_follow.py index 79e6f5dab17..74d8cc75b88 100644 --- a/ckan/tests/functional/api/test_follow.py +++ b/ckan/tests/functional/api/test_follow.py @@ -424,36 +424,36 @@ def test_01_user_follow_user_bad_apikey(self): error = ckan.tests.call_action_api(self.app, 'follow_user', id=self.russianfan['id'], apikey=apikey, status=403) - assert error['message'] == 'Access denied' + assert error['__type'] == 'Authorization Error' def test_01_user_follow_dataset_bad_apikey(self): for apikey in ('bad api key', '', ' ', 'None', '3', '35.7', 'xxx'): error = ckan.tests.call_action_api(self.app, 'follow_dataset', id=self.warandpeace['id'], apikey=apikey, status=403) - assert error['message'] == 'Access denied' + assert error['__type'] == 'Authorization Error' def test_01_user_follow_group_bad_apikey(self): for apikey in ('bad api key', '', ' ', 'None', '3', '35.7', 'xxx'): error = ckan.tests.call_action_api(self.app, 'follow_group', id=self.rogers_group['id'], apikey=apikey, status=403) - assert error['message'] == 'Access denied' + assert error['__type'] == 'Authorization Error' def test_01_user_follow_user_missing_apikey(self): error = ckan.tests.call_action_api(self.app, 'follow_user', id=self.russianfan['id'], status=403) - assert error['message'] == 'Access denied' + assert error['__type'] == 'Authorization Error' def test_01_user_follow_dataset_missing_apikey(self): error = ckan.tests.call_action_api(self.app, 'follow_dataset', id=self.warandpeace['id'], status=403) - assert error['message'] == 'Access denied' + assert error['__type'] == 'Authorization Error' def test_01_user_follow_group_missing_apikey(self): error = ckan.tests.call_action_api(self.app, 'follow_group', id=self.rogers_group['id'], status=403) - assert error['message'] == 'Access denied' + assert error['__type'] == 'Authorization Error' def test_01_follow_bad_object_id(self): for action in ('follow_user', 'follow_dataset', 'follow_group'): @@ -878,14 +878,14 @@ def test_01_unfollow_bad_apikey(self): 'xxx'): error = ckan.tests.call_action_api(self.app, action, apikey=apikey, status=403, id=self.joeadmin['id']) - assert error['message'] == 'Access denied' + assert error['__type'] == 'Authorization Error' def test_01_unfollow_missing_apikey(self): '''Test error response when calling unfollow_* without api key.''' for action in ('unfollow_user', 'unfollow_dataset', 'unfollow_group'): error = ckan.tests.call_action_api(self.app, action, status=403, id=self.joeadmin['id']) - assert error['message'] == 'Access denied' + assert error['__type'] == 'Authorization Error' def test_01_unfollow_bad_object_id(self): '''Test error response when calling unfollow_* with bad object id.''' diff --git a/ckan/tests/functional/test_related.py b/ckan/tests/functional/test_related.py index 003f0bb424b..6fa2fed4266 100644 --- a/ckan/tests/functional/test_related.py +++ b/ckan/tests/functional/test_related.py @@ -498,4 +498,4 @@ def test_api_delete_fail(self): extra_environ=extra) r = json.loads(res.body) assert r['success'] == False, r - assert r[u'error'][u'message'] == u'Access denied' , r + assert r[u'error'][u'__type'] == "Authorization Error", r diff --git a/ckan/tests/logic/test_action.py b/ckan/tests/logic/test_action.py index c295d9271ae..41c31a469bc 100644 --- a/ckan/tests/logic/test_action.py +++ b/ckan/tests/logic/test_action.py @@ -528,10 +528,7 @@ def test_12_user_update(self): res_obj = json.loads(res.body) assert res_obj['help'].startswith("Update a user account.") - assert res_obj['error'] == { - '__type': 'Authorization Error', - 'message': 'Access denied' - } + assert res_obj['error']['__type'] == 'Authorization Error' assert res_obj['success'] is False def test_12_user_update_errors(self): @@ -893,7 +890,7 @@ def test_22_task_status_normal_user_not_authorized(self): res_obj = json.loads(res.body) assert res_obj['help'].startswith("Update a task status.") assert res_obj['success'] is False - assert res_obj['error'] == {'message': 'Access denied', '__type': 'Authorization Error'} + assert res_obj['error']['__type'] == 'Authorization Error' def test_23_task_status_validation(self): task_status = {} diff --git a/ckan/tests/logic/test_tag.py b/ckan/tests/logic/test_tag.py index 82d0cdd1e1e..4844fa055ca 100644 --- a/ckan/tests/logic/test_tag.py +++ b/ckan/tests/logic/test_tag.py @@ -151,7 +151,7 @@ def test_08_user_create_not_authorized(self): res_obj = json.loads(res.body) assert res_obj['help'].startswith("Create a new user.") assert res_obj['success'] is False - assert res_obj['error'] == {'message': 'Access denied', '__type': 'Authorization Error'} + assert res_obj['error']['__type'] == 'Authorization Error' def test_09_user_create(self): user_dict = {'name':'test_create_from_action_api', diff --git a/ckan/tests/test_coding_standards.py b/ckan/tests/test_coding_standards.py index f0b7469c3f4..349f4785f6a 100644 --- a/ckan/tests/test_coding_standards.py +++ b/ckan/tests/test_coding_standards.py @@ -828,7 +828,6 @@ class TestPep8(object): 'ckanext/example_idatasetform/plugin.py', 'ckanext/example_itemplatehelpers/plugin.py', 'ckanext/multilingual/plugin.py', - 'ckanext/multilingual/tests/test_multilingual_plugin.py', 'ckanext/reclinepreview/plugin.py', 'ckanext/reclinepreview/tests/test_preview.py', 'ckanext/resourceproxy/plugin.py', diff --git a/ckanext/datapusher/__init__.py b/ckanext/datapusher/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/datapusher/helpers.py b/ckanext/datapusher/helpers.py new file mode 100644 index 00000000000..d5291425b28 --- /dev/null +++ b/ckanext/datapusher/helpers.py @@ -0,0 +1,11 @@ +import ckan.plugins.toolkit as toolkit + + +def datapusher_status(resource_id): + try: + return toolkit.get_action('datapusher_status')( + {}, {'resource_id': resource_id}) + except toolkit.ObjectNotFound: + return { + 'status': 'unknown' + } diff --git a/ckanext/datapusher/logic/__init__.py b/ckanext/datapusher/logic/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/datapusher/logic/action.py b/ckanext/datapusher/logic/action.py new file mode 100644 index 00000000000..1872a1ef265 --- /dev/null +++ b/ckanext/datapusher/logic/action.py @@ -0,0 +1,209 @@ +import logging +import json +import urlparse +import datetime + +import pylons +import requests + +import ckan.lib.navl.dictization_functions +import ckan.logic as logic +import ckan.plugins as p +import ckanext.datapusher.logic.schema as dpschema + +log = logging.getLogger(__name__) +_get_or_bust = logic.get_or_bust +_validate = ckan.lib.navl.dictization_functions.validate + + +def datapusher_submit(context, data_dict): + ''' Submit a job to the datapusher. The datapusher is a service that + imports tabular data into the datastore. + + :param resource_id: The resource id of the resource that the data + should be imported in. The resource's URL will be used to get the data. + :type resource_id: string + :param set_url_type: If set to True, the ``url_type`` of the resource will + be set to ``datastore`` and the resource URL will automatically point + to the :ref:`datastore dump ` URL. (optional, default: False) + :type set_url_type: bool + + Returns ``True`` if the job has been submitted and ``False`` if the job + has not been submitted, i.e. when the datapusher is not configured. + + :rtype: bool + ''' + + schema = context.get('schema', dpschema.datapusher_submit_schema()) + data_dict, errors = _validate(data_dict, schema, context) + if errors: + raise p.toolkit.ValidationError(errors) + + res_id = data_dict['resource_id'] + + p.toolkit.check_access('datapusher_submit', context, data_dict) + + datapusher_url = pylons.config.get('ckan.datapusher.url') + + callback_url = p.toolkit.url_for( + controller='api', action='action', logic_function='datapusher_hook', + ver=3, qualified=True) + + user = p.toolkit.get_action('user_show')(context, {'id': context['user']}) + + task = { + 'entity_id': res_id, + 'entity_type': 'resource', + 'task_type': 'datapusher', + 'last_updated': str(datetime.datetime.now()), + 'state': 'submitting', + 'key': 'datapusher', + 'value': '{}', + 'error': '{}', + } + try: + task_id = p.toolkit.get_action('task_status_show')(context, { + 'entity_id': res_id, + 'task_type': 'datapusher', + 'key': 'datapusher' + })['id'] + task['id'] = task_id + except logic.NotFound: + pass + + context['ignore_auth'] = True + result = p.toolkit.get_action('task_status_update')(context, task) + task_id = result['id'] + + try: + r = requests.post( + urlparse.urljoin(datapusher_url, 'job'), + headers={ + 'Content-Type': 'application/json' + }, + data=json.dumps({ + 'api_key': user['apikey'], + 'job_type': 'push_to_datastore', + 'result_url': callback_url, + 'metadata': { + 'ckan_url': pylons.config['ckan.site_url'], + 'resource_id': res_id, + 'set_url_type': data_dict.get('set_url_type', False) + } + })) + r.raise_for_status() + except requests.exceptions.ConnectionError, e: + error = {'message': 'Could not connect to DataPusher.', + 'details': str(e)} + task['error'] = json.dumps(error) + task['state'] = 'error' + task['last_updated'] = str(datetime.datetime.now()), + p.toolkit.get_action('task_status_update')(context, task) + raise p.toolkit.ValidationError(error) + + except requests.exceptions.HTTPError, e: + m = 'An Error occurred while sending the job: {0}'.format(e.message) + try: + body = e.response.json() + except ValueError: + body = e.response.text + error = {'message': m, + 'details': body, + 'status_code': r.status_code} + task['error'] = json.dumps(error) + task['state'] = 'error' + task['last_updated'] = str(datetime.datetime.now()), + p.toolkit.get_action('task_status_update')(context, task) + raise p.toolkit.ValidationError(error) + + value = json.dumps({'job_id': r.json()['job_id'], + 'job_key': r.json()['job_key']}) + + task['value'] = value + task['state'] = 'pending' + task['last_updated'] = str(datetime.datetime.now()), + p.toolkit.get_action('task_status_update')(context, task) + + return True + + +def datapusher_hook(context, data_dict): + ''' Update datapusher task. This action is typically called by the + datapusher whenever the status of a job changes. + + :param metadata: metadata produced by datapuser service must have + resource_id property. + :type metadata: dict + :param status: status of the job from the datapusher service + :type status: string + ''' + + metadata, status = _get_or_bust(data_dict, ['metadata', 'status']) + + p.toolkit.check_access('datapusher_submit', context, data_dict) + + res_id = metadata.get('resource_id') + + task = p.toolkit.get_action('task_status_show')(context, { + 'entity_id': res_id, + 'task_type': 'datapusher', + 'key': 'datapusher' + }) + + task['state'] = status + task['last_updated'] = str(datetime.datetime.now()) + + p.toolkit.get_action('task_status_update')(context, task) + + +def datapusher_status(context, data_dict): + ''' Get the status of a datapusher job for a certain resource. + + :param resource_id: The resource id of the resource that you want the + datapusher status for. + :type resource_id: string + ''' + + p.toolkit.check_access('datapusher_status', context, data_dict) + + if 'id' in data_dict: + data_dict['resource_id'] = data_dict['id'] + res_id = _get_or_bust(data_dict, 'resource_id') + + task = p.toolkit.get_action('task_status_show')(context, { + 'entity_id': res_id, + 'task_type': 'datapusher', + 'key': 'datapusher' + }) + + datapusher_url = pylons.config.get('ckan.datapusher.url') + if not datapusher_url: + raise p.toolkit.ValidationError( + {'configuration': ['ckan.datapusher.url not in config file']}) + + value = json.loads(task['value']) + job_key = value.get('job_key') + job_id = value.get('job_id') + url = None + job_detail = None + + if job_id: + url = urlparse.urljoin(datapusher_url, 'job' + '/' + job_id) + try: + r = requests.get(url, headers={'Content-Type': 'application/json', + 'Authorization': job_key}) + r.raise_for_status() + job_detail = r.json() + except (requests.exceptions.ConnectionError, + requests.exceptions.HTTPError), e: + job_detail = {'error': 'cannot connect to datapusher'} + + return { + 'status': task['state'], + 'job_id': job_id, + 'job_url': url, + 'last_updated': task['last_updated'], + 'job_key': job_key, + 'task_info': job_detail, + 'error': json.loads(task['error']) + } diff --git a/ckanext/datapusher/logic/auth.py b/ckanext/datapusher/logic/auth.py new file mode 100644 index 00000000000..55a7e832488 --- /dev/null +++ b/ckanext/datapusher/logic/auth.py @@ -0,0 +1,9 @@ +import ckanext.datastore.logic.auth as auth + + +def datapusher_submit(context, data_dict): + return auth.datastore_auth(context, data_dict) + + +def datapusher_status(context, data_dict): + return auth.datastore_auth(context, data_dict) diff --git a/ckanext/datapusher/logic/schema.py b/ckanext/datapusher/logic/schema.py new file mode 100644 index 00000000000..07e8a36aec8 --- /dev/null +++ b/ckanext/datapusher/logic/schema.py @@ -0,0 +1,25 @@ +import ckan.plugins as p +import ckanext.datastore.logic.schema as dsschema + +get_validator = p.toolkit.get_validator + +not_missing = get_validator('not_missing') +not_empty = get_validator('not_empty') +resource_id_exists = get_validator('resource_id_exists') +package_id_exists = get_validator('package_id_exists') +ignore_missing = get_validator('ignore_missing') +empty = get_validator('empty') +boolean_validator = get_validator('boolean_validator') +int_validator = get_validator('int_validator') +OneOf = get_validator('OneOf') + + +def datapusher_submit_schema(): + schema = { + 'resource_id': [not_missing, not_empty, unicode], + 'id': [ignore_missing], + 'set_url_type': [ignore_missing, boolean_validator], + '__junk': [empty], + '__before': [dsschema.rename('id', 'resource_id')] + } + return schema diff --git a/ckanext/datapusher/plugin.py b/ckanext/datapusher/plugin.py new file mode 100644 index 00000000000..c53dd14790d --- /dev/null +++ b/ckanext/datapusher/plugin.py @@ -0,0 +1,131 @@ +import logging + +import ckan.plugins as p +import ckan.lib.base as base +import ckan.lib.helpers as core_helpers +import ckanext.datapusher.logic.action as action +import ckanext.datapusher.logic.auth as auth +import ckanext.datapusher.helpers as helpers +import ckan.logic as logic +import ckan.model as model +import ckan.plugins.toolkit as toolkit + +log = logging.getLogger(__name__) +_get_or_bust = logic.get_or_bust + +DEFAULT_FORMATS = ['csv', 'xls', 'application/csv', 'application/vnd.ms-excel'] + + +class DatastoreException(Exception): + pass + + +class ResourceDataController(base.BaseController): + + def resource_data(self, id, resource_id): + + if toolkit.request.method == 'POST': + try: + toolkit.c.pkg_dict = p.toolkit.get_action('datapusher_submit')( + None, {'resource_id': resource_id} + ) + except logic.ValidationError: + pass + + base.redirect(core_helpers.url_for( + controller='ckanext.datapusher.plugin:ResourceDataController', + action='resource_data', + id=id, + resource_id=resource_id) + ) + + try: + toolkit.c.pkg_dict = p.toolkit.get_action('package_show')( + None, {'id': id} + ) + toolkit.c.resource = p.toolkit.get_action('resource_show')( + None, {'id': resource_id} + ) + except logic.NotFound: + base.abort(404, _('Resource not found')) + except logic.NotAuthorized: + base.abort(401, _('Unauthorized to edit this resource')) + + try: + datapusher_status = p.toolkit.get_action('datapusher_status')( + None, {'resource_id': resource_id} + ) + except logic.NotFound: + datapusher_status = {} + + return base.render('package/resource_data.html', + extra_vars={'status': datapusher_status}) + + +class DatapusherPlugin(p.SingletonPlugin): + p.implements(p.IConfigurable, inherit=True) + p.implements(p.IActions) + p.implements(p.IAuthFunctions) + p.implements(p.IResourceUrlChange) + p.implements(p.IDomainObjectModification, inherit=True) + p.implements(p.ITemplateHelpers) + p.implements(p.IRoutes, inherit=True) + + legacy_mode = False + resource_show_action = None + + def configure(self, config): + self.config = config + + datapusher_formats = config.get('ckan.datapusher.formats', '').lower() + self.datapusher_formats = datapusher_formats.split() or DEFAULT_FORMATS + + datapusher_url = config.get('ckan.datapusher.url') + if not datapusher_url: + raise Exception( + 'Config option `ckan.datapusher.url` has to be set.') + + def notify(self, entity, operation=None): + if isinstance(entity, model.Resource): + if (operation == model.domain_object.DomainObjectOperation.new + or not operation): + # if operation is None, resource URL has been changed, as + # the notify function in IResourceUrlChange only takes + # 1 parameter + context = {'model': model, 'ignore_auth': True, + 'defer_commit': True} + package = p.toolkit.get_action('package_show')(context, { + 'id': entity.get_package_id() + }) + if (not package['private'] and entity.format and + entity.format.lower() in self.datapusher_formats and + entity.url_type != 'datapusher'): + try: + p.toolkit.get_action('datapusher_submit')(context, { + 'resource_id': entity.id + }) + except p.toolkit.ValidationError, e: + # If datapusher is offline want to catch error instead + # of raising otherwise resource save will fail with 500 + log.critical(e) + pass + + def before_map(self, m): + m.connect( + 'resource_data', '/dataset/{id}/resource_data/{resource_id}', + controller='ckanext.datapusher.plugin:ResourceDataController', + action='resource_data', ckan_icon='cloud-upload') + return m + + def get_actions(self): + return {'datapusher_submit': action.datapusher_submit, + 'datapusher_hook': action.datapusher_hook, + 'datapusher_status': action.datapusher_status} + + def get_auth_functions(self): + return {'datapusher_submit': auth.datapusher_submit, + 'datapusher_status': auth.datapusher_status} + + def get_helpers(self): + return { + 'datapusher_status': helpers.datapusher_status} diff --git a/ckanext/datapusher/tests/__init__.py b/ckanext/datapusher/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/datapusher/tests/test.py b/ckanext/datapusher/tests/test.py new file mode 100644 index 00000000000..2ec2df42d21 --- /dev/null +++ b/ckanext/datapusher/tests/test.py @@ -0,0 +1,184 @@ +import json +import httpretty +import nose +import sys +import datetime + +import pylons +from pylons import config +import sqlalchemy.orm as orm +import paste.fixture + +import ckan.plugins as p +import ckan.lib.create_test_data as ctd +import ckan.model as model +import ckan.tests as tests +import ckan.config.middleware as middleware + +import ckanext.datastore.db as db +from ckanext.datastore.tests.helpers import rebuild_all_dbs, set_url_type + + +# avoid hanging tests https://github.com/gabrielfalcao/HTTPretty/issues/34 +if sys.version_info < (2, 7, 0): + import socket + socket.setdefaulttimeout(1) + + +class TestDatastoreCreate(tests.WsgiAppCase): + sysadmin_user = None + normal_user = None + + @classmethod + def setup_class(cls): + + wsgiapp = middleware.make_app(config['global_conf'], **config) + cls.app = paste.fixture.TestApp(wsgiapp) + if not tests.is_datastore_supported(): + raise nose.SkipTest("Datastore not supported") + p.load('datastore') + p.load('datapusher') + ctd.CreateTestData.create() + cls.sysadmin_user = model.User.get('testsysadmin') + cls.normal_user = model.User.get('annafan') + engine = db._get_engine( + {'connection_url': pylons.config['ckan.datastore.write_url']}) + cls.Session = orm.scoped_session(orm.sessionmaker(bind=engine)) + set_url_type( + model.Package.get('annakarenina').resources, cls.sysadmin_user) + + @classmethod + def teardown_class(cls): + rebuild_all_dbs(cls.Session) + p.unload('datastore') + p.unload('datapusher') + + def test_create_ckan_resource_in_package(self): + package = model.Package.get('annakarenina') + data = { + 'resource': {'package_id': package.id} + } + postparams = '%s=1' % json.dumps(data) + auth = {'Authorization': str(self.sysadmin_user.apikey)} + res = self.app.post('/api/action/datastore_create', params=postparams, + extra_environ=auth, status=200) + res_dict = json.loads(res.body) + + assert 'resource_id' in res_dict['result'] + assert len(model.Package.get('annakarenina').resources) == 3 + + res = tests.call_action_api( + self.app, 'resource_show', id=res_dict['result']['resource_id']) + assert res['url'] == '/datastore/dump/' + res['id'], res + + @httpretty.activate + def test_providing_res_with_url_calls_datapusher_correctly(self): + pylons.config['datapusher.url'] = 'http://datapusher.ckan.org' + httpretty.HTTPretty.register_uri( + httpretty.HTTPretty.POST, + 'http://datapusher.ckan.org/job', + content_type='application/json', + body=json.dumps({'job_id': 'foo', 'job_key': 'bar'})) + + package = model.Package.get('annakarenina') + + tests.call_action_api( + self.app, 'datastore_create', apikey=self.sysadmin_user.apikey, + resource=dict(package_id=package.id, url='demo.ckan.org')) + + assert len(package.resources) == 4, len(package.resources) + resource = package.resources[3] + data = json.loads(httpretty.last_request().body) + assert data['metadata']['resource_id'] == resource.id, data + assert data['result_url'].endswith('/action/datapusher_hook'), data + assert data['result_url'].startswith('http://'), data + + def test_cant_provide_resource_and_resource_id(self): + package = model.Package.get('annakarenina') + resource = package.resources[0] + data = { + 'resource_id': resource.id, + 'resource': {'package_id': package.id} + } + postparams = '%s=1' % json.dumps(data) + auth = {'Authorization': str(self.sysadmin_user.apikey)} + res = self.app.post('/api/action/datastore_create', params=postparams, + extra_environ=auth, status=409) + res_dict = json.loads(res.body) + + assert res_dict['error']['__type'] == 'Validation Error' + + @httpretty.activate + def test_send_datapusher_creates_task(self): + httpretty.HTTPretty.register_uri( + httpretty.HTTPretty.POST, + 'http://datapusher.ckan.org/job', + content_type='application/json', + body=json.dumps({'job_id': 'foo', 'job_key': 'bar'})) + + package = model.Package.get('annakarenina') + resource = package.resources[0] + + context = { + 'ignore_auth': True, + 'user': self.sysadmin_user.name + } + + p.toolkit.get_action('datapusher_submit')(context, { + 'resource_id': resource.id + }) + + context.pop('task_status', None) + + task = p.toolkit.get_action('task_status_show')(context, { + 'entity_id': resource.id, + 'task_type': 'datapusher', + 'key': 'datapusher' + }) + + assert task['state'] == 'pending', task + + def test_datapusher_hook(self): + package = model.Package.get('annakarenina') + resource = package.resources[0] + + context = { + 'user': self.sysadmin_user.name + } + + p.toolkit.get_action('task_status_update')(context, { + 'entity_id': resource.id, + 'entity_type': 'resource', + 'task_type': 'datapusher', + 'key': 'datapusher', + 'value': '{"job_id": "my_id", "job_key":"my_key"}', + 'last_updated': str(datetime.datetime.now()), + 'state': 'pending' + }) + + data = { + 'status': 'success', + 'metadata': { + 'resource_id': resource.id + } + } + postparams = '%s=1' % json.dumps(data) + auth = {'Authorization': str(self.sysadmin_user.apikey)} + res = self.app.post('/api/action/datapusher_hook', params=postparams, + extra_environ=auth, status=200) + print res.body + res_dict = json.loads(res.body) + + assert res_dict['success'] is True + + task = tests.call_action_api( + self.app, 'task_status_show', entity_id=resource.id, + task_type='datapusher', key='datapusher') + + assert task['state'] == 'success', task + + task = tests.call_action_api( + self.app, 'task_status_show', entity_id=resource.id, + task_type='datapusher', key='datapusher') + + assert task['state'] == 'success', task diff --git a/ckanext/datastore/logic/action.py b/ckanext/datastore/logic/action.py index d9d291b9df3..804fec1a197 100644 --- a/ckanext/datastore/logic/action.py +++ b/ckanext/datastore/logic/action.py @@ -1,10 +1,6 @@ import logging -import json -import urlparse -import datetime import pylons -import requests import sqlalchemy import ckan.lib.navl.dictization_functions @@ -39,6 +35,8 @@ def datastore_create(context, data_dict): :param resource_id: resource id that the data is going to be stored against. :type resource_id: string + :param force: set to True to edit a read-only resource + :type force: bool (optional, default: False) :param resource: resource dictionary that is passed to :meth:`~ckan.logic.action.create.resource_create`. Use instead of ``resource_id`` (optional) @@ -90,22 +88,32 @@ def datastore_create(context, data_dict): if 'resource' in data_dict: has_url = 'url' in data_dict['resource'] - data_dict['resource'].setdefault('url', '_tmp') + # A datastore only resource does not have a url in the db + data_dict['resource'].setdefault('url', '_datastore_only_resource') res = p.toolkit.get_action('resource_create')(context, data_dict['resource']) data_dict['resource_id'] = res['id'] # create resource from file if has_url: + if not p.plugin_loaded('datapusher'): + raise p.toolkit.ValidationError({'resource': [ + 'The datapusher has to be enabled.']}) p.toolkit.get_action('datapusher_submit')(context, { 'resource_id': res['id'], - 'set_url_to_dump': True + 'set_url_type': True }) + # since we'll overwrite the datastore resource anyway, we + # don't need to create it here + return + # create empty resource else: # no need to set the full url because it will be set in before_show res['url_type'] = 'datastore' p.toolkit.get_action('resource_update')(context, res) + else: + _check_read_only(context, data_dict) data_dict['connection_url'] = pylons.config['ckan.datastore.write_url'] @@ -153,6 +161,8 @@ def datastore_upsert(context, data_dict): :param resource_id: resource id that the data is going to be stored under. :type resource_id: string + :param force: set to True to edit a read-only resource + :type force: bool (optional, default: False) :param records: the data, eg: [{"dob": "2005", "some_stuff": ["a","b"]}] (optional) :type records: list of dictionaries :param method: the method to use to put the data into the datastore. @@ -173,6 +183,10 @@ def datastore_upsert(context, data_dict): if errors: raise p.toolkit.ValidationError(errors) + p.toolkit.check_access('datastore_upsert', context, data_dict) + + _check_read_only(context, data_dict) + data_dict['connection_url'] = pylons.config['ckan.datastore.write_url'] res_id = data_dict['resource_id'] @@ -186,8 +200,6 @@ def datastore_upsert(context, data_dict): u'Resource "{0}" was not found.'.format(res_id) )) - p.toolkit.check_access('datastore_upsert', context, data_dict) - result = db.upsert(context, data_dict) result.pop('id', None) result.pop('connection_url') @@ -199,6 +211,8 @@ def datastore_delete(context, data_dict): :param resource_id: resource id that the data will be deleted from. (optional) :type resource_id: string + :param force: set to True to edit a read-only resource + :type force: bool (optional, default: False) :param filters: filters to apply before deleting (eg {"name": "fred"}). If missing delete whole table and all dependent views. (optional) :type filters: dictionary @@ -217,6 +231,10 @@ def datastore_delete(context, data_dict): if errors: raise p.toolkit.ValidationError(errors) + p.toolkit.check_access('datastore_delete', context, data_dict) + + _check_read_only(context, data_dict) + data_dict['connection_url'] = pylons.config['ckan.datastore.write_url'] res_id = data_dict['resource_id'] @@ -230,8 +248,6 @@ def datastore_delete(context, data_dict): u'Resource "{0}" was not found.'.format(res_id) )) - p.toolkit.check_access('datastore_delete', context, data_dict) - result = db.delete(context, data_dict) result.pop('id', None) result.pop('connection_url') @@ -427,129 +443,8 @@ def datastore_make_public(context, data_dict): db.make_public(context, data_dict) -def datapusher_submit(context, data_dict): - ''' Submit a job to the datapusher. The datapusher is a service that - imports tabular data into the datastore. - - :param resource_id: The resource id of the resource that the data - should be imported in. The resource's URL will be used to get the data. - :type resource_id: string - :param set_url_type: If set to true, the ``url_type`` of the resource will - be set to ``datastore`` and the resource URL will automatically point - to the :ref:`datastore dump ` URL. (optional, default: False) - :type set_url_type: boolean - - Returns ``True`` if the job has been submitted and ``False`` if the job - has not been submitted, i.e. when the datapusher is not configured. - - :rtype: boolean - ''' - - if 'id' in data_dict: - data_dict['resource_id'] = data_dict['id'] - res_id = _get_or_bust(data_dict, 'resource_id') - - p.toolkit.check_access('datapusher_submit', context, data_dict) - - datapusher_url = pylons.config.get('datapusher.url') - - # no datapusher url means the datapusher should not be used - if not datapusher_url: - return False - - callback_url = p.toolkit.url_for( - controller='api', action='action', logic_function='datapusher_hook', - ver=3, qualified=True) - - user = p.toolkit.get_action('user_show')(context, {'id': context['user']}) - try: - r = requests.post( - urlparse.urljoin(datapusher_url, 'job'), - headers={ - 'Content-Type': 'application/json' - }, - data=json.dumps({ - 'api_key': user['apikey'], - 'job_type': 'push_to_datastore', - 'result_url': callback_url, - 'metadata': { - 'ckan_url': pylons.config['ckan.site_url'], - 'resource_id': res_id, - 'set_url_type': data_dict.get('set_url_type', False) - } - })) - r.raise_for_status() - except requests.exceptions.ConnectionError, e: - raise p.toolkit.ValidationError({'datapusher': { - 'message': 'Could not connect to DataPusher.', - 'details': str(e)}}) - except requests.exceptions.HTTPError, e: - m = 'An Error occurred while sending the job: {0}'.format(e.message) - try: - body = e.response.json() - except ValueError: - body = e.response.text - raise p.toolkit.ValidationError({'datapusher': { - 'message': m, - 'details': body, - 'status_code': r.status_code}}) - - empty_task = { - 'entity_id': res_id, - 'entity_type': 'resource', - 'task_type': 'datapusher', - 'last_updated': str(datetime.datetime.now()), - 'state': 'pending' - } - - tasks = [] - for (k, v) in [('job_id', r.json()['job_id']), - ('job_key', r.json()['job_key'])]: - t = empty_task.copy() - t['key'] = k - t['value'] = v - tasks.append(t) - p.toolkit.get_action('task_status_update_many')(context, {'data': tasks}) - - return True - - -def datapusher_hook(context, data_dict): - """ Update datapusher task. This action is typically called by the - datapusher whenever the status of a job changes. - - Expects a job with ``status`` and ``metadata`` with a ``resource_id``. - """ - - # TODO: use a schema to validate - - p.toolkit.check_access('datapusher_submit', context, data_dict) - - res_id = data_dict['metadata']['resource_id'] - - task_id = p.toolkit.get_action('task_status_show')(context, { - 'entity_id': res_id, - 'task_type': 'datapusher', - 'key': 'job_id' - }) - - task_key = p.toolkit.get_action('task_status_show')(context, { - 'entity_id': res_id, - 'task_type': 'datapusher', - 'key': 'job_key' - }) - - tasks = [task_id, task_key] - - for task in tasks: - task['state'] = data_dict['status'] - task['last_updated'] = str(datetime.datetime.now()) - - p.toolkit.get_action('task_status_update_many')(context, {'data': tasks}) - - def _resource_exists(context, data_dict): - # Returns true if the resource exists in CKAN and in the datastore + ''' Returns true if the resource exists in CKAN and in the datastore ''' model = _get_or_bust(context, 'model') res_id = _get_or_bust(data_dict, 'resource_id') if not model.Resource.get(res_id): @@ -559,3 +454,18 @@ def _resource_exists(context, data_dict): WHERE name = :id AND alias_of IS NULL''') results = db._get_engine(data_dict).execute(resources_sql, id=res_id) return results.rowcount > 0 + + +def _check_read_only(context, data_dict): + ''' Raises exception if the resource is read-only. + Make sure the resource id is in resource_id + ''' + if data_dict.get('force'): + return + res = p.toolkit.get_action('resource_show')( + context, {'id': data_dict['resource_id']}) + if res.get('url_type') != 'datastore': + raise p.toolkit.ValidationError({ + 'read-only': ['Cannot edit read-only resource. Either pass' + '"force=True" or change url-type to "datastore"'] + }) diff --git a/ckanext/datastore/logic/auth.py b/ckanext/datastore/logic/auth.py index ff410536e21..f99d7f45ae3 100644 --- a/ckanext/datastore/logic/auth.py +++ b/ckanext/datastore/logic/auth.py @@ -1,7 +1,7 @@ import ckan.plugins as p -def _datastore_auth(context, data_dict, privilege='resource_update'): +def datastore_auth(context, data_dict, privilege='resource_update'): if not 'id' in data_dict: data_dict['id'] = data_dict.get('resource_id') user = context.get('user') @@ -19,25 +19,21 @@ def _datastore_auth(context, data_dict, privilege='resource_update'): def datastore_create(context, data_dict): - return _datastore_auth(context, data_dict) + return datastore_auth(context, data_dict) def datastore_upsert(context, data_dict): - return _datastore_auth(context, data_dict) + return datastore_auth(context, data_dict) def datastore_delete(context, data_dict): - return _datastore_auth(context, data_dict) + return datastore_auth(context, data_dict) @p.toolkit.auth_allow_anonymous_access def datastore_search(context, data_dict): - return _datastore_auth(context, data_dict, 'resource_show') - - -def datapusher_submit(context, data_dict): - return _datastore_auth(context, data_dict) + return datastore_auth(context, data_dict, 'resource_show') def datastore_change_permissions(context, data_dict): - return _datastore_auth(context, data_dict) + return datastore_auth(context, data_dict) diff --git a/ckanext/datastore/logic/schema.py b/ckanext/datastore/logic/schema.py index 018eb249d79..6f0a74723ca 100644 --- a/ckanext/datastore/logic/schema.py +++ b/ckanext/datastore/logic/schema.py @@ -68,6 +68,7 @@ def json_validator(value, context): def datastore_create_schema(): schema = { 'resource_id': [ignore_missing, unicode, resource_id_exists], + 'force': [ignore_missing, boolean_validator], 'id': [ignore_missing], 'aliases': [ignore_missing, list_of_strings_or_string], 'fields': { @@ -85,6 +86,7 @@ def datastore_create_schema(): def datastore_upsert_schema(): schema = { 'resource_id': [not_missing, not_empty, unicode], + 'force': [ignore_missing, boolean_validator], 'id': [ignore_missing], 'method': [ignore_missing, unicode, OneOf( ['upsert', 'insert', 'update'])], @@ -97,6 +99,7 @@ def datastore_upsert_schema(): def datastore_delete_schema(): schema = { 'resource_id': [not_missing, not_empty, unicode], + 'force': [ignore_missing, boolean_validator], 'id': [ignore_missing], '__junk': [empty], '__before': [rename('id', 'resource_id')] diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index 04f201f9eb0..f91310ecdec 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -61,9 +61,9 @@ def configure(self, config): else: self.read_url = self.config['ckan.datastore.read_url'] - read_engine = db._get_engine( + self.read_engine = db._get_engine( {'connection_url': self.read_url}) - if not model.engine_is_pg(read_engine): + if not model.engine_is_pg(self.read_engine): log.warn('We detected that you do not use a PostgreSQL ' 'database. The DataStore will NOT work and DataStore ' 'tests will be skipped.') @@ -75,63 +75,15 @@ def configure(self, config): 'of _table_metadata are skipped.') else: self._check_urls_and_permissions() - self._create_alias_table() - # update the resource_show action to have datastore_active property - if self.resource_show_action is None: - resource_show = p.toolkit.get_action('resource_show') - - @logic.side_effect_free - def new_resource_show(context, data_dict): - new_data_dict = resource_show(context, data_dict) - try: - connection = read_engine.connect() - result = connection.execute( - 'SELECT 1 FROM "_table_metadata" WHERE name = %s AND alias_of IS NULL', - new_data_dict['id'] - ).fetchone() - if result: - new_data_dict['datastore_active'] = True - else: - new_data_dict['datastore_active'] = False - finally: - connection.close() - return new_data_dict - - self.resource_show_action = new_resource_show def notify(self, entity, operation=None): - ''' - if not isinstance(entity, model.Resource): - return - if operation: - if operation == model.domain_object.DomainObjectOperation.new: - self._create_datastorer_task(entity) - else: - # if operation is None, resource URL has been changed, as the - # notify function in IResourceUrlChange only takes 1 parameter - self._create_datastorer_task(entity) - ''' - context = {'model': model, 'ignore_auth': True} - if isinstance(entity, model.Resource): - if (operation == model.domain_object.DomainObjectOperation.new - or not operation): - # if operation is None, resource URL has been changed, as - # the notify function in IResourceUrlChange only takes - # 1 parameter - package = p.toolkit.get_action('package_show')(context, { - 'id': entity.get_package_id() - }) - if (not package['private'] and - entity.format in self.datapusher_formats): - p.toolkit.get_action('datapusher_submit')(context, { - 'resource_id': entity.id - }) if not isinstance(entity, model.Package) or self.legacy_mode: return # if a resource is new, it cannot have a datastore resource, yet if operation == model.domain_object.DomainObjectOperation.changed: + context = {'model': model, 'ignore_auth': True} if entity.private: func = p.toolkit.get_action('datastore_make_private') else: @@ -158,7 +110,7 @@ def _check_urls_and_permissions(self): self._log_or_raise('CKAN and DataStore database ' 'cannot be the same.') - # in legacy mode, the read and write url are ths same (both write url) + # in legacy mode, the read and write url are the same (both write url) # consequently the same url check and and write privilege check # don't make sense if not self.legacy_mode: @@ -256,9 +208,6 @@ def get_actions(self): 'datastore_upsert': action.datastore_upsert, 'datastore_delete': action.datastore_delete, 'datastore_search': action.datastore_search, - 'datapusher_submit': action.datapusher_submit, - 'datapusher_hook': action.datapusher_hook, - 'resource_show': self.resource_show_action, } if not self.legacy_mode: actions.update({ @@ -272,8 +221,7 @@ def get_auth_functions(self): 'datastore_upsert': auth.datastore_upsert, 'datastore_delete': auth.datastore_delete, 'datastore_search': auth.datastore_search, - 'datastore_change_permissions': auth.datastore_change_permissions, - 'datapusher_submit': auth.datapusher_submit} + 'datastore_change_permissions': auth.datastore_change_permissions} def before_map(self, m): m.connect('/datastore/dump/{resource_id}', @@ -282,11 +230,23 @@ def before_map(self, m): return m def before_show(self, resource_dict): - ''' Modify the resource url of datastore resources so that - they link to the datastore dumps. - ''' + # Modify the resource url of datastore resources so that + # they link to the datastore dumps. if resource_dict.get('url_type') == 'datastore': resource_dict['url'] = p.toolkit.url_for( controller='ckanext.datastore.controller:DatastoreController', action='dump', resource_id=resource_dict['id']) + + try: + connection = self.read_engine.connect() + result = connection.execute( + 'SELECT 1 FROM "_table_metadata" WHERE name = %s AND alias_of IS NULL', + resource_dict['id'] + ).fetchone() + if result: + resource_dict['datastore_active'] = True + else: + resource_dict['datastore_active'] = False + finally: + connection.close() return resource_dict diff --git a/ckanext/datastore/tests/helpers.py b/ckanext/datastore/tests/helpers.py index cf83f57378a..3ee89cdda20 100644 --- a/ckanext/datastore/tests/helpers.py +++ b/ckanext/datastore/tests/helpers.py @@ -1,6 +1,8 @@ import ckan.model as model import ckan.lib.cli as cli +import ckan.plugins as p + def extract(d, keys): return dict((k, d[k]) for k in keys if k in d) @@ -29,3 +31,12 @@ def rebuild_all_dbs(Session): model.repo.tables_created_and_initialised = False clear_db(Session) model.repo.rebuild_db() + + +def set_url_type(resources, user): + context = {'user': user.name} + for resource in resources: + resource = p.toolkit.get_action('resource_show')( + context, {'id': resource.id}) + resource['url_type'] = 'datastore' + p.toolkit.get_action('resource_update')(context, resource) diff --git a/ckanext/datastore/tests/test_create.py b/ckanext/datastore/tests/test_create.py index d7b893235ed..f2db232af8a 100644 --- a/ckanext/datastore/tests/test_create.py +++ b/ckanext/datastore/tests/test_create.py @@ -17,7 +17,7 @@ import ckan.config.middleware as middleware import ckanext.datastore.db as db -from ckanext.datastore.tests.helpers import rebuild_all_dbs +from ckanext.datastore.tests.helpers import rebuild_all_dbs, set_url_type # avoid hanging tests https://github.com/gabrielfalcao/HTTPretty/issues/34 @@ -44,6 +44,8 @@ def setup_class(cls): engine = db._get_engine( {'connection_url': pylons.config['ckan.datastore.write_url']}) cls.Session = orm.scoped_session(orm.sessionmaker(bind=engine)) + set_url_type( + model.Package.get('annakarenina').resources, cls.sysadmin_user) @classmethod def teardown_class(cls): @@ -536,144 +538,6 @@ def test_create_basic(self): assert res_dict['success'] is True, res_dict - def test_create_ckan_resource_in_package(self): - package = model.Package.get('annakarenina') - data = { - 'resource': {'package_id': package.id} - } - postparams = '%s=1' % json.dumps(data) - auth = {'Authorization': str(self.sysadmin_user.apikey)} - res = self.app.post('/api/action/datastore_create', params=postparams, - extra_environ=auth, status=200) - res_dict = json.loads(res.body) - - assert 'resource_id' in res_dict['result'] - assert len(model.Package.get('annakarenina').resources) == 3 - - res = tests.call_action_api( - self.app, 'resource_show', id=res_dict['result']['resource_id']) - assert res['url'] == '/datastore/dump/' + res['id'], res - - @httpretty.activate - def test_providing_res_with_url_calls_datapusher_correctly(self): - pylons.config['datapusher.url'] = 'http://datapusher.ckan.org' - httpretty.HTTPretty.register_uri( - httpretty.HTTPretty.POST, - 'http://datapusher.ckan.org/job', - content_type='application/json', - body=json.dumps({'job_id': 'foo', 'job_key': 'bar'})) - - package = model.Package.get('annakarenina') - - tests.call_action_api( - self.app, 'datastore_create', apikey=self.sysadmin_user.apikey, - resource=dict(package_id=package.id, url='demo.ckan.org')) - - assert len(package.resources) == 4, len(package.resources) - resource = package.resources[3] - data = json.loads(httpretty.last_request().body) - assert data['metadata']['resource_id'] == resource.id, data - assert data['result_url'].endswith('/action/datapusher_hook'), data - assert data['result_url'].startswith('http://'), data - - def test_cant_provide_resource_and_resource_id(self): - package = model.Package.get('annakarenina') - resource = package.resources[0] - data = { - 'resource_id': resource.id, - 'resource': {'package_id': package.id} - } - postparams = '%s=1' % json.dumps(data) - auth = {'Authorization': str(self.sysadmin_user.apikey)} - res = self.app.post('/api/action/datastore_create', params=postparams, - extra_environ=auth, status=409) - res_dict = json.loads(res.body) - - assert res_dict['error']['__type'] == 'Validation Error' - - @httpretty.activate - def test_send_datapusher_creates_task(self): - httpretty.HTTPretty.register_uri( - httpretty.HTTPretty.POST, - 'http://datapusher.ckan.org/job', - content_type='application/json', - body=json.dumps({'job_id': 'foo', 'job_key': 'bar'})) - - package = model.Package.get('annakarenina') - resource = package.resources[0] - - context = { - 'ignore_auth': True, - 'user': self.sysadmin_user.name - } - - p.toolkit.get_action('datapusher_submit')(context, { - 'resource_id': resource.id - }) - - task = p.toolkit.get_action('task_status_show')(context, { - 'entity_id': resource.id, - 'task_type': 'datapusher', - 'key': 'job_id' - }) - - assert task['state'] == 'pending', task - - def test_datapusher_hook(self): - package = model.Package.get('annakarenina') - resource = package.resources[0] - - context = { - 'user': self.sysadmin_user.name - } - - p.toolkit.get_action('task_status_update')(context, { - 'entity_id': resource.id, - 'entity_type': 'resource', - 'task_type': 'datapusher', - 'key': 'job_id', - 'value': 'my_id', - 'last_updated': str(datetime.datetime.now()), - 'state': 'pending' - }) - - p.toolkit.get_action('task_status_update')(context, { - 'entity_id': resource.id, - 'entity_type': 'resource', - 'task_type': 'datapusher', - 'key': 'job_key', - 'value': 'my_key', - 'last_updated': str(datetime.datetime.now()), - 'state': 'pending' - }) - - data = { - 'status': 'success', - 'metadata': { - 'resource_id': resource.id - } - } - postparams = '%s=1' % json.dumps(data) - auth = {'Authorization': str(self.sysadmin_user.apikey)} - res = self.app.post('/api/action/datapusher_hook', params=postparams, - extra_environ=auth, status=200) - print res.body - res_dict = json.loads(res.body) - - assert res_dict['success'] is True - - task = tests.call_action_api( - self.app, 'task_status_show', entity_id=resource.id, - task_type='datapusher', key='job_id') - - assert task['state'] == 'success', task - - task = tests.call_action_api( - self.app, 'task_status_show', entity_id=resource.id, - task_type='datapusher', key='job_key') - - assert task['state'] == 'success', task - def test_guess_types(self): resource = model.Package.get('annakarenina').resources[1] diff --git a/ckanext/datastore/tests/test_delete.py b/ckanext/datastore/tests/test_delete.py index ce1b02efdb1..bb7db39217a 100644 --- a/ckanext/datastore/tests/test_delete.py +++ b/ckanext/datastore/tests/test_delete.py @@ -11,7 +11,7 @@ import ckan.tests as tests import ckanext.datastore.db as db -from ckanext.datastore.tests.helpers import rebuild_all_dbs +from ckanext.datastore.tests.helpers import rebuild_all_dbs, set_url_type class TestDatastoreDelete(tests.WsgiAppCase): @@ -43,6 +43,8 @@ def setup_class(cls): engine = db._get_engine( {'connection_url': pylons.config['ckan.datastore.write_url']}) cls.Session = orm.scoped_session(orm.sessionmaker(bind=engine)) + set_url_type( + model.Package.get('annakarenina').resources, cls.sysadmin_user) @classmethod def teardown_class(cls): diff --git a/ckanext/datastore/tests/test_dump.py b/ckanext/datastore/tests/test_dump.py index 041105af986..6061535f4ba 100644 --- a/ckanext/datastore/tests/test_dump.py +++ b/ckanext/datastore/tests/test_dump.py @@ -32,6 +32,7 @@ def setup_class(cls): resource = model.Package.get('annakarenina').resources[0] cls.data = { 'resource_id': resource.id, + 'force': True, 'aliases': 'books', 'fields': [{'id': u'b\xfck', 'type': 'text'}, {'id': 'author', 'type': 'text'}, diff --git a/ckanext/datastore/tests/test_search.py b/ckanext/datastore/tests/test_search.py index 21cb19de4e2..1b7c9486da7 100644 --- a/ckanext/datastore/tests/test_search.py +++ b/ckanext/datastore/tests/test_search.py @@ -30,6 +30,7 @@ def setup_class(cls): cls.resource = cls.dataset.resources[0] cls.data = { 'resource_id': cls.resource.id, + 'force': True, 'aliases': 'books3', 'fields': [{'id': u'b\xfck', 'type': 'text'}, {'id': 'author', 'type': 'text'}, @@ -116,7 +117,7 @@ def test_search_private_dataset(self): context, {'name': 'privatedataset', 'private': True, - 'owner_org' : self.organization['id'], + 'owner_org': self.organization['id'], 'groups': [{ 'id': group.id }]}) @@ -128,6 +129,7 @@ def test_search_private_dataset(self): postparams = '%s=1' % json.dumps({ 'resource_id': resource['id'], + 'force': True }) auth = {'Authorization': str(self.sysadmin_user.apikey)} res = self.app.post('/api/action/datastore_create', params=postparams, @@ -425,6 +427,7 @@ def setup_class(cls): resource = model.Package.get('annakarenina').resources[0] cls.data = dict( resource_id=resource.id, + force=True, fields=[ {'id': 'id'}, {'id': 'date', 'type':'date'}, @@ -499,6 +502,7 @@ def setup_class(cls): resource = cls.dataset.resources[0] cls.data = { 'resource_id': resource.id, + 'force': True, 'aliases': 'books4', 'fields': [{'id': u'b\xfck', 'type': 'text'}, {'id': 'author', 'type': 'text'}, @@ -517,7 +521,7 @@ def setup_class(cls): extra_environ=auth) res_dict = json.loads(res.body) assert res_dict['success'] is True - + # Make an organization, because private datasets must belong to one. cls.organization = tests.call_action_api( cls.app, 'organization_create', @@ -669,6 +673,7 @@ def test_new_datastore_table_from_private_resource(self): postparams = '%s=1' % json.dumps({ 'resource_id': resource['id'], + 'force': True }) auth = {'Authorization': str(self.sysadmin_user.apikey)} res = self.app.post('/api/action/datastore_create', params=postparams, @@ -708,7 +713,9 @@ def test_making_resource_private_makes_datastore_private(self): 'package_id': package['id']}) postparams = '%s=1' % json.dumps({ - 'resource_id': resource['id']}) + 'resource_id': resource['id'], + 'force': True + }) auth = {'Authorization': str(self.sysadmin_user.apikey)} res = self.app.post('/api/action/datastore_create', params=postparams, extra_environ=auth) diff --git a/ckanext/datastore/tests/test_upsert.py b/ckanext/datastore/tests/test_upsert.py index e27f8b9c523..9d65700198a 100644 --- a/ckanext/datastore/tests/test_upsert.py +++ b/ckanext/datastore/tests/test_upsert.py @@ -11,7 +11,7 @@ import ckan.tests as tests import ckanext.datastore.db as db -from ckanext.datastore.tests.helpers import rebuild_all_dbs +from ckanext.datastore.tests.helpers import rebuild_all_dbs, set_url_type class TestDatastoreUpsert(tests.WsgiAppCase): @@ -26,6 +26,8 @@ def setup_class(cls): ctd.CreateTestData.create() cls.sysadmin_user = model.User.get('testsysadmin') cls.normal_user = model.User.get('annafan') + set_url_type( + model.Package.get('annakarenina').resources, cls.sysadmin_user) resource = model.Package.get('annakarenina').resources[0] cls.data = { 'resource_id': resource.id, @@ -249,6 +251,8 @@ def setup_class(cls): ctd.CreateTestData.create() cls.sysadmin_user = model.User.get('testsysadmin') cls.normal_user = model.User.get('annafan') + set_url_type( + model.Package.get('annakarenina').resources, cls.sysadmin_user) resource = model.Package.get('annakarenina').resources[0] cls.data = { 'resource_id': resource.id, @@ -349,6 +353,8 @@ def setup_class(cls): ctd.CreateTestData.create() cls.sysadmin_user = model.User.get('testsysadmin') cls.normal_user = model.User.get('annafan') + set_url_type( + model.Package.get('annakarenina').resources, cls.sysadmin_user) resource = model.Package.get('annakarenina').resources[0] hhguide = u"hitchhiker's guide to the galaxy" cls.data = { diff --git a/ckanext/example_iauthfunctions/tests/test_example_iauthfunctions.py b/ckanext/example_iauthfunctions/tests/test_example_iauthfunctions.py index 49b250c62b0..7031867df27 100644 --- a/ckanext/example_iauthfunctions/tests/test_example_iauthfunctions.py +++ b/ckanext/example_iauthfunctions/tests/test_example_iauthfunctions.py @@ -103,8 +103,7 @@ def test_group_create_with_visitor(self): result = tests.call_action_api(self.app, 'group_create', name='this_group_should_not_be_created', status=403) - assert result == {'__type': 'Authorization Error', - 'message': 'Access denied'} + assert result['__type'] == 'Authorization Error' def test_group_create_with_non_curator(self): '''A user who isn't a member of the curators group should not be able @@ -116,8 +115,7 @@ def test_group_create_with_non_curator(self): name='this_group_should_not_be_created', apikey=noncurator['apikey'], status=403) - assert result == {'__type': 'Authorization Error', - 'message': 'Access denied'} + assert result['__type'] == 'Authorization Error' def test_group_create_with_curator(self): '''A member of the curators group should be able to create a group. @@ -174,8 +172,7 @@ def test_group_create_with_visitor(self): response = tests.call_action_api(self.app, 'group_create', name='this_group_shouldnt_be_created', status=403) - assert response == {'__type': 'Authorization Error', - 'message': 'Access denied'} + assert response['__type'] == 'Authorization Error' class TestExampleIAuthFunctionsPluginV2(TestExampleIAuthFunctionsPlugin): @@ -203,5 +200,4 @@ def test_group_create_with_curator(self): name='this_group_should_not_be_created', apikey=curator['apikey'], status=403) - assert result == {'__type': 'Authorization Error', - 'message': 'Access denied'} + assert result['__type'] == 'Authorization Error' diff --git a/ckanext/multilingual/plugin.py b/ckanext/multilingual/plugin.py index a554042f209..a4ee31e3b48 100644 --- a/ckanext/multilingual/plugin.py +++ b/ckanext/multilingual/plugin.py @@ -1,7 +1,7 @@ import sets import ckan from ckan.plugins import SingletonPlugin, implements, IPackageController -from ckan.plugins import IGroupController, ITagController +from ckan.plugins import IGroupController, IOrganizationController, ITagController import pylons import ckan.logic.action.get as action_get from pylons import config @@ -253,6 +253,7 @@ class MultilingualGroup(SingletonPlugin): ''' implements(IGroupController, inherit=True) + implements(IOrganizationController, inherit=True) def before_view(self, data_dict): translated_data_dict = translate_data_dict(data_dict) diff --git a/ckanext/multilingual/tests/test_multilingual_plugin.py b/ckanext/multilingual/tests/test_multilingual_plugin.py index ecc22b745f7..88041b54d59 100644 --- a/ckanext/multilingual/tests/test_multilingual_plugin.py +++ b/ckanext/multilingual/tests/test_multilingual_plugin.py @@ -3,16 +3,18 @@ import ckan.lib.helpers import ckan.lib.create_test_data import ckan.logic.action.update +import ckan.model as model import ckan.tests import ckan.tests.html_check import routes import paste.fixture import pylons.test -class TestDatasetTermTranslation(ckan.tests.html_check.HtmlCheckMethods): - '''Test the translation of datasets by the multilingual_dataset plugin. +_create_test_data = ckan.lib.create_test_data + - ''' +class TestDatasetTermTranslation(ckan.tests.html_check.HtmlCheckMethods): + 'Test the translation of datasets by the multilingual_dataset plugin.' @classmethod def setup(cls): cls.app = paste.fixture.TestApp(pylons.test.pylonsapp) @@ -20,24 +22,35 @@ def setup(cls): ckan.plugins.load('multilingual_group') ckan.plugins.load('multilingual_tag') ckan.tests.setup_test_search_index() - ckan.lib.create_test_data.CreateTestData.create_translations_test_data() + _create_test_data.CreateTestData.create_translations_test_data() + + cls.sysadmin_user = model.User.get('testsysadmin') + cls.org = {'name': 'test_org', + 'title': 'russian', + 'description': 'Roger likes these books.'} + ckan.tests.call_action_api(cls.app, 'organization_create', + apikey=cls.sysadmin_user.apikey, + **cls.org) + dataset = {'name': 'test_org_dataset', + 'title': 'A Novel By Tolstoy', + 'owner_org': cls.org['name']} + ckan.tests.call_action_api(cls.app, 'package_create', + apikey=cls.sysadmin_user.apikey, + **dataset) + # Add translation terms that match a couple of group names and package # names. Group names and package names should _not_ get translated even # if there are terms matching them, because they are used to form URLs. for term in ('roger', 'david', 'annakarenina', 'warandpeace'): for lang_code in ('en', 'de', 'fr'): - data_dict = { - 'term': term, - 'term_translation': 'this should not be rendered', - 'lang_code': lang_code, - } - context = { - 'model': ckan.model, - 'session': ckan.model.Session, - 'user': 'testsysadmin', - } - ckan.logic.action.update.term_translation_update(context, - data_dict) + data_dict = {'term': term, + 'term_translation': 'this should not be rendered', + 'lang_code': lang_code} + context = {'model': ckan.model, + 'session': ckan.model.Session, + 'user': 'testsysadmin'} + ckan.logic.action.update.term_translation_update( + context, data_dict) @classmethod def teardown(cls): @@ -54,34 +67,34 @@ def test_dataset_read_translation(self): ''' # Fetch the dataset view page for a number of different languages and # test for the presence of translated and not translated terms. - offset = routes.url_for(controller='package', action='read', - id='annakarenina') + offset = routes.url_for( + controller='package', action='read', id='annakarenina') for (lang_code, translations) in ( - ('de', ckan.lib.create_test_data.german_translations), - ('fr', ckan.lib.create_test_data.french_translations), - ('en', ckan.lib.create_test_data.english_translations), + ('de', _create_test_data.german_translations), + ('fr', _create_test_data.french_translations), + ('en', _create_test_data.english_translations), ('pl', {})): response = self.app.get(offset, status=200, - extra_environ={'CKAN_LANG': lang_code, - 'CKAN_CURRENT_URL': offset}) + extra_environ={'CKAN_LANG': lang_code, + 'CKAN_CURRENT_URL': offset}) terms = ('A Novel By Tolstoy', - 'Index of the novel', - 'russian', - 'tolstoy', - "Dave's books", - "Roger's books", - 'romantic novel', - 'book', - '123', - '456', - '789', - 'plain text', - ) + 'Index of the novel', + 'russian', + 'tolstoy', + "Dave's books", + "Roger's books", + 'romantic novel', + 'book', + '123', + '456', + '789', + 'plain text',) for term in terms: if term in translations: assert translations[term] in response - elif term in ckan.lib.create_test_data.english_translations: - assert ckan.lib.create_test_data.english_translations[term] in response + elif term in _create_test_data.english_translations: + assert (_create_test_data.english_translations[term] + in response) else: assert term in response for tag_name in ('123', '456', '789', 'russian', 'tolstoy'): @@ -96,23 +109,25 @@ def test_tag_read_translation(self): ''' for tag_name in ('123', '456', '789', 'russian', 'tolstoy'): - offset = routes.url_for(controller='tag', action='read', - id=tag_name) + offset = routes.url_for( + controller='tag', action='read', id=tag_name) for (lang_code, translations) in ( - ('de', ckan.lib.create_test_data.german_translations), - ('fr', ckan.lib.create_test_data.french_translations), - ('en', ckan.lib.create_test_data.english_translations), + ('de', _create_test_data.german_translations), + ('fr', _create_test_data.french_translations), + ('en', _create_test_data.english_translations), ('pl', {})): - response = self.app.get(offset, status=200, - extra_environ={'CKAN_LANG': lang_code, - 'CKAN_CURRENT_URL': offset}) + response = self.app.get( + offset, + status=200, + extra_environ={'CKAN_LANG': lang_code, + 'CKAN_CURRENT_URL': offset}) terms = ('A Novel By Tolstoy', tag_name, 'plain text', 'json') for term in terms: if term in translations: assert translations[term] in response - elif term in ( - ckan.lib.create_test_data.english_translations): - assert ckan.lib.create_test_data.english_translations[term] in response + elif term in _create_test_data.english_translations: + assert (_create_test_data.english_translations[term] + in response) else: assert term in response assert 'this should not be rendered' not in response @@ -123,88 +138,111 @@ def test_user_read_translation(self): ''' for user_name in ('annafan',): - offset = routes.url_for(controller='user', action='read', - id=user_name) + offset = routes.url_for( + controller='user', action='read', id=user_name) for (lang_code, translations) in ( - ('de', ckan.lib.create_test_data.german_translations), - ('fr', ckan.lib.create_test_data.french_translations), - ('en', ckan.lib.create_test_data.english_translations), + ('de', _create_test_data.german_translations), + ('fr', _create_test_data.french_translations), + ('en', _create_test_data.english_translations), ('pl', {})): - response = self.app.get(offset, status=200, - extra_environ={'CKAN_LANG': lang_code, - 'CKAN_CURRENT_URL': offset}) + response = self.app.get( + offset, + status=200, + extra_environ={'CKAN_LANG': lang_code, + 'CKAN_CURRENT_URL': offset}) terms = ('A Novel By Tolstoy', 'plain text', 'json') for term in terms: if term in translations: assert translations[term] in response - elif term in ( - ckan.lib.create_test_data.english_translations): - assert ckan.lib.create_test_data.english_translations[term] in response + elif term in _create_test_data.english_translations: + assert (_create_test_data.english_translations[term] + in response) else: assert term in response assert 'this should not be rendered' not in response def test_group_read_translation(self): for (lang_code, translations) in ( - ('de', ckan.lib.create_test_data.german_translations), - ('fr', ckan.lib.create_test_data.french_translations), - ('en', ckan.lib.create_test_data.english_translations), + ('de', _create_test_data.german_translations), + ('fr', _create_test_data.french_translations), + ('en', _create_test_data.english_translations), ('pl', {})): offset = '/%s/group/roger' % lang_code response = self.app.get(offset, status=200) terms = ('A Novel By Tolstoy', - 'Index of the novel', - 'russian', - 'tolstoy', - #"Dave's books", - "Roger's books", - #'Other (Open)', - #'romantic novel', - #'book', - '123', - '456', - '789', - 'plain text', - 'Roger likes these books.', - ) + 'Index of the novel', + 'russian', + 'tolstoy', + "Roger's books", + '123', + '456', + '789', + 'plain text', + 'Roger likes these books.',) for term in terms: if term in translations: assert translations[term] in response - elif term in ckan.lib.create_test_data.english_translations: - assert ckan.lib.create_test_data.english_translations[term] in response + elif term in _create_test_data.english_translations: + assert (_create_test_data.english_translations[term] + in response) else: assert term in response for tag_name in ('123', '456', '789', 'russian', 'tolstoy'): assert '%s?tags=%s' % (offset, tag_name) in response assert 'this should not be rendered' not in response + def test_org_read_translation(self): + for (lang_code, translations) in ( + ('de', _create_test_data.german_translations), + ('fr', _create_test_data.french_translations), + ('en', _create_test_data.english_translations), + ('pl', {})): + offset = '/{0}/organization/{1}'.format( + lang_code, self.org['name']) + response = self.app.get(offset, status=200) + terms = ('A Novel By Tolstoy', + 'russian', + 'Roger likes these books.') + for term in terms: + if term in translations: + assert translations[term] in response + elif term in _create_test_data.english_translations: + assert (_create_test_data.english_translations[term] + in response) + else: + assert term in response + assert 'this should not be rendered' not in response + def test_dataset_index_translation(self): for (lang_code, translations) in ( - ('de', ckan.lib.create_test_data.german_translations), - ('fr', ckan.lib.create_test_data.french_translations), - ('en', ckan.lib.create_test_data.english_translations), + ('de', _create_test_data.german_translations), + ('fr', _create_test_data.french_translations), + ('en', _create_test_data.english_translations), ('pl', {})): offset = '/%s/dataset' % lang_code response = self.app.get(offset, status=200) for term in ('Index of the novel', 'russian', 'tolstoy', - "Dave's books", "Roger's books", 'plain text'): + "Dave's books", "Roger's books", 'plain text'): if term in translations: assert translations[term] in response - elif term in ckan.lib.create_test_data.english_translations: - assert ckan.lib.create_test_data.english_translations[term] in response + elif term in _create_test_data.english_translations: + assert (_create_test_data.english_translations[term] + in response) else: assert term in response for tag_name in ('123', '456', '789', 'russian', 'tolstoy'): - assert '/%s/dataset?tags=%s' % (lang_code, tag_name) in response + assert ('/%s/dataset?tags=%s' % (lang_code, tag_name) + in response) for group_name in ('david', 'roger'): - assert '/%s/dataset?groups=%s' % (lang_code, group_name) in response + assert ('/%s/dataset?groups=%s' % (lang_code, group_name) + in response) assert 'this should not be rendered' not in response def test_group_index_translation(self): for (lang_code, translations) in ( - ('de', ckan.lib.create_test_data.german_translations), - ('fr', ckan.lib.create_test_data.french_translations), - ('en', ckan.lib.create_test_data.english_translations), + ('de', _create_test_data.german_translations), + ('fr', _create_test_data.french_translations), + ('en', _create_test_data.english_translations), ('pl', {})): offset = '/%s/group' % lang_code response = self.app.get(offset, status=200) @@ -217,19 +255,40 @@ def test_group_index_translation(self): for term in terms: if term in translations: assert translations[term] in response - elif term in ckan.lib.create_test_data.english_translations: - assert ckan.lib.create_test_data.english_translations[term] in response + elif term in _create_test_data.english_translations: + assert (_create_test_data.english_translations[term] + in response) else: assert term in response for group_name in ('david', 'roger'): assert '/%s/group/%s' % (lang_code, group_name) in response assert 'this should not be rendered' not in response + def test_org_index_translation(self): + for (lang_code, translations) in ( + ('de', _create_test_data.german_translations), + ('fr', _create_test_data.french_translations), + ('en', _create_test_data.english_translations), + ('pl', {})): + offset = '/{0}/organization'.format(lang_code) + response = self.app.get(offset, status=200) + for term in ('russian', 'Roger likes these books.'): + if term in translations: + assert translations[term] in response + elif term in _create_test_data.english_translations: + assert (_create_test_data.english_translations[term] + in response) + else: + assert term in response, response + assert ('/{0}/organization/{1}'.format(lang_code, self.org['name']) + in response) + assert 'this should not be rendered' not in response + def test_tag_index_translation(self): for (lang_code, translations) in ( - ('de', ckan.lib.create_test_data.german_translations), - ('fr', ckan.lib.create_test_data.french_translations), - ('en', ckan.lib.create_test_data.english_translations), + ('de', _create_test_data.german_translations), + ('fr', _create_test_data.french_translations), + ('en', _create_test_data.english_translations), ('pl', {})): offset = '/%s/tag' % lang_code response = self.app.get(offset, status=200) @@ -243,13 +302,15 @@ def test_tag_index_translation(self): for term in terms: if term in translations: assert translations[term] in response - elif term in ckan.lib.create_test_data.english_translations: - assert ckan.lib.create_test_data.english_translations[term] in response + elif term in _create_test_data.english_translations: + assert (_create_test_data.english_translations[term] + in response) else: assert term in response assert '/%s/tag/%s' % (lang_code, term) in response assert 'this should not be rendered' not in response + class TestDatasetSearchIndex(): @classmethod @@ -260,36 +321,28 @@ def setup_class(cls): data_dicts = [ {'term': 'moo', 'term_translation': 'french_moo', - 'lang_code': 'fr', - }, # + 'lang_code': 'fr'}, {'term': 'moo', 'term_translation': 'this should not be rendered', - 'lang_code': 'fsdas', - }, + 'lang_code': 'fsdas'}, {'term': 'an interesting note', 'term_translation': 'french note', - 'lang_code': 'fr', - }, + 'lang_code': 'fr'}, {'term': 'moon', 'term_translation': 'french moon', - 'lang_code': 'fr', - }, + 'lang_code': 'fr'}, {'term': 'boon', 'term_translation': 'french boon', - 'lang_code': 'fr', - }, + 'lang_code': 'fr'}, {'term': 'boon', 'term_translation': 'italian boon', - 'lang_code': 'it', - }, + 'lang_code': 'it'}, {'term': 'david', 'term_translation': 'french david', - 'lang_code': 'fr', - }, + 'lang_code': 'fr'}, {'term': 'david', 'term_translation': 'italian david', - 'lang_code': 'it', - }, + 'lang_code': 'it'} ] context = { @@ -299,39 +352,41 @@ def setup_class(cls): 'ignore_auth': True, } for data_dict in data_dicts: - ckan.logic.action.update.term_translation_update(context, - data_dict) + ckan.logic.action.update.term_translation_update( + context, data_dict) @classmethod def teardown(cls): ckan.plugins.unload('multilingual_dataset') ckan.plugins.unload('multilingual_group') - def test_translate_terms(self): sample_index_data = { - 'download_url': u'moo', - 'notes': u'an interesting note', - 'tags': [u'moon', 'boon'], - 'title': u'david', - } + 'download_url': u'moo', + 'notes': u'an interesting note', + 'tags': [u'moon', 'boon'], + 'title': u'david', + } - result = mulilingual_plugin.MultilingualDataset().before_index(sample_index_data) + result = mulilingual_plugin.MultilingualDataset().before_index( + sample_index_data) - assert result == {'text_pl': '', - 'text_de': '', - 'text_ro': '', - 'title': u'david', - 'notes': u'an interesting note', - 'tags': [u'moon', 'boon'], - 'title_en': u'david', - 'download_url': u'moo', - 'text_it': u'italian boon', - 'text_es': '', - 'text_en': u'an interesting note moon boon moo', - 'text_nl': '', - 'title_it': u'italian david', - 'text_pt': '', - 'title_fr': u'french david', - 'text_fr': u'french note french boon french_moo french moon'}, result + assert result == { + 'text_pl': '', + 'text_de': '', + 'text_ro': '', + 'title': u'david', + 'notes': u'an interesting note', + 'tags': [u'moon', 'boon'], + 'title_en': u'david', + 'download_url': u'moo', + 'text_it': u'italian boon', + 'text_es': '', + 'text_en': u'an interesting note moon boon moo', + 'text_nl': '', + 'title_it': u'italian david', + 'text_pt': '', + 'title_fr': u'french david', + 'text_fr': u'french note french boon french_moo french moon' + }, result diff --git a/doc/configuration.rst b/doc/configuration.rst index 2a6e3aa592e..0b623c5e656 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -1098,23 +1098,23 @@ Secret Access Key. DataPusher Settings ------------------- -.. _datapusher.formats: +.. _ckan.datapusher.formats: -datapusher.formats +ckan.datapusher.formats ^^^^^^^^^^^^^^^^^^ Example:: - datapusher.formats = csv xls xlsx + ckan.datapusher.formats = csv xls xlsx .. todo:: Expand -.. _datapusher.url: +.. _ckan.datapusher.url: -datapusher.url -^^^^^^^^^^^^^^ +ckan.datapusher.url +^^^^^^^^^^^^^^^^^^^ Example:: - datapusher.url = http://datapusher.ckan.org/ + ckan.datapusher.url = http://datapusher.ckan.org/ .. todo:: Expand diff --git a/setup.py b/setup.py index 3f5115bfa8f..cfe3bc63cda 100644 --- a/setup.py +++ b/setup.py @@ -115,6 +115,7 @@ organizations=ckanext.organizations.forms:OrganizationForm organizations_dataset=ckanext.organizations.forms:OrganizationDatasetForm datastore=ckanext.datastore.plugin:DatastorePlugin + datapusher=ckanext.datapusher.plugin:DatapusherPlugin test_tag_vocab_plugin=ckanext.test_tag_vocab_plugin:MockVocabTagsPlugin resource_proxy=ckanext.resourceproxy.plugin:ResourceProxy text_preview=ckanext.textpreview.plugin:TextPreview diff --git a/test-core.ini b/test-core.ini index c4278e6540c..ac493b5461f 100644 --- a/test-core.ini +++ b/test-core.ini @@ -25,6 +25,8 @@ sqlalchemy.url = postgresql://ckan_default:pass@localhost/ckan_test ckan.datastore.write_url = postgresql://ckan_default:pass@localhost/datastore_test ckan.datastore.read_url = postgresql://datastore_default:pass@localhost/datastore_test +ckan.datapusher.url = http://datapusher.ckan.org/ + ## Solr support solr_url = http://127.0.0.1:8983/solr