Skip to content

Commit

Permalink
feat(options): implement is_hidden jexl on options
Browse files Browse the repository at this point in the history
This commit adds an `is_hidden` jexl to Options. This nwill be evaluated
and enforced on saving of answers. Addiotionally, the Options got a new
filter `visible_in_document`.
  • Loading branch information
open-dynaMIX committed Jun 17, 2024
1 parent 5ead484 commit 38ca1c4
Show file tree
Hide file tree
Showing 7 changed files with 349 additions and 8 deletions.
3 changes: 2 additions & 1 deletion caluma/caluma_form/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ class Params:
class OptionFactory(DjangoModelFactory):
slug = Faker("slug")
label = Faker("multilang", faker_provider="name")
is_hidden = "false"
is_archived = False
meta = {}

Expand All @@ -130,7 +131,7 @@ class Meta:

class QuestionOptionFactory(DjangoModelFactory):
option = SubFactory(OptionFactory)
question = SubFactory(QuestionFactory)
question = SubFactory(QuestionFactory, type=models.Question.TYPE_CHOICE)
sort = 0

class Meta:
Expand Down
67 changes: 65 additions & 2 deletions caluma/caluma_form/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
import graphene
from django.core import exceptions
from django.db import ProgrammingError
from django.db.models import Q
from django.db.models import F, Func, OuterRef, Q, Subquery
from django.forms import BooleanField
from django.utils import translation
from django_filters.constants import EMPTY_VALUES
from django_filters.rest_framework import Filter, FilterSet, MultipleChoiceFilter
from graphene import Enum, InputObjectType, List
from graphene_django.forms.converter import convert_form_field
from graphene_django.registry import get_global_registry
from rest_framework.exceptions import ValidationError

from ..caluma_core.filters import (
CompositeFieldClass,
Expand All @@ -23,7 +24,7 @@
from ..caluma_core.forms import GlobalIDFormField
from ..caluma_core.ordering import AttributeOrderingFactory, MetaFieldOrdering
from ..caluma_core.relay import extract_global_id
from ..caluma_form.models import Answer, DynamicOption, Form, Question
from ..caluma_form.models import Answer, DynamicOption, Form, Question, QuestionOption
from ..caluma_form.ordering import AnswerValueOrdering
from . import models, validators

Expand Down Expand Up @@ -64,8 +65,70 @@ class Meta:
fields = ("meta", "attribute")


class VisibleOptionFilter(Filter):
"""
Filter options to only show ones whose `is_hidden` JEXL evaluates to false.
This will make sure all the `is_hidden`-JEXLs on the options are evaluated in the
context of the provided document.
Note:
This filter can only be used if the options in the QuerySet all belong only to
one single question. Generally forms are built that way, but theoretically,
options could be shared between questions. In that case it will throw a
`ValidationError`.
Also note that this evaluates JEXL for all the options of the question, which
has a good bit of performance impact.
"""

field_class = GlobalIDFormField

def _validate(self, qs):
# can't directly annotate, because the filter might already restrict to a
# certain Question. In that case, the count would always be one
questions = (
QuestionOption.objects.filter(option_id=OuterRef("pk"))
.order_by()
.annotate(count=Func(F("pk"), function="Count"))
.values("count")
)

qs = qs.annotate(num_questions=Subquery(questions)).annotate(
question=F("questions")
)

if (
qs.filter(num_questions__gt=1).exists()
or len(set(qs.values_list("question", flat=True))) > 1
):
raise ValidationError(
"The `visibleInDocument`-filter can only be used if the filtered "
"Options all belong to one unique question"
)

def filter(self, qs, value):
if value in EMPTY_VALUES or not qs.exists(): # pragma: no cover
return qs

self._validate(qs)

document_id = extract_global_id(value)

# assuming qs can only ever be in the context of a single document
document = models.Document.objects.get(pk=document_id)
validator = validators.AnswerValidator()
return qs.filter(
slug__in=validator.visible_options(
document, qs.first().questionoption_set.first().question, qs
)
)


class OptionFilterSet(MetaFilterSet):
search = SearchFilter(fields=("slug", "label"))
visible_in_document = VisibleOptionFilter()

class Meta:
model = models.Option
Expand Down
22 changes: 22 additions & 0 deletions caluma/caluma_form/migrations/0048_option_is_hidden.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 4.2.10 on 2024-05-31 06:26

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("caluma_form", "0047_alter_answer_documents"),
]

operations = [
migrations.AddField(
model_name="historicaloption",
name="is_hidden",
field=models.TextField(default="false"),
),
migrations.AddField(
model_name="option",
name="is_hidden",
field=models.TextField(default="false"),
),
]
1 change: 1 addition & 0 deletions caluma/caluma_form/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ class Meta:

