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",