Skip to content

Commit

Permalink
feat(jexl): add support for nested forms (#398)
Browse files Browse the repository at this point in the history
* feat(jexl): add support for nested forms

* Add tests

* Refactor jexl api: prefix path in answer transform
  • Loading branch information
czosel authored Apr 29, 2019
1 parent 3e83b44 commit 1835c42
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 32 deletions.
12 changes: 11 additions & 1 deletion caluma/form/jexl.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,18 @@ class QuestionJexl(JEXL):
def __init__(self, answer_by_question={}, **kwargs):
super().__init__(**kwargs)

self.add_transform("answer", lambda question: answer_by_question.get(question))
self.context = answer_by_question
self.add_transform("answer", self.answer_transform)
self.add_transform("mapby", lambda arr, key: [obj[key] for obj in arr])

def answer_transform(self, question_with_path):
current_context = self.context
segments = question_with_path.split(".")
question = segments.pop()
for segment in segments:
current_context = current_context.get(segment)

return current_context.get(question)

def validate(self, expression):
return super().validate(expression, QuestionValidatingAnalyzer)
19 changes: 19 additions & 0 deletions caluma/form/tests/test_jexl.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,22 @@
def test_question_jexl_validate(expression, num_errors):
jexl = QuestionJexl()
assert len(list(jexl.validate(expression))) == num_errors


@pytest.mark.parametrize(
"expression,result",
[
('"a1"|answer', "A1"),
('"parent.form_b.b1"|answer', "B1"),
('"parent.form_b.parent.form_a.a1"|answer', "A1"),
],
)
def test_jexl_traversal(expression, result):
form_a = {"a1": "A1"}
form_b = {"b1": "B1"}
parent = {"form_a": form_a, "form_b": form_b}
form_a["parent"] = parent
form_b["parent"] = parent

jexl = QuestionJexl(form_a)
assert jexl.evaluate(expression) == result
86 changes: 86 additions & 0 deletions caluma/form/tests/test_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import pytest
from rest_framework.exceptions import ValidationError

from ...form.models import Document, Question
from ..validators import DocumentValidator


@pytest.mark.parametrize("is_required", ["true", "false"])
def test_validate_simple_required_field(
db, is_required, form_question, document_factory
):
form_question.question.is_required = is_required
form_question.question.save()

document = document_factory(form=form_question.form)
error_msg = f"Questions {form_question.question.slug} are required but not provided"
if is_required == "true":
with pytest.raises(ValidationError, match=error_msg):
DocumentValidator().validate(document)
else:
DocumentValidator().validate(document)


@pytest.mark.parametrize(
"required_jexl,should_throw",
[
("true", True),
("false", False),
("'parent.sub_2.sub_2_question_1'|answer == 'foo'", True),
("'parent.sub_2.sub_2_question_1'|answer == 'bar'", False),
],
)
def test_validate_nested_form(
db,
required_jexl,
should_throw,
form,
form_question_factory,
document_factory,
question_factory,
answer_factory,
answer_document_factory,
):
sub_form_question_1 = form_question_factory(
form__slug="sub_1",
question__type=Question.TYPE_TEXT,
question__is_required=required_jexl,
question__slug="sub_1_question_1",
)
sub_form_question_2 = form_question_factory(
form__slug="sub_2",
question__type=Question.TYPE_TEXT,
question__is_required="true",
question__slug="sub_2_question_1",
)

main_form_question_1 = form_question_factory(
question__type=Question.TYPE_FORM,
question__sub_form=sub_form_question_1.form,
question__slug="sub_1",
question__is_required="false",
)
form_question_factory(
form=main_form_question_1.form,
question__type=Question.TYPE_FORM,
question__sub_form=sub_form_question_2.form,
question__slug="sub_2",
)

main_document = document_factory(form=main_form_question_1.form)

Document.objects.create_and_link_child_documents(
main_form_question_1.form, main_document
)

sub_2_document = Document.objects.filter(form__slug="sub_2").first()
answer_factory(
document=sub_2_document, question=sub_form_question_2.question, value="foo"
)

if should_throw:
error_msg = f"Questions {sub_form_question_1.question.slug} are required but not provided"
with pytest.raises(ValidationError, match=error_msg):
DocumentValidator().validate(main_document)
else:
DocumentValidator().validate(main_document)
86 changes: 56 additions & 30 deletions caluma/form/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
from rest_framework import exceptions

from . import jexl
from .models import Question


class AnswerValidator:
def _validate_question_text(self, question, value):
def _validate_question_text(self, question, value, **kwargs):
max_length = (
question.max_length if question.max_length is not None else sys.maxsize
)
Expand All @@ -16,10 +17,10 @@ def _validate_question_text(self, question, value):
f"Should be of type str and max length {max_length}"
)

def _validate_question_textarea(self, question, value):
def _validate_question_textarea(self, question, value, **kwargs):
self._validate_question_text(question, value)

def _validate_question_float(self, question, value):
def _validate_question_float(self, question, value, document):
min_value = (
question.min_value if question.min_value is not None else float("-inf")
)
Expand All @@ -34,7 +35,7 @@ def _validate_question_float(self, question, value):
f"and not greater than {max_value}"
)

def _validate_question_integer(self, question, value):
def _validate_question_integer(self, question, value, **kwargs):
min_value = (
question.min_value if question.min_value is not None else float("-inf")
)
Expand All @@ -49,18 +50,18 @@ def _validate_question_integer(self, question, value):
f"and not greater than {max_value}"
)

def _validate_question_date(self, question, value):
def _validate_question_date(self, question, value, **kwargs):
pass

def _validate_question_choice(self, question, value):
def _validate_question_choice(self, question, value, **kwargs):
options = question.options.values_list("slug", flat=True)
if not isinstance(value, str) or value not in options:
raise exceptions.ValidationError(
f"Invalid value {value}. "
f"Should be of type str and one of the options {'.'.join(options)}"
)

def _validate_question_multiple_choice(self, question, value):
def _validate_question_multiple_choice(self, question, value, **kwargs):
options = question.options.values_list("slug", flat=True)
invalid_options = set(value) - set(options)
if not isinstance(value, list) or invalid_options:
Expand All @@ -69,17 +70,17 @@ def _validate_question_multiple_choice(self, question, value):
f"Should be one of the options [{', '.join(options)}]"
)

def _validate_question_table(self, question, value):
for document in value:
DocumentValidator().validate(form=document.form, answers=document.answers)
def _validate_question_table(self, question, value, document):
for _document in value:
DocumentValidator().validate(_document, parent=document)

def _validate_question_form(self, question, value):
DocumentValidator().validate(form=value.form, answers=value.answers)
def _validate_question_form(self, question, value, document):
DocumentValidator().validate(value, parent=document)

def _validate_question_file(self, question, value):
def _validate_question_file(self, question, value, **kwargs):
pass

def validate(self, *, question, **kwargs):
def validate(self, *, question, document, **kwargs):
# Check all possible fields for value
value = None
for i in ["value", "file", "date", "documents", "value_document"]:
Expand All @@ -90,35 +91,63 @@ def validate(self, *, question, **kwargs):
# empty values are allowed
# required check will be done in DocumentValidator
if value:
getattr(self, f"_validate_question_{question.type}")(question, value)
getattr(self, f"_validate_question_{question.type}")(
question, value, document=document
)


class DocumentValidator:
def validate(self, *, form, answers, **kwargs):
def validate(self, document, **kwargs):
def get_answers_by_question(document):
answers = document.answers.select_related("question").prefetch_related(
"question__options"
)
return {
answer.question.slug: self.get_answer_value(answer, document)
for answer in answers
}

answer_by_question = get_answers_by_question(document)
parent = kwargs.get("parent", None)
if parent:
answer_by_question["parent"] = get_answers_by_question(parent)

self.validate_required(document, answer_by_question)

for answer in document.answers.all():
AnswerValidator().validate(
document=document,
question=answer.question,
value=answer.value,
value_document=answer.value_document,
)

def get_answer_value(self, answer, document):
def get_document_answers(document):
return {
answer.question.pk: get_answer_value(answer)
answer.question.pk: self.get_answer_value(answer, document)
for answer in document.answers.all()
}

def get_answer_value(answer):
if answer.value is None:
if answer.value is None:
if answer.question.type == Question.TYPE_FORM:
# form type maps to dict
return get_document_answers(answer.value_document)

elif answer.question.type == Question.TYPE_TABLE:
# table type maps to list of dicts
return [
get_document_answers(document)
for document in answer.documents.all()
]
else: # pragma: no cover
raise Exception("unhandled question type mapping")

return answer.value
return answer.value

answers = answers.select_related("question").prefetch_related(
"question__options"
)
answer_by_question = {
answer.question.slug: get_answer_value(answer) for answer in answers
}
def validate_required(self, document, answer_by_question):
required_but_empty = []
for question in form.questions.all():
for question in document.form.questions.all():
if jexl.QuestionJexl(answer_by_question).evaluate(question.is_required):
if not answer_by_question.get(question.slug, None):
required_but_empty.append(question.slug)
Expand All @@ -127,6 +156,3 @@ def get_answer_value(answer):
raise exceptions.ValidationError(
f"Questions {','.join(required_but_empty)} are required but not provided."
)

for answer in answers:
AnswerValidator().validate(question=answer.question, value=answer.value)
2 changes: 1 addition & 1 deletion caluma/workflow/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def _validate_task_complete_workflow_form(self, task, case, document):
self._validate_task_complete_task_form(task, case, case.document)

def _validate_task_complete_task_form(self, task, case, document):
DocumentValidator().validate(answers=document.answers, form=document.form)
DocumentValidator().validate(document)

def validate(self, *, status, child_case, case, task, document, **kwargs):
if status != models.WorkItem.STATUS_READY:
Expand Down

0 comments on commit 1835c42

Please sign in to comment.