class Option(core_models.SlugModel):
label = LocalizedField(blank=False, null=False, required=False)
is_hidden = models.TextField(default="false")
is_archived = models.BooleanField(default=False)
meta = models.JSONField(default=dict)
source = models.ForeignKey(
Expand Down
189 changes: 189 additions & 0 deletions caluma/caluma_form/tests/test_option.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from graphql_relay import to_global_id

from ...caluma_core.tests import extract_serializer_input_fields
from .. import models, serializers
Expand Down Expand Up @@ -50,3 +51,191 @@ def test_copy_option(db, option, schema_executor):
assert new_option.label == "Test Option"
assert new_option.meta == option.meta
assert new_option.source == option


@pytest.fixture(params=[models.Question.TYPE_CHOICE, True])
def option_jexl_setup(
document,
question_option_factory,
question_factory,
answer_document_factory,
form_question_factory,
request,
):
def setup():
question_type, is_hidden = request.param
text_question = question_factory(type=models.Question.TYPE_TEXT)
is_hidden_jexl = "false"
if is_hidden:
is_hidden_jexl = f"'{text_question.slug}'|answer == 'foo'"
question_option = question_option_factory(
option__is_hidden=is_hidden_jexl,
option__slug="bar",
question__type=question_type,
)
question_option_selected = question_option_factory(
question=question_option.question
)
answer_document_factory(
document=document,
answer__document=document,
answer__question=text_question,
answer__value="foo",
)
answer_document_factory(
document=document,
answer__document=document,
answer__question=question_option.question,
answer__value=question_option_selected.option.pk,
)
form_question_factory(form=document.form, question=question_option.question)
form_question_factory(form=document.form, question=text_question)
return document, is_hidden, question_option

return setup


@pytest.mark.parametrize(
"option_jexl_setup,option_for_multiple_questions",
[
(
[models.Question.TYPE_CHOICE, True],
False,
),
(
[models.Question.TYPE_CHOICE, False],
False,
),
(
[models.Question.TYPE_MULTIPLE_CHOICE, True],
False,
),
(
[models.Question.TYPE_MULTIPLE_CHOICE, False],
False,
),
(
[models.Question.TYPE_CHOICE, True],
True,
),
],
indirect=["option_jexl_setup"],
)
def test_option_is_hidden(
db,
option_jexl_setup,
option_for_multiple_questions,
question_option_factory,
question_factory,
schema_executor,
):
document, is_hidden, question_option = option_jexl_setup()
if option_for_multiple_questions:
question_option_factory(
option=question_option.option, question=question_factory()
)

query = """
query Document($id: ID!, $question_id: ID!) {
allDocuments(filter: [{id: $id}]) {
edges {
node {
answers(filter: [{question: $question_id}]) {
edges {
node {
... on StringAnswer {
question {
... on ChoiceQuestion {
options(filter: [{visibleInDocument: $id}]) {
edges {
node {
slug
}
}
}
}
}
}
... on ListAnswer {
question {
... on MultipleChoiceQuestion {
options(filter: [{visibleInDocument: $id}]) {
edges {
node {
slug
}
}
}
}
}
}
}
}
}
}
}
}
}
"""

variables = {
"id": to_global_id("Document", document),
"question_id": to_global_id("Question", question_option.question.pk),
}

result = schema_executor(query, variable_values=variables)
assert bool(result.errors) is option_for_multiple_questions
if option_for_multiple_questions:
assert len(result.errors) == 1
assert result.errors[0].message == (
"[ErrorDetail(string='The `visibleInDocument`-filter can only be used if "
"the filtered Options all belong to one unique question', code='invalid')]"
)
return

options = result.data["allDocuments"]["edges"][0]["node"]["answers"]["edges"][0][
"node"
]["question"]["options"]["edges"]
expected = [{"node": {"slug": "bar"}}, {"node": {"slug": "thing-piece"}}]
if is_hidden:
expected = [{"node": {"slug": "thing-piece"}}]
assert options == expected


@pytest.mark.parametrize(
"option_jexl_setup",
[
[models.Question.TYPE_CHOICE, True],
[models.Question.TYPE_CHOICE, False],
],
indirect=["option_jexl_setup"],
)
def test_option_is_hidden_save(
db,
option_jexl_setup,
schema_executor,
):
document, is_hidden, choice_question_option = option_jexl_setup()

query = """
mutation saveDocumentStringAnswer($input: SaveDocumentStringAnswerInput!) {
saveDocumentStringAnswer(input: $input) {
answer {
__typename
}
}
}
"""

variables = {
"input": {
"document": to_global_id("Document", document.pk),
"question": to_global_id(
"ChoiceQuestion", choice_question_option.question.pk
),
"value": choice_question_option.option.pk,
}
}

result = schema_executor(query, variable_values=variables)
assert bool(result.errors) is is_hidden
Loading

0 comments on commit 38ca1c4

Please sign in to comment.