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})