From bcfe91a5278351d26fdfd61b28955b2c5dbe24a1 Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Tue, 29 Nov 2016 15:12:10 +0100 Subject: [PATCH] Refactoring and permissions fix --- cadasta/questionnaires/renderer/__init__.py | 0 cadasta/questionnaires/renderer/xform.py | 117 --------------- .../questionnaires/tests/test_views_api.py | 10 -- cadasta/questionnaires/views/api.py | 10 +- cadasta/xforms/renderers.py | 138 ++++++++++++++++++ cadasta/xforms/serializers.py | 8 +- .../tests/test_renderer.py | 2 +- cadasta/xforms/tests/test_serializers.py | 9 +- cadasta/xforms/tests/test_urls_api.py | 7 + cadasta/xforms/tests/test_views_api.py | 70 +++++++-- cadasta/xforms/urls/api.py | 4 +- cadasta/xforms/views/api.py | 29 +++- 12 files changed, 245 insertions(+), 159 deletions(-) delete mode 100644 cadasta/questionnaires/renderer/__init__.py delete mode 100644 cadasta/questionnaires/renderer/xform.py rename cadasta/{questionnaires => xforms}/tests/test_renderer.py (99%) diff --git a/cadasta/questionnaires/renderer/__init__.py b/cadasta/questionnaires/renderer/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/cadasta/questionnaires/renderer/xform.py b/cadasta/questionnaires/renderer/xform.py deleted file mode 100644 index b94ada48d..000000000 --- a/cadasta/questionnaires/renderer/xform.py +++ /dev/null @@ -1,117 +0,0 @@ -from pyxform.builder import create_survey_element_from_dict -from lxml import etree -from rest_framework.renderers import BaseRenderer -from ..models import Question -from ..managers import fix_languages - -QUESTION_TYPES = dict(Question.TYPE_CHOICES) - - -class XFormRenderer(BaseRenderer): - format = 'xform' - media_type = 'application/xml' - - def transform_questions(self, questions): - children = [] - for q in questions: - q['type'] = QUESTION_TYPES[q['type']] - - if q.get('label', -1) is None: - del q['label'] - - if 'options' in q: - q['choices'] = q['options'] - - bind = {} - if q.get('required', False) is True: - bind['required'] = 'yes' - if q.get('relevant'): - bind['relevant'] = q.get('relevant') - - if bind: - q['bind'] = bind - - children.append(q) - return children - - def transform_groups(self, groups): - transformed_groups = [] - for g in groups: - group = { - 'type': 'group', - 'name': g.get('name'), - 'label': g.get('label'), - 'children': self.transform_questions(g.get('questions')), - 'index': g.get('index') - } - if group['label'] is None: - del group['label'] - - bind = {} - if g.get('relevant'): - bind['relevant'] = g.get('relevant') - - if bind: - group['bind'] = bind - transformed_groups.append(group) - return transformed_groups - - def transform_to_xform_json(self, data): - json = { - 'default_language': 'default', - 'name': data.get('id_string'), - 'sms_keyword': data.get('id_string'), - 'type': 'survey', - 'id_string': data.get('id_string'), - 'title': data.get('id_string') - } - - questions = self.transform_questions(data.get('questions', [])) - question_groups = self.transform_groups( - data.get('question_groups', [])) - json['children'] = sorted(questions + question_groups, - key=lambda x: x['index']) - return json - - def insert_version_attribute(self, xform, root_node, version): - ns = {'xf': 'http://www.w3.org/2002/xforms'} - root = etree.fromstring(xform) - inst = root.find( - './/xf:instance/xf:{root_node}'.format( - root_node=root_node - ), namespaces=ns - ) - inst.set('version', str(version)) - xml = etree.tostring( - root, method='xml', encoding='utf-8', pretty_print=True - ) - return xml - - def insert_uuid_bind(self, xform, id_string): - ns = {'xf': 'http://www.w3.org/2002/xforms'} - root = etree.fromstring(xform) - model = root.find('.//xf:model', namespaces=ns) - etree.SubElement(model, 'bind', { - 'calculate': "concat('uuid:', uuid())", - 'nodeset': '/{}/meta/instanceID'.format(id_string), - 'readonly': 'true()', - 'type': 'string' - }) - xml = etree.tostring( - root, method='xml', encoding='utf-8', pretty_print=True - ) - return xml - - def render(self, data, *args, **kwargs): - json = self.transform_to_xform_json(data) - survey = create_survey_element_from_dict(json) - xml = survey.xml() - fix_languages(xml) - xml = xml.toxml() - - xml = self.insert_version_attribute(xml, - data.get('id_string'), - data.get('version')) - xml = self.insert_uuid_bind(xml, data.get('id_string')) - - return xml diff --git a/cadasta/questionnaires/tests/test_views_api.py b/cadasta/questionnaires/tests/test_views_api.py index baee259ad..5d20d9378 100644 --- a/cadasta/questionnaires/tests/test_views_api.py +++ b/cadasta/questionnaires/tests/test_views_api.py @@ -51,16 +51,6 @@ def test_get_questionnaire(self): assert response.status_code == 200 assert response.content['id'] == questionnaire.id - def test_get_questionnaire_as_xform(self): - questionnaire = QuestionnaireFactory.create(project=self.prj) - response = self.request(user=self.user, get_data={'format': 'xform'}) - assert response.status_code == 200 - assert (response.headers['content-type'][1] == - 'application/xml; charset=utf-8') - assert '<{id} id="{id}" version="{v}"/>'.format( - id=questionnaire.id_string, - v=questionnaire.version) in response.content - def test_get_questionnaire_that_does_not_exist(self): response = self.request(user=self.user) assert response.status_code == 404 diff --git a/cadasta/questionnaires/views/api.py b/cadasta/questionnaires/views/api.py index 08117e46a..0c467d41c 100644 --- a/cadasta/questionnaires/views/api.py +++ b/cadasta/questionnaires/views/api.py @@ -3,25 +3,17 @@ from rest_framework import generics, mixins, status from rest_framework.response import Response -from rest_framework.renderers import JSONRenderer, BrowsableAPIRenderer from tutelary.mixins import APIPermissionRequiredMixin from organization.models import Project from ..models import Questionnaire from ..serializers import QuestionnaireSerializer from ..exceptions import InvalidXLSForm -from ..renderer.xform import XFormRenderer class QuestionnaireDetail(APIPermissionRequiredMixin, mixins.CreateModelMixin, generics.RetrieveUpdateAPIView): - renderer_classes = (JSONRenderer, BrowsableAPIRenderer, XFormRenderer, ) - - def get_actions(self, request): - if request.GET.get('format') == 'xform': - return None - return 'questionnaire.view' def patch_actions(self, request): try: @@ -31,7 +23,7 @@ def patch_actions(self, request): serializer_class = QuestionnaireSerializer permission_required = { - 'GET': get_actions, + 'GET': 'questionnaire.view', 'PUT': patch_actions, } diff --git a/cadasta/xforms/renderers.py b/cadasta/xforms/renderers.py index e5cc77ac0..95d6228c7 100644 --- a/cadasta/xforms/renderers.py +++ b/cadasta/xforms/renderers.py @@ -4,6 +4,14 @@ from django.utils.encoding import smart_text from rest_framework.compat import six +from pyxform.builder import create_survey_element_from_dict +from lxml import etree +from rest_framework.renderers import BaseRenderer +from questionnaires.models import Question +from questionnaires.managers import fix_languages + +QUESTION_TYPES = dict(Question.TYPE_CHOICES) + class XFormListRenderer(renderers.BaseRenderer): """ @@ -56,3 +64,133 @@ def _to_xml(self, xml, data): else: xml.characters(smart_text(data)) + + +class XFormRenderer(BaseRenderer): + format = 'xform' + media_type = 'application/xml' + + def transform_questions(self, questions): + children = [] + for q in questions: + q['type'] = QUESTION_TYPES[q['type']] + + if q.get('label', -1) is None: + del q['label'] + + if 'options' in q: + q['choices'] = q['options'] + + bind = {} + if q.get('required', False) is True: + bind['required'] = 'yes' + if q.get('relevant'): + bind['relevant'] = q.get('relevant') + + if bind: + q['bind'] = bind + + children.append(q) + return children + + def transform_groups(self, groups): + transformed_groups = [] + for g in groups: + group = { + 'type': 'group', + 'name': g.get('name'), + 'label': g.get('label'), + 'children': self.transform_questions(g.get('questions')), + 'index': g.get('index') + } + if group['label'] is None: + del group['label'] + + bind = {} + if g.get('relevant'): + bind['relevant'] = g.get('relevant') + + if bind: + group['bind'] = bind + transformed_groups.append(group) + return transformed_groups + + def transform_to_xform_json(self, data): + json = { + 'default_language': 'default', + 'name': data.get('id_string'), + 'sms_keyword': data.get('id_string'), + 'type': 'survey', + 'id_string': data.get('id_string'), + 'title': data.get('id_string') + } + + questions = self.transform_questions(data.get('questions', [])) + question_groups = self.transform_groups( + data.get('question_groups', [])) + json['children'] = sorted(questions + question_groups, + key=lambda x: x['index']) + return json + + def insert_version_attribute(self, xform, root_node, version): + ns = {'xf': 'http://www.w3.org/2002/xforms'} + root = etree.fromstring(xform) + inst = root.find( + './/xf:instance/xf:{root_node}'.format( + root_node=root_node + ), namespaces=ns + ) + inst.set('version', str(version)) + xml = etree.tostring( + root, method='xml', encoding='utf-8', pretty_print=True + ) + return xml + + def insert_uuid_bind(self, xform, id_string): + ns = {'xf': 'http://www.w3.org/2002/xforms'} + root = etree.fromstring(xform) + model = root.find('.//xf:model', namespaces=ns) + etree.SubElement(model, 'bind', { + 'calculate': "concat('uuid:', uuid())", + 'nodeset': '/{}/meta/instanceID'.format(id_string), + 'readonly': 'true()', + 'type': 'string' + }) + xml = etree.tostring( + root, method='xml', encoding='utf-8', pretty_print=True + ) + return xml + + def render(self, data, *args, **kwargs): + charset = 'utf-8' + root_node = 'xforms' + xmlns = "http://openrosa.org/xforms/xformsList" + + if 'detail' in data.keys(): + stream = StringIO() + + xml = SimplerXMLGenerator(stream, charset) + xml.startDocument() + xml.startElement(root_node, {'xmlns': xmlns}) + + for key, value in six.iteritems(data): + xml.startElement(key, {}) + xml.characters(smart_text(value)) + xml.endElement(key) + + xml.endElement(root_node) + xml.endDocument() + return stream.getvalue() + else: + json = self.transform_to_xform_json(data) + survey = create_survey_element_from_dict(json) + xml = survey.xml() + fix_languages(xml) + xml = xml.toxml() + + xml = self.insert_version_attribute(xml, + data.get('id_string'), + data.get('version')) + xml = self.insert_uuid_bind(xml, data.get('id_string')) + + return xml diff --git a/cadasta/xforms/serializers.py b/cadasta/xforms/serializers.py index c31073d42..1ec33a437 100644 --- a/cadasta/xforms/serializers.py +++ b/cadasta/xforms/serializers.py @@ -21,13 +21,9 @@ class XFormListSerializer(FieldSelectorSerializer, downloadUrl = serializers.SerializerMethodField('get_xml_form') def get_xml_form(self, obj): - url = reverse('api:v1:questionnaires:detail', - kwargs={ - 'organization': obj.project.organization.slug, - 'project': obj.project.slug - }) + '?format=xform' + url = reverse('form-download', args=[obj.id]) url = self.context['request'].build_absolute_uri(url) - if self.context['request'].META['SERVER_PROTOCOL'] != 'HTTP/1.1': + if self.context['request'].META['SERVER_PROTOCOL'] == 'HTTPS/1.1': url = url.replace('http://', 'https://') return url diff --git a/cadasta/questionnaires/tests/test_renderer.py b/cadasta/xforms/tests/test_renderer.py similarity index 99% rename from cadasta/questionnaires/tests/test_renderer.py rename to cadasta/xforms/tests/test_renderer.py index 5c8d86f70..916211a7d 100644 --- a/cadasta/questionnaires/tests/test_renderer.py +++ b/cadasta/xforms/tests/test_renderer.py @@ -1,5 +1,5 @@ from django.test import TestCase -from ..renderer.xform import XFormRenderer +from ..renderers import XFormRenderer class XFormRendererTest(TestCase): diff --git a/cadasta/xforms/tests/test_serializers.py b/cadasta/xforms/tests/test_serializers.py index d32cd3e1a..32c23ee94 100644 --- a/cadasta/xforms/tests/test_serializers.py +++ b/cadasta/xforms/tests/test_serializers.py @@ -1,6 +1,7 @@ import pytest from django.test import TestCase +from django.core.urlresolvers import reverse from rest_framework.test import APIRequestFactory, force_authenticate from core.tests.utils.cases import UserTestCase, FileStorageTestCase @@ -40,16 +41,14 @@ def _test_serialize(self, https=False): serializer = serializers.XFormListSerializer( form, context={'request': request}) + url_refix = 'https' if https else 'http' assert serializer.data['formID'] == questionnaire.data['id_string'] assert serializer.data['name'] == questionnaire.data['title'] assert serializer.data['version'] == questionnaire.data['version'] assert (serializer.data['downloadUrl'] == - '{}://testserver/api/v1/organizations/{}/projects/{}' - '/questionnaire/?format=xform'.format( - ('https' if https else 'http'), - project.organization.slug, - project.slug)) + url_refix + '://testserver' + + reverse('form-download', args=[form.id])) assert serializer.data['hash'] == questionnaire.data['md5_hash'] def test_serialize(self): diff --git a/cadasta/xforms/tests/test_urls_api.py b/cadasta/xforms/tests/test_urls_api.py index 24b66387f..c77b485dd 100644 --- a/cadasta/xforms/tests/test_urls_api.py +++ b/cadasta/xforms/tests/test_urls_api.py @@ -11,6 +11,13 @@ def test_xforms_list(self): resolved = resolve('/collect/') assert resolved.func.__name__ == api.XFormListView.__name__ + def test_xforms_download(self): + assert reverse('form-download', args=['a']) == '/collect/formList/a/' + + resolved = resolve('/collect/formList/a/') + assert resolved.func.__name__ == api.XFormDownloadView.__name__ + assert resolved.kwargs['questionnaire'] == 'a' + def test_xforms_submission(self): assert reverse('submissions') == '/collect/submission' diff --git a/cadasta/xforms/tests/test_views_api.py b/cadasta/xforms/tests/test_views_api.py index 95eeb7bdc..831b9be86 100644 --- a/cadasta/xforms/tests/test_views_api.py +++ b/cadasta/xforms/tests/test_views_api.py @@ -1,3 +1,4 @@ +import json import io import pytest @@ -8,6 +9,7 @@ from django.core.exceptions import ValidationError from django.core.files.uploadedfile import InMemoryUploadedFile from skivvy import APITestCase +from tutelary.models import Policy from accounts.tests.factories import UserFactory from core.tests.utils.cases import UserTestCase, FileStorageTestCase @@ -62,9 +64,8 @@ def test_get_xforms(self): assert response.status_code == 200 assert questionnaire.md5_hash in response.content - assert ('/v1/organizations/{}/projects/{}/questionnaire/'.format( - questionnaire.project.organization.slug, - questionnaire.project.slug) in response.content) + assert ('/collect/formList/{}/'.format( + questionnaire.id) in response.content) assert str(questionnaire.version) in response.content def test_get_xforms_with_unauthroized_user(self): @@ -73,9 +74,8 @@ def test_get_xforms_with_unauthroized_user(self): assert response.status_code == 200 assert questionnaire.md5_hash not in response.content - assert ('/v1/organizations/{}/projects/{}/questionnaire/'.format( - questionnaire.project.organization.slug, - questionnaire.project.slug) not in response.content) + assert ('/collect/formList/{}/'.format( + questionnaire.id) not in response.content) def test_get_xforms_with_superuser(self): self.user.assign_policies(self.superuser_role) @@ -84,9 +84,8 @@ def test_get_xforms_with_superuser(self): assert response.status_code == 200 assert questionnaire.md5_hash in response.content - assert ('/v1/organizations/{}/projects/{}/questionnaire/'.format( - questionnaire.project.organization.slug, - questionnaire.project.slug) in response.content) + assert ('/collect/formList/{}/'.format( + questionnaire.id) in response.content) def test_get_xforms_with_no_superuser(self): OrganizationRole.objects.create( @@ -663,3 +662,56 @@ def test_form_repeat_party_minus_tenure(self): self._test_resource('test_image_three', party_one) self._test_resource('test_image_four', party_one) self._test_resource('test_image_five', party_two) + + +class XFormDownloadView(APITestCase, UserTestCase, TestCase): + view_class = api.XFormDownloadView + + def setup_models(self): + clause = { + 'clause': [ + { + 'effect': 'allow', + 'object': ['project/*/*'], + 'action': ['questionnaire.*'] + } + ] + } + policy = Policy.objects.create( + name='test-policy', + body=json.dumps(clause)) + self.user = UserFactory.create() + self.user.assign_policies(policy) + + self.questionnaire = QuestionnaireFactory.create() + + def setup_url_kwargs(self): + return {'questionnaire': self.questionnaire.id} + + def test_get_questionnaire_as_xform(self): + response = self.request(user=self.user) + assert response.status_code == 200 + assert (response.headers['content-type'][1] == + 'application/xml; charset=utf-8') + assert '<{id} id="{id}" version="{v}"/>'.format( + id=self.questionnaire.id_string, + v=self.questionnaire.version) in response.content + + def test_get_questionnaire_that_does_not_exist(self): + response = self.request(user=self.user, + url_kwargs={'questionnaire': 'abc'}) + assert response.status_code == 404 + assert 'Questionnaire not found.' in response.content + + def test_get_questionnaire_with_unauthorized_user(self): + user = UserFactory.create() + response = self.request(user=user) + assert response.status_code == 403 + assert ('You do not have permission to perform this ' + 'action.' in response.content) + + def test_get_questionnaire_with_authenticated_user(self): + response = self.request(user=None) + assert response.status_code == 401 + assert ('Authentication credentials were ' + 'not provided.' in response.content) diff --git a/cadasta/xforms/urls/api.py b/cadasta/xforms/urls/api.py index 86545c0e1..131e84d9a 100644 --- a/cadasta/xforms/urls/api.py +++ b/cadasta/xforms/urls/api.py @@ -4,8 +4,10 @@ urlpatterns = [ url(r'^$', api.XFormListView.as_view( {'get': 'list'}), name='form-list'), - url(r'^formList/', api.XFormListView.as_view( + url(r'^formList/$', api.XFormListView.as_view( {'get': 'list'}), name='form-list'), + url(r'^formList/(?P[-\w]+)/$', + api.XFormDownloadView.as_view(), name='form-download'), url(r'^submission$', api.XFormSubmissionViewSet.as_view( {'post': 'create', 'head': 'create'}), diff --git a/cadasta/xforms/views/api.py b/cadasta/xforms/views/api.py index 3683be9b6..4d39e4281 100644 --- a/cadasta/xforms/views/api.py +++ b/cadasta/xforms/views/api.py @@ -1,21 +1,25 @@ import logging +from django.shortcuts import get_object_or_404 from django.utils.six import BytesIO from django.utils.translation import ugettext as _ from questionnaires.models import Questionnaire -from rest_framework import status, viewsets +from rest_framework import status, viewsets, generics from rest_framework.authentication import BasicAuthentication from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from tutelary.models import Role +from tutelary.mixins import APIPermissionRequiredMixin from xforms.models import XFormSubmission from xforms.mixins.model_helper import ModelHelper from xforms.mixins.openrosa_headers_mixin import OpenRosaHeadersMixin from xforms.renderers import XFormListRenderer from xforms.serializers import XFormListSerializer, XFormSubmissionSerializer from xforms.exceptions import InvalidXMLSubmission +from questionnaires.serializers import QuestionnaireSerializer +from ..renderers import XFormRenderer logger = logging.getLogger('xform.submissions') @@ -135,3 +139,26 @@ def list(self, request, *args, **kwargs): serializer = self.get_serializer(self.object_list, many=True) return Response( serializer.data, headers=self.get_openrosa_headers(request)) + + +class XFormDownloadView(APIPermissionRequiredMixin, generics.RetrieveAPIView): + authentication_classes = (BasicAuthentication,) + permission_classes = (IsAuthenticated,) + renderer_classes = (XFormRenderer,) + serializer_class = QuestionnaireSerializer + permission_required = 'questionnaire.view' + + def get_perms_objects(self): + return [self.get_object().project] + + def get_object(self): + if not hasattr(self, '_object'): + self._object = get_object_or_404( + Questionnaire, id=self.kwargs['questionnaire']) + + return self._object + + def get_serializer_context(self, *args, **kwargs): + context = super().get_serializer_context(*args, **kwargs) + context['project'] = self.get_object().project + return context