Skip to content

Commit

Permalink
Adds support for accuracyThreshold (#1703)
Browse files Browse the repository at this point in the history
* Add accuracy validator

* Refactor Question.TYPE_CHOICES

* Add gps_accuracy field to Question model

* Read accuracyThreshold from XLSForm

* Add accuracyThreshold to XForm renderer

* Add gps_accuracy to QuestionSerializer

* Add accuracy validator to questionnaire schema validation

* positive now returns val > 0

* Validate accuracyThreshold only for geomtry fields

* Fix typos
  • Loading branch information
oliverroick authored and amplifi committed Aug 15, 2017
1 parent f2a9ee6 commit 0cf53b3
Show file tree
Hide file tree
Showing 12 changed files with 361 additions and 75 deletions.
30 changes: 30 additions & 0 deletions cadasta/questionnaires/choices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
QUESTION_TYPES = (
('IN', 'integer'),
('DE', 'decimal'),
('TX', 'text'),
('S1', 'select one'),
('SM', 'select all that apply'),
('NO', 'note'),
('GP', 'geopoint'),
('GT', 'geotrace'),
('GS', 'geoshape'),
('DA', 'date'),
('TI', 'time'),
('DT', 'dateTime'),
('CA', 'calculate'),
('AC', 'acknowledge'),
('PH', 'photo'),
('AU', 'audio'),
('VI', 'video'),
('BC', 'barcode'),

# Meta data
('ST', 'start'),
('EN', 'end'),
('TD', 'today'),
('DI', 'deviceid'),
('SI', 'subsciberid'),
('SS', 'simserial'),
('PN', 'phonenumber'))

XFORM_GEOM_FIELDS = ('GP', 'GT', 'GS', )
16 changes: 13 additions & 3 deletions cadasta/questionnaires/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
from pyxform.builder import create_survey_element_from_dict
from pyxform.errors import PyXFormError
from pyxform.xls2json import parse_file_to_json
from .exceptions import InvalidQuestionnaire
from .messages import MISSING_RELEVANT
from core.messages import SANITIZE_ERROR
from core.validators import sanitize_string
from .choices import QUESTION_TYPES, XFORM_GEOM_FIELDS
from .exceptions import InvalidQuestionnaire
from .messages import MISSING_RELEVANT, INVALID_ACCURACY
from .validators import validate_accuracy

ATTRIBUTE_GROUPS = settings.ATTRIBUTE_GROUPS

Expand Down Expand Up @@ -294,7 +296,7 @@ class QuestionManager(models.Manager):
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}
type_dict = {name: code for code, name in QUESTION_TYPES}

relevant = None
required = False
Expand All @@ -303,10 +305,18 @@ def create_from_dict(self, errors=[], index=0, **kwargs):
relevant = bind.get('relevant', None)
required = True if bind.get('required', 'no') == 'yes' else False

gps_accuracy = None
control = dict.get('control')
if control and type_dict[dict.get('type')] in XFORM_GEOM_FIELDS:
gps_accuracy = control.get('accuracyThreshold', None)
if gps_accuracy and not validate_accuracy(gps_accuracy):
raise(InvalidQuestionnaire([INVALID_ACCURACY]))

instance.type = type_dict[dict.get('type')]
instance.name = dict.get('name')
instance.label_xlat = dict.get('label', {})
instance.required = required
instance.gps_accuracy = gps_accuracy
instance.constraint = dict.get('constraint')
instance.default = dict.get('default', None)
instance.hint = dict.get('hint', None)
Expand Down
1 change: 1 addition & 0 deletions cadasta/questionnaires/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
"sure to add a 'relevant' clause to the question group "
"definition when adding more than one question group for "
"a model entity.")
INVALID_ACCURACY = _("Accuracy threshold must be positive float.")
26 changes: 26 additions & 0 deletions cadasta/questionnaires/migrations/0019_add_gps_accuracy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-08-07 10:09
from __future__ import unicode_literals

from django.db import migrations, models
import questionnaires.validators


class Migration(migrations.Migration):

dependencies = [
('questionnaires', '0018_add_ordering'),
]

operations = [
migrations.AddField(
model_name='historicalquestion',
name='gps_accuracy',
field=models.FloatField(blank=True, null=True, validators=[questionnaires.validators.validate_accuracy]),
),
migrations.AddField(
model_name='question',
name='gps_accuracy',
field=models.FloatField(blank=True, null=True, validators=[questionnaires.validators.validate_accuracy]),
),
]
35 changes: 5 additions & 30 deletions cadasta/questionnaires/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
from simple_history.models import HistoricalRecords
from tutelary.decorators import permissioned_model

from . import managers, messages
from . import managers, messages, choices
from .validators import validate_accuracy


class MultilingualLabelsMixin:
Expand Down Expand Up @@ -123,39 +124,13 @@ class Question(MultilingualLabelsMixin, RandomIDModel):
class Meta:
ordering = ('index',)

TYPE_CHOICES = (('IN', 'integer'),
('DE', 'decimal'),
('TX', 'text'),
('S1', 'select one'),
('SM', 'select all that apply'),
('NO', 'note'),
('GP', 'geopoint'),
('GT', 'geotrace'),
('GS', 'geoshape'),
('DA', 'date'),
('TI', 'time'),
('DT', 'dateTime'),
('CA', 'calculate'),
('AC', 'acknowledge'),
('PH', 'photo'),
('AU', 'audio'),
('VI', 'video'),
('BC', 'barcode'),

# Meta data
('ST', 'start'),
('EN', 'end'),
('TD', 'today'),
('DI', 'deviceid'),
('SI', 'subsciberid'),
('SS', 'simserial'),
('PN', 'phonenumber'))

name = models.CharField(max_length=100)
label_xlat = JSONField(default={})
type = models.CharField(max_length=2, choices=TYPE_CHOICES)
type = models.CharField(max_length=2, choices=choices.QUESTION_TYPES)
required = models.BooleanField(default=False)
default = models.CharField(max_length=100, null=True, blank=True)
gps_accuracy = models.FloatField(null=True, blank=True,
validators=[validate_accuracy])
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)
Expand Down
18 changes: 13 additions & 5 deletions cadasta/questionnaires/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from jsonattrs.models import Attribute, AttributeType, Schema
from .messages import MISSING_RELEVANT
from .messages import MISSING_RELEVANT, INVALID_ACCURACY
from .exceptions import InvalidQuestionnaire
from .validators import validate_questionnaire
from .validators import validate_questionnaire, validate_accuracy
from .managers import fix_labels
from . import models
from . import models, choices


ATTRIBUTE_GROUPS = settings.ATTRIBUTE_GROUPS
QUESTION_TYPES = dict(models.Question.TYPE_CHOICES)
QUESTION_TYPES = dict(choices.QUESTION_TYPES)


def create_questions(questions, context):
Expand Down Expand Up @@ -75,10 +75,18 @@ class QuestionSerializer(FindInitialMixin, serializers.ModelSerializer):
class Meta:
model = models.Question
fields = ('id', 'name', 'label', 'type', 'required', 'constraint',
'default', 'hint', 'relevant', 'label_xlat', 'index', )
'default', 'hint', 'relevant', 'label_xlat', 'index',
'gps_accuracy')
read_only_fields = ('id', )
write_only_fields = ('label_xlat', )

def validate_gps_accuracy(self, value):
if (value and
self.initial_data.get('type') in choices.XFORM_GEOM_FIELDS and
not validate_accuracy(value)):
raise serializers.ValidationError(INVALID_ACCURACY)
return value

def to_representation(self, instance):
rep = super().to_representation(instance)

Expand Down
89 changes: 89 additions & 0 deletions cadasta/questionnaires/tests/test_managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,95 @@ def test_create_from_dict(self):
assert model.relevant == '${party_id}="abc123"'
assert model.required is True

def test_create_from_dict_include_accuracy_threshold(self):
question_dict = {
'label': 'point',
'name': 'point',
'type': 'geopoint',
'control': {
'accuracyThreshold': 1.5
}
}
questionnaire = factories.QuestionnaireFactory.create()

model = models.Question.objects.create_from_dict(
dict=question_dict,
questionnaire=questionnaire
)

assert model.question_group is None
assert model.questionnaire == questionnaire
assert model.label == question_dict['label']
assert model.name == question_dict['name']
assert model.type == 'GP'
assert model.gps_accuracy == 1.5

def test_create_from_dict_ingnore_accuracy_threshold(self):
"""For non-geometry fields accuracy should be ignored"""
question_dict = {
'label': 'int',
'name': 'int',
'type': 'integer',
'control': {
'accuracyThreshold': 1.5
}
}
questionnaire = factories.QuestionnaireFactory.create()

model = models.Question.objects.create_from_dict(
dict=question_dict,
questionnaire=questionnaire
)

assert model.question_group is None
assert model.questionnaire == questionnaire
assert model.label == question_dict['label']
assert model.name == question_dict['name']
assert model.type == 'IN'
assert model.gps_accuracy is None

def test_create_from_dict_include_accuracy_threshold_as_string(self):
question_dict = {
'label': 'point',
'name': 'point',
'type': 'geopoint',
'control': {
'accuracyThreshold': '1.5'
}
}
questionnaire = factories.QuestionnaireFactory.create()

model = models.Question.objects.create_from_dict(
dict=question_dict,
questionnaire=questionnaire
)

assert model.question_group is None
assert model.questionnaire == questionnaire
assert model.label == question_dict['label']
assert model.name == question_dict['name']
assert model.type == 'GP'

model.refresh_from_db()
assert model.gps_accuracy == 1.5

def test_create_from_dict_include_invalid_accuracy(self):
question_dict = {
'label': 'point',
'name': 'point',
'type': 'geopoint',
'control': {
'accuracyThreshold': -1.5
}
}
questionnaire = factories.QuestionnaireFactory.create()

with pytest.raises(InvalidQuestionnaire):
models.Question.objects.create_from_dict(
dict=question_dict,
questionnaire=questionnaire
)

def test_create_from_dict_with_group(self):
question_dict = {
'hint': 'For this field (type=integer)',
Expand Down
35 changes: 33 additions & 2 deletions cadasta/questionnaires/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1193,7 +1193,8 @@ def test_serialize(self):
question = factories.QuestionFactory.create(
default='some default',
hint='An informative hint',
relevant='${party_id}="abc123"'
relevant='${party_id}="abc123"',
gps_accuracy=1.5
)
serializer = serializers.QuestionSerializer(question)

Expand All @@ -1206,6 +1207,7 @@ def test_serialize(self):
assert serializer.data['default'] == question.default
assert serializer.data['hint'] == question.hint
assert serializer.data['relevant'] == question.relevant
assert serializer.data['gps_accuracy'] == question.gps_accuracy
assert 'options' not in serializer.data
assert 'questionnaire' not in serializer.data
assert 'question_group' not in serializer.data
Expand Down Expand Up @@ -1252,7 +1254,8 @@ def test_create_question(self):
data = {
'label': 'A question',
'name': 'question',
'type': 'TX'
'type': 'TX',
'gps_accuracy': 1.5
}
serializer = serializers.QuestionSerializer(
data=data,
Expand All @@ -1264,6 +1267,34 @@ def test_create_question(self):
assert question.label == data['label']
assert question.type == data['type']
assert question.name == data['name']
assert question.gps_accuracy == data['gps_accuracy']

def test_create_question_with_invalid_accuracy(self):
questionnaire = factories.QuestionnaireFactory.create()
data = {
'label': 'A question',
'name': 'question',
'type': 'GP',
'gps_accuracy': -1.5
}
serializer = serializers.QuestionSerializer(
data=data,
context={'questionnaire_id': questionnaire.id})
with pytest.raises(ValidationError):
serializer.is_valid(raise_exception=True)

def test_create_question_ignore_invalid_accuracy(self):
questionnaire = factories.QuestionnaireFactory.create()
data = {
'label': 'A question',
'name': 'question',
'type': 'TX',
'gps_accuracy': -1.5
}
serializer = serializers.QuestionSerializer(
data=data,
context={'questionnaire_id': questionnaire.id})
assert serializer.is_valid(raise_exception=True) is True

def test_with_options(self):
questionnaire = factories.QuestionnaireFactory.create()
Expand Down
Loading

0 comments on commit 0cf53b3

Please sign in to comment.