diff --git a/docs/forms.rst b/docs/forms.rst index 9d3c752bf0..c71ff44196 100644 --- a/docs/forms.rst +++ b/docs/forms.rst @@ -929,6 +929,33 @@ Response "enketo_preview_url": "https://H6Ic6.enketo.org/webform/preview?server=https://api.ona.io/geoffreymuchai/&id=form_id" } +Get single submission url +------------------------- +.. raw:: html + +
+  GET /api/v1/forms/{pk}/enketo?survey_type=single
+ +Request +^^^^^^^ +:: + + curl -X GET https://api.ona.io/api/v1/forms/28058/enketo?survey_type=single + +Response +^^^^^^^^ +:: + + HTTP 200 OK + +Response +^^^^^^^^^ +:: + + { + "single_submit_url": "https://enke.to/single/::abcd" + } + Get form data in xls, csv format. --------------------------------- diff --git a/onadata/apps/api/tests/mocked_data.py b/onadata/apps/api/tests/mocked_data.py index ded1fa0cbd..592e0eaedb 100644 --- a/onadata/apps/api/tests/mocked_data.py +++ b/onadata/apps/api/tests/mocked_data.py @@ -6,7 +6,7 @@ import json import requests -from httmock import urlmatch +from httmock import urlmatch, all_requests @urlmatch(netloc=r'(.*\.)?ona\.io$', path=r'^/examples/forms/tutorial/form$') @@ -77,6 +77,18 @@ def enketo_mock(url, request): # pylint: disable=unused-argument return response +@all_requests +def enketo_single_submission_mock(url, request): + """Return mocked enketo single submission Response object.""" + response = requests.Response() + response.status_code = 200 + # pylint: disable=protected-access + response._content = \ + '{\n "single_url": "https:\\/\\/enketo.ona.io\\/single/::XZqoZ94y",\n'\ + ' "code": "200"\n}' + return response + + @urlmatch(netloc=r'(.*\.)?enketo\.ona\.io$', path=r'^/api_v1/survey/preview$') def enketo_preview_url_mock(url, request): # pylint: disable=unused-argument """ diff --git a/onadata/apps/api/tests/viewsets/test_xform_viewset.py b/onadata/apps/api/tests/viewsets/test_xform_viewset.py index a412654886..b9919cc9f5 100644 --- a/onadata/apps/api/tests/viewsets/test_xform_viewset.py +++ b/onadata/apps/api/tests/viewsets/test_xform_viewset.py @@ -9,6 +9,7 @@ import json import os import re +from builtins import open from collections import OrderedDict from datetime import datetime from datetime import timedelta @@ -17,7 +18,6 @@ from xml.dom import minidom import jwt -from builtins import open from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.cache import cache @@ -39,7 +39,8 @@ enketo_url_mock, external_mock, external_mock_single_instance, external_mock_single_instance2, xls_url_no_extension_mock, xls_url_no_extension_mock_content_disposition_attr_jumbled_v1, - xls_url_no_extension_mock_content_disposition_attr_jumbled_v2) + xls_url_no_extension_mock_content_disposition_attr_jumbled_v2, + enketo_single_submission_mock) from onadata.apps.api.tests.viewsets.test_abstract_viewset import \ TestAbstractViewSet from onadata.apps.api.viewsets.project_viewset import ProjectViewSet @@ -732,8 +733,11 @@ def test_enketo_url_error502(self): response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.data, data) + @override_settings(TESTING_MODE=False) def test_enketo_url(self): - with HTTMock(enketo_preview_url_mock, enketo_url_mock): + """Test functionality to expose enketo urls.""" + with HTTMock(enketo_preview_url_mock, enketo_url_mock, + enketo_single_submission_mock): self._publish_xls_form_to_project() view = XFormViewSet.as_view({ 'get': 'enketo' @@ -747,6 +751,21 @@ def test_enketo_url(self): data = {"enketo_url": url, "enketo_preview_url": preview_url} self.assertEqual(response.data, data) + def test_get_single_submit_url(self): + with HTTMock(enketo_preview_url_mock, enketo_url_mock, + enketo_single_submission_mock): + self._publish_xls_form_to_project() + view = XFormViewSet.as_view({ + 'get': 'enketo' + }) + formid = self.xform.pk + get_data = {'survey_type': 'single'} + request = self.factory.get('/', data=get_data, **self.extra) + response = view(request, pk=formid) + submit_url = "https://enketo.ona.io/single/::XZqoZ94y" + data = {"single_submit_url": submit_url} + self.assertEqual(response.data, data) + def test_enketo_url_with_default_form_params(self): with HTTMock(enketo_preview_url_mock, enketo_mock_with_form_defaults): self._publish_xls_form_to_project() diff --git a/onadata/apps/api/viewsets/xform_viewset.py b/onadata/apps/api/viewsets/xform_viewset.py index f75273deb1..f80cf0998e 100644 --- a/onadata/apps/api/viewsets/xform_viewset.py +++ b/onadata/apps/api/viewsets/xform_viewset.py @@ -72,7 +72,8 @@ from onadata.libs.utils.viewer_tools import (enketo_url, generate_enketo_form_defaults, get_enketo_preview_url, - get_form_url) + get_form_url, + get_enketo_single_submit_url) from onadata.libs.exceptions import EnketoError from onadata.settings.common import XLS_EXTENSIONS, CSV_EXTENSION @@ -387,6 +388,9 @@ def login(self, request, **kwargs): @action(methods=['GET'], detail=True) def enketo(self, request, **kwargs): + """Expose enketo urls.""" + survey_type = self.kwargs.get('survey_type') or \ + request.GET.get('survey_type') self.object = self.get_object() form_url = get_form_url( request, self.object.user.username, settings.ENKETO_PROTOCOL, @@ -400,7 +404,8 @@ def enketo(self, request, **kwargs): request_vars = request.GET defaults = generate_enketo_form_defaults( self.object, **request_vars) - url = enketo_url(form_url, self.object.id_string, **defaults) + url = enketo_url( + form_url, self.object.id_string, **defaults) preview_url = get_enketo_preview_url(request, self.object.user.username, self.object.id_string, @@ -408,9 +413,15 @@ def enketo(self, request, **kwargs): except EnketoError as e: data = {'message': _(u"Enketo error: %s" % e)} else: - if url and preview_url: + if survey_type == 'single': + single_submit_url = get_enketo_single_submit_url( + request, self.object.user.username, self.object.id_string, + xform_pk=self.object.pk) + data = {"single_submit_url": single_submit_url} + elif url and preview_url: http_status = status.HTTP_200_OK - data = {"enketo_url": url, "enketo_preview_url": preview_url} + data = {"enketo_url": url, + "enketo_preview_url": preview_url} return Response(data, http_status) diff --git a/onadata/libs/tests/utils/test_viewer_tools.py b/onadata/libs/tests/utils/test_viewer_tools.py index ffc321908a..d62ea28566 100644 --- a/onadata/libs/tests/utils/test_viewer_tools.py +++ b/onadata/libs/tests/utils/test_viewer_tools.py @@ -1,42 +1,39 @@ # -*- coding: utf-8 -*- -""" -Test onadata.libs.utils.viewer_tools -""" +"""Test onadata.libs.utils.viewer_tools.""" import os -from mock import patch +import requests_mock +from django.conf import settings from django.core.files.base import File from django.http import Http404 from django.test.client import RequestFactory from django.test.utils import override_settings from django.utils import timezone +from mock import patch from onadata.apps.logger.models import XForm, Instance, Attachment from onadata.apps.main.tests.test_base import TestBase -from onadata.libs.utils.viewer_tools import (export_def_from_filename, +from onadata.libs.exceptions import EnketoError +from onadata.libs.utils.viewer_tools import (create_attachments_zipfile, + export_def_from_filename, generate_enketo_form_defaults, get_client_ip, get_form, get_form_url, - create_attachments_zipfile) + get_enketo_single_submit_url) class TestViewerTools(TestBase): - """ - Test viewer_tools functions - """ + """Test viewer_tools functions.""" + def test_export_def_from_filename(self): - """ - Test export_def_from_filename(). - """ + """Test export_def_from_filename().""" filename = "path/filename.xlsx" ext, mime_type = export_def_from_filename(filename) self.assertEqual(ext, 'xlsx') self.assertEqual(mime_type, 'vnd.openxmlformats') def test_get_client_ip(self): - """ - Test get_client_ip(). - """ + """Test get_client_ip().""" request = RequestFactory().get("/") client_ip = get_client_ip(request) self.assertIsNotNone(client_ip) @@ -45,9 +42,7 @@ def test_get_client_ip(self): # pylint: disable=C0103 def test_get_enketo_defaults_without_vars(self): - """ - Test generate_enketo_form_defaults() without vars. - """ + """Test generate_enketo_form_defaults() without vars.""" # create xform self._publish_transportation_form() # create map without variables @@ -58,9 +53,7 @@ def test_get_enketo_defaults_without_vars(self): # pylint: disable=C0103 def test_get_enketo_defaults_with_right_xform(self): - """ - Test generate_enketo_form_defaults() with xform vars. - """ + """Test generate_enketo_form_defaults() with xform vars.""" # create xform self._publish_transportation_form() # create kwargs with existing xform variable @@ -76,9 +69,7 @@ def test_get_enketo_defaults_with_right_xform(self): # pylint: disable=C0103 def test_get_enketo_defaults_with_multiple_params(self): - """ - Test generate_enketo_form_defaults() with multiple params - """ + """Test generate_enketo_form_defaults() with multiple params.""" # create xform self._publish_transportation_form() # create kwargs with existing xform variable @@ -106,9 +97,7 @@ def test_get_enketo_defaults_with_multiple_params(self): # pylint: disable=C0103 def test_get_enketo_defaults_with_non_existent_field(self): - """ - Test generate_enketo_form_defaults() with non existent field. - """ + """Test generate_enketo_form_defaults() with non existent field.""" # create xform self._publish_transportation_form() # create kwargs with NON-existing xform variable @@ -117,9 +106,7 @@ def test_get_enketo_defaults_with_non_existent_field(self): self.assertEqual(defaults, {}) def test_get_form(self): - """ - Test get_form(). - """ + """Test get_form().""" # non existent id_string with self.assertRaises(Http404): get_form({'id_string': 'non_existent_form'}) @@ -144,9 +131,7 @@ def test_get_form(self): @override_settings(TESTING_MODE=False) def test_get_form_url(self): - """ - Test get_form_url() - """ + """Test get_form_url().""" request = RequestFactory().get('/') # default https://ona.io @@ -193,3 +178,55 @@ def test_create_attachments_zipfile_file_too_big(self, rpt_mock): self.assertTrue(rpt_mock.called) rpt_mock.assert_called_with(message[0], message[1]) + + @override_settings(TESTING_MODE=False) + def test_get_submissions_url(self): + """Test get_submissions_url().""" + @override_settings(TESTING_MODE=False, ENKETO_URL='https://enketo.ona.io') + @requests_mock.Mocker() + def test_get_enketo_single_submit_url(self, mocked): + """Test get_single_submit_url. + + Ensures single submit url is being received. + """ + request = RequestFactory().get('/') + + mocked_response = { + "single_url": "https://enketo.ona.io/single/::XZqoZ94y", + "code": 200 + } + + enketo_url = settings.ENKETO_URL + "/api/v2/survey/single/once" + username = "bob" + server_url = get_form_url( + request, username, settings.ENKETO_PROTOCOL, True, xform_pk=1) + + url = '{}?server_url={}&form_id={}'.format( + enketo_url, server_url, "tag_team") + mocked.get(url, json=mocked_response) + response = get_enketo_single_submit_url( + request, username, id_string="tag_team", xform_pk=1) + + self.assertEqual( + response, 'https://enketo.ona.io/single/::XZqoZ94y') + + @override_settings(TESTING_MODE=False, ENKETO_URL='https://enketo.ona.io') + @requests_mock.Mocker() + def test_get_single_submit_url_error_action(self, mocked): + """Test get_single_submit_url to raises EnketoError.""" + request = RequestFactory().get('/') + + enketo_url = settings.ENKETO_URL + "/api/v2/survey/single/once" + username = "Milly" + server_url = get_form_url( + request, username, settings.ENKETO_PROTOCOL, True, xform_pk=1) + + url = '{}?server_url={}&form_id={}'.format( + enketo_url, server_url, "tag_team") + mocked.get(url, status_code=401) + msg = "There was a problem with your submissionor form."\ + " Please contact support." + self.assertRaisesMessage( + EnketoError, + msg, get_enketo_single_submit_url, + request, username, "tag_team", 1) diff --git a/onadata/libs/utils/viewer_tools.py b/onadata/libs/utils/viewer_tools.py index 45bc5413b9..c7073e9dff 100644 --- a/onadata/libs/utils/viewer_tools.py +++ b/onadata/libs/utils/viewer_tools.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- -""" -Util functions for data views. -""" +"""Util functions for data views.""" import json import os import sys @@ -29,9 +27,7 @@ def image_urls_for_form(xform): - """ - Returns image urls of all image attachments of the xform. - """ + """Return image urls of all image attachments of the xform.""" return sum( [ image_urls(s) @@ -40,9 +36,7 @@ def image_urls_for_form(xform): def get_path(path, suffix): - """ - Apply the suffix to the path. - """ + """Apply the suffix to the path.""" filename, file_extension = os.path.splitext(path) return filename + suffix + file_extension @@ -50,7 +44,10 @@ def get_path(path, suffix): def image_urls(instance): """ - Returns image urls of all image attachments of the submission instance. + Return image urls of all image attachments of the submission instance. + + arguments: + instance -- Instance submission object. """ default_storage = get_storage_class()() urls = [] @@ -67,9 +64,9 @@ def image_urls(instance): def parse_xform_instance(xml_str): - """ - 'xml_str' is a str object holding the XML of an XForm - instance. Return a python object representation of this XML file. + """'xml_str' is a str object holding the XML of an XForm instance. + + Return a python object representation of this XML file. """ xml_obj = minidom.parseString(xml_str) root_node = xml_obj.documentElement @@ -119,9 +116,7 @@ def _path_value_pairs(node): def _all_attributes(node): - """ - Go through an XML document returning all the attributes we see. - """ + """Go through an XML document returning all the attributes we see.""" if hasattr(node, "hasAttributes") and node.hasAttributes(): for key in list(node.attributes): yield key, node.getAttribute(key) @@ -131,9 +126,7 @@ def _all_attributes(node): def django_file(path, field_name, content_type): - """ - Returns an InMemoryUploadedFile object for file uploads. - """ + """Return an InMemoryUploadedFile object for file uploads.""" # adapted from here: http://groups.google.com/group/django-users/browse_th\ # read/thread/834f988876ff3c45/ file_object = open(path, 'rb') @@ -148,9 +141,7 @@ def django_file(path, field_name, content_type): def export_def_from_filename(filename): - """ - Returns file extension and mimetype from filename. - """ + """Return file extension and mimetype from filename.""" __, ext = os.path.splitext(filename) ext = ext[1:] mime_type = EXPORT_MIMES[ext] @@ -159,8 +150,10 @@ def export_def_from_filename(filename): def get_client_ip(request): - """ - Returns an IP from HTTP_X_FORWARDED_FOR or REMOTE_ADDR request headers. + """Return an IP from HTTP_X_FORWARDED_FOR or REMOTE_ADDR request headers. + + arguments: + request -- HttpRequest object. """ x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: @@ -175,9 +168,7 @@ def enketo_url(form_url, instance_id=None, return_url=None, **kwargs): - """ - Returns Enketo webform URL. - """ + """Return Enketo webform URL.""" if (not hasattr(settings, 'ENKETO_URL') or not hasattr(settings, 'ENKETO_API_SURVEY_PATH') or not hasattr(settings, 'ENKETO_API_TOKEN') or @@ -216,29 +207,30 @@ def enketo_url(form_url, data.get('url')) if url: return url - else: - try: - data = json.loads(response.content) - except ValueError: - report_exception("HTTP Error {}".format(response.status_code), - response.text, sys.exc_info()) - if response.status_code == 502: - raise EnketoError( - u"Sorry, we cannot load your form right now. Please try " - "again later.") - raise EnketoError() - else: - if 'message' in data: - raise EnketoError(data['message']) - raise EnketoError(response.text) - raise EnketoError() + handle_enketo_error(response) + + +def handle_enketo_error(response): + """Handle enketo error response.""" + try: + data = json.loads(response.content) + except ValueError: + report_exception("HTTP Error {}".format(response.status_code), + response.text, sys.exc_info()) + if response.status_code == 502: + raise EnketoError( + u"Sorry, we cannot load your form right now. Please try " + "again later.") + raise EnketoError() + else: + if 'message' in data: + raise EnketoError(data['message']) + raise EnketoError(response.text) def generate_enketo_form_defaults(xform, **kwargs): - """ - Returns Enketo default options for preloading data into a web form. - """ + """Return Enketo default options for preloading data into a web form.""" defaults = {} if kwargs: @@ -251,9 +243,7 @@ def generate_enketo_form_defaults(xform, **kwargs): def create_attachments_zipfile(attachments): - """ - Returns a zip file with submission attachments. - """ + """Return a zip file with submission attachments.""" # create zip_file tmp = NamedTemporaryFile() with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as z: @@ -279,9 +269,7 @@ def create_attachments_zipfile(attachments): def get_form(kwargs): - """ - Returns XForm object by applying kwargs on an XForm queryset. - """ + """Return XForm object by applying kwargs on an XForm queryset.""" # adding inline imports here because adding them at the top of the file # triggers the following error: # django.core.exceptions.AppRegistryNotReady: Apps aren't loaded yet. @@ -303,7 +291,7 @@ def get_form_url(request, preview=False, xform_pk=None): """ - Returns a form list url endpoint to be used to make a request to Enketo. + Return a form list url endpoint to be used to make a request to Enketo. For example, it will return https://example.com and Enketo will know to look for the form list at https://example.com/formList. If a username is @@ -331,10 +319,8 @@ def get_form_url(request, def get_enketo_edit_url(request, instance, return_url): - """ - Given a submssion instance, returns an Enketo link to edit the specified - submission. - """ + """Given a submssion instance, + returns an Enketo link to edit the specified submission.""" form_url = get_form_url( request, instance.xform.user.username, @@ -350,9 +336,7 @@ def get_enketo_edit_url(request, instance, return_url): def get_enketo_preview_url(request, username, id_string, xform_pk=None): - """ - Returns an Enketo preview URL. - """ + """Return an Enketo preview URL.""" form_url = get_form_url( request, username, settings.ENKETO_PROTOCOL, True, xform_pk=xform_pk) values = {'form_id': id_string, 'server_url': form_url} @@ -374,3 +358,26 @@ def get_enketo_preview_url(request, username, id_string, xform_pk=None): raise EnketoError(response['message']) return False + + +def get_enketo_single_submit_url(request, username, id_string, xform_pk=None): + """Return single submit url of the submission instance.""" + enketo_url = urljoin(settings.ENKETO_URL, getattr( + settings, 'ENKETO_SINGLE_SUBMIT_PATH', "/api/v2/survey/single/once")) + form_id = id_string + server_url = get_form_url( + request, username, settings.ENKETO_PROTOCOL, True, xform_pk=xform_pk) + + url = '{}?server_url={}&form_id={}'.format( + enketo_url, server_url, form_id) + + response = requests.get(url, auth=(settings.ENKETO_API_TOKEN, '')) + + if response.status_code == 200: + try: + data = json.loads(response.content) + except ValueError: + pass + return data['single_url'] + + handle_enketo_error(response) diff --git a/onadata/settings/docker.py b/onadata/settings/docker.py index d4b3eefd11..263449060f 100644 --- a/onadata/settings/docker.py +++ b/onadata/settings/docker.py @@ -76,6 +76,7 @@ ENKETO_API_INSTANCE_PATH = '/api_v1/instance' ENKETO_PREVIEW_URL = urljoin(ENKETO_URL, ENKETO_API_SURVEY_PATH + '/preview') + ENKETO_SINGLE_SUBMIT_PATH = '/api/v2/survey/single/once' ENKETO_API_INSTANCE_IFRAME_URL = ENKETO_URL + "api_v1/instance/iframe" else: MEDIA_ROOT = os.path.join(PROJECT_ROOT, 'media/') # noqa diff --git a/onadata/settings/travis_test.py b/onadata/settings/travis_test.py index 18a7a044b2..c3c15584e0 100644 --- a/onadata/settings/travis_test.py +++ b/onadata/settings/travis_test.py @@ -45,6 +45,7 @@ ENKETO_API_INSTANCE_PATH = '/api_v1/instance' ENKETO_PREVIEW_URL = urljoin(ENKETO_URL, ENKETO_API_SURVEY_PATH + '/preview') + ENKETO_SINGLE_SUBMIT_PATH = '/api/v2/survey/single/once' ENKETO_API_INSTANCE_IFRAME_URL = ENKETO_URL + "api_v1/instance/iframe" else: MEDIA_ROOT = os.path.join(PROJECT_ROOT, 'media/') diff --git a/requirements/base.pip b/requirements/base.pip index 90b8a4ed65..8bde49c06c 100644 --- a/requirements/base.pip +++ b/requirements/base.pip @@ -110,6 +110,7 @@ pyxform==0.13.1 raven==6.9.0 recaptcha-client==1.0.6 requests==2.20.1 # via datapackage, django-oauth-toolkit, httmock, sphinx, tableschema, tabulator +requests_mock==1.5.2 rfc3986==1.2.0 # via tableschema rsa==4.0 # via google-auth, oauth2client savreaderwriter==3.4.2 diff --git a/requirements/dev.pip b/requirements/dev.pip index 0ed4258777..07d91a5f05 100644 --- a/requirements/dev.pip +++ b/requirements/dev.pip @@ -130,6 +130,7 @@ pyxform==0.12.2 raven==6.9.0 recaptcha-client==1.0.6 requests==2.20.1 # via datapackage, django-oauth-toolkit, httmock, sphinx, tableschema, tabulator +requests_mock==1.5.2 rfc3986==1.2.0 # via tableschema rsa==4.0 # via google-auth, oauth2client savreaderwriter==3.4.2 diff --git a/setup.py b/setup.py index 1c261a68eb..733ef9212d 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ """ -Setup file for onadata +Setup file for onadata. Onadata is a Django application that provides APIs for data collection and aggregation. @@ -100,6 +100,7 @@ "python-dateutil", "pytz", "requests", + "requests-mock", "simplejson", "google-api-python-client", "uwsgi",