From 042b70ed59d3d59041aec21211c2a047498560a1 Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Thu, 22 Sep 2016 12:29:40 +0200 Subject: [PATCH 1/6] Questioannire API - Block questionnaires from uploading - Render XForm from questionnaire instance - Add XForm rendering to API view - Extend API to update questioanaires --- cadasta/config/settings/default.py | 21 +- cadasta/core/views/api.py | 2 +- cadasta/organization/models.py | 6 + cadasta/organization/tests/test_models.py | 9 + .../tests/test_views_default_projects.py | 25 +- cadasta/organization/views/default.py | 17 +- cadasta/questionnaires/managers.py | 84 +- .../migrations/0014_api_additional_fields.py | 86 ++ cadasta/questionnaires/models.py | 24 +- cadasta/questionnaires/renderer/__init__.py | 0 cadasta/questionnaires/renderer/xform.py | 99 ++ cadasta/questionnaires/serializers.py | 184 ++- cadasta/questionnaires/tests/factories.py | 3 +- cadasta/questionnaires/tests/test_managers.py | 68 +- cadasta/questionnaires/tests/test_models.py | 18 + cadasta/questionnaires/tests/test_renderer.py | 179 +++ .../questionnaires/tests/test_serializers.py | 1021 ++++++++++++++++- .../questionnaires/tests/test_validators.py | 226 ++++ .../questionnaires/tests/test_views_api.py | 71 +- cadasta/questionnaires/validators.py | 121 ++ cadasta/questionnaires/views/api.py | 13 +- .../organization/project_edit_details.html | 9 + cadasta/xforms/serializers.py | 20 +- cadasta/xforms/tests/test_serializers.py | 10 +- cadasta/xforms/tests/test_views_api.py | 13 +- cadasta/xforms/views/api.py | 10 + 26 files changed, 2182 insertions(+), 157 deletions(-) create mode 100644 cadasta/questionnaires/migrations/0014_api_additional_fields.py create mode 100644 cadasta/questionnaires/renderer/__init__.py create mode 100644 cadasta/questionnaires/renderer/xform.py create mode 100644 cadasta/questionnaires/tests/test_renderer.py create mode 100644 cadasta/questionnaires/tests/test_validators.py create mode 100644 cadasta/questionnaires/validators.py diff --git a/cadasta/config/settings/default.py b/cadasta/config/settings/default.py index 825ca97f0..140d91a54 100644 --- a/cadasta/config/settings/default.py +++ b/cadasta/config/settings/default.py @@ -318,23 +318,28 @@ ATTRIBUTE_GROUPS = { 'location_attributes': { 'app_label': 'spatial', - 'model': 'spatialunit' - }, - 'location_relationship_attributes': { - 'app_label': 'spatial', - 'model': 'spatialrelationship' + 'model': 'spatialunit', + 'label': 'Location' }, 'party_attributes': { 'app_label': 'party', - 'model': 'party' + 'model': 'party', + 'label': 'Party' + }, + 'location_relationship_attributes': { + 'app_label': 'spatial', + 'model': 'spatialrelationship', + 'label': 'Spatial relationship' }, 'party_relationship_attributes': { 'app_label': 'party', - 'model': 'partyrelationship' + 'model': 'partyrelationship', + 'label': 'Party relationship' }, 'tenure_relationship_attributes': { 'app_label': 'party', - 'model': 'tenurerelationship' + 'model': 'tenurerelationship', + 'label': 'Tenure Relationship' } } diff --git a/cadasta/core/views/api.py b/cadasta/core/views/api.py index 97af3c3a1..3fb1e1990 100644 --- a/cadasta/core/views/api.py +++ b/cadasta/core/views/api.py @@ -31,7 +31,7 @@ def eval_json(response_data): for e in response_data[key]: try: errors.append(json.loads(e)) - except ValueError: + except (ValueError, TypeError): errors.append(e) response_data[key] = errors diff --git a/cadasta/organization/models.py b/cadasta/organization/models.py index e07c4d154..a410ac57e 100644 --- a/cadasta/organization/models.py +++ b/cadasta/organization/models.py @@ -1,3 +1,4 @@ +from django.utils.functional import cached_property from django.core.urlresolvers import reverse from django.conf import settings from django.db import models @@ -267,6 +268,11 @@ def ui_detail_url(self): }, ) + @cached_property + def has_records(self): + check_records = ['parties', 'tenure_relationships', 'spatial_units'] + return any([getattr(self, r).exists() for r in check_records]) + def save(self, *args, **kwargs): if ((self.country is None or self.country == '') and self.extent is not None): diff --git a/cadasta/organization/tests/test_models.py b/cadasta/organization/tests/test_models.py index cbd18aecf..df0c2da2c 100644 --- a/cadasta/organization/tests/test_models.py +++ b/cadasta/organization/tests/test_models.py @@ -6,6 +6,7 @@ from core.tests.utils.cases import UserTestCase from accounts.tests.factories import UserFactory from geography import load as load_countries +from spatial.tests.factories import SpatialUnitFactory from .factories import OrganizationFactory, ProjectFactory from ..models import OrganizationRole, ProjectRole @@ -163,6 +164,14 @@ def test_ui_detail_url(self): org=project.organization.slug, prj=project.slug)) + def test_has_records(self): + project = ProjectFactory.create() + assert project.has_records is False + + project = ProjectFactory.create() + SpatialUnitFactory.create(project=project) + assert project.has_records is True + class ProjectRoleTest(UserTestCase, TestCase): def setUp(self): diff --git a/cadasta/organization/tests/test_views_default_projects.py b/cadasta/organization/tests/test_views_default_projects.py index d9ebefaed..19dacc173 100644 --- a/cadasta/organization/tests/test_views_default_projects.py +++ b/cadasta/organization/tests/test_views_default_projects.py @@ -934,7 +934,7 @@ class ProjectEditDetailsTest(ViewTestCase, UserTestCase, } def setup_models(self): - self.project = ProjectFactory.create() + self.project = ProjectFactory.create(current_questionnaire='abc') def setup_url_kwargs(self): return { @@ -954,6 +954,17 @@ def test_get_with_authorized_user(self): response = self.request(user=user) assert response.status_code == 200 assert response.content == self.expected_content + assert 'Select the questionnaire' in self.expected_content + + def test_get_with_blocked_questionnaire_upload(self): + user = UserFactory.create() + assign_policies(user) + SpatialUnitFactory.create(project=self.project) + + response = self.request(user=user) + assert response.status_code == 200 + assert response.content == self.expected_content + assert 'Select the questionnaire' not in self.expected_content def test_get_with_authorized_user_include_questionnaire(self): questionnaire = QuestionnaireFactory.create(project=self.project) @@ -1000,6 +1011,18 @@ def test_post_with_authorized_user(self): assert self.project.name == self.post_data['name'] assert self.project.description == self.post_data['description'] + def test_post_with_blocked_questionnaire_upload(self): + SpatialUnitFactory.create(project=self.project) + user = UserFactory.create() + assign_policies(user) + response = self.request(user=user, method='POST') + + assert response.status_code == 200 + self.project.refresh_from_db() + assert self.project.name != self.post_data['name'] + assert self.project.description != self.post_data['description'] + assert self.project.current_questionnaire == 'abc' + def test_post_invalid_form(self): question = self.get_form('xls-form-invalid') user = UserFactory.create() diff --git a/cadasta/organization/views/default.py b/cadasta/organization/views/default.py index a42a31636..46d095afb 100644 --- a/cadasta/organization/views/default.py +++ b/cadasta/organization/views/default.py @@ -651,13 +651,16 @@ def get_initial(self): return initial def post(self, *args, **kwargs): - try: - return super().post(*args, **kwargs) - except InvalidXLSForm as e: - form = self.get_form() - for err in e.errors: - form.add_error('questionnaire', err) - return self.form_invalid(form) + if self.get_project().has_records: + return self.get(*args, **kwargs) + else: + try: + return super().post(*args, **kwargs) + except InvalidXLSForm as e: + form = self.get_form() + for err in e.errors: + form.add_error('questionnaire', err) + return self.form_invalid(form) class ProjectEditPermissions(ProjectEdit, generic.UpdateView): diff --git a/cadasta/questionnaires/managers.py b/cadasta/questionnaires/managers.py index 16bae9443..ad8806b38 100644 --- a/cadasta/questionnaires/managers.py +++ b/cadasta/questionnaires/managers.py @@ -1,12 +1,6 @@ -import hashlib import itertools -import os import re -from datetime import datetime - -from lxml import etree from xml.dom.minidom import Element - from django.apps import apps from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -26,10 +20,7 @@ def create_children(children, errors=[], project=None, default_language='', kwargs={}): if children: for c in children: - if c.get('type') == 'repeat': - create_children(c['children'], errors, project, - default_language, kwargs) - elif c.get('type') == 'group': + if c.get('type') in ['group', 'repeat']: model_name = 'QuestionGroup' # parse attribute group @@ -49,9 +40,8 @@ def create_children(children, errors=[], project=None, else: model_name = 'Question' - if c.get('type') != 'repeat': - model = apps.get_model('questionnaires', model_name) - model.objects.create_from_dict(dict=c, errors=errors, **kwargs) + model = apps.get_model('questionnaires', model_name) + model.objects.create_from_dict(dict=c, errors=errors, **kwargs) def create_options(options, question, errors=[]): @@ -215,26 +205,10 @@ def create_from_form(self, xls_form=None, original_file=None, instance.filename = json.get('name') instance.title = json.get('title') instance.id_string = json.get('id_string') - instance.version = int( - datetime.utcnow().strftime('%Y%m%d%H%M%S%f')[:-4] - ) - instance.md5_hash = self.get_hash( - instance.filename, instance.id_string, instance.version - ) survey = create_survey_element_from_dict(json) xml_form = survey.xml() fix_languages(xml_form) - xml_form = xml_form.toxml() - # insert version attr into the xform instance root node - xml = self.insert_version_attribute( - xml_form, instance.filename, instance.version - ) - name = os.path.join(instance.xml_form.field.upload_to, - os.path.basename(instance.filename)) - url = instance.xml_form.storage.save( - '{}.xml'.format(name), xml) - instance.xml_form = url instance.save() @@ -258,38 +232,30 @@ def create_from_form(self, xls_form=None, original_file=None, except PyXFormError as e: raise InvalidXLSForm([str(e)]) - def get_hash(self, filename, id_string, version): - string = str(filename) + str(id_string) + str(version) - return hashlib.md5(string.encode()).hexdigest() - - 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 - class QuestionGroupManager(models.Manager): - def create_from_dict(self, dict=None, questionnaire=None, errors=[]): - instance = self.model(questionnaire=questionnaire) + def create_from_dict(self, dict=None, question_group=None, + questionnaire=None, errors=[]): + instance = self.model(questionnaire=questionnaire, + question_group=question_group) + + relevant = None + bind = dict.get('bind') + if bind: + relevant = bind.get('relevant', None) instance.name = dict.get('name') instance.label_xlat = dict.get('label', {}) + instance.type = dict.get('type') + instance.relevant = relevant instance.save() create_children( children=dict.get('children'), errors=errors, project=questionnaire.project, + default_language=questionnaire.default_language, kwargs={ 'questionnaire': questionnaire, 'question_group': instance @@ -306,19 +272,21 @@ def create_from_dict(self, errors=[], **kwargs): instance = self.model(**kwargs) type_dict = {name: code for code, name in instance.TYPE_CHOICES} - instance.type = type_dict[dict.get('type')] - - # try: - # instance.type = type_dict[dict.get('type')] - # except KeyError as e: - # errors.append( - # _('{type} is not an accepted question type').format(type=e) - # ) + relevant = None + required = False + bind = dict.get('bind') + if bind: + relevant = bind.get('relevant', None) + required = True if bind.get('required', 'no') == 'yes' else False + instance.type = type_dict[dict.get('type')] instance.name = dict.get('name') instance.label_xlat = dict.get('label', {}) - instance.required = dict.get('required', False) + instance.required = required instance.constraint = dict.get('constraint') + instance.default = dict.get('default', None) + instance.hint = dict.get('hint', None) + instance.relevant = relevant instance.save() if instance.has_options: diff --git a/cadasta/questionnaires/migrations/0014_api_additional_fields.py b/cadasta/questionnaires/migrations/0014_api_additional_fields.py new file mode 100644 index 000000000..90a6c089c --- /dev/null +++ b/cadasta/questionnaires/migrations/0014_api_additional_fields.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.10 on 2016-11-02 14:59 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('questionnaires', '0013_reprocess_multilingual_forms'), + ] + + operations = [ + migrations.AddField( + model_name='historicalquestion', + name='default', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='historicalquestion', + name='hint', + field=models.CharField(blank=True, max_length=2500, null=True), + ), + migrations.AddField( + model_name='historicalquestion', + name='relevant', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='historicalquestiongroup', + name='question_group', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='questionnaires.QuestionGroup'), + ), + migrations.AddField( + model_name='historicalquestiongroup', + name='relevant', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='historicalquestiongroup', + name='type', + field=models.CharField(default='group', max_length=50), + ), + migrations.AddField( + model_name='question', + name='default', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='question', + name='hint', + field=models.CharField(blank=True, max_length=2500, null=True), + ), + migrations.AddField( + model_name='question', + name='relevant', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='questiongroup', + name='question_group', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='question_groups', to='questionnaires.QuestionGroup'), + ), + migrations.AddField( + model_name='questiongroup', + name='relevant', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='questiongroup', + name='type', + field=models.CharField(default='group', max_length=50), + ), + migrations.AlterField( + model_name='historicalquestionnaire', + name='version', + field=models.BigIntegerField(), + ), + migrations.AlterField( + model_name='questionnaire', + name='version', + field=models.BigIntegerField(), + ), + ] diff --git a/cadasta/questionnaires/models.py b/cadasta/questionnaires/models.py index cfb28cd4e..6873bbde0 100644 --- a/cadasta/questionnaires/models.py +++ b/cadasta/questionnaires/models.py @@ -1,3 +1,5 @@ +import hashlib +from datetime import datetime from buckets.fields import S3FileField from core.models import RandomIDModel from django.db import models @@ -47,7 +49,7 @@ class Questionnaire(RandomIDModel): original_file = models.CharField(max_length=200, null=True) project = models.ForeignKey('organization.Project', related_name='questionnaires') - version = models.BigIntegerField(default=1) + version = models.BigIntegerField() md5_hash = models.CharField(max_length=50, default=False) objects = managers.QuestionnaireManager() @@ -79,12 +81,29 @@ def __repr__(self): ' project={obj.project.slug}>') return repr_string.format(obj=self) + def save(self, *args, **kwargs): + if not self.id and not self.version: + self.version = int( + datetime.utcnow().strftime('%Y%m%d%H%M%S%f')[:-4]) + + if not self.id and not self.md5_hash: + string = (str(self.filename) + str(self.id_string) + + str(self.version)) + self.md5_hash = hashlib.md5(string.encode()).hexdigest() + + return super().save(*args, **kwargs) + class QuestionGroup(MultilingualLabelsMixin, RandomIDModel): name = models.CharField(max_length=100) label_xlat = JSONField(default={}) + relevant = models.CharField(max_length=100, null=True, blank=True) questionnaire = models.ForeignKey(Questionnaire, related_name='question_groups') + question_group = models.ForeignKey('QuestionGroup', + related_name='question_groups', + null=True) + type = models.CharField(max_length=50, default='group') objects = managers.QuestionGroupManager() @@ -129,6 +148,9 @@ class Question(MultilingualLabelsMixin, RandomIDModel): label_xlat = JSONField(default={}) type = models.CharField(max_length=2, choices=TYPE_CHOICES) required = models.BooleanField(default=False) + default = models.CharField(max_length=100, null=True, blank=True) + hint = models.CharField(max_length=2500, null=True, blank=True) + relevant = models.CharField(max_length=100, null=True, blank=True) constraint = models.CharField(max_length=50, null=True, blank=True) questionnaire = models.ForeignKey(Questionnaire, related_name='questions') question_group = models.ForeignKey(QuestionGroup, diff --git a/cadasta/questionnaires/renderer/__init__.py b/cadasta/questionnaires/renderer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cadasta/questionnaires/renderer/xform.py b/cadasta/questionnaires/renderer/xform.py new file mode 100644 index 000000000..ee4701669 --- /dev/null +++ b/cadasta/questionnaires/renderer/xform.py @@ -0,0 +1,99 @@ +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')) + } + 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'] = questions + question_groups + 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 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')) + + return xml diff --git a/cadasta/questionnaires/serializers.py b/cadasta/questionnaires/serializers.py index 677e01409..6ef355f78 100644 --- a/cadasta/questionnaires/serializers.py +++ b/cadasta/questionnaires/serializers.py @@ -1,47 +1,149 @@ from buckets.serializers import S3Field from rest_framework import serializers +from .validators import validate_questionnaire from . import models -class QuestionOptionSerializer(serializers.ModelSerializer): +def create_questions(questions, context): + question_serializer = QuestionSerializer( + data=questions, + many=True, + context=context) + question_serializer.is_valid(raise_exception=True) + question_serializer.save() + + +def create_groups(groups, context): + group_serializer = QuestionGroupSerializer( + data=groups, + many=True, + context=context) + group_serializer.is_valid(raise_exception=True) + group_serializer.save() + + +class FindInitialMixin: + def find_initial_data(self, name): + if isinstance(self.initial_data, list): + for item in self.initial_data: + if item['name'] == name: + return item + + return self.initial_data + + +class QuestionOptionSerializer(FindInitialMixin, serializers.ModelSerializer): + label_xlat = serializers.JSONField(required=False) class Meta: model = models.QuestionOption - fields = ('id', 'name', 'label',) - read_only_fields = ('id', 'name', 'label',) + fields = ('id', 'name', 'label', 'index', 'label_xlat') + read_only_fields = ('id',) + write_only_fields = ('label_xlat', ) + def to_representation(self, instance): + rep = super().to_representation(instance) -class QuestionSerializer(serializers.ModelSerializer): + if isinstance(instance, models.QuestionOption): + rep['label'] = instance.label_xlat + return rep + + def create(self, validated_data): + initial_data = self.find_initial_data(validated_data['name']) + validated_data['question_id'] = self.context.get('question_id') + validated_data['label_xlat'] = initial_data['label'] + return super().create(validated_data) + + +class QuestionSerializer(FindInitialMixin, serializers.ModelSerializer): + label_xlat = serializers.JSONField(required=False) class Meta: model = models.Question - fields = ('id', 'name', 'label', 'type', 'required', 'constraint',) - read_only_fields = ('id', 'name', 'label', 'type', 'required', - 'constraint',) + fields = ('id', 'name', 'label', 'type', 'required', 'constraint', + 'default', 'hint', 'relevant', 'label_xlat', ) + read_only_fields = ('id', ) + write_only_fields = ('label_xlat', ) def to_representation(self, instance): rep = super().to_representation(instance) - if (isinstance(instance, models.Question) and instance.has_options): - serializer = QuestionOptionSerializer(instance.options, many=True) - rep['options'] = serializer.data + if isinstance(instance, models.Question): + rep['label'] = instance.label_xlat + if instance.has_options: + serializer = QuestionOptionSerializer(instance.options, + many=True) + rep['options'] = serializer.data return rep + def create(self, validated_data): + initial_data = self.find_initial_data(validated_data['name']) + validated_data['label_xlat'] = initial_data['label'] + question = models.Question.objects.create( + questionnaire_id=self.context.get('questionnaire_id'), + question_group_id=self.context.get('question_group_id'), + **validated_data) + + option_serializer = QuestionOptionSerializer( + data=initial_data.get('options', []), + many=True, + context={'question_id': question.id}) + + option_serializer.is_valid(raise_exception=True) + option_serializer.save() + + return question + -class QuestionGroupSerializer(serializers.ModelSerializer): +class QuestionGroupSerializer(FindInitialMixin, serializers.ModelSerializer): questions = QuestionSerializer(many=True, read_only=True) + question_groups = serializers.SerializerMethodField() + label_xlat = serializers.JSONField(required=False) class Meta: model = models.QuestionGroup - fields = ('id', 'name', 'label', 'questions',) - read_only_fields = ('id', 'name', 'label', 'questions',) + fields = ('id', 'name', 'label', 'type', 'questions', + 'question_groups', 'label_xlat', 'relevant', ) + read_only_fields = ('id', 'questions', 'question_groups', ) + write_only_fields = ('label_xlat', ) + + def to_representation(self, instance): + rep = super().to_representation(instance) + + if isinstance(instance, models.QuestionGroup): + rep['label'] = instance.label_xlat + return rep + + def get_question_groups(self, group): + serializer = QuestionGroupSerializer( + group.question_groups.all(), + many=True, + ) + return serializer.data + + def create(self, validated_data): + initial_data = self.find_initial_data(validated_data['name']) + validated_data['label_xlat'] = initial_data['label'] + group = models.QuestionGroup.objects.create( + questionnaire_id=self.context.get('questionnaire_id'), + question_group_id=self.context.get('question_group_id'), + **validated_data) + + context = { + 'question_group_id': group.id, + 'questionnaire_id': self.context.get('questionnaire_id') + } + + create_groups(initial_data.get('question_groups', []), context) + create_questions(initial_data.get('questions', []), context) + + return group class QuestionnaireSerializer(serializers.ModelSerializer): - xls_form = S3Field() - xml_form = S3Field(required=False) + xls_form = S3Field(required=False) id_string = serializers.CharField( max_length=50, required=False, default='' ) @@ -52,22 +154,58 @@ class QuestionnaireSerializer(serializers.ModelSerializer): class Meta: model = models.Questionnaire fields = ( - 'id', 'filename', 'title', 'id_string', 'xls_form', 'xml_form', - 'version', 'questions', 'question_groups', + 'id', 'filename', 'title', 'id_string', 'xls_form', + 'version', 'questions', 'question_groups', 'md5_hash' ) read_only_fields = ( 'id', 'filename', 'title', 'id_string', 'version', 'questions', 'question_groups' ) + def validate_json(self, json, raise_exception=False): + errors = validate_questionnaire(json) + self._validated_data = json + self._errors = {} + + if errors: + self._validated_data = {} + self._errors = errors + + if raise_exception: + raise serializers.ValidationError(self.errors) + + return not bool(self._errors) + + def is_valid(self, **kwargs): + if 'xls_form' in self.initial_data: + return super().is_valid(**kwargs) + else: + return self.validate_json(self.initial_data, **kwargs) + def create(self, validated_data): project = self.context['project'] - form = validated_data['xls_form'] - instance = models.Questionnaire.objects.create_from_form( - xls_form=form, - project=project - ) - return instance + if 'xls_form' in validated_data: + form = validated_data['xls_form'] + instance = models.Questionnaire.objects.create_from_form( + xls_form=form, + project=project + ) + return instance + else: + questions = validated_data.pop('questions', []) + question_groups = validated_data.pop('question_groups', []) + instance = models.Questionnaire.objects.create( + project=project, + **validated_data) + + context = {'questionnaire_id': instance.id} + create_questions(questions, context) + create_groups(question_groups, context) + + project.current_questionnaire = instance.id + project.save() + + return instance def get_questions(self, instance): questions = instance.questions.filter(question_group__isnull=True) diff --git a/cadasta/questionnaires/tests/factories.py b/cadasta/questionnaires/tests/factories.py index c4b1bb04b..dbc04d386 100644 --- a/cadasta/questionnaires/tests/factories.py +++ b/cadasta/questionnaires/tests/factories.py @@ -13,13 +13,14 @@ class Meta: filename = factory.Sequence(lambda n: "questionnaire_%s" % n) title = factory.Sequence(lambda n: "Questionnaire #%s" % n) - id_string = factory.Sequence(lambda n: "q_id_%s" % n) + id_string = factory.Sequence(lambda n: "questionnaire_%s" % n) xls_form = 'http://example.com/test.txt' xml_form = 'http://example.com/test.txt' version = int(datetime.utcnow().strftime('%Y%m%d%H%M%S%f')[:-4]) md5_hash = hashlib.md5((str(filename) + str(id_string) + str(version) ).encode()).hexdigest() project = factory.SubFactory(ProjectFactory) + default_language = 'en' @factory.post_generation def add_current_questionnaire(self, create, extracted, **kwargs): diff --git a/cadasta/questionnaires/tests/test_managers.py b/cadasta/questionnaires/tests/test_managers.py index 527cb3581..6e0aff1f6 100644 --- a/cadasta/questionnaires/tests/test_managers.py +++ b/cadasta/questionnaires/tests/test_managers.py @@ -1,5 +1,4 @@ import pytest -from lxml import etree from django.db import IntegrityError from django.test import TestCase @@ -93,12 +92,12 @@ def test_create_children_with_repeat_group(self): create_children(children, kwargs={'questionnaire': questionnaire}) assert models.QuestionGroup.objects.filter( - questionnaire=questionnaire).count() == 1 + questionnaire=questionnaire).count() == 2 assert models.Question.objects.filter( questionnaire=questionnaire).count() == 3 assert models.Question.objects.filter( questionnaire=questionnaire, - question_group__isnull=False).count() == 1 + question_group__isnull=False).count() == 2 class CreateOptionsTest(TestCase): @@ -175,27 +174,6 @@ def test_create_from_invald_form(self): assert models.QuestionGroup.objects.exists() is False assert models.Question.objects.exists() is False - def test_insert_version_attr(self): - xform = self.get_file( - '/questionnaires/tests/files/ekcjvf464y5afks6b33qkct3.xml', - 'r') - id_string = 'jurassic_park_survey' - version = '2016072518593012' - filename = 'ekcjvf464y5afks6b33qkct3' - xml = models.Questionnaire.objects.insert_version_attribute( - xform, filename, version - ) - root = etree.fromstring(xml) - ns = {'xf': 'http://www.w3.org/2002/xforms'} - root_node = root.find( - './/xf:instance/xf:{root_node}'.format( - root_node=filename - ), namespaces=ns - ) - assert root_node is not None - assert root_node.get('id') == id_string - assert root_node.get('version') == version - def test_unique_together_idstring_version(self): project = ProjectFactory.create() q1 = factories.QuestionnaireFactory.create( @@ -217,7 +195,8 @@ def test_create_from_dict(self): question_group_dict = { 'label': 'Basic Select question types', 'name': 'select_questions', - 'type': 'group' + 'type': 'group', + 'bind': {'relevant': "${party_type}='IN'"} } questionnaire = factories.QuestionnaireFactory.create() @@ -228,6 +207,33 @@ def test_create_from_dict(self): assert model.questionnaire == questionnaire assert model.label == question_group_dict['label'] assert model.name == question_group_dict['name'] + assert model.type == 'group' + assert model.relevant == question_group_dict['bind']['relevant'] + assert model.question_groups.count() == 0 + + def test_create_nested_group_from_dict(self): + question_group_dict = { + 'label': 'Repeat', + 'name': 'repeat_me', + 'type': 'repeat', + 'children': [{ + 'label': 'Basic Select question types', + 'name': 'select_questions', + 'type': 'group' + }] + } + questionnaire = factories.QuestionnaireFactory.create() + + model = models.QuestionGroup.objects.create_from_dict( + dict=question_group_dict, + questionnaire=questionnaire + ) + assert model.questionnaire == questionnaire + assert model.label == question_group_dict['label'] + assert model.name == question_group_dict['name'] + assert model.type == 'repeat' + assert model.question_groups.count() == 1 + assert questionnaire.question_groups.count() == 2 class QuestionManagerTest(TestCase): @@ -237,7 +243,13 @@ def test_create_from_dict(self): 'hint': 'For this field (type=integer)', 'label': 'Integer', 'name': 'my_int', - 'type': 'integer' + 'type': 'integer', + 'default': 'default val', + 'hint': 'An informative hint', + 'bind': { + 'relevant': '${party_id}="abc123"', + 'required': 'yes' + } } questionnaire = factories.QuestionnaireFactory.create() @@ -251,6 +263,10 @@ def test_create_from_dict(self): assert model.label == question_dict['label'] assert model.name == question_dict['name'] assert model.type == 'IN' + assert model.default == 'default val' + assert model.hint == 'An informative hint' + assert model.relevant == '${party_id}="abc123"' + assert model.required is True def test_create_from_dict_with_group(self): question_dict = { diff --git a/cadasta/questionnaires/tests/test_models.py b/cadasta/questionnaires/tests/test_models.py index a706ce2b1..6d54615ab 100644 --- a/cadasta/questionnaires/tests/test_models.py +++ b/cadasta/questionnaires/tests/test_models.py @@ -12,6 +12,24 @@ def test_repr(self): assert repr(questionnaire) == ('') + def test_save(self): + questionnaire = factories.QuestionnaireFactory.build() + questionnaire.version = None + questionnaire.md5_hash = None + + questionnaire.save() + assert questionnaire.version is not None + assert questionnaire.md5_hash is not None + + questionnaire = factories.QuestionnaireFactory.build( + version=129839021903, + md5_hash='sakohjd89su90us9a0jd90sau90d' + ) + + questionnaire.save() + assert questionnaire.version == 129839021903 + assert questionnaire.md5_hash == 'sakohjd89su90us9a0jd90sau90d' + class QuestionGroupTest(TestCase): def test_repr(self): diff --git a/cadasta/questionnaires/tests/test_renderer.py b/cadasta/questionnaires/tests/test_renderer.py new file mode 100644 index 000000000..0c6c48de2 --- /dev/null +++ b/cadasta/questionnaires/tests/test_renderer.py @@ -0,0 +1,179 @@ +from django.test import TestCase +from ..renderer.xform import XFormRenderer + + +class XFormRendererTest(TestCase): + def test_transform_questions(self): + questions = [{ + 'id': "xp8vjr6dsk46p47u22fft7bg", + 'name': "tenure_type", + 'label': "What is the social tenure type?", + 'type': "S1", + 'required': True, + 'constraint': None, + 'default': 'some default', + 'hint': 'An informative hint', + 'relevant': '${party_id}="abc123"', + 'options': [{ + 'id': "d9pkepyjg4sgaepdytgwkgfv", + 'name': "WR", + 'label': "Water Rights" + }, { + 'id': "vz9533y64f2rns6v8frssc5v", + 'name': "UC", + 'label': "Undivided Co-ownership" + }] + }, { + 'id': "bzs2984c3gxgwcjhvambdt3w", + 'name': "start", + 'label': None, + 'type': "ST", + 'required': False, + 'constraint': None + }] + renderer = XFormRenderer() + transformed = renderer.transform_questions(questions) + assert len(transformed) == 2 + for q in transformed: + if q['name'] == 'start': + assert q['type'] == 'start' + assert 'label' not in q + elif q['name'] == 'tenure_type': + assert q['type'] == 'select one' + assert q['choices'] == questions[0]['options'] + assert q['bind']['required'] == 'yes' + assert q['bind']['relevant'] == '${party_id}="abc123"' + assert q['default'] == 'some default' + assert q['hint'] == 'An informative hint' + + def test_transform_groups(self): + groups = [{ + 'id': '123', + 'name': 'group_1', + 'label': 'Group 2', + 'relevant': "${party_type}='IN'", + 'questions': [{ + 'id': "bzs2984c3gxgwcjhvambdt3w", + 'name': "start", + 'label': None, + 'type': "ST", + }] + }, { + 'id': '456', + 'name': 'group_2', + 'label': None, + 'questions': [{ + 'id': "xp8vjr6dsk46p47u22fft7bg", + 'name': "tenure_type", + 'label': "What is the social tenure type?", + 'type': "TX", + }] + }] + renderer = XFormRenderer() + transformed = renderer.transform_groups(groups) + assert len(transformed) == 2 + for g in transformed: + assert g['type'] == 'group' + assert len(g['children']) == 1 + if g['name'] == 'group_1': + assert g['bind']['relevant'] == "${party_type}='IN'" + if g['name'] == 'group_2': + assert 'label' not in g + + def test_transform_to_xform_json(self): + data = { + 'id_string': 'abc123', + 'version': '1234567890', + 'filename': 'abc123', + 'questions': [ + { + 'id': "xp8vjr6dsk46p47u22fft7bg", + 'name': "tenure_type", + 'label': "What is the social tenure type?", + 'type': "S1", + 'required': False, + 'constraint': None, + 'options': [ + { + 'id': "d9pkepyjg4sgaepdytgwkgfv", + 'name': "WR", + 'label': "Water Rights" + }, + { + 'id': "vz9533y64f2rns6v8frssc5v", + 'name': "UC", + 'label': "Undivided Co-ownership" + }, + ] + } + ], + 'question_groups': [{ + 'id': '123', + 'name': 'group_1', + 'label': 'Group 2', + 'questions': [{ + 'id': "bzs2984c3gxgwcjhvambdt3w", + 'name': "start", + 'label': None, + 'type': "ST", + }] + }, { + 'id': '456', + 'name': 'group_2', + 'label': 'Group 2', + 'questions': [{ + 'id': "xp8vjr6dsk46p47u22fft7bg", + 'name': "party_type", + 'label': "What is the party type?", + 'type': "TX", + }] + }] + } + renderer = XFormRenderer() + transformed = renderer.transform_to_xform_json(data) + assert transformed['name'] == 'abc123' + assert transformed['sms_keyword'] == 'abc123' + assert transformed['id_string'] == 'abc123' + assert transformed['title'] == 'abc123' + assert len(transformed['children']) == 3 + + def test_render(self): + data = { + 'id_string': 'abc123', + 'version': '1234567890', + 'filename': 'abc123', + 'questions': [ + { + 'id': "xp8vjr6dsk46p47u22fft7bg", + 'name': "tenure_type", + 'label': "What is the social tenure type?", + 'type': "S1", + 'required': False, + 'constraint': None, + 'options': [ + { + 'id': "d9pkepyjg4sgaepdytgwkgfv", + 'name': "WR", + 'label': "Water Rights" + }, + { + 'id': "vz9533y64f2rns6v8frssc5v", + 'name': "UC", + 'label': "Undivided Co-ownership" + }, + ] + }, + { + 'id': "bzs2984c3gxgwcjhvambdt3w", + 'name': "start", + 'label': None, + 'type': "ST", + 'required': False, + 'constraint': None + } + ] + } + renderer = XFormRenderer() + xml = renderer.render(data).decode() + assert 'abc123' in xml + assert '' in xml diff --git a/cadasta/questionnaires/tests/test_serializers.py b/cadasta/questionnaires/tests/test_serializers.py index 2bd3f9484..3860efa6a 100644 --- a/cadasta/questionnaires/tests/test_serializers.py +++ b/cadasta/questionnaires/tests/test_serializers.py @@ -1,5 +1,6 @@ import pytest from django.test import TestCase +from rest_framework.serializers import ValidationError from organization.tests.factories import ProjectFactory from questionnaires.exceptions import InvalidXLSForm @@ -8,7 +9,7 @@ from . import factories from .. import serializers -from ..models import Questionnaire +from ..models import Questionnaire, QuestionOption @pytest.mark.usefixtures('make_dirs') @@ -37,7 +38,6 @@ def test_deserialize(self): assert serializer.data['title'] == questionnaire.title assert serializer.data['id_string'] == questionnaire.id_string assert serializer.data['xls_form'] == questionnaire.xls_form.url - assert serializer.data['xml_form'] == questionnaire.xml_form.url assert serializer.data['version'] == questionnaire.version assert len(serializer.data['questions']) == 1 @@ -55,6 +55,62 @@ def test_deserialize_invalid_form(self): serializer.save() assert Questionnaire.objects.count() == 0 + def test_deserialize_json(self): + data = { + 'title': 'yx8sqx6488wbc4yysnkrbnfq', + 'id_string': 'yx8sqx6488wbc4yysnkrbnfq', + 'questions': [{ + 'name': "start", + 'label': 'Label', + 'type': "ST", + 'required': False, + 'constraint': None + }, { + 'name': "end", + 'label': 'Label', + 'type': "EN", + }] + } + project = ProjectFactory.create() + + serializer = serializers.QuestionnaireSerializer( + data=data, + context={'project': project} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + assert Questionnaire.objects.count() == 1 + questionnaire = Questionnaire.objects.first() + assert project.current_questionnaire == questionnaire.id + assert questionnaire.questions.count() == 2 + + def test_invalid_deserialize_json(self): + data = { + 'id_string': 'yx8sqx6488wbc4yysnkrbnfq', + 'questions': [{ + 'name': "start", + 'label': 'Label', + 'type': "ST", + 'required': False, + 'constraint': None + }, { + 'name': "end", + 'label': 'Label', + 'type': "EN", + }] + } + project = ProjectFactory.create() + + serializer = serializers.QuestionnaireSerializer( + data=data, + context={'project': project} + ) + assert serializer.is_valid() is False + assert serializer.errors == {'title': ['This field is required.']} + + with pytest.raises(ValidationError): + assert serializer.is_valid(raise_exception=True) + def test_serialize(self): questionnaire = factories.QuestionnaireFactory() serializer = serializers.QuestionnaireSerializer(questionnaire) @@ -64,10 +120,694 @@ def test_serialize(self): assert serializer.data['title'] == questionnaire.title assert serializer.data['id_string'] == questionnaire.id_string assert serializer.data['xls_form'] == questionnaire.xls_form.url - assert serializer.data['xml_form'] == questionnaire.xml_form.url assert serializer.data['version'] == questionnaire.version assert 'project' not in serializer.data + def test_huge(self): + data = { + "filename": "wa6hrqr4e4vcf49q6kxjc443", + "title": "wa6hrqr4e4vcf49q6kxjc443", + "id_string": "wa6hrqr4e4vcf49q6kxjc443", + "questions": [ + { + "id": "f44zrz6ch4mj8xcvhb55343c", + "name": "start", + "label": "Label", + "type": "ST", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + }, + { + "id": "uigb9zd9zgmhjewvaf92awru", + "name": "end", + "label": "Label", + "type": "EN", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + }, + { + "id": "rw7mt32858cu2w5urbf9z3a4", + "name": "today", + "label": "Label", + "type": "TD", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + }, + { + "id": "sgz4peaw5buq7pyjv87fuv6u", + "name": "deviceid", + "label": "Label", + "type": "DI", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + }, + { + "id": "h9r973qgscumzh6emkx9jnba", + "name": "title", + "label": "Cadasta Platform - UAT Survey", + "type": "NO", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + }, + { + "id": "hq5tvivqwnqttaiudk7zz2fu", + "name": "party_type", + "label": "Party Classification", + "type": "S1", + "required": True, + "constraint": None, + "default": None, + "hint": None, + "relevant": None, + "options": [ + { + "id": "tr8gc2xanrqzq3idfrysk2cn", + "name": "GR", + "label": "Group", + "index": 1 + }, + { + "id": "h5px4fgbzh92bx7mbxjcqwpp", + "name": "IN", + "label": "Individual", + "index": 2 + }, + { + "id": "ddhfbhex77fdmgdnqbuqytvy", + "name": "CO", + "label": "Corporation", + "index": 3 + } + ] + }, + { + "id": "7snc5dvma8a42xzkhnsgjpvq", + "name": "party_name", + "label": "Party Name", + "type": "TX", + "required": True, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + }, + { + "id": "w4eb66p8c2ctshdkachc26zd", + "name": "location_geometry", + "label": "Location of Parcel", + "type": "GT", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + }, + { + "id": "vks2dqjktf2t2in76jv38rdj", + "name": "location_type", + "label": "What is the land feature?", + "type": "S1", + "required": True, + "constraint": None, + "default": None, + "hint": None, + "relevant": None, + "options": [ + { + "id": "xq9eumfmxe2h3mk4cibx6az2", + "name": "PA", + "label": "Parcel", + "index": 1 + }, + { + "id": "39wpggcaw8bhknj6uden4y52", + "name": "CB", + "label": "Community Boundary", + "index": 2 + }, + { + "id": "vtag4awngt8qqkfuy6r2tm55", + "name": "BU", + "label": "Building", + "index": 3 + }, + { + "id": "8s59tvh4qzvuphwuq7h63thf", + "name": "AP", + "label": "Apartment", + "index": 4 + }, + { + "id": "5rvig9t4mpbzgkuwtiegzf82", + "name": "PX", + "label": "Project Extent", + "index": 5 + }, + { + "id": "srzjrbmfwqk7s45mh83mj6tf", + "name": "RW", + "label": "Right-of-way", + "index": 6 + }, + { + "id": "39afz6h95zs4mn8pup85gzsb", + "name": "NP", + "label": "National Park Boundary", + "index": 7 + }, + { + "id": "bpnahr4a57yqvrzn4kjeygei", + "name": "MI", + "label": "Miscellaneous", + "index": 8 + } + ] + }, + { + "id": "kzen2egxny4jhrcwcngdxuku", + "name": "location_photo", + "label": "Photo of Parcel?", + "type": "PH", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + }, + { + "id": "v4ydy2ihvd2xfdhiqhwhgfed", + "name": "party_photo", + "label": "Photo of Party?", + "type": "PH", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + }, + { + "id": "x5x4ts8ujbhpkk92s53icrx7", + "name": "tenure_resource_photo", + "label": "Photo of Tenure?", + "type": "PH", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + }, + { + "id": "merrppduxyk6y3fja74pym7p", + "name": "tenure_type", + "label": "What is the social tenure type?", + "type": "S1", + "required": True, + "constraint": None, + "default": None, + "hint": None, + "relevant": None, + "options": [ + { + "id": "ua22mni8hszcxjjr6brnw25k", + "name": "AL", + "label": "All Types", + "index": 1 + }, + { + "id": "tff64xg9mvrhq9gsgzqvt28i", + "name": "CR", + "label": "Carbon Rights", + "index": 2 + }, + { + "id": "w8u9a2szv5enwsj6d7rx8cgf", + "name": "CO", + "label": "Concessionary Rights", + "index": 3 + }, + { + "id": "qjjzerwc64mhfxnq2izuh6tc", + "name": "CU", + "label": "Customary Rights", + "index": 4 + }, + { + "id": "5igsk5i2ratwcnxsfqjv6k9r", + "name": "EA", + "label": "Easement", + "index": 5 + }, + { + "id": "q5pt35uw3cngnqb9hn5arzuv", + "name": "ES", + "label": "Equitable Servitude", + "index": 6 + }, + { + "id": "ugisaw4bzxvtibubuaqv7agw", + "name": "FH", + "label": "Freehold", + "index": 7 + }, + { + "id": "jmz9uk96d3nxjm9m9bhmi3uk", + "name": "GR", + "label": "Grazing Rights", + "index": 8 + }, + { + "id": "63784su22n8g5mapuxtk22m2", + "name": "HR", + "label": "Hunting/Fishing/Harvest Rights", + "index": 9 + }, + { + "id": "g3qneyqttiyfp8n2e5pmyu4f", + "name": "IN", + "label": "Indigenous Land Rights", + "index": 10 + }, + { + "id": "7gentkaf9ns2x5xmcx29y8h2", + "name": "JT", + "label": "Joint Tenancy", + "index": 11 + }, + { + "id": "xtuzg83nscxkugxb6tmtc3hi", + "name": "LH", + "label": "Leasehold", + "index": 12 + }, + { + "id": "gd8ev83ha4y8v7sin5umrcc8", + "name": "LL", + "label": "Longterm leasehold", + "index": 13 + }, + { + "id": "qfs3kuajvadp5kwsuwayfb7m", + "name": "MR", + "label": "Mineral Rights", + "index": 14 + }, + { + "id": "nev97vg7t3uqrxhvcj7pkhws", + "name": "OC", + "label": "Occupancy (No Documented Rights)", + "index": 15 + }, + { + "id": "vjuz3hygccugyqmiijrkxygd", + "name": "TN", + "label": "Tenancy (Documented Sub-lease)", + "index": 16 + }, + { + "id": "qpkgwr6pxg3v57ibc5q39hgy", + "name": "TC", + "label": "Tenancy in Common", + "index": 17 + }, + { + "id": "kcurf4gv86rn9c3m24wzvwma", + "name": "UC", + "label": "Undivided Co-ownership", + "index": 18 + }, + { + "id": "36x2m7j94gc3j6pc6rvz7cuu", + "name": "WR", + "label": "Water Rights", + "index": 19 + } + ] + } + ], + "question_groups": [ + { + "id": "xnbj4kdr66w8k478f6cdta3n", + "name": "meta", + "label": "Label", + "type": 'group', + "questions": [ + { + "id": "8v5znbuyvtyinsdd96ytyrui", + "name": "instanceID", + "label": "Label", + "type": "CA", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + } + ] + }, + { + "id": "9z6k336q7t8ws6qvf39akvym", + "name": "tenure_relationship_attributes", + "label": "Tenure relationship attributes", + "type": 'group', + "questions": [ + { + "id": "mprrfpk5cyg69f9tr7jvv742", + "name": "notes", + "label": "Notes", + "type": "TX", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + } + ] + }, + { + "id": "48gdce8vrqfsw6ikpr77p5u5", + "name": "party_relationship_attributes", + "label": "Party relationship attributes", + "type": 'group', + "questions": [ + { + "id": "njemw8e6n2squqghiqgx8a7b", + "name": "notes", + "label": "Notes", + "type": "TX", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + } + ] + }, + { + "id": "n2eg65xw5mtby8peae38mcb6", + "name": "party_attributes_group", + "label": "Group Party Attributes", + "type": 'group', + "questions": [ + { + "id": "xta42t6ye53ujetniebknytw", + "name": "number_of_members", + "label": "Number of Members", + "type": "IN", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + }, + { + "id": "jzy45gy539k7yfffz8h4vs7g", + "name": "date_formed", + "label": "Date Group Formed", + "type": "DA", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + } + ] + }, + { + "id": "fwfbg2tc4ffbw4qnnmb46bzz", + "name": "party_attributes_individual", + "label": "Individual Party Attributes", + "type": 'group', + "questions": [ + { + "id": "ph3xrdtxkcwacqavg8k8rj3v", + "name": "gender", + "label": "Gender", + "type": "S1", + "required": False, + "constraint": None, + "default": "f", + "hint": None, + "relevant": None, + "options": [ + { + "id": "d465rbsz27bdvk9qtsrpivva", + "name": "m", + "label": "Male", + "index": 1 + }, + { + "id": "trhdqh9jzszw75ybtgsiej7y", + "name": "f", + "label": "Female", + "index": 2 + } + ] + }, + { + "id": "jwugjuavpc9teep64bzk97s8", + "name": "homeowner", + "label": "Homeowner", + "type": "S1", + "required": False, + "constraint": None, + "default": "no", + "hint": "Is homeowner", + "relevant": None, + "options": [ + { + "id": "i9mveb9cyiq5e2iszadgvx3p", + "name": "yes", + "label": "Yes", + "index": 1 + }, + { + "id": "ubkvrbdfc4zavgkg7my4stbc", + "name": "no", + "label": "No", + "index": 2 + } + ] + }, + { + "id": "br2uuzpfty43dp92z5wj4dyk", + "name": "dob", + "label": "Date of Birth", + "type": "DA", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + } + ] + }, + { + "id": "7678k4ixg2idpptda46fh5f8", + "name": "party_attributes_default", + "label": "Default Party Attributes", + "type": 'group', + "questions": [ + { + "id": "kw8t69w57aaw2jbuy72ky8bs", + "name": "notes", + "label": "Notes", + "type": "TX", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + } + ] + }, + { + "id": "iapqx3jrcx8vmzp73r465d3a", + "name": "location_attributes", + "label": "Location Attributes", + "type": 'group', + "questions": [ + { + "id": "bj7z2hz3jnz8c76pcwks4v2y", + "name": "name", + "label": "Name of Location", + "type": "TX", + "required": False, + "constraint": None, + "default": None, + "hint": None, + "relevant": None + }, + { + "id": "36s3imu4h47cps2m64ddcf8h", + "name": "quality", + "label": "Spatial Unit Quality", + "type": "S1", + "required": False, + "constraint": None, + "default": "none", + "hint": "Quality of parcel geometry", + "relevant": None, + "options": [ + { + "id": "sa8wd8hx8ipz9r8v6qznh6d9", + "name": "none", + "label": "No data", + "index": 1 + }, + { + "id": "525szguxtzqnamg8zh9dcw3m", + "name": "text", + "label": "Textual", + "index": 2 + }, + { + "id": "fhjrs9igzvp8fbqgbh76hq8i", + "name": "point", + "label": "Point data", + "index": 3 + }, + { + "id": "rea3pvsrmzwrifbu4b6meekw", + "name": "polygon_low", + "label": "Low quality polygon", + "index": 4 + }, + { + "id": "yfc9qwrgkfcv5kabq83iyz2r", + "name": "polygon_high", + "label": "High quality polygon", + "index": 5 + } + ] + }, + { + "id": "mnstx4zzfz4dxyjyxhuqskam", + "name": "acquired_how", + "label": "How was this location acquired?", + "type": "S1", + "required": False, + "constraint": None, + "default": "OT", + "hint": None, + "relevant": None, + "options": [ + { + "id": "efcuuw578hu2d922sgwvwvmg", + "name": "CS", + "label": "Contractual Share Crop", + "index": 1 + }, + { + "id": "rv7xrxq5tsfhpqyesbsju5xr", + "name": "CA", + "label": "Customary Arrangement", + "index": 2 + }, + { + "id": "qatxiebhrf27544i2cp4cs92", + "name": "GF", + "label": "Gift", + "index": 3 + }, + { + "id": "7hpqfrnyujjt54kca7u5bn4h", + "name": "HS", + "label": "Homestead", + "index": 4 + }, + { + "id": "fm64jhr5ywgvadi96t9pmb3x", + "name": "IO", + "label": "Informal Occupant", + "index": 5 + }, + { + "id": "ec3jvzg9fri9i6pz953y4vzx", + "name": "IN", + "label": "Inheritance", + "index": 6 + }, + { + "id": "4emhecd6zyneykp9xhfcbft9", + "name": "LH", + "label": "Leasehold", + "index": 7 + }, + { + "id": "725eq8p9z6axvi9nxr54dk9h", + "name": "PF", + "label": "Purchased Freehold", + "index": 8 + }, + { + "id": "sfpdbu7p7qz84ph9hpkaatc5", + "name": "RN", + "label": "Rental", + "index": 9 + }, + { + "id": "txay4h5ps2gt76xkb3higwme", + "name": "OT", + "label": "Other", + "index": 10 + } + ] + }, + { + "id": "rzyfu9k84avfuvvh9afr87ry", + "name": "acquired_when", + "label": "When was this location acquired?", + "type": "DA", + "required": False, + "constraint": None, + "default": "none", + "hint": None, + "relevant": None + }, + { + "id": "ppckgucjmvt8xtg8tykifvm6", + "name": "notes", + "label": "Notes", + "type": "TX", + "required": False, + "constraint": None, + "default": None, + "hint": "Additional Notes", + "relevant": None + } + ] + } + ] + } + project = ProjectFactory.create() + + serializer = serializers.QuestionnaireSerializer( + data=data, + context={'project': project} + ) + assert serializer.is_valid(raise_exception=True) is True + serializer.save() + + assert Questionnaire.objects.count() == 1 + questionnaire = Questionnaire.objects.first() + assert questionnaire.questions.count() == 27 + assert questionnaire.question_groups.count() == 7 + class QuestionGroupSerializerTest(TestCase): def test_serialize(self): @@ -82,20 +822,137 @@ def test_serialize(self): not_in = factories.QuestionFactory.create( questionnaire=questionnaire ) - question_group.refresh_from_db() + factories.QuestionGroupFactory.create_batch( + 2, + questionnaire=questionnaire, + question_group=question_group + ) + question_group.refresh_from_db() serializer = serializers.QuestionGroupSerializer(question_group) assert serializer.data['id'] == question_group.id assert serializer.data['name'] == question_group.name assert serializer.data['label'] == question_group.label + assert serializer.data['type'] == question_group.type assert len(serializer.data['questions']) == 2 + assert len(serializer.data['question_groups']) == 2 + assert all(g['name'] for g in serializer.data['question_groups']) assert not_in.id not in [q['id'] for q in serializer.data['questions']] assert 'questionnaire' not in serializer.data + def test_serialize_mulitple_lang(self): + questionnaire = factories.QuestionnaireFactory() + question_group = factories.QuestionGroupFactory.create( + questionnaire=questionnaire, + label={'en': 'Group', 'de': 'Gruppe'}) + factories.QuestionFactory.create_batch( + 2, + questionnaire=questionnaire, + question_group=question_group + ) + not_in = factories.QuestionFactory.create( + questionnaire=questionnaire + ) + factories.QuestionGroupFactory.create_batch( + 2, + questionnaire=questionnaire, + question_group=question_group + ) + + question_group.refresh_from_db() + serializer = serializers.QuestionGroupSerializer(question_group) + assert serializer.data['id'] == question_group.id + assert serializer.data['name'] == question_group.name + assert serializer.data['label'] == {'en': 'Group', 'de': 'Gruppe'} + assert serializer.data['type'] == question_group.type + assert len(serializer.data['questions']) == 2 + assert len(serializer.data['question_groups']) == 2 + assert all(g['name'] for g in serializer.data['question_groups']) + assert not_in.id not in [q['id'] for q in serializer.data['questions']] + assert 'questionnaire' not in serializer.data + + def test_create_group(self): + questionnaire = factories.QuestionnaireFactory.create() + data = { + 'label': 'A question', + 'name': 'question' + } + serializer = serializers.QuestionGroupSerializer( + data=data, + context={'questionnaire_id': questionnaire.id}) + serializer.is_valid(raise_exception=True) + serializer.save() + assert questionnaire.question_groups.count() == 1 + question = questionnaire.question_groups.first() + assert question.name == data['name'] + assert question.label == data['label'] + + def test_create_nested_group(self): + questionnaire = factories.QuestionnaireFactory.create() + data = { + 'label': 'A question group', + 'name': 'question_group', + 'question_groups': [{ + 'label': 'Another question group', + 'name': 'group_2', + }] + } + serializer = serializers.QuestionGroupSerializer( + data=data, + context={'questionnaire_id': questionnaire.id}) + serializer.is_valid(raise_exception=True) + serializer.save() + assert questionnaire.question_groups.count() == 2 + question_group = questionnaire.question_groups.get( + name='question_group') + assert question_group.name == data['name'] + assert question_group.label == data['label'] + assert question_group.question_groups.count() == 1 + nested_group = question_group.question_groups.first() + assert nested_group.question_groups.count() == 0 + + def test_bulk_create_group(self): + questionnaire = factories.QuestionnaireFactory.create() + data = [{ + 'label': 'A group', + 'name': 'group', + 'questions': [{ + 'name': "start", + 'label': 'Start', + 'type': "ST", + }] + }, { + 'label': 'Another group', + 'name': 'another_group', + 'questions': [{ + 'name': "end", + 'label': 'End', + 'type': "EN", + }] + }] + serializer = serializers.QuestionGroupSerializer( + data=data, + many=True, + context={'questionnaire_id': questionnaire.id}) + serializer.is_valid(raise_exception=True) + serializer.save() + assert questionnaire.question_groups.count() == 2 + + for group in questionnaire.question_groups.all(): + assert group.questions.count() + if group.name == 'group': + assert group.questions.first().name == 'start' + elif group.name == 'another_group': + assert group.questions.first().name == 'end' + class QuestionSerializerTest(TestCase): def test_serialize(self): - question = factories.QuestionFactory.create() + question = factories.QuestionFactory.create( + default='some default', + hint='An informative hint', + relevant='${party_id}="abc123"' + ) serializer = serializers.QuestionSerializer(question) assert serializer.data['id'] == question.id @@ -104,6 +961,31 @@ def test_serialize(self): assert serializer.data['type'] == question.type assert serializer.data['required'] == question.required assert serializer.data['constraint'] == question.constraint + assert serializer.data['default'] == question.default + assert serializer.data['hint'] == question.hint + assert serializer.data['relevant'] == question.relevant + assert 'options' not in serializer.data + assert 'questionnaire' not in serializer.data + assert 'question_group' not in serializer.data + + def test_serialize_multiple_lang(self): + question = factories.QuestionFactory.create( + default='some default', + hint='An informative hint', + relevant='${party_id}="abc123"', + label={'en': 'Question', 'de': 'Frage'} + ) + serializer = serializers.QuestionSerializer(question) + + assert serializer.data['id'] == question.id + assert serializer.data['name'] == question.name + assert serializer.data['label'] == {'en': 'Question', 'de': 'Frage'} + assert serializer.data['type'] == question.type + assert serializer.data['required'] == question.required + assert serializer.data['constraint'] == question.constraint + assert serializer.data['default'] == question.default + assert serializer.data['hint'] == question.hint + assert serializer.data['relevant'] == question.relevant assert 'options' not in serializer.data assert 'questionnaire' not in serializer.data assert 'question_group' not in serializer.data @@ -123,6 +1005,85 @@ def test_serialize_with_options(self): assert 'questionnaire' not in serializer.data assert 'question_group' not in serializer.data + def test_create_question(self): + questionnaire = factories.QuestionnaireFactory.create() + data = { + 'label': 'A question', + 'name': 'question', + 'type': 'TX' + } + serializer = serializers.QuestionSerializer( + data=data, + context={'questionnaire_id': questionnaire.id}) + serializer.is_valid(raise_exception=True) + serializer.save() + assert questionnaire.questions.count() == 1 + question = questionnaire.questions.first() + assert question.label == data['label'] + assert question.type == data['type'] + assert question.name == data['name'] + + def test_with_options(self): + questionnaire = factories.QuestionnaireFactory.create() + data = { + 'label': 'A question', + 'name': 'question', + 'type': 'S1', + 'options': [{ + 'label': 'An option', + 'name': 'option', + 'index': 0 + }] + } + serializer = serializers.QuestionSerializer( + data=data, + context={'questionnaire_id': questionnaire.id}) + serializer.is_valid(raise_exception=True) + serializer.save() + assert questionnaire.questions.count() == 1 + question = questionnaire.questions.first() + assert question.label == data['label'] + assert question.type == data['type'] + assert question.name == data['name'] + assert QuestionOption.objects.count() == 1 + assert question.options.count() == 1 + + def test_bulk_create(self): + questionnaire = factories.QuestionnaireFactory.create() + data = [{ + 'label': 'A question', + 'name': 'question', + 'type': 'TX' + }, { + 'label': 'Another question', + 'name': 'another_question', + 'type': 'S1', + 'options': [{ + 'label': 'An option', + 'name': 'option', + 'index': 0 + }] + }] + serializer = serializers.QuestionSerializer( + data=data, + many=True, + context={'questionnaire_id': questionnaire.id}) + serializer.is_valid(raise_exception=True) + serializer.save() + + questions = questionnaire.questions.all() + assert questions.count() == 2 + + for q in questions: + if q.name == 'question': + assert q.label == 'A question' + assert q.type == 'TX' + assert q.options.count() == 0 + if q.name == 'another_question': + assert q.label == 'Another question' + assert q.type == 'S1' + assert q.options.count() == 1 + class QuestionOptionSerializerTest(TestCase): def test_serialize(self): @@ -133,3 +1094,53 @@ def test_serialize(self): assert serializer.data['name'] == question_option.name assert serializer.data['label'] == question_option.label assert 'question' not in serializer.data + + def test_serialize_with_multiple_langs(self): + question_option = factories.QuestionOptionFactory.create( + label={'en': 'An option', 'de': 'Eine Option'}) + serializer = serializers.QuestionOptionSerializer(question_option) + + assert serializer.data['id'] == question_option.id + assert serializer.data['name'] == question_option.name + assert serializer.data['label'] == {'en': 'An option', + 'de': 'Eine Option'} + assert 'question' not in serializer.data + + def test_create_option(self): + question = factories.QuestionFactory.create() + data = { + 'name': 'option', + 'label': 'An option', + 'index': 0 + } + serializer = serializers.QuestionOptionSerializer( + data=data, + context={'question_id': question.id}) + serializer.is_valid() + serializer.save() + + assert QuestionOption.objects.count() == 1 + option = QuestionOption.objects.first() + assert option.name == data['name'] + assert option.label == data['label'] + assert option.label_xlat == data['label'] + assert option.question_id == question.id + + def test_bulk_create(self): + question = factories.QuestionFactory.create() + data = [{ + 'name': 'option', + 'label': 'An option', + 'index': 0 + }, { + 'name': 'option_2', + 'label': 'Another', + 'index': 1 + }] + serializer = serializers.QuestionOptionSerializer( + data=data, + many=True, + context={'question_id': question.id}) + serializer.is_valid() + serializer.save() + assert question.options.count() == 2 diff --git a/cadasta/questionnaires/tests/test_validators.py b/cadasta/questionnaires/tests/test_validators.py new file mode 100644 index 000000000..d13df3879 --- /dev/null +++ b/cadasta/questionnaires/tests/test_validators.py @@ -0,0 +1,226 @@ +from django.test import TestCase +from .. import validators + + +class ValidateTypeTest(TestCase): + def test_validate_string(self): + assert validators.validate_type('string', 'akshdk') is True + assert validators.validate_type('string', 1) is False + assert validators.validate_type('string', True) is False + assert validators.validate_type('string', []) is False + + def test_validate_number(self): + assert validators.validate_type('number', 'akshdk') is False + assert validators.validate_type('number', 1) is True + assert validators.validate_type('number', True) is False + assert validators.validate_type('number', []) is False + + def test_validate_boolean(self): + assert validators.validate_type('boolean', 'akshdk') is False + assert validators.validate_type('boolean', 1) is False + assert validators.validate_type('boolean', True) is True + assert validators.validate_type('boolean', []) is False + + def test_validate_array(self): + assert validators.validate_type('array', 'akshdk') is False + assert validators.validate_type('array', 1) is False + assert validators.validate_type('array', True) is False + assert validators.validate_type('array', []) is True + + +class ValidateSchemaTest(TestCase): + SCHEMA = { + 'title': { + 'type': 'string', + 'required': True + }, + 'id_string': { + 'type': 'string', + 'required': True + }, + 'some_list': { + 'type': 'string', + 'enum': ['A', 'B', 'C'] + } + } + + def test_valid_schema(self): + data = { + 'title': 'Title', + 'id_string': 'askljdaskljdl', + 'some_list': 'A' + } + assert validators.validate_schema(self.SCHEMA, data) == {} + + def test_invalid_schema(self): + data = { + 'id_string': 123, + 'some_list': 'D' + } + errors = validators.validate_schema(self.SCHEMA, data) + assert 'This field is required.' in errors['title'] + assert 'Value must be of type string.' in errors['id_string'] + assert 'D is not an accepted value.' in errors['some_list'] + + +class QuestionnaireTestCase(TestCase): + def test_valid_questionnaire(self): + data = { + 'title': 'yx8sqx6488wbc4yysnkrbnfq', + 'id_string': 'yx8sqx6488wbc4yysnkrbnfq', + 'questions': [{ + 'name': "start", + 'label': None, + 'type': "ST", + 'required': False, + 'constraint': None + }] + } + errors = validators.validate_questionnaire(data) + assert errors is None + + def test_invalid_questionnaire(self): + data = { + 'questions': [{ + 'label': None, + 'type': "ST", + 'required': False, + 'constraint': None + }], + 'question_groups': [{ + 'label': 'A group' + }] + } + errors = validators.validate_questionnaire(data) + assert 'This field is required.' in errors['title'] + assert 'This field is required.' in errors['id_string'] + assert 'This field is required.' in errors['questions'][0]['name'] + assert ('This field is required.' in + errors['question_groups'][0]['name']) + + +class QuestionGroupTestCase(TestCase): + def test_valid_questiongroup(self): + data = [{ + 'name': "location_attributes", + 'label': "Location Attributes", + 'type': 'group', + 'questions': [{ + 'name': "start", + 'label': 'Start', + 'type': "ST", + }] + }] + errors = validators.validate_question_groups(data) + assert errors == [{}] + + def test_invalid_questiongroup(self): + data = [{ + 'label': "location attributes", + 'type': 'group', + 'questions': [{ + 'label': 'Start', + 'type': "ST", + }] + }] + errors = validators.validate_question_groups(data) + assert errors == [{'name': ['This field is required.'], + 'questions': [ + {'name': ['This field is required.']} + ]} + ] + + def test_valid_nested_questiongroup(self): + data = [{ + 'name': "location_attributes", + 'label': "Location Attributes", + 'type': 'repeat', + 'question_groups': [{ + 'name': "location_attributes", + 'label': "Location Attributes", + 'type': 'group', + }], + 'questions': [{ + 'name': "start", + 'label': 'Start', + 'type': "ST", + }] + }] + errors = validators.validate_question_groups(data) + assert errors == [{}] + + def test_invalid_nested_questiongroup(self): + data = [{ + 'label': "Location Attributes", + 'type': 'repeat', + 'question_groups': [{ + 'name': "location_attributes", + 'label': "Location Attributes" + }], + 'questions': [{ + 'label': 'Start', + 'type': "ST", + }] + }] + errors = validators.validate_question_groups(data) + print(errors) + assert errors == [{'name': ['This field is required.'], + 'questions': [ + {'name': ['This field is required.']}], + 'question_groups': [ + {'type': ['This field is required.']}] + } + ] + + +class QuestionTestCase(TestCase): + def test_valid_question(self): + data = [{ + 'name': "start", + 'label': None, + 'type': "ST", + 'required': False, + 'constraint': None + }] + errors = validators.validate_questions(data) + assert errors == [{}] + + def test_invalid_question(self): + data = [{ + 'name': "start", + 'label': None, + 'type': "ST", + 'required': False, + 'constraint': None + }, { + 'label': None, + 'type': "S1", + 'required': False, + 'constraint': None, + 'options': [{'name': 'Name'}] + }] + errors = validators.validate_questions(data) + assert errors == [{}, + {'name': ['This field is required.'], + 'options': [{'label': ['This field is required.']}]} + ] + + +class QuestionOptionTestCase(TestCase): + def test_valid_option(self): + data = [{ + 'name': "start", + 'label': "Start", + }] + errors = validators.validate_question_options(data) + assert errors == [{}] + + def test_invalid_option(self): + data = [{ + 'name': "start", + 'label': "Start", + }, { + 'name': 'end' + }] + errors = validators.validate_question_options(data) + assert errors == [{}, {'label': ['This field is required.']}] diff --git a/cadasta/questionnaires/tests/test_views_api.py b/cadasta/questionnaires/tests/test_views_api.py index 29cdfc581..baee259ad 100644 --- a/cadasta/questionnaires/tests/test_views_api.py +++ b/cadasta/questionnaires/tests/test_views_api.py @@ -9,6 +9,7 @@ from core.tests.utils.files import make_dirs # noqa from accounts.tests.factories import UserFactory from organization.tests.factories import OrganizationFactory, ProjectFactory +from spatial.tests.factories import SpatialUnitFactory from ..views import api from ..models import Questionnaire from .factories import QuestionnaireFactory @@ -19,10 +20,6 @@ class QuestionnaireDetailTest(APITestCase, UserTestCase, FileStorageTestCase, TestCase): view_class = api.QuestionnaireDetail - def setup_post_data(self): - form = self.get_form('xls-form') - return {'xls_form': form} - def setup_models(self): clause = { 'clause': [ @@ -54,6 +51,16 @@ 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 @@ -71,15 +78,16 @@ def test_get_questionnaire_with_unauthorized_user(self): assert response.status_code == 403 def test_create_questionnaire(self): - response = self.request(method='PUT', user=self.user) + data = {'xls_form': self.get_form('xls-form')} + response = self.request(method='PUT', user=self.user, post_data=data) assert response.status_code == 201 - assert (response.content.get('xls_form') == - self.setup_post_data()['xls_form']) + assert response.content.get('xls_form') == data['xls_form'] assert Questionnaire.objects.filter(project=self.prj).count() == 1 def test_create_questionnaire_with_unauthorized_user(self): + data = {'xls_form': self.get_form('xls-form')} user = UserFactory.create() - response = self.request(method='PUT', user=user) + response = self.request(method='PUT', user=user, post_data=data) assert response.status_code == 403 assert Questionnaire.objects.filter(project=self.prj).count() == 0 @@ -90,3 +98,50 @@ def test_create_invalid_questionnaire(self): # assert ("Unknown question type 'interger'." in response.content.get('xls_form')) assert Questionnaire.objects.filter(project=self.prj).count() == 0 + + def test_create_with_blocked_questionnaire_upload(self): + data = {'xls_form': self.get_form('xls-form')} + SpatialUnitFactory.create(project=self.prj) + response = self.request(method='PUT', user=self.user, post_data=data) + + assert response.status_code == 400 + assert ("Data has already been contributed to this " + "project. To ensure data integrity, uploading a " + "new questionnaire is diabled for this project." in + response.content.get('xls_form')) + + def test_create_valid_questionnaire_from_json(self): + data = { + 'title': 'yx8sqx6488wbc4yysnkrbnfq', + 'id_string': 'yx8sqx6488wbc4yysnkrbnfq', + 'questions': [{ + 'name': "start", + 'label': "Start", + 'type': "ST", + 'required': False, + 'constraint': None + }, { + 'name': "end", + 'label': "end", + 'type': "EN", + }] + } + response = self.request(method='PUT', post_data=data, user=self.user) + + assert response.status_code == 201 + assert Questionnaire.objects.filter(project=self.prj).count() == 1 + assert response.content['title'] == data['title'] + self.prj.refresh_from_db() + assert self.prj.current_questionnaire == response.content['id'] + + def test_create_invalid_questionnaire_from_json(self): + data = { + 'id_string': 'yx8sqx6488wbc4yysnkrbnfq', + } + response = self.request(method='PUT', post_data=data, user=self.user) + + assert response.status_code == 400 + assert Questionnaire.objects.filter(project=self.prj).count() == 0 + assert response.content['title'] == ['This field is required.'] + self.prj.refresh_from_db() + assert self.prj.current_questionnaire is None diff --git a/cadasta/questionnaires/validators.py b/cadasta/questionnaires/validators.py new file mode 100644 index 000000000..b5bac2ef3 --- /dev/null +++ b/cadasta/questionnaires/validators.py @@ -0,0 +1,121 @@ +from django.utils.translation import ugettext as _ +from .models import Question + + +QUESTIONNAIRE_SCHEMA = { + 'title': {'type': 'string', 'required': True}, + 'id_string': {'type': 'string', 'required': True}, +} + +QUESTION_SCHEMA = { + 'name': {'type': 'string', 'required': True}, + 'label': {'type': 'string'}, + 'type': {'type': 'string', + 'required': True, + 'enum': [c[0] for c in Question.TYPE_CHOICES]}, + 'required': {'type': 'boolean'}, + 'constraint': {'type': 'string'} +} + +QUESTION_GROUP_SCHEMA = { + 'name': {'type': 'string', 'required': True}, + 'label': {'type': 'string'}, + 'type': {'type': 'string', 'required': True}, +} + +QUESTION_OPTION_SCHEMA = { + 'name': {'type': 'string', 'required': True}, + 'label': {'type': 'string', 'required': True}, +} + + +def validate_type(type, value): + if type == 'string': + return isinstance(value, str) + elif type == 'number': + return ((not isinstance(value, bool) and + isinstance(value, (int, float)))) + elif type == 'boolean': + return isinstance(value, bool) + elif type == 'array': + return isinstance(value, list) + + +def validate_schema(schema, json): + errors = {} + for key, reqs in schema.items(): + item_errors = [] + item = json.get(key, None) + + if reqs.get('required', False) and not item: + item_errors.append(_("This field is required.")) + elif item: + if not validate_type(reqs.get('type'), item): + item_errors.append( + _("Value must be of type {}.").format(reqs.get('type'))) + if reqs.get('enum') and item not in reqs.get('enum'): + item_errors.append( + _("{} is not an accepted value.").format(item)) + + if item_errors: + errors[key] = item_errors + + return errors + + +def validate_question_options(options): + errors = [] + + for option in options: + errors.append(validate_schema(QUESTION_OPTION_SCHEMA, option)) + + return errors + + +def validate_questions(questions): + errors = [] + + for question in questions: + question_errs = validate_schema(QUESTION_SCHEMA, question) + option_errs = validate_question_options(question.get('options', [])) + + if any([o for o in option_errs]): + question_errs['options'] = option_errs + errors.append(question_errs) + + return errors + + +def validate_question_groups(groups): + errors = [] + + for group in groups: + group_errs = validate_schema(QUESTION_GROUP_SCHEMA, group) + + questions_errs = validate_questions(group.get('questions', [])) + if any([q for q in questions_errs]): + group_errs['questions'] = questions_errs + + questions_group_errs = validate_question_groups( + group.get('question_groups', [])) + if any([q for q in questions_group_errs]): + group_errs['question_groups'] = questions_group_errs + + errors.append(group_errs) + + return errors + + +def validate_questionnaire(json): + errors = validate_schema(QUESTIONNAIRE_SCHEMA, json) + + question_errs = validate_questions(json.get('questions', [])) + if any([q for q in question_errs]): + errors['questions'] = question_errs + + group_errs = validate_question_groups(json.get('question_groups', [])) + if any([q for q in group_errs]): + errors['question_groups'] = group_errs + + if errors: + return errors diff --git a/cadasta/questionnaires/views/api.py b/cadasta/questionnaires/views/api.py index fbb6cf8ac..d90778963 100644 --- a/cadasta/questionnaires/views/api.py +++ b/cadasta/questionnaires/views/api.py @@ -3,21 +3,26 @@ from rest_framework import generics, mixins, status from rest_framework.response import Response +from rest_framework.renderers import JSONRenderer, BrowsableAPIRenderer +from rest_framework.permissions import IsAuthenticated 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, ) + permission_classes = (IsAuthenticated,) + def patch_actions(self, request): try: self.get_object() - # return ('questionnaire.edit') except Questionnaire.DoesNotExist: return ('questionnaire.add') @@ -70,4 +75,10 @@ def get(self, request, *args, **kwargs): raise Http404('No Questionnaire matches the given query.') def put(self, request, *args, **kwargs): + if self.get_project().has_records: + return Response( + {'xls_form': "Data has already been contributed to this " + "project. To ensure data integrity, uploading a " + "new questionnaire is diabled for this project."}, + status=status.HTTP_400_BAD_REQUEST) return self.create(request) diff --git a/cadasta/templates/organization/project_edit_details.html b/cadasta/templates/organization/project_edit_details.html index 166bf5ecd..f0dbafde8 100644 --- a/cadasta/templates/organization/project_edit_details.html +++ b/cadasta/templates/organization/project_edit_details.html @@ -59,6 +59,7 @@

{% trans "1. General information" %}

{% trans "2. Questionnaire" %}

+ {% if not project.has_records %}
@@ -74,6 +75,14 @@

{% trans "2. Questionnaire" %}

+ {% else %} + + {% endif %}
diff --git a/cadasta/xforms/serializers.py b/cadasta/xforms/serializers.py index ce50e396d..c31073d42 100644 --- a/cadasta/xforms/serializers.py +++ b/cadasta/xforms/serializers.py @@ -1,3 +1,4 @@ +from django.core.urlresolvers import reverse from rest_framework import serializers from core.serializers import FieldSelectorSerializer from xforms.models import XFormSubmission @@ -20,16 +21,15 @@ class XFormListSerializer(FieldSelectorSerializer, downloadUrl = serializers.SerializerMethodField('get_xml_form') def get_xml_form(self, obj): - if obj.xml_form.url.startswith('/media/s3/uploads/'): - if self.context['request'].META['SERVER_PROTOCOL'] == 'HTTP/1.1': - url = 'http://' - else: - url = 'https://' - url += self.context['request'].META.get('HTTP_HOST', - 'localhost:8000') - return url + obj.xml_form.url - - return obj.xml_form.url + url = reverse('api:v1:questionnaires:detail', + kwargs={ + 'organization': obj.project.organization.slug, + 'project': obj.project.slug + }) + '?format=xform' + url = self.context['request'].build_absolute_uri(url) + if self.context['request'].META['SERVER_PROTOCOL'] != 'HTTP/1.1': + url = url.replace('http://', 'https://') + return url class XFormSubmissionSerializer(FieldSelectorSerializer, diff --git a/cadasta/xforms/tests/test_serializers.py b/cadasta/xforms/tests/test_serializers.py index 30558116a..d32cd3e1a 100644 --- a/cadasta/xforms/tests/test_serializers.py +++ b/cadasta/xforms/tests/test_serializers.py @@ -44,11 +44,13 @@ def _test_serialize(self, https=False): assert serializer.data['formID'] == questionnaire.data['id_string'] assert serializer.data['name'] == questionnaire.data['title'] assert serializer.data['version'] == questionnaire.data['version'] - protocol = 'https' if https else 'http' assert (serializer.data['downloadUrl'] == - protocol + '://localhost:8000' + - questionnaire.data['xml_form']) - assert serializer.data['hash'] == form.md5_hash + '{}://testserver/api/v1/organizations/{}/projects/{}' + '/questionnaire/?format=xform'.format( + ('https' if https else 'http'), + project.organization.slug, + project.slug)) + assert serializer.data['hash'] == questionnaire.data['md5_hash'] def test_serialize(self): self._test_serialize() diff --git a/cadasta/xforms/tests/test_views_api.py b/cadasta/xforms/tests/test_views_api.py index 224f2727b..95eeb7bdc 100644 --- a/cadasta/xforms/tests/test_views_api.py +++ b/cadasta/xforms/tests/test_views_api.py @@ -35,6 +35,7 @@ class XFormListTest(APITestCase, UserTestCase, FileStorageTestCase, TestCase): view_class = api.XFormListView viewset_actions = {'get': 'list'} + request_meta = {'SERVER_PROTOCOL': 'HTTP/1.1'} def setup_models(self): self.user = UserFactory.create() @@ -61,7 +62,9 @@ def test_get_xforms(self): assert response.status_code == 200 assert questionnaire.md5_hash in response.content - assert questionnaire.xml_form.url in response.content + assert ('/v1/organizations/{}/projects/{}/questionnaire/'.format( + questionnaire.project.organization.slug, + questionnaire.project.slug) in response.content) assert str(questionnaire.version) in response.content def test_get_xforms_with_unauthroized_user(self): @@ -70,7 +73,9 @@ def test_get_xforms_with_unauthroized_user(self): assert response.status_code == 200 assert questionnaire.md5_hash not in response.content - assert questionnaire.xml_form.url not in response.content + assert ('/v1/organizations/{}/projects/{}/questionnaire/'.format( + questionnaire.project.organization.slug, + questionnaire.project.slug) not in response.content) def test_get_xforms_with_superuser(self): self.user.assign_policies(self.superuser_role) @@ -79,7 +84,9 @@ def test_get_xforms_with_superuser(self): assert response.status_code == 200 assert questionnaire.md5_hash in response.content - assert questionnaire.xml_form.url in response.content + assert ('/v1/organizations/{}/projects/{}/questionnaire/'.format( + questionnaire.project.organization.slug, + questionnaire.project.slug) in response.content) def test_get_xforms_with_no_superuser(self): OrganizationRole.objects.create( diff --git a/cadasta/xforms/views/api.py b/cadasta/xforms/views/api.py index 4085a4d7e..70fd90a46 100644 --- a/cadasta/xforms/views/api.py +++ b/cadasta/xforms/views/api.py @@ -41,10 +41,15 @@ class XFormSubmissionViewSet(OpenRosaHeadersMixin, parser_classes = (FormParser, MultiPartParser,) serializer_class = XFormSubmissionSerializer + def dispatch(self, *args, **kwargs): + print(args[0].__dict__) + return super().dispatch(*args, **kwargs) + def create(self, request, *args, **kwargs): if request.method.upper() == 'HEAD': return Response(headers=self.get_openrosa_headers(request), status=status.HTTP_204_NO_CONTENT,) + print('REQUEST') try: instance = ModelHelper( ).upload_submission_data(request) @@ -105,6 +110,11 @@ class XFormListView(OpenRosaHeadersMixin, renderer_classes = (XFormListRenderer,) serializer_class = XFormListSerializer + def get_serializer_context(self, *args, **kwargs): + context = super().get_serializer_context(*args, **kwargs) + context['request'] = self.request + return context + def get_user_forms(self): forms = [] policies = self.request.user.assigned_policies() From 26efb229395597f74079c39105f21674b26e3a4b Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Thu, 17 Nov 2016 13:36:42 +0100 Subject: [PATCH 2/6] Add permissions --- cadasta/config/permissions/data-collector.json | 5 +++++ cadasta/config/permissions/superuser.json | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/cadasta/config/permissions/data-collector.json b/cadasta/config/permissions/data-collector.json index f7ca20d20..c3acab322 100644 --- a/cadasta/config/permissions/data-collector.json +++ b/cadasta/config/permissions/data-collector.json @@ -40,6 +40,11 @@ "effect": "allow", "action": ["tenure_rel.*", "tenure_rel.resources.*"], "object": ["tenure_rel/$organization/$project/*"] + }, + { + "effect": "allow", + "action": ["questionnaire.view"], + "object": ["project/$organization/$project"] } ] } diff --git a/cadasta/config/permissions/superuser.json b/cadasta/config/permissions/superuser.json index 7294aedf0..734f3e05a 100644 --- a/cadasta/config/permissions/superuser.json +++ b/cadasta/config/permissions/superuser.json @@ -28,7 +28,11 @@ "action": ["resource.*"], "object": ["project/*/*", "resource/*/*/*"] }, - + { + "effect": "allow", + "action": ["questionnaire.*"], + "object": ["project/*/*"] + }, { "effect": "allow", "action": ["spatial.*"], From 1bba6e2cc5ef0433e8b4b6717c2eb9955d0baa8b Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Wed, 23 Nov 2016 12:20:17 +0100 Subject: [PATCH 3/6] Fix ordering, add UUID --- cadasta/questionnaires/managers.py | 13 ++-- .../0015_add_question_index_field.py | 35 ++++++++++ .../0016_populate_question_index_field.py | 64 +++++++++++++++++++ cadasta/questionnaires/models.py | 8 +++ cadasta/questionnaires/renderer/xform.py | 24 ++++++- cadasta/questionnaires/serializers.py | 4 +- cadasta/questionnaires/tests/test_renderer.py | 12 +++- cadasta/xforms/views/api.py | 8 +-- 8 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 cadasta/questionnaires/migrations/0015_add_question_index_field.py create mode 100644 cadasta/questionnaires/migrations/0016_populate_question_index_field.py diff --git a/cadasta/questionnaires/managers.py b/cadasta/questionnaires/managers.py index ad8806b38..fcb05a3a5 100644 --- a/cadasta/questionnaires/managers.py +++ b/cadasta/questionnaires/managers.py @@ -19,7 +19,7 @@ def create_children(children, errors=[], project=None, default_language='', kwargs={}): if children: - for c in children: + for c, idx in zip(children, itertools.count()): if c.get('type') in ['group', 'repeat']: model_name = 'QuestionGroup' @@ -41,7 +41,10 @@ def create_children(children, errors=[], project=None, model_name = 'Question' model = apps.get_model('questionnaires', model_name) - model.objects.create_from_dict(dict=c, errors=errors, **kwargs) + model.objects.create_from_dict(dict=c, + index=idx, + errors=errors, + **kwargs) def create_options(options, question, errors=[]): @@ -236,7 +239,7 @@ def create_from_form(self, xls_form=None, original_file=None, class QuestionGroupManager(models.Manager): def create_from_dict(self, dict=None, question_group=None, - questionnaire=None, errors=[]): + questionnaire=None, errors=[], index=0): instance = self.model(questionnaire=questionnaire, question_group=question_group) @@ -249,6 +252,7 @@ def create_from_dict(self, dict=None, question_group=None, instance.label_xlat = dict.get('label', {}) instance.type = dict.get('type') instance.relevant = relevant + instance.index = index instance.save() create_children( @@ -267,7 +271,7 @@ def create_from_dict(self, dict=None, question_group=None, class QuestionManager(models.Manager): - def create_from_dict(self, errors=[], **kwargs): + def create_from_dict(self, errors=[], index=0, **kwargs): dict = kwargs.pop('dict') instance = self.model(**kwargs) type_dict = {name: code for code, name in instance.TYPE_CHOICES} @@ -287,6 +291,7 @@ def create_from_dict(self, errors=[], **kwargs): instance.default = dict.get('default', None) instance.hint = dict.get('hint', None) instance.relevant = relevant + instance.index = index instance.save() if instance.has_options: diff --git a/cadasta/questionnaires/migrations/0015_add_question_index_field.py b/cadasta/questionnaires/migrations/0015_add_question_index_field.py new file mode 100644 index 000000000..b24b7ddc4 --- /dev/null +++ b/cadasta/questionnaires/migrations/0015_add_question_index_field.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.11 on 2016-11-22 16:58 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('questionnaires', '0014_api_additional_fields'), + ] + + operations = [ + migrations.AddField( + model_name='historicalquestion', + name='index', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='historicalquestiongroup', + name='index', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='question', + name='index', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='questiongroup', + name='index', + field=models.IntegerField(null=True), + ), + ] diff --git a/cadasta/questionnaires/migrations/0016_populate_question_index_field.py b/cadasta/questionnaires/migrations/0016_populate_question_index_field.py new file mode 100644 index 000000000..1d649ba15 --- /dev/null +++ b/cadasta/questionnaires/migrations/0016_populate_question_index_field.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.11 on 2016-11-23 09:04 +from __future__ import unicode_literals + +import itertools +from django.db import migrations +from pyxform.xls2json import parse_file_to_json + + +def populate_index_fields(apps, schema_editor): + Project = apps.get_model('organization', 'Project') + Questionnaire = apps.get_model('questionnaires', 'Questionnaire') + QuestionGroup = apps.get_model('questionnaires', 'QuestionGroup') + Question = apps.get_model('questionnaires', 'Question') + + def update_question(idx, **kwargs): + question = Question.objects.get(**kwargs) + question.index = idx + question.save() + + def update_group(idx, **kwargs): + group = QuestionGroup.objects.get(**kwargs) + group.index = idx + group.save() + return group.id + + def update_children(children, questionnaire_id, question_group_id=None): + for child, idx in zip(children, itertools.count()): + if child['type'] in ['group', 'repeat']: + group_id = update_group(idx, + questionnaire_id=questionnaire_id, + question_group_id=question_group_id, + name=child['name']) + update_children(child.get('children', []), + questionnaire_id, + question_group_id=group_id) + else: + update_question(idx, + questionnaire_id=questionnaire_id, + question_group_id=question_group_id, + name=child['name']) + + for project in Project.objects.all(): + if project.current_questionnaire: + questionnaire = Questionnaire.objects.get( + id=project.current_questionnaire) + + if questionnaire.xls_form: + q_json = parse_file_to_json(questionnaire.xls_form.file.name) + update_children(q_json.get('children', []), questionnaire.id) + + +class Migration(migrations.Migration): + + dependencies = [ + ('questionnaires', '0015_add_question_index_field'), + ] + + operations = [ + migrations.RunPython( + populate_index_fields, + reverse_code=migrations.RunPython.noop + ) + ] diff --git a/cadasta/questionnaires/models.py b/cadasta/questionnaires/models.py index 6873bbde0..631ad82e4 100644 --- a/cadasta/questionnaires/models.py +++ b/cadasta/questionnaires/models.py @@ -95,6 +95,9 @@ def save(self, *args, **kwargs): class QuestionGroup(MultilingualLabelsMixin, RandomIDModel): + class Meta: + ordering = ('index',) + name = models.CharField(max_length=100) label_xlat = JSONField(default={}) relevant = models.CharField(max_length=100, null=True, blank=True) @@ -104,6 +107,7 @@ class QuestionGroup(MultilingualLabelsMixin, RandomIDModel): related_name='question_groups', null=True) type = models.CharField(max_length=50, default='group') + index = models.IntegerField(null=True) objects = managers.QuestionGroupManager() @@ -116,6 +120,9 @@ def __repr__(self): class Question(MultilingualLabelsMixin, RandomIDModel): + class Meta: + ordering = ('index',) + TYPE_CHOICES = (('IN', 'integer'), ('DE', 'decimal'), ('TX', 'text'), @@ -156,6 +163,7 @@ class Question(MultilingualLabelsMixin, RandomIDModel): question_group = models.ForeignKey(QuestionGroup, related_name='questions', null=True) + index = models.IntegerField(null=True) objects = managers.QuestionManager() diff --git a/cadasta/questionnaires/renderer/xform.py b/cadasta/questionnaires/renderer/xform.py index ee4701669..3d2b3a4ab 100644 --- a/cadasta/questionnaires/renderer/xform.py +++ b/cadasta/questionnaires/renderer/xform.py @@ -41,7 +41,8 @@ def transform_groups(self, groups): 'type': 'group', 'name': g.get('name'), 'label': g.get('label'), - 'children': self.transform_questions(g.get('questions')) + 'children': self.transform_questions(g.get('questions')), + 'index': g.get('index') } if group['label'] is None: del group['label'] @@ -68,7 +69,10 @@ def transform_to_xform_json(self, data): questions = self.transform_questions(data.get('questions', [])) question_groups = self.transform_groups( data.get('question_groups', [])) - json['children'] = questions + question_groups + json['children'] = sorted(questions + question_groups, + key=lambda x: x['index']) + for c in json['children']: + print(c['name'], c.get('index', "nothing")) return json def insert_version_attribute(self, xform, root_node, version): @@ -85,6 +89,21 @@ def insert_version_attribute(self, xform, root_node, version): ) 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) @@ -95,5 +114,6 @@ def render(self, data, *args, **kwargs): 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/serializers.py b/cadasta/questionnaires/serializers.py index 6ef355f78..4c626470a 100644 --- a/cadasta/questionnaires/serializers.py +++ b/cadasta/questionnaires/serializers.py @@ -62,7 +62,7 @@ class QuestionSerializer(FindInitialMixin, serializers.ModelSerializer): class Meta: model = models.Question fields = ('id', 'name', 'label', 'type', 'required', 'constraint', - 'default', 'hint', 'relevant', 'label_xlat', ) + 'default', 'hint', 'relevant', 'label_xlat', 'index', ) read_only_fields = ('id', ) write_only_fields = ('label_xlat', ) @@ -105,7 +105,7 @@ class QuestionGroupSerializer(FindInitialMixin, serializers.ModelSerializer): class Meta: model = models.QuestionGroup fields = ('id', 'name', 'label', 'type', 'questions', - 'question_groups', 'label_xlat', 'relevant', ) + 'question_groups', 'label_xlat', 'relevant', 'index', ) read_only_fields = ('id', 'questions', 'question_groups', ) write_only_fields = ('label_xlat', ) diff --git a/cadasta/questionnaires/tests/test_renderer.py b/cadasta/questionnaires/tests/test_renderer.py index 0c6c48de2..5c8d86f70 100644 --- a/cadasta/questionnaires/tests/test_renderer.py +++ b/cadasta/questionnaires/tests/test_renderer.py @@ -93,6 +93,7 @@ def test_transform_to_xform_json(self): 'type': "S1", 'required': False, 'constraint': None, + 'index': 0, 'options': [ { 'id': "d9pkepyjg4sgaepdytgwkgfv", @@ -111,21 +112,25 @@ def test_transform_to_xform_json(self): 'id': '123', 'name': 'group_1', 'label': 'Group 2', + 'index': 1, 'questions': [{ 'id': "bzs2984c3gxgwcjhvambdt3w", 'name': "start", 'label': None, 'type': "ST", + 'index': 0, }] }, { 'id': '456', 'name': 'group_2', 'label': 'Group 2', + 'index': 2, 'questions': [{ 'id': "xp8vjr6dsk46p47u22fft7bg", 'name': "party_type", 'label': "What is the party type?", 'type': "TX", + 'index': 0, }] }] } @@ -150,6 +155,7 @@ def test_render(self): 'type': "S1", 'required': False, 'constraint': None, + 'index': 0, 'options': [ { 'id': "d9pkepyjg4sgaepdytgwkgfv", @@ -169,7 +175,8 @@ def test_render(self): 'label': None, 'type': "ST", 'required': False, - 'constraint': None + 'constraint': None, + 'index': 1, } ] } @@ -177,3 +184,6 @@ def test_render(self): xml = renderer.render(data).decode() assert 'abc123' in xml assert '' in xml + assert ('' in xml) diff --git a/cadasta/xforms/views/api.py b/cadasta/xforms/views/api.py index 70fd90a46..3683be9b6 100644 --- a/cadasta/xforms/views/api.py +++ b/cadasta/xforms/views/api.py @@ -41,18 +41,12 @@ class XFormSubmissionViewSet(OpenRosaHeadersMixin, parser_classes = (FormParser, MultiPartParser,) serializer_class = XFormSubmissionSerializer - def dispatch(self, *args, **kwargs): - print(args[0].__dict__) - return super().dispatch(*args, **kwargs) - def create(self, request, *args, **kwargs): if request.method.upper() == 'HEAD': return Response(headers=self.get_openrosa_headers(request), status=status.HTTP_204_NO_CONTENT,) - print('REQUEST') try: - instance = ModelHelper( - ).upload_submission_data(request) + instance = ModelHelper().upload_submission_data(request) except InvalidXMLSubmission as e: logger.debug(str(e)) return self._sendErrorResponse(request, e) From 8bd5670f661a9ed262cf8b4bff4f9d6dc73a1e48 Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Mon, 28 Nov 2016 18:13:22 +0100 Subject: [PATCH 4/6] Remove permission for Xform API view --- cadasta/questionnaires/renderer/xform.py | 2 -- cadasta/questionnaires/views/api.py | 9 ++++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cadasta/questionnaires/renderer/xform.py b/cadasta/questionnaires/renderer/xform.py index 3d2b3a4ab..b94ada48d 100644 --- a/cadasta/questionnaires/renderer/xform.py +++ b/cadasta/questionnaires/renderer/xform.py @@ -71,8 +71,6 @@ def transform_to_xform_json(self, data): data.get('question_groups', [])) json['children'] = sorted(questions + question_groups, key=lambda x: x['index']) - for c in json['children']: - print(c['name'], c.get('index', "nothing")) return json def insert_version_attribute(self, xform, root_node, version): diff --git a/cadasta/questionnaires/views/api.py b/cadasta/questionnaires/views/api.py index d90778963..08117e46a 100644 --- a/cadasta/questionnaires/views/api.py +++ b/cadasta/questionnaires/views/api.py @@ -4,7 +4,6 @@ from rest_framework import generics, mixins, status from rest_framework.response import Response from rest_framework.renderers import JSONRenderer, BrowsableAPIRenderer -from rest_framework.permissions import IsAuthenticated from tutelary.mixins import APIPermissionRequiredMixin from organization.models import Project @@ -18,7 +17,11 @@ class QuestionnaireDetail(APIPermissionRequiredMixin, mixins.CreateModelMixin, generics.RetrieveUpdateAPIView): renderer_classes = (JSONRenderer, BrowsableAPIRenderer, XFormRenderer, ) - permission_classes = (IsAuthenticated,) + + def get_actions(self, request): + if request.GET.get('format') == 'xform': + return None + return 'questionnaire.view' def patch_actions(self, request): try: @@ -28,7 +31,7 @@ def patch_actions(self, request): serializer_class = QuestionnaireSerializer permission_required = { - 'GET': 'questionnaire.view', + 'GET': get_actions, 'PUT': patch_actions, } From 16df8ae433e54c92505f4ad4b122cc3410842b55 Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Tue, 29 Nov 2016 15:12:10 +0100 Subject: [PATCH 5/6] Refactoring and permissions fix --- .../0016_populate_question_index_field.py | 1 + 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 +++- 13 files changed, 246 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/migrations/0016_populate_question_index_field.py b/cadasta/questionnaires/migrations/0016_populate_question_index_field.py index 1d649ba15..c9e19199e 100644 --- a/cadasta/questionnaires/migrations/0016_populate_question_index_field.py +++ b/cadasta/questionnaires/migrations/0016_populate_question_index_field.py @@ -19,6 +19,7 @@ def update_question(idx, **kwargs): question.save() def update_group(idx, **kwargs): + print(kwargs) group = QuestionGroup.objects.get(**kwargs) group.index = idx group.save() 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 From db8bf8ef8bef16b891bd22d3242b5554153b7680 Mon Sep 17 00:00:00 2001 From: Oliver Roick Date: Fri, 2 Dec 2016 18:28:54 +0100 Subject: [PATCH 6/6] Fix migrations --- .../migrations/0016_populate_question_index_field.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/cadasta/questionnaires/migrations/0016_populate_question_index_field.py b/cadasta/questionnaires/migrations/0016_populate_question_index_field.py index c9e19199e..9f9e0f7c3 100644 --- a/cadasta/questionnaires/migrations/0016_populate_question_index_field.py +++ b/cadasta/questionnaires/migrations/0016_populate_question_index_field.py @@ -5,6 +5,7 @@ import itertools from django.db import migrations from pyxform.xls2json import parse_file_to_json +from botocore.exceptions import ClientError def populate_index_fields(apps, schema_editor): @@ -19,7 +20,6 @@ def update_question(idx, **kwargs): question.save() def update_group(idx, **kwargs): - print(kwargs) group = QuestionGroup.objects.get(**kwargs) group.index = idx group.save() @@ -47,8 +47,13 @@ def update_children(children, questionnaire_id, question_group_id=None): id=project.current_questionnaire) if questionnaire.xls_form: - q_json = parse_file_to_json(questionnaire.xls_form.file.name) - update_children(q_json.get('children', []), questionnaire.id) + try: + q_json = parse_file_to_json( + questionnaire.xls_form.file.name) + update_children( + q_json.get('children', []), questionnaire.id) + except ClientError: + pass class Migration(migrations.Migration):