Skip to content

Commit bd9286a

Browse files
authored
Merge pull request #2220 from open-dynaMIX/is-hidden-jexl-on-options
feat(options): implement is_hidden jexl on options
2 parents 5ead484 + 38ca1c4 commit bd9286a

File tree

7 files changed

+349
-8
lines changed

7 files changed

+349
-8
lines changed

caluma/caluma_form/factories.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ class Params:
121121
class OptionFactory(DjangoModelFactory):
122122
slug = Faker("slug")
123123
label = Faker("multilang", faker_provider="name")
124+
is_hidden = "false"
124125
is_archived = False
125126
meta = {}
126127

@@ -130,7 +131,7 @@ class Meta:
130131

131132
class QuestionOptionFactory(DjangoModelFactory):
132133
option = SubFactory(OptionFactory)
133-
question = SubFactory(QuestionFactory)
134+
question = SubFactory(QuestionFactory, type=models.Question.TYPE_CHOICE)
134135
sort = 0
135136

136137
class Meta:

caluma/caluma_form/filters.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
import graphene
55
from django.core import exceptions
66
from django.db import ProgrammingError
7-
from django.db.models import Q
7+
from django.db.models import F, Func, OuterRef, Q, Subquery
88
from django.forms import BooleanField
99
from django.utils import translation
1010
from django_filters.constants import EMPTY_VALUES
1111
from django_filters.rest_framework import Filter, FilterSet, MultipleChoiceFilter
1212
from graphene import Enum, InputObjectType, List
1313
from graphene_django.forms.converter import convert_form_field
1414
from graphene_django.registry import get_global_registry
15+
from rest_framework.exceptions import ValidationError
1516

1617
from ..caluma_core.filters import (
1718
CompositeFieldClass,
@@ -23,7 +24,7 @@
2324
from ..caluma_core.forms import GlobalIDFormField
2425
from ..caluma_core.ordering import AttributeOrderingFactory, MetaFieldOrdering
2526
from ..caluma_core.relay import extract_global_id
26-
from ..caluma_form.models import Answer, DynamicOption, Form, Question
27+
from ..caluma_form.models import Answer, DynamicOption, Form, Question, QuestionOption
2728
from ..caluma_form.ordering import AnswerValueOrdering
2829
from . import models, validators
2930

@@ -64,8 +65,70 @@ class Meta:
6465
fields = ("meta", "attribute")
6566

6667

68+
class VisibleOptionFilter(Filter):
69+
"""
70+
Filter options to only show ones whose `is_hidden` JEXL evaluates to false.
71+
72+
This will make sure all the `is_hidden`-JEXLs on the options are evaluated in the
73+
context of the provided document.
74+
75+
Note:
76+
This filter can only be used if the options in the QuerySet all belong only to
77+
one single question. Generally forms are built that way, but theoretically,
78+
options could be shared between questions. In that case it will throw a
79+
`ValidationError`.
80+
81+
Also note that this evaluates JEXL for all the options of the question, which
82+
has a good bit of performance impact.
83+
84+
"""
85+
86+
field_class = GlobalIDFormField
87+
88+
def _validate(self, qs):
89+
# can't directly annotate, because the filter might already restrict to a
90+
# certain Question. In that case, the count would always be one
91+
questions = (
92+
QuestionOption.objects.filter(option_id=OuterRef("pk"))
93+
.order_by()
94+
.annotate(count=Func(F("pk"), function="Count"))
95+
.values("count")
96+
)
97+
98+
qs = qs.annotate(num_questions=Subquery(questions)).annotate(
99+
question=F("questions")
100+
)
101+
102+
if (
103+
qs.filter(num_questions__gt=1).exists()
104+
or len(set(qs.values_list("question", flat=True))) > 1
105+
):
106+
raise ValidationError(
107+
"The `visibleInDocument`-filter can only be used if the filtered "
108+
"Options all belong to one unique question"
109+
)
110+
111+
def filter(self, qs, value):
112+
if value in EMPTY_VALUES or not qs.exists(): # pragma: no cover
113+
return qs
114+
115+
self._validate(qs)
116+
117+
document_id = extract_global_id(value)
118+
119+
# assuming qs can only ever be in the context of a single document
120+
document = models.Document.objects.get(pk=document_id)
121+
validator = validators.AnswerValidator()
122+
return qs.filter(
123+
slug__in=validator.visible_options(
124+
document, qs.first().questionoption_set.first().question, qs
125+
)
126+
)
127+
128+
67129
class OptionFilterSet(MetaFilterSet):
68130
search = SearchFilter(fields=("slug", "label"))
131+
visible_in_document = VisibleOptionFilter()
69132

70133
class Meta:
71134
model = models.Option
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 4.2.10 on 2024-05-31 06:26
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("caluma_form", "0047_alter_answer_documents"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="historicaloption",
14+
name="is_hidden",
15+
field=models.TextField(default="false"),
16+
),
17+
migrations.AddField(
18+
model_name="option",
19+
name="is_hidden",
20+
field=models.TextField(default="false"),
21+
),
22+
]

caluma/caluma_form/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ class Meta:
285285

286286
class Option(core_models.SlugModel):
287287
label = LocalizedField(blank=False, null=False, required=False)
288+
is_hidden = models.TextField(default="false")
288289
is_archived = models.BooleanField(default=False)
289290
meta = models.JSONField(default=dict)
290291
source = models.ForeignKey(

caluma/caluma_form/tests/test_option.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pytest
2+
from graphql_relay import to_global_id
23

34
from ...caluma_core.tests import extract_serializer_input_fields
45
from .. import models, serializers
@@ -50,3 +51,191 @@ def test_copy_option(db, option, schema_executor):
5051
assert new_option.label == "Test Option"
5152
assert new_option.meta == option.meta
5253
assert new_option.source == option
54+
55+
56+
@pytest.fixture(params=[models.Question.TYPE_CHOICE, True])
57+
def option_jexl_setup(
58+
document,
59+
question_option_factory,
60+
question_factory,
61+
answer_document_factory,
62+
form_question_factory,
63+
request,
64+
):
65+
def setup():
66+
question_type, is_hidden = request.param
67+
text_question = question_factory(type=models.Question.TYPE_TEXT)
68+
is_hidden_jexl = "false"
69+
if is_hidden:
70+
is_hidden_jexl = f"'{text_question.slug}'|answer == 'foo'"
71+
question_option = question_option_factory(
72+
option__is_hidden=is_hidden_jexl,
73+
option__slug="bar",
74+
question__type=question_type,
75+
)
76+
question_option_selected = question_option_factory(
77+
question=question_option.question
78+
)
79+
answer_document_factory(
80+
document=document,
81+
answer__document=document,
82+
answer__question=text_question,
83+
answer__value="foo",
84+
)
85+
answer_document_factory(
86+
document=document,
87+
answer__document=document,
88+
answer__question=question_option.question,
89+
answer__value=question_option_selected.option.pk,
90+
)
91+
form_question_factory(form=document.form, question=question_option.question)
92+
form_question_factory(form=document.form, question=text_question)
93+
return document, is_hidden, question_option
94+
95+
return setup
96+
97+
98+
@pytest.mark.parametrize(
99+
"option_jexl_setup,option_for_multiple_questions",
100+
[
101+
(
102+
[models.Question.TYPE_CHOICE, True],
103+
False,
104+
),
105+
(
106+
[models.Question.TYPE_CHOICE, False],
107+
False,
108+
),
109+
(
110+
[models.Question.TYPE_MULTIPLE_CHOICE, True],
111+
False,
112+
),
113+
(
114+
[models.Question.TYPE_MULTIPLE_CHOICE, False],
115+
False,
116+
),
117+
(
118+
[models.Question.TYPE_CHOICE, True],
119+
True,
120+
),
121+
],
122+
indirect=["option_jexl_setup"],
123+
)
124+
def test_option_is_hidden(
125+
db,
126+
option_jexl_setup,
127+
option_for_multiple_questions,
128+
question_option_factory,
129+
question_factory,
130+
schema_executor,
131+
):
132+
document, is_hidden, question_option = option_jexl_setup()
133+
if option_for_multiple_questions:
134+
question_option_factory(
135+
option=question_option.option, question=question_factory()
136+
)
137+
138+
query = """
139+
query Document($id: ID!, $question_id: ID!) {
140+
allDocuments(filter: [{id: $id}]) {
141+
edges {
142+
node {
143+
answers(filter: [{question: $question_id}]) {
144+
edges {
145+
node {
146+
... on StringAnswer {
147+
question {
148+
... on ChoiceQuestion {
149+
options(filter: [{visibleInDocument: $id}]) {
150+
edges {
151+
node {
152+
slug
153+
}
154+
}
155+
}
156+
}
157+
}
158+
}
159+
... on ListAnswer {
160+
question {
161+
... on MultipleChoiceQuestion {
162+
options(filter: [{visibleInDocument: $id}]) {
163+
edges {
164+
node {
165+
slug
166+
}
167+
}
168+
}
169+
}
170+
}
171+
}
172+
}
173+
}
174+
}
175+
}
176+
}
177+
}
178+
}
179+
"""
180+
181+
variables = {
182+
"id": to_global_id("Document", document),
183+
"question_id": to_global_id("Question", question_option.question.pk),
184+
}
185+
186+
result = schema_executor(query, variable_values=variables)
187+
assert bool(result.errors) is option_for_multiple_questions
188+
if option_for_multiple_questions:
189+
assert len(result.errors) == 1
190+
assert result.errors[0].message == (
191+
"[ErrorDetail(string='The `visibleInDocument`-filter can only be used if "
192+
"the filtered Options all belong to one unique question', code='invalid')]"
193+
)
194+
return
195+
196+
options = result.data["allDocuments"]["edges"][0]["node"]["answers"]["edges"][0][
197+
"node"
198+
]["question"]["options"]["edges"]
199+
expected = [{"node": {"slug": "bar"}}, {"node": {"slug": "thing-piece"}}]
200+
if is_hidden:
201+
expected = [{"node": {"slug": "thing-piece"}}]
202+
assert options == expected
203+
204+
205+
@pytest.mark.parametrize(
206+
"option_jexl_setup",
207+
[
208+
[models.Question.TYPE_CHOICE, True],
209+
[models.Question.TYPE_CHOICE, False],
210+
],
211+
indirect=["option_jexl_setup"],
212+
)
213+
def test_option_is_hidden_save(
214+
db,
215+
option_jexl_setup,
216+
schema_executor,
217+
):
218+
document, is_hidden, choice_question_option = option_jexl_setup()
219+
220+
query = """
221+
mutation saveDocumentStringAnswer($input: SaveDocumentStringAnswerInput!) {
222+
saveDocumentStringAnswer(input: $input) {
223+
answer {
224+
__typename
225+
}
226+
}
227+
}
228+
"""
229+
230+
variables = {
231+
"input": {
232+
"document": to_global_id("Document", document.pk),
233+
"question": to_global_id(
234+
"ChoiceQuestion", choice_question_option.question.pk
235+
),
236+
"value": choice_question_option.option.pk,
237+
}
238+
}
239+
240+
result = schema_executor(query, variable_values=variables)
241+
assert bool(result.errors) is is_hidden

0 commit comments

Comments
 (0)