diff --git a/jsapp/js/enketoHandler.es6 b/jsapp/js/enketoHandler.es6
index 15846fa8a0..d2331abefc 100644
--- a/jsapp/js/enketoHandler.es6
+++ b/jsapp/js/enketoHandler.es6
@@ -59,23 +59,19 @@ const enketoHandler = {
resolve();
} else {
dataIntMethod(aid, sid)
- .done((enketoData) => {
+ .always((enketoData) => {
if (enketoData.url) {
this._saveEnketoUrl(urlId, enketoData.url);
this._openEnketoUrl(urlId);
resolve();
} else {
let errorMsg = t('There was an error loading Enketo.');
- if (enketoData.detail) {
- errorMsg += `
${enketoData.detail}
`;
+ if (enketoData?.responseJSON?.detail) {
+ errorMsg += `
${enketoData.responseJSON.detail}
`;
}
notify(errorMsg, 'error');
reject();
}
- })
- .fail(() => {
- notify(t('There was an error getting Enketo link'), 'error');
- reject();
});
}
}).catch(() => {
diff --git a/kobo/apps/mfa/forms.py b/kobo/apps/mfa/forms.py
index 23cebf6029..1dee5f30d3 100644
--- a/kobo/apps/mfa/forms.py
+++ b/kobo/apps/mfa/forms.py
@@ -2,7 +2,7 @@
from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm
-from django.utils.translation import gettext_lazy as _
+from django.utils.translation import gettext_lazy as t
from trench.serializers import CodeLoginSerializer
from trench.utils import (
get_mfa_model,
@@ -53,7 +53,7 @@ class MFATokenForm(forms.Form):
required=True,
widget=forms.TextInput(
attrs={
- 'placeholder': _(
+ 'placeholder': t(
'Enter the ##token length##-character token'
).replace('##token length##', str(settings.TRENCH_AUTH['CODE_LENGTH']))
}
@@ -65,7 +65,7 @@ class MFATokenForm(forms.Form):
)
error_messages = {
- 'invalid_code': _(
+ 'invalid_code': t(
'Your token is invalid'
)
}
diff --git a/kobo/apps/reports/constants.py b/kobo/apps/reports/constants.py
index 0d93ae5ee5..61c67b4091 100644
--- a/kobo/apps/reports/constants.py
+++ b/kobo/apps/reports/constants.py
@@ -7,3 +7,6 @@
SPECIFIC_REPORTS_KEY = 'specified'
DEFAULT_REPORTS_KEY = 'default'
+
+FUZZY_VERSION_ID_KEY = '_version_'
+INFERRED_VERSION_ID_KEY = '__inferred_version__'
\ No newline at end of file
diff --git a/kobo/apps/reports/report_data.py b/kobo/apps/reports/report_data.py
index ce867ba6ba..590d96c320 100644
--- a/kobo/apps/reports/report_data.py
+++ b/kobo/apps/reports/report_data.py
@@ -4,9 +4,13 @@
from django.utils.translation import gettext as t
from rest_framework import serializers
-
from formpack import FormPack
+
from kpi.utils.log import logging
+from .constants import (
+ FUZZY_VERSION_ID_KEY,
+ INFERRED_VERSION_ID_KEY,
+)
def build_formpack(asset, submission_stream=None, use_all_form_versions=True):
@@ -16,8 +20,6 @@ def build_formpack(asset, submission_stream=None, use_all_form_versions=True):
then only the newest version of the form is considered, and all submissions
are assumed to have been collected with that version of the form.
"""
- FUZZY_VERSION_ID_KEY = '_version_'
- INFERRED_VERSION_ID_KEY = '__inferred_version__'
if asset.has_deployment:
if use_all_form_versions:
@@ -38,9 +40,8 @@ def build_formpack(asset, submission_stream=None, use_all_form_versions=True):
except TypeError as e:
# https://github.com/kobotoolbox/kpi/issues/1361
logging.error(
- 'Failed to get formpack schema for version: %s'
- % repr(e),
- exc_info=True
+ f'Failed to get formpack schema for version: {repr(e)}',
+ exc_info=True
)
else:
fp_schema['version_id_key'] = INFERRED_VERSION_ID_KEY
@@ -58,7 +59,7 @@ def build_formpack(asset, submission_stream=None, use_all_form_versions=True):
# Find the AssetVersion UID for each deprecated reversion ID
_reversion_ids = dict([
(str(v._reversion_version_id), v.uid)
- for v in _versions if v._reversion_version_id
+ for v in _versions if v._reversion_version_id
])
# A submission often contains many version keys, e.g. `__version__`,
diff --git a/kobo/settings/base.py b/kobo/settings/base.py
index 854a4e3425..94204cd266 100644
--- a/kobo/settings/base.py
+++ b/kobo/settings/base.py
@@ -476,6 +476,8 @@ def __init__(self, *args, **kwargs):
# http://apidocs.enketo.org/v2/
ENKETO_SURVEY_ENDPOINT = 'api/v2/survey/all'
ENKETO_PREVIEW_ENDPOINT = 'api/v2/survey/preview/iframe'
+ENKETO_EDIT_INSTANCE_ENDPOINT = 'api/v2/instance'
+ENKETO_VIEW_INSTANCE_ENDPOINT = 'api/v2/instance/view'
''' Celery configuration '''
@@ -852,4 +854,4 @@ def __init__(self, *args, **kwargs):
# Session Authentication is supported by default.
MFA_SUPPORTED_AUTH_CLASSES = [
'kpi.authentication.TokenAuthentication',
-]
+]
\ No newline at end of file
diff --git a/kobo/settings/testing.py b/kobo/settings/testing.py
index 01641fbe95..9d1df29e93 100644
--- a/kobo/settings/testing.py
+++ b/kobo/settings/testing.py
@@ -25,3 +25,6 @@
MONGO_CONNECTION = MockMongoClient(
MONGO_CONNECTION_URL, j=True, tz_aware=True)
MONGO_DB = MONGO_CONNECTION['formhub_test']
+
+ENKETO_URL = 'http://enketo.mock'
+ENKETO_INTERNAL_URL = 'http://enketo.mock'
diff --git a/kpi/authentication.py b/kpi/authentication.py
index 54be3d4e24..84d8f9cf96 100644
--- a/kpi/authentication.py
+++ b/kpi/authentication.py
@@ -1,8 +1,13 @@
# coding: utf-8
+from django.utils.translation import gettext as t
+from django_digest import HttpDigestAuthenticator
from rest_framework.authentication import (
+ BaseAuthentication,
BasicAuthentication as DRFBasicAuthentication,
TokenAuthentication as DRFTokenAuthentication,
+ get_authorization_header,
)
+from rest_framework.exceptions import AuthenticationFailed
from kpi.mixins.mfa import MFABlockerMixin
@@ -24,6 +29,40 @@ def authenticate_credentials(self, userid, password, request=None):
return user, _
+class DigestAuthentication(MFABlockerMixin, BaseAuthentication):
+
+ verbose_name = 'Digest authentication'
+
+ def __init__(self):
+ self.authenticator = HttpDigestAuthenticator()
+
+ def authenticate(self, request):
+
+ auth = get_authorization_header(request).split()
+ if not auth or auth[0].lower() != b'digest':
+ return None
+
+ if self.authenticator.authenticate(request):
+
+ # If user provided correct credentials but their account is
+ # disabled, return a 401
+ if not request.user.is_active:
+ raise AuthenticationFailed()
+
+ self.validate_mfa_not_active(request.user)
+
+ return request.user, None
+ else:
+ raise AuthenticationFailed(t('Invalid username/password'))
+
+ def authenticate_header(self, request):
+ response = self.build_challenge_response()
+ return response['WWW-Authenticate']
+
+ def build_challenge_response(self):
+ return self.authenticator.build_challenge_response()
+
+
class TokenAuthentication(MFABlockerMixin, DRFTokenAuthentication):
"""
Extend DRF class to support MFA.
diff --git a/kpi/deployment_backends/base_backend.py b/kpi/deployment_backends/base_backend.py
index 82af11e74c..0091dc4eb7 100644
--- a/kpi/deployment_backends/base_backend.py
+++ b/kpi/deployment_backends/base_backend.py
@@ -68,6 +68,33 @@ def connect(self, active=False):
def delete(self):
self.asset._deployment_data.clear() # noqa
+ @abc.abstractmethod
+ def delete_submission(self, submission_id: int, user: 'auth.User') -> dict:
+ pass
+
+ @abc.abstractmethod
+ def delete_submissions(self, data: dict, user: 'auth.User', **kwargs) -> dict:
+ pass
+
+ @abc.abstractmethod
+ def duplicate_submission(
+ self, submission_id: int, user: 'auth.User'
+ ) -> dict:
+ pass
+
+ @abc.abstractmethod
+ def get_attachment(
+ self,
+ submission_id: int,
+ user: 'auth.User',
+ attachment_id: Optional[int] = None,
+ xpath: Optional[str] = None,
+ ) -> tuple:
+ pass
+
+ def get_attachment_objects_from_dict(self, submission: dict) -> list:
+ pass
+
def get_data(
self, dotted_path: str = None, default=None
) -> Union[None, int, str, dict]:
@@ -98,52 +125,22 @@ def get_data(
return value
- @abc.abstractmethod
- def get_attachment(
- self,
- submission_id: int,
- user: 'auth.User',
- attachment_id: Optional[int] = None,
- xpath: Optional[str] = None,
- ) -> tuple:
- pass
-
- @abc.abstractmethod
- def delete_submission(self, submission_id: int, user: 'auth.User') -> dict:
- pass
-
- @abc.abstractmethod
- def delete_submissions(self, data: dict, user: 'auth.User', **kwargs) -> dict:
- pass
-
- @abc.abstractmethod
- def duplicate_submission(
- self, submission_id: int, user: 'auth.User'
- ) -> dict:
- pass
-
@abc.abstractmethod
def get_data_download_links(self):
pass
- @abc.abstractmethod
- def get_enketo_submission_url(
- self, submission_id: int, user: 'auth.User', params: dict = None
- ) -> dict:
- """
- Return a formatted dict to be passed to a Response object
- """
- pass
-
@abc.abstractmethod
def get_enketo_survey_links(self):
pass
- def get_submission(self,
- submission_id: int,
- user: 'auth.User',
- format_type: str = SUBMISSION_FORMAT_TYPE_JSON,
- **mongo_query_params: dict) -> Union[dict, str, None]:
+ def get_submission(
+ self,
+ submission_id: int,
+ user: 'auth.User',
+ format_type: str = SUBMISSION_FORMAT_TYPE_JSON,
+ request: Optional['rest_framework.request.Request'] = None,
+ **mongo_query_params: dict
+ ) -> Union[dict, str, None]:
"""
Retrieve the corresponding submission whose id equals `submission_id`
and which `user` is allowed to access.
@@ -163,7 +160,11 @@ def get_submission(self,
submissions = list(
self.get_submissions(
- user, format_type, [int(submission_id)], **mongo_query_params
+ user,
+ format_type,
+ [int(submission_id)],
+ request,
+ **mongo_query_params
)
)
try:
diff --git a/kpi/deployment_backends/kc_access/shadow_models.py b/kpi/deployment_backends/kc_access/shadow_models.py
index 2c0e143b0b..594caacaba 100644
--- a/kpi/deployment_backends/kc_access/shadow_models.py
+++ b/kpi/deployment_backends/kc_access/shadow_models.py
@@ -530,6 +530,7 @@ class Meta(ShadowModel.Meta):
uuid = models.CharField(max_length=32, default='')
last_submission_time = models.DateTimeField(blank=True, null=True)
num_of_submissions = models.IntegerField(default=0)
+ kpi_asset_uid = models.CharField(max_length=32, null=True)
@property
def md5_hash(self):
@@ -566,12 +567,15 @@ class Meta(ReadOnlyModel.Meta):
db_index=True)
media_file_basename = models.CharField(
max_length=260, null=True, blank=True, db_index=True)
- # `PositiveIntegerField` will only accomodate 2 GiB, so we should consider
+ # `PositiveIntegerField` will only accommodate 2 GiB, so we should consider
# `PositiveBigIntegerField` after upgrading to Django 3.1+
media_file_size = models.PositiveIntegerField(blank=True, null=True)
mimetype = models.CharField(
max_length=100, null=False, blank=True, default=''
)
+ # TODO: hide attachments that were deleted or replaced; see
+ # kobotoolbox/kobocat#792
+ # replaced_at = models.DateTimeField(blank=True, null=True)
@property
def absolute_mp3_path(self):
@@ -666,6 +670,7 @@ def _wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except ProgrammingError as e:
- raise ProgrammingError('kc_access error accessing kobocat '
- 'tables: {}'.format(e.message))
+ raise ProgrammingError(
+ 'kc_access error accessing kobocat tables: {}'.format(str(e))
+ )
return _wrapper
diff --git a/kpi/deployment_backends/kobocat_backend.py b/kpi/deployment_backends/kobocat_backend.py
index 94f6e297c0..bfb2520a53 100644
--- a/kpi/deployment_backends/kobocat_backend.py
+++ b/kpi/deployment_backends/kobocat_backend.py
@@ -15,6 +15,8 @@
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
+from django.core.files import File
+from django.db.models.query import QuerySet
from django.utils.translation import gettext_lazy as t
from rest_framework import status
from rest_framework.authtoken.models import Token
@@ -27,11 +29,11 @@
PERM_CHANGE_SUBMISSIONS,
PERM_DELETE_SUBMISSIONS,
PERM_VALIDATE_SUBMISSIONS,
- PERM_VIEW_SUBMISSIONS,
)
from kpi.exceptions import (
AttachmentNotFoundException,
InvalidXPathException,
+ SubmissionIntegrityError,
SubmissionNotFoundException,
XPathNotFoundException,
)
@@ -42,8 +44,7 @@
from kpi.utils.log import logging
from kpi.utils.mongo_helper import MongoHelper
from kpi.utils.permissions import is_user_anonymous
-from kpi.utils.datetime import several_minutes_from_now
-from kpi.utils.xml import edit_submission_xml
+from kpi.utils.xml import edit_submission_xml, strip_nodes
from .base_backend import BaseDeploymentBackend
from .kc_access.shadow_models import (
KobocatOneTimeAuthToken,
@@ -470,6 +471,70 @@ def duplicate_submission(
else:
raise KobocatDuplicateSubmissionException
+ def edit_submission(
+ self, xml_submission_file: File, user: 'auth.User', attachments: dict = None
+ ):
+ """
+ Edit a submission through KoBoCAT proxy on behalf of `user`.
+ Attachments can be uploaded by passing a dictionary (name, File object)
+
+ The returned Response should be in XML (expected format by Enketo Express)
+ """
+ submission_xml = xml_submission_file.read()
+ try:
+ xml_root = ET.fromstring(submission_xml)
+ except ET.ParseError:
+ raise SubmissionIntegrityError(
+ t('Your submission XML is malformed.')
+ )
+ try:
+ deprecated_uuid = xml_root.find('.//meta/deprecatedID').text
+ xform_uuid = xml_root.find('.//formhub/uuid').text
+ except AttributeError:
+ raise SubmissionIntegrityError(
+ t('Your submission XML is missing critical elements.')
+ )
+ # Remove UUID prefix
+ deprecated_uuid = deprecated_uuid[len('uuid:'):]
+ try:
+ instance = ReadOnlyKobocatInstance.objects.get(
+ uuid=deprecated_uuid,
+ xform__uuid=xform_uuid,
+ xform__kpi_asset_uid=self.asset.uid,
+ )
+ except ReadOnlyKobocatInstance.DoesNotExist:
+ raise SubmissionIntegrityError(
+ t(
+ 'The submission you attempted to edit could not be found, '
+ 'or you do not have access to it.'
+ )
+ )
+
+ # Validate write access for users with partial permissions
+ self.validate_access_with_partial_perms(
+ user=user,
+ perm=PERM_CHANGE_SUBMISSIONS,
+ submission_ids=[instance.pk]
+ )
+
+ # Set the In-Memory file’s current position to 0 before passing it to
+ # Request.
+ xml_submission_file.seek(0)
+ files = {'xml_submission_file': xml_submission_file}
+
+ # Combine all files altogether
+ if attachments:
+ files.update(attachments)
+
+ kc_request = requests.Request(
+ method='POST', url=self.submission_url, files=files
+ )
+ # ToDo use system account instead of asset.owner
+ kc_response = self.__kobocat_proxy_request(kc_request, self.asset.owner)
+ return self.__prepare_as_drf_response_signature(
+ kc_response, expected_response_format='xml'
+ )
+
@staticmethod
def external_to_internal_url(url):
"""
@@ -526,11 +591,15 @@ def get_attachment(
raise XPathNotFoundException
filters = {
+ # TODO: hide attachments that were deleted or replaced; see
+ # kobotoolbox/kobocat#792
+ # 'replaced_at': None,
'instance_id': submission_id,
'media_file_basename': attachment_filename,
}
else:
filters = {
+ # 'replaced_at': None,
'instance_id': submission_id,
'pk': attachment_id,
}
@@ -542,6 +611,27 @@ def get_attachment(
return attachment
+ def get_attachment_objects_from_dict(self, submission: dict) -> QuerySet:
+
+ # First test that there are attachments to avoid a call to the DB for
+ # nothing
+ if not submission.get('_attachments'):
+ return []
+
+ # Get filenames from DB because Mongo does not contain the
+ # original basename.
+ # EE excepts the original basename before Django renames it and
+ # stores it in Mongo
+ # E.g.:
+ # - XML filename: Screenshot 2022-01-19 222028-13_45_57.jpg
+ # - Mongo: Screenshot_2022-01-19_222028-13_45_57.jpg
+
+ # ToDo What about adding the original basename and the question
+ # name in Mongo to avoid another DB query?
+ return ReadOnlyKobocatAttachment.objects.filter(
+ instance_id=submission['_id']
+ )
+
def get_data_download_links(self):
exports_base_url = '/'.join((
settings.KOBOCAT_URL.rstrip('/'),
@@ -573,76 +663,6 @@ def get_data_download_links(self):
}
return links
- def get_enketo_submission_url(
- self,
- submission_id: int,
- user: 'auth.User',
- params: dict = None,
- action_: str = 'edit',
- ) -> dict:
- """
- Get URLs of the submission from KoBoCAT through proxy
- """
- if action_ == 'edit':
- partial_perm = PERM_CHANGE_SUBMISSIONS
- elif action_ == 'view':
- partial_perm = PERM_VIEW_SUBMISSIONS
- else:
- raise NotImplementedError(
- "Only 'view' and 'edit' actions are currently supported"
- )
-
- submission_ids = self.validate_access_with_partial_perms(
- user=user,
- perm=partial_perm,
- submission_ids=[submission_id],
- )
-
- # If `submission_ids` is not empty, user has partial permissions.
- # Otherwise, they have have full access.
- headers = {}
- use_partial_perms = False
- if submission_ids:
- use_partial_perms = True
- headers.update(
- KobocatOneTimeAuthToken.get_or_create_token(
- user,
- method='GET',
- request_identifier=f'get_enketo_submission_url_{action_}',
- ).get_header()
- )
- url = '{detail_url}/enketo_{action}'.format(
- detail_url=self.get_submission_detail_url(submission_id),
- action=action_,
- )
- kc_request = requests.Request(
- method='GET', url=url, params=params, headers=headers
- )
- kc_response = self.__kobocat_proxy_request(kc_request, user)
-
- # if `headers` is not empty, user has partial permissions. We need to
- # allow Enketo Express to communicate with KoBoCAT when data is
- # submitted. We whitelist the URL through KobocatOneTimeAuthToken
- # to make KoBoCAT accept the edited submission from this user
- if use_partial_perms and kc_response.status_code == status.HTTP_200_OK:
- json_response = kc_response.json()
- try:
- url = json_response['url']
- except KeyError:
- pass
- else:
- # Give the token a longer life in case the edit takes longer
- # than `KobocatOneTimeAuthToken` default expiration time
- KobocatOneTimeAuthToken.get_or_create_token(
- user=user,
- method='POST',
- request_identifier=url,
- use_identifier_as_token=True,
- expiration_time=several_minutes_from_now(24 * 60)
- )
-
- return self.__prepare_as_drf_response_signature(kc_response)
-
def get_enketo_survey_links(self):
data = {
'server_url': '{}/{}'.format(
@@ -1341,7 +1361,9 @@ def __parse_identifier(identifier: str) -> tuple:
return server, parsed_identifier.path
@staticmethod
- def __prepare_as_drf_response_signature(requests_response):
+ def __prepare_as_drf_response_signature(
+ requests_response, expected_response_format='json'
+ ):
"""
Prepares a dict from `Requests` response.
Useful to get response from KoBoCAT and use it as a dict or pass it to
@@ -1366,7 +1388,10 @@ def __prepare_as_drf_response_signature(requests_response):
prepared_drf_response['data'] = json.loads(
requests_response.content)
except ValueError as e:
- if not requests_response.status_code == status.HTTP_204_NO_CONTENT:
+ if (
+ not requests_response.status_code == status.HTTP_204_NO_CONTENT
+ and expected_response_format == 'json'
+ ):
prepared_drf_response['data'] = {
'detail': t(
'KoBoCAT returned an unexpected response: {}'.format(
@@ -1462,7 +1487,7 @@ def __rewrite_json_attachment_urls(
kpi_url = reverse(
'attachment-detail',
args=(self.asset.uid, submission['_id'], attachment['id']),
- request=request
+ request=request,
)
key = f'download{suffix}_url'
try:
diff --git a/kpi/deployment_backends/mock_backend.py b/kpi/deployment_backends/mock_backend.py
index 538f7090b7..0e5e572cb5 100644
--- a/kpi/deployment_backends/mock_backend.py
+++ b/kpi/deployment_backends/mock_backend.py
@@ -252,50 +252,22 @@ def get_attachment(
is_good_file = int(attachment['id']) == int(attachment_id)
if is_good_file:
- video_file = os.path.join(
- settings.BASE_DIR,
- 'kpi',
- 'tests',
- filename
- )
- return MockAttachment(video_file)
+ return MockAttachment(pk=attachment_id, **attachment)
raise AttachmentNotFoundException
- def get_data_download_links(self):
- return {}
+ def get_attachment_objects_from_dict(self, submission: dict) -> list:
- def get_enketo_submission_url(
- self,
- submission_id: int,
- user: 'auth.User',
- params: dict = None,
- action_: str = 'edit',
- ) -> dict:
- """
- Gets URL of the submission in a format FE can understand
- """
- if action_ == 'edit':
- partial_perm = PERM_CHANGE_SUBMISSIONS
- elif action_ == 'view':
- partial_perm = PERM_VIEW_SUBMISSIONS
- else:
- raise NotImplementedError(
- "Only 'view' and 'edit' actions are currently supported"
- )
+ if not submission.get('_attachments'):
+ return []
- submission_ids = self.validate_access_with_partial_perms(
- user=user,
- perm=partial_perm,
- submission_ids=[submission_id],
- )
+ return [
+ MockAttachment(pk=attachment['id'], **attachment)
+ for attachment in attachments
+ ]
- return {
- 'content_type': 'application/json',
- 'data': {
- 'url': f'http://server.mock/enketo/{action_}/{submission_id}'
- }
- }
+ def get_data_download_links(self):
+ return {}
def get_enketo_survey_links(self):
# `self` is a demo Enketo form, but there's no guarantee it'll be
diff --git a/kpi/exceptions.py b/kpi/exceptions.py
index f82d91fd67..bf2c86f782 100644
--- a/kpi/exceptions.py
+++ b/kpi/exceptions.py
@@ -140,6 +140,10 @@ class SearchQueryTooShortException(InvalidSearchException):
default_code = 'query_too_short'
+class SubmissionIntegrityError(Exception):
+ pass
+
+
class SubmissionNotFoundException(Exception):
pass
diff --git a/kpi/models/asset.py b/kpi/models/asset.py
index 9d5fa85c73..4311021f32 100644
--- a/kpi/models/asset.py
+++ b/kpi/models/asset.py
@@ -898,6 +898,17 @@ def version_number_and_date(self) -> str:
return f'{count} {self.date_modified:(%Y-%m-%d %H:%M:%S)}'
+ # TODO: take leading underscore off of `_snapshot()` and call it directly?
+ # we would also have to remove or rename the `snapshot` property
+ def versioned_snapshot(
+ self, version_uid: str, root_node_name: Optional[str] = None
+ ) -> AssetSnapshot:
+ return self._snapshot(
+ regenerate=True,
+ version_uid=version_uid,
+ root_node_name=root_node_name,
+ )
+
def _populate_report_styles(self):
default = self.report_styles.get(DEFAULT_REPORTS_KEY, {})
specifieds = self.report_styles.get(SPECIFIC_REPORTS_KEY, {})
@@ -927,8 +938,16 @@ def _populate_summary(self):
self.summary = analyzer.summary
@transaction.atomic
- def _snapshot(self, regenerate=True):
- asset_version = self.latest_version
+ def _snapshot(
+ self,
+ regenerate: bool = True,
+ version_uid: Optional[str] = None,
+ root_node_name: Optional[str] = None,
+ ) -> AssetSnapshot:
+ if version_uid:
+ asset_version = self.asset_versions.get(uid=version_uid)
+ else:
+ asset_version = self.latest_version
try:
snapshot = AssetSnapshot.objects.get(asset=self,
@@ -946,18 +965,29 @@ def _snapshot(self, regenerate=True):
snapshot = False
if not snapshot:
- if self.name != '':
- form_title = self.name
- else:
- _settings = self.content.get('settings', {})
- form_title = _settings.get('id_string', 'Untitled')
-
- self._append(self.content, settings={
- 'form_title': form_title,
- })
- snapshot = AssetSnapshot.objects.create(asset=self,
- asset_version=asset_version,
- source=self.content)
+ try:
+ form_title = asset_version.form_title
+ content = asset_version.version_content
+ except AttributeError:
+ form_title = self.form_title
+ content = self.content
+
+ settings_ = {'form_title': form_title}
+
+ if root_node_name:
+ # `name` may not sound like the right setting to control the
+ # XML root node name, but it is, according to the XLSForm
+ # specification:
+ # https://xlsform.org/en/#specify-xforms-root-node-name
+ settings_['name'] = root_node_name
+ settings_['id_string'] = root_node_name
+
+ self._append(content, settings=settings_)
+
+ snapshot = AssetSnapshot.objects.create(
+ asset=self, asset_version=asset_version, source=content
+ )
+
return snapshot
def _update_partial_permissions(
diff --git a/kpi/models/asset_snapshot.py b/kpi/models/asset_snapshot.py
index 4d2f2a7403..78fae4ac35 100644
--- a/kpi/models/asset_snapshot.py
+++ b/kpi/models/asset_snapshot.py
@@ -146,29 +146,42 @@ def save(self, *args, **kwargs):
self._strip_empty_rows(_source)
self._autoname(_source)
self._remove_empty_expressions(_source)
+ # TODO: move these inside `generate_xml_from_source()`?
_settings = _source.get('settings', {})
form_title = _settings.get('form_title')
id_string = _settings.get('id_string')
-
- self.xml, self.details = \
- self.generate_xml_from_source(_source,
- include_note=_note,
- root_node_name='data',
- form_title=form_title,
- id_string=id_string)
+ root_node_name = _settings.get('name')
+ self.xml, self.details = self.generate_xml_from_source(
+ _source,
+ include_note=_note,
+ root_node_name=root_node_name,
+ form_title=form_title,
+ id_string=id_string,
+ )
self.source = _source
return super().save(*args, **kwargs)
def generate_xml_from_source(self,
source,
include_note=False,
- root_node_name='snapshot_xml',
+ root_node_name=None,
form_title=None,
id_string=None):
- if form_title is None:
- form_title = 'Snapshot XML'
+
+ if not root_node_name:
+ if self.asset and self.asset.uid:
+ root_node_name = self.asset.uid
+ else:
+ root_node_name = 'snapshot_xml'
+
+ if not form_title:
+ if self.asset and self.asset.name:
+ form_title = self.asset.name
+ else:
+ form_title = 'Snapshot XML'
+
if id_string is None:
- id_string = 'snapshot_xml'
+ id_string = root_node_name
if include_note and 'survey' in source:
_translations = source.get('translations', [])
diff --git a/kpi/models/asset_version.py b/kpi/models/asset_version.py
index 30e94c85b2..81f33f7a8e 100644
--- a/kpi/models/asset_version.py
+++ b/kpi/models/asset_version.py
@@ -62,6 +62,14 @@ def content_hash(self):
_json_string = json.dumps(self.version_content, sort_keys=True)
return calculate_hash(_json_string, 'sha1')
+ @property
+ def form_title(self):
+ if self.name != '':
+ return self.name
+ else:
+ _settings = self.version_content.get('settings', {})
+ return _settings.get('id_string', 'Untitled')
+
def __str__(self):
return '{}@{} T{}{}'.format(
self.asset.uid, self.uid,
diff --git a/kpi/models/import_export_task.py b/kpi/models/import_export_task.py
index aa293ff294..9814943b9a 100644
--- a/kpi/models/import_export_task.py
+++ b/kpi/models/import_export_task.py
@@ -22,13 +22,6 @@
import formpack
from formpack.constants import KOBO_LOCK_SHEET
-from formpack.schema.fields import (
- IdCopyField,
- NotesCopyField,
- SubmissionTimeCopyField,
- TagsCopyField,
- ValidationStatusCopyField,
-)
from formpack.utils.string import ellipsize
from formpack.utils.kobo_locking import get_kobo_locking_profiles
from kpi.constants import (
@@ -49,7 +42,6 @@
from kpi.utils.models import (
_load_library_content,
create_assets,
- remove_string_prefix,
resolve_url_to_asset,
)
from ..models import Asset
diff --git a/kpi/permissions.py b/kpi/permissions.py
index 2e45b7f1fd..ecfdce9cf6 100644
--- a/kpi/permissions.py
+++ b/kpi/permissions.py
@@ -326,8 +326,23 @@ class DuplicateSubmissionPermission(SubmissionPermission):
class EditSubmissionPermission(SubmissionPermission):
perms_map = {
'GET': ['%(app_label)s.change_%(model_name)s'],
+ 'HEAD': ['%(app_label)s.change_%(model_name)s'],
+ 'POST': ['%(app_label)s.change_%(model_name)s'],
}
+ def has_object_permission(self, request, view, obj):
+ # Authentication validation has already been made in `has_permission()`
+ # because we validate the permissions on the `obj`'s parent, i.e. the asset.
+ # But we do want to be sure that user is authenticated before going further.
+ #
+ # It will force DRF to send authentication header (i.e. `WWW-authenticate`)
+ # when the first authentication class implements an authentication header
+ # response.
+ # See
+ # - https://github.com/encode/django-rest-framework/blob/45082b39368729caa70534dde11b0788ef186a37/rest_framework/views.py#L190
+ # - https://github.com/encode/django-rest-framework/blob/45082b39368729caa70534dde11b0788ef186a37/rest_framework/views.py#L453-L456
+ return not request.user.is_anonymous
+
class ViewSubmissionPermission(SubmissionPermission):
perms_map = {
diff --git a/kpi/tests/api/v1/test_api_assets.py b/kpi/tests/api/v1/test_api_assets.py
index 31925febc9..ab0cab011b 100644
--- a/kpi/tests/api/v1/test_api_assets.py
+++ b/kpi/tests/api/v1/test_api_assets.py
@@ -2,13 +2,10 @@
import json
import unittest
-import requests
-from django.conf import settings
from django.contrib.auth.models import User
from django.urls import reverse
from formpack.utils.expand_content import SCHEMA_VERSION
from lxml import etree
-from private_storage.storage.files import PrivateFileSystemStorage
from rest_framework import status
from rest_framework.authtoken.models import Token
diff --git a/kpi/tests/api/v2/test_api_submissions.py b/kpi/tests/api/v2/test_api_submissions.py
index e55eeb5e98..a0494a880c 100644
--- a/kpi/tests/api/v2/test_api_submissions.py
+++ b/kpi/tests/api/v2/test_api_submissions.py
@@ -7,6 +7,7 @@
from datetime import datetime
import pytz
+import responses
from django.conf import settings
from django.contrib.auth.models import User
from django.urls import reverse
@@ -26,6 +27,10 @@
from kpi.tests.base_test_case import BaseTestCase
from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE
from kpi.utils.object_permission import get_anonymous_user
+from kpi.tests.utils.mock import (
+ enketo_edit_instance_response,
+ enketo_view_instance_response,
+)
class BaseSubmissionTestCase(BaseTestCase):
@@ -92,11 +97,13 @@ def __add_submissions(self):
for i in range(20):
submitted_by = random.choice(['', 'someuser', 'anotheruser'])
+ uuid_ = uuid.uuid4()
submission = {
'__version__': v_uid,
'q1': ''.join(random.choice(letters) for l in range(10)),
'q2': ''.join(random.choice(letters) for l in range(10)),
- 'meta/instanceID': f'uuid:{uuid.uuid4()}',
+ 'meta/instanceID': f'uuid:{uuid_}',
+ '_uuid': str(uuid_),
'_validation_status': {
'by_whom': 'someuser',
'timestamp': int(time.time()),
@@ -787,29 +794,51 @@ def setUp(self):
'edit', 'enketo/edit'
)
+ @responses.activate
def test_get_legacy_edit_link_submission_as_owner(self):
"""
someuser is the owner of the project.
someuser can retrieve enketo edit link through old API endpoint
"""
+ ee_url = (
+ f'{settings.ENKETO_URL}/{settings.ENKETO_EDIT_INSTANCE_ENDPOINT}'
+ )
+ # Mock Enketo response
+ responses.add_callback(
+ responses.POST, ee_url,
+ callback=enketo_edit_instance_response,
+ content_type='application/json',
+ )
+
response = self.client.get(self.submission_url_legacy, {'format': 'json'})
assert response.status_code == status.HTTP_200_OK
expected_response = {
- 'url': 'http://server.mock/enketo/edit/{}'.format(self.submission['_id'])
+ 'url': f"{settings.ENKETO_URL}/edit/{self.submission['_uuid']}"
}
assert response.data == expected_response
+ @responses.activate
def test_get_edit_link_submission_as_owner(self):
"""
someuser is the owner of the project.
someuser can retrieve enketo edit link
"""
+ ee_url = (
+ f'{settings.ENKETO_URL}/{settings.ENKETO_EDIT_INSTANCE_ENDPOINT}'
+ )
+ # Mock Enketo response
+ responses.add_callback(
+ responses.POST, ee_url,
+ callback=enketo_edit_instance_response,
+ content_type='application/json',
+ )
+
response = self.client.get(self.submission_url, {'format': 'json'})
assert response.status_code == status.HTTP_200_OK
-
- url = f"http://server.mock/enketo/edit/{self.submission['_id']}"
- expected_response = {'url': url}
+ expected_response = {
+ 'url': f"{settings.ENKETO_URL}/edit/{self.submission['_uuid']}"
+ }
self.assertEqual(response.data, expected_response)
def test_get_edit_link_submission_as_anonymous(self):
@@ -847,6 +876,7 @@ def test_cannot_get_edit_link_submission_shared_with_view_as_anotheruser(self):
# FIXME if anotheruser has view permissions, they should receive a 403
assert response.status_code == status.HTTP_404_NOT_FOUND
+ @responses.activate
def test_get_edit_link_submission_shared_with_edit_as_anotheruser(self):
"""
someuser is the owner of the project.
@@ -855,9 +885,22 @@ def test_get_edit_link_submission_shared_with_edit_as_anotheruser(self):
"""
self.asset.assign_perm(self.anotheruser, PERM_CHANGE_SUBMISSIONS)
self._log_in_as_another_user()
+
+ ee_url = (
+ f'{settings.ENKETO_URL}/{settings.ENKETO_EDIT_INSTANCE_ENDPOINT}'
+ )
+
+ # Mock Enketo response
+ responses.add_callback(
+ responses.POST, ee_url,
+ callback=enketo_edit_instance_response,
+ content_type='application/json',
+ )
+
response = self.client.get(self.submission_url, {'format': 'json'})
assert response.status_code == status.HTTP_200_OK
+ @responses.activate
def test_get_edit_link_with_partial_perms_as_anotheruser(self):
"""
someuser is the owner of the project.
@@ -897,9 +940,20 @@ def test_get_edit_link_with_partial_perms_as_anotheruser(self):
'pk': submission['_id'],
},
)
+
+ ee_url = (
+ f'{settings.ENKETO_URL}/{settings.ENKETO_EDIT_INSTANCE_ENDPOINT}'
+ )
+ # Mock Enketo response
+ responses.add_callback(
+ responses.POST, ee_url,
+ callback=enketo_edit_instance_response,
+ content_type='application/json',
+ )
+
response = self.client.get(url, {'format': 'json'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
- url = f"http://server.mock/enketo/edit/{submission['_id']}"
+ url = f"{settings.ENKETO_URL}/edit/{submission['_uuid']}"
expected_response = {'url': url}
self.assertEqual(response.data, expected_response)
@@ -917,16 +971,28 @@ def setUp(self):
},
)
+ @responses.activate
def test_get_view_link_submission_as_owner(self):
"""
someuser is the owner of the project.
someuser can get enketo view link
"""
+ ee_url = (
+ f'{settings.ENKETO_URL}/{settings.ENKETO_VIEW_INSTANCE_ENDPOINT}'
+ )
+
+ # Mock Enketo response
+ responses.add_callback(
+ responses.POST, ee_url,
+ callback=enketo_view_instance_response,
+ content_type='application/json',
+ )
+
response = self.client.get(self.submission_view_link_url, {'format': 'json'})
assert response.status_code == status.HTTP_200_OK
expected_response = {
- 'url': 'http://server.mock/enketo/view/{}'.format(self.submission['_id'])
+ 'url': f"{settings.ENKETO_URL}/view/{self.submission['_uuid']}"
}
assert response.data == expected_response
@@ -952,6 +1018,7 @@ def test_cannot_get_view_link_submission_not_shared_as_anotheruser(self):
response = self.client.get(self.submission_view_link_url, {'format': 'json'})
assert response.status_code == status.HTTP_404_NOT_FOUND
+ @responses.activate
def test_get_view_link_submission_shared_with_view_only_as_anotheruser(self):
"""
someuser is the owner of the project.
@@ -960,9 +1027,21 @@ def test_get_view_link_submission_shared_with_view_only_as_anotheruser(self):
"""
self.asset.assign_perm(self.anotheruser, PERM_VIEW_SUBMISSIONS)
self._log_in_as_another_user()
+
+ ee_url = (
+ f'{settings.ENKETO_URL}/{settings.ENKETO_VIEW_INSTANCE_ENDPOINT}'
+ )
+ # Mock Enketo response
+ responses.add_callback(
+ responses.POST, ee_url,
+ callback=enketo_view_instance_response,
+ content_type='application/json',
+ )
+
response = self.client.get(self.submission_view_link_url, {'format': 'json'})
assert response.status_code == status.HTTP_200_OK
+ @responses.activate
def test_get_view_link_with_partial_perms_as_anotheruser(self):
"""
someuser is the owner of the project.
@@ -1003,9 +1082,20 @@ def test_get_view_link_with_partial_perms_as_anotheruser(self):
'pk': submission['_id'],
},
)
+
+ ee_url = (
+ f'{settings.ENKETO_URL}/{settings.ENKETO_VIEW_INSTANCE_ENDPOINT}'
+ )
+ # Mock Enketo response
+ responses.add_callback(
+ responses.POST, ee_url,
+ callback=enketo_view_instance_response,
+ content_type='application/json',
+ )
+
response = self.client.get(url, {'format': 'json'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
- url = f"http://server.mock/enketo/view/{submission['_id']}"
+ url = f"{settings.ENKETO_URL}/view/{submission['_uuid']}"
expected_response = {'url': url}
self.assertEqual(response.data, expected_response)
diff --git a/kpi/tests/kpi_test_case.py b/kpi/tests/kpi_test_case.py
index 56c48e5921..0f5dfb6312 100644
--- a/kpi/tests/kpi_test_case.py
+++ b/kpi/tests/kpi_test_case.py
@@ -38,11 +38,12 @@ def login(self, username=None, password=None, expect_success=True):
kwargs = {'username': username, 'password': password}
self.assertEqual(self.client.login(**kwargs), expect_success)
- def _url_to_uid(self, url):
+ @staticmethod
+ def _url_to_uid(url):
return re.match(r'.+/(.+)/.*$', url).groups()[0]
def url_to_obj(self, url):
- uid = re.match(r'.+/(.+)/.*$', url).groups()[0]
+ uid = self._url_to_uid(url)
if uid.startswith('a'):
klass = Asset
elif uid.startswith('p'):
diff --git a/kpi/tests/test_assets.py b/kpi/tests/test_assets.py
index 7a90035143..46c663823d 100644
--- a/kpi/tests/test_assets.py
+++ b/kpi/tests/test_assets.py
@@ -512,7 +512,7 @@ def test_surveys_exported_to_xml_have_id_string_and_title(self):
asset_type='survey')
export = a1.snapshot
self.assertTrue('abcxyz' in export.xml)
- self.assertTrue('' in export.xml)
+ self.assertTrue(f'<{a1.uid} id="xid_stringx">' in export.xml)
# TODO: test values of "valid_xlsform_content"
diff --git a/kpi/tests/utils/mock.py b/kpi/tests/utils/mock.py
index 6d224c6cdd..7bbd063264 100644
--- a/kpi/tests/utils/mock.py
+++ b/kpi/tests/utils/mock.py
@@ -1,25 +1,77 @@
# coding: utf-8
+import json
import os
from mimetypes import guess_type
from tempfile import NamedTemporaryFile
from typing import Optional
+from urllib.parse import parse_qs, unquote
+from django.conf import settings
from django.core.files import File
+from rest_framework import status
from kpi.mixins.mp3_converter import MP3ConverterMixin
+def enketo_edit_instance_response(request):
+ """
+ Simulate Enketo response
+ """
+ # Decode `x-www-form-urlencoded` data
+ body = {k: v[0] for k, v in parse_qs(unquote(request.body)).items()}
+
+ resp_body = {
+ 'edit_url': (
+ f"{settings.ENKETO_URL}/edit/{body['instance_id']}"
+ )
+ }
+ headers = {}
+ return status.HTTP_201_CREATED, headers, json.dumps(resp_body)
+
+
+def enketo_view_instance_response(request):
+ """
+ Simulate Enketo response
+ """
+ # Decode `x-www-form-urlencoded` data
+ body = {k: v[0] for k, v in parse_qs(unquote(request.body)).items()}
+
+ resp_body = {
+ 'view_url': (
+ f"{settings.ENKETO_URL}/view/{body['instance_id']}"
+ )
+ }
+ headers = {}
+ return status.HTTP_201_CREATED, headers, json.dumps(resp_body)
+
+
class MockAttachment(MP3ConverterMixin):
"""
Mock object to simulate ReadOnlyKobocatAttachment.
Relationship with ReadOnlyKobocatInstance is ignored but could be implemented
"""
- def __init__(self, file_):
- self.media_file = File(open(file_, 'rb'), os.path.basename(file_))
+ def __init__(self, pk: int, filename: str, mimetype: str = None, **kwargs):
+
+ self.id = pk # To mimic Django model instances
+ self.pk = pk
+ basename = os.path.basename(filename)
+ file_ = os.path.join(
+ settings.BASE_DIR,
+ 'kpi',
+ 'tests',
+ basename
+ )
+
+ self.media_file = File(open(file_, 'rb'), basename)
self.media_file.path = file_
- self.media_file_basename = os.path.basename(file_)
- self.mimetype, _ = guess_type(file_)
self.content = self.media_file.read()
+ self.media_file_basename = basename
+ if not mimetype:
+ self.mimetype, _ = guess_type(file_)
+ else:
+ self.mimetype = mimetype
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
self.media_file.close()
@property
diff --git a/kpi/urls/router_api_v2.py b/kpi/urls/router_api_v2.py
index e3b4ea32e0..cee2fdc5ec 100644
--- a/kpi/urls/router_api_v2.py
+++ b/kpi/urls/router_api_v2.py
@@ -1,4 +1,5 @@
# coding: utf-8
+from django.urls import path
from rest_framework_extensions.routers import ExtendedDefaultRouter
from kobo.apps.hook.views.v2.hook import HookViewSet
@@ -20,9 +21,43 @@
from kpi.views.v2.user_asset_subscription import UserAssetSubscriptionViewSet
+class ExtendedDefaultRouterWithPathAliases(ExtendedDefaultRouter):
+ """
+ Historically, all of this application's endpoints have used trailing
+ slashes (the DRF default). Requests missing their trailing slashes have
+ been automatically redirected by Django's `APPEND_SLASH` setting, which
+ defaults to `True`.
+
+ That behavior is unacceptable for OpenRosa endpoints, which do *not* end
+ with slashes and cannot be redirected without losing their POST payloads.
+
+ This router explicitly adds URL patterns without trailing slashes for
+ OpenRosa endpoints so that their responses can be served directly, without
+ redirection.
+ """
+ def get_urls(self, *args, **kwargs):
+ urls = super().get_urls(*args, **kwargs)
+ names_to_alias_paths = {
+ 'assetsnapshot-form-list': 'asset_snapshots//formList',
+ 'assetsnapshot-manifest': 'asset_snapshots//manifest',
+ 'assetsnapshot-submission': 'asset_snapshots//submission',
+ }
+ alias_urls = []
+ for url in urls:
+ if url.name in names_to_alias_paths:
+ alias_paths = names_to_alias_paths[url.name]
+ # only consider the first match
+ del names_to_alias_paths[url.name]
+ alias_urls.append(
+ path(alias_paths, url.callback, name=f'{url.name}-alias')
+ )
+ urls.extend(alias_urls)
+ return urls
+
+
URL_NAMESPACE = 'api_v2'
-router_api_v2 = ExtendedDefaultRouter()
+router_api_v2 = ExtendedDefaultRouterWithPathAliases()
asset_routes = router_api_v2.register(r'assets', AssetViewSet, basename='asset')
asset_routes.register(r'files',
diff --git a/kpi/views/v2/asset_snapshot.py b/kpi/views/v2/asset_snapshot.py
index f140f4e826..799e85a417 100644
--- a/kpi/views/v2/asset_snapshot.py
+++ b/kpi/views/v2/asset_snapshot.py
@@ -4,14 +4,18 @@
import requests
from django.http import HttpResponseRedirect
from django.conf import settings
-from rest_framework import renderers
+from rest_framework import renderers, serializers, status
+from rest_framework.exceptions import AuthenticationFailed
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.reverse import reverse
+from kpi.authentication import DigestAuthentication
+from kpi.exceptions import SubmissionIntegrityError
from kpi.filters import RelatedAssetPermissionsFilter
from kpi.highlighters import highlight_xform
from kpi.models import AssetSnapshot, AssetFile, PairedData
+from kpi.permissions import EditSubmissionPermission
from kpi.renderers import (
OpenRosaFormListRenderer,
OpenRosaManifestRenderer,
@@ -39,13 +43,27 @@ class AssetSnapshotViewSet(OpenRosaViewSetMixin, NoUpdateModelViewSet):
XMLRenderer,
]
+ @property
+ def asset(self):
+ asset_snapshot = self.get_object()
+ return asset_snapshot.asset
+
def filter_queryset(self, queryset):
- if (self.action == 'retrieve' and
- self.request.accepted_renderer.format == 'xml'):
+ if (
+ self.action == 'submission'
+ or (
+ self.action == 'retrieve'
+ and self.request.accepted_renderer.format == 'xml'
+ )
+ ):
# The XML renderer is totally public and serves anyone, so
# /asset_snapshot/valid_uid.xml is world-readable, even though
# /asset_snapshot/valid_uid/ requires ownership. Return the
# queryset unfiltered
+
+ # If action is 'submission', we also need to return the queryset
+ # unfiltered to avoid returning a 404 if user has not been authenticated
+ # yet. The filtering will be handled by the `submission()` method itself.
return queryset
else:
user = self.request.user
@@ -55,12 +73,14 @@ def filter_queryset(self, queryset):
return owned_snapshots | RelatedAssetPermissionsFilter(
).filter_queryset(self.request, queryset, view=self)
- @action(detail=True,
- renderer_classes=[OpenRosaFormListRenderer],
- url_path='formList')
+ @action(
+ detail=True,
+ renderer_classes=[OpenRosaFormListRenderer],
+ url_path='formList',
+ )
def form_list(self, request, *args, **kwargs):
"""
- This route is used by enketo when it fetches external resources.
+ This route is used by Enketo when it fetches external resources.
It let us specify manifests for preview
"""
snapshot = self.get_object()
@@ -69,12 +89,15 @@ def form_list(self, request, *args, **kwargs):
return Response(serializer.data, headers=self.get_headers())
- @action(detail=True, renderer_classes=[OpenRosaManifestRenderer])
+ @action(
+ detail=True,
+ renderer_classes=[OpenRosaManifestRenderer],
+ )
def manifest(self, request, *args, **kwargs):
"""
- This route is used by enketo when it fetches external resources.
+ This route is used by Enketo when it fetches external resources.
It returns form media files location in order to display them within
- enketo preview
+ Enketo preview
"""
snapshot = self.get_object()
asset = snapshot.asset
@@ -120,12 +143,53 @@ def preview(self, request, *args, **kwargs):
json_response = response.json()
preview_url = json_response.get('preview_url')
-
+
return HttpResponseRedirect(preview_url)
else:
response_data = copy.copy(snapshot.details)
return Response(response_data, template_name='preview_error.html')
+ @action(
+ detail=True,
+ permission_classes=[EditSubmissionPermission],
+ methods=['HEAD', 'POST'],
+ authentication_classes=[DigestAuthentication],
+ )
+ def submission(self, request, *args, **kwargs):
+ if request.method == 'HEAD':
+ # Return an empty response with OpenRosa headers
+ # See https://docs.getodk.org/openrosa-form-submission/#extended-transmission-considerations
+ return Response(
+ '',
+ headers=self.get_headers(),
+ status=status.HTTP_204_NO_CONTENT,
+ )
+
+ asset_snapshot = self.get_object()
+
+ xml_submission_file = request.data['xml_submission_file']
+
+ # Prepare attachments even if all files are present in `request.FILES`
+ # (i.e.: submission XML and attachments)
+ attachments = None
+ # Remove 'xml_submission_file' since it is already handled
+ request.FILES.pop('xml_submission_file')
+ if len(request.FILES):
+ attachments = {}
+ for name, attachment in request.FILES.items():
+ attachments[name] = attachment
+
+ try:
+ xml_response = asset_snapshot.asset.deployment.edit_submission(
+ xml_submission_file, request.user, attachments
+ )
+ except SubmissionIntegrityError as e:
+ raise serializers.ValidationError(str(e))
+
+ # Add OpenRosa headers to response
+ xml_response['headers'].update(self.get_headers())
+ return Response(**xml_response)
+
@action(detail=True, renderer_classes=[renderers.TemplateHTMLRenderer])
def xform(self, request, *args, **kwargs):
"""
diff --git a/kpi/views/v2/data.py b/kpi/views/v2/data.py
index a2b9479a1c..607637a38d 100644
--- a/kpi/views/v2/data.py
+++ b/kpi/views/v2/data.py
@@ -1,7 +1,9 @@
# coding: utf-8
+from xml.etree import ElementTree as ET
+
+import requests
from django.conf import settings
from django.http import Http404
-from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as t
from rest_framework import (
renderers,
@@ -11,17 +13,23 @@
)
from rest_framework.decorators import action
from rest_framework.pagination import _positive_int as positive_int
+from rest_framework.request import Request
from rest_framework.response import Response
+from rest_framework.reverse import reverse
from rest_framework_extensions.mixins import NestedViewSetMixin
+from kobo.apps.reports.constants import INFERRED_VERSION_ID_KEY
+from kobo.apps.reports.report_data import build_formpack
from kpi.constants import (
SUBMISSION_FORMAT_TYPE_JSON,
+ SUBMISSION_FORMAT_TYPE_XML,
PERM_CHANGE_SUBMISSIONS,
PERM_DELETE_SUBMISSIONS,
PERM_VALIDATE_SUBMISSIONS,
+ PERM_VIEW_SUBMISSIONS,
)
from kpi.exceptions import ObjectDeploymentDoesNotExist
-from kpi.models import Asset, AssetExportSettings
+from kpi.models import Asset, AssetVersion, AssetExportSettings
from kpi.paginators import DataPagination
from kpi.permissions import (
DuplicateSubmissionPermission,
@@ -376,7 +384,8 @@ def destroy(self, request, pk, *args, **kwargs):
url_path='(enketo\/)?edit',
)
def enketo_edit(self, request, pk, *args, **kwargs):
- return self._enketo_request(request, pk, action_='edit', *args, **kwargs)
+ submission_id = positive_int(pk)
+ return self._get_enketo_link(request, submission_id, 'edit')
@action(
detail=True,
@@ -386,7 +395,8 @@ def enketo_edit(self, request, pk, *args, **kwargs):
url_path='enketo/view',
)
def enketo_view(self, request, pk, *args, **kwargs):
- return self._enketo_request(request, pk, action_='view', *args, **kwargs)
+ submission_id = positive_int(pk)
+ return self._get_enketo_link(request, submission_id, 'view')
@action(
detail=False,
@@ -510,17 +520,6 @@ def validation_statuses(self, request, *args, **kwargs):
return Response(**json_response)
- def _enketo_request(self, request, pk, action_, *args, **kwargs):
- deployment = self._get_deployment()
- submission_id = positive_int(pk)
- json_response = deployment.get_enketo_submission_url(
- submission_id,
- user=request.user,
- action_=action_,
- params=request.GET
- )
- return Response(**json_response)
-
def _filter_mongo_query(self, request):
"""
Build filters to pass to Mongo query.
@@ -549,3 +548,112 @@ def _filter_mongo_query(self, request):
)
return filters
+
+ def _get_enketo_link(
+ self, request: Request, submission_id: int, action_: str
+ ) -> Response:
+
+ deployment = self._get_deployment()
+ user = request.user
+
+ if action_ == 'edit':
+ enketo_endpoint = settings.ENKETO_EDIT_INSTANCE_ENDPOINT
+ partial_perm = PERM_CHANGE_SUBMISSIONS
+ elif action_ == 'view':
+ enketo_endpoint = settings.ENKETO_VIEW_INSTANCE_ENDPOINT
+ partial_perm = PERM_VIEW_SUBMISSIONS
+
+ # User's permissions are validated by the permission class. This extra step
+ # is needed to validate at a row level for users with partial permissions.
+ # A `PermissionDenied` error will be raised if it is not the case.
+ # `validate_access_with_partial_perms()` is called no matter what are the
+ # user's permissions. The first check inside the method is the user's
+ # permissions. `submission_ids` should be equal to `None` if user has
+ # regular permissions.
+ deployment.validate_access_with_partial_perms(
+ user=user,
+ perm=partial_perm,
+ submission_ids=[submission_id],
+ )
+
+ # The XML version is needed for Enketo
+ submission_xml = deployment.get_submission(
+ submission_id, user, SUBMISSION_FORMAT_TYPE_XML
+ )
+ # The JSON version is needed to detect its version
+ submission_json = deployment.get_submission(
+ submission_id, user, request=request
+ )
+
+ # TODO: un-nest `_infer_version_id()` from `build_formpack()` and move
+ # it into some utility file
+ _, submissions_stream = build_formpack(
+ self.asset,
+ submission_stream=[submission_json],
+ use_all_form_versions=True
+ )
+ version_uid = list(submissions_stream)[0][INFERRED_VERSION_ID_KEY]
+
+ # Retrieve the XML root node name from the submission. The instance's
+ # root node name specified in the form XML (i.e. the first child of
+ # ``) must match the root node name of the submission XML,
+ # otherwise Enketo will refuse to open the submission.
+ xml_root_node_name = ET.fromstring(submission_xml).tag
+
+ # This will raise `AssetVersion.DoesNotExist` if the inferred version
+ # of the submission disappears between the call to `build_formpack()`
+ # and here, but allow a 500 error in that case because there's nothing
+ # the client can do about it
+ snapshot = self.asset.versioned_snapshot(
+ version_uid=version_uid, root_node_name=xml_root_node_name
+ )
+
+ data = {
+ 'server_url': reverse(
+ viewname='assetsnapshot-detail',
+ kwargs={'uid': snapshot.uid},
+ request=request,
+ ),
+ 'instance': submission_xml,
+ 'instance_id': submission_json['_uuid'],
+ 'form_id': snapshot.uid,
+ 'return_url': 'false' # String to be parsed by EE as a boolean
+ }
+
+ # Add attachments if any.
+ attachments = deployment.get_attachment_objects_from_dict(submission_json)
+ for attachment in attachments:
+ key_ = f'instance_attachments[{attachment.media_file_basename}]'
+ data[key_] = reverse(
+ 'attachment-detail',
+ args=(self.asset.uid, submission_id, attachment.pk),
+ request=request,
+ )
+
+ response = requests.post(
+ f'{settings.ENKETO_URL}/{enketo_endpoint}',
+ # bare tuple implies basic auth
+ auth=(settings.ENKETO_API_TOKEN, ''),
+ data=data
+ )
+ if response.status_code != status.HTTP_201_CREATED:
+ # Some Enketo errors are useful to the client. Attempt to pass them
+ # along if possible
+ try:
+ parsed_resp = response.json()
+ except ValueError:
+ parsed_resp = None
+ if parsed_resp and 'message' in parsed_resp:
+ message = parsed_resp['message']
+ else:
+ message = response.reason
+ return Response(
+ # This doesn't seem worth translating
+ {'detail': 'Enketo error: ' + message},
+ status=response.status_code,
+ )
+
+ json_response = response.json()
+ enketo_url = json_response.get(f'{action_}_url')
+
+ return Response({'url': enketo_url})