Skip to content

Commit

Permalink
feat(search): support multiple choice and improve choice search
Browse files Browse the repository at this point in the history
Add support for searching multiple choice questions (both dynamic and
static). And while we're at it, also change the lookup of choice value
to use the option's label instead of the slug, which ought to be much
more useful.

Note: This implies that the label is present in the user's current
language, otherwise it won't find possible matches.
  • Loading branch information
winged committed Dec 17, 2020
1 parent 9f53094 commit 456bf08
Show file tree
Hide file tree
Showing 2 changed files with 136 additions and 5 deletions.
56 changes: 51 additions & 5 deletions caluma/caluma_form/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.db import ProgrammingError
from django.db.models import Q
from django.forms import BooleanField
from django.utils import translation
from django_filters.constants import EMPTY_VALUES
from django_filters.rest_framework import CharFilter, Filter, FilterSet
from graphene import Enum, InputObjectType, List
Expand All @@ -21,7 +22,7 @@
SlugMultipleChoiceFilter,
)
from ..caluma_core.ordering import AttributeOrderingFactory, MetaFieldOrdering
from ..caluma_form.models import Answer, Question
from ..caluma_form.models import Answer, DynamicOption, Question
from ..caluma_form.ordering import AnswerValueOrdering
from . import models, validators

Expand Down Expand Up @@ -292,7 +293,9 @@ class SearchAnswersFilter(Filter):
Question.TYPE_TEXTAREA: "value",
Question.TYPE_DATE: "date",
Question.TYPE_CHOICE: "value",
Question.TYPE_MULTIPLE_CHOICE: "value",
Question.TYPE_DYNAMIC_CHOICE: "value",
Question.TYPE_DYNAMIC_MULTIPLE_CHOICE: "value",
Question.TYPE_INTEGER: "value",
Question.TYPE_FLOAT: "value",
}
Expand Down Expand Up @@ -331,19 +334,62 @@ def _apply_filter(self, qs, value):

return qs

def _answers_with_word(self, questions, word, lookup):
exprs = [
Q(
def _word_lookup_for_question(self, question, word, lookup):
if question.type not in (
Question.TYPE_CHOICE,
Question.TYPE_DYNAMIC_CHOICE,
Question.TYPE_MULTIPLE_CHOICE,
Question.TYPE_DYNAMIC_MULTIPLE_CHOICE,
):
return Q(
**{
f"{self.FIELD_MAP[question.type]}__{lookup}": word,
"question": question,
}
)

# (Multiple) choice lookups need more specific treatment...
lang = translation.get_language()
is_multiple = question.type in (
Question.TYPE_MULTIPLE_CHOICE,
Question.TYPE_DYNAMIC_MULTIPLE_CHOICE,
)
is_dynamic = question.type in (
Question.TYPE_DYNAMIC_CHOICE,
Question.TYPE_DYNAMIC_MULTIPLE_CHOICE,
)

# find all options of our given question that match the
# word, then use their slugs for lookup
if is_dynamic:
matching_options = (
DynamicOption.objects.all()
.filter(question=question, **{f"label__{lang}__{lookup}": word})
.values_list("slug", flat=True)
)
else:
matching_options = question.options.filter(
**{f"label__{lang}__{lookup}": word}
).values_list("slug", flat=True)

if not matching_options:
# no labels = no results
return Q(value=False) & Q(value=True)

filt = "value__contains" if is_multiple else "value"
return reduce(
lambda a, b: a | b, [Q(**{filt: slug}) for slug in matching_options]
)

def _answers_with_word(self, questions, word, lookup):
exprs = [
self._word_lookup_for_question(question, word, lookup)
for q_slug, question in questions.items()
]

# join expressions with OR
return Answer.objects.filter(reduce(lambda a, b: a | b, exprs))
answer_qs = Answer.objects.filter(reduce(lambda a, b: a | b, exprs))
return answer_qs

def _validate_and_get_questions(self, questions):
res = {}
Expand Down
85 changes: 85 additions & 0 deletions caluma/caluma_form/tests/test_search_answers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from ...caluma_core.relay import extract_global_id
from .. import models

Expand Down Expand Up @@ -58,6 +60,89 @@ def _search(slugs, word, expect_count):
)


@pytest.mark.parametrize(
"question_type, search_text",
[
(models.Question.TYPE_CHOICE, "hello world"),
(models.Question.TYPE_DYNAMIC_CHOICE, "hello world"),
(models.Question.TYPE_MULTIPLE_CHOICE, "hello world"),
(models.Question.TYPE_DYNAMIC_MULTIPLE_CHOICE, "hello world"),
(models.Question.TYPE_CHOICE, "unrelated"),
],
)
def test_search_choice(
schema_executor,
db,
form_factory,
form_question_factory,
question_factory,
document_factory,
answer_factory,
form,
question_option_factory,
question_type,
search_text,
):
dynamic = "dynamic" in question_type

option_factory = (
models.DynamicOption.objects.create if dynamic else models.Option.objects.create
)

doc_a, doc_b = document_factory.create_batch(2, form=form)

question_a = question_factory(type=question_type)
if not dynamic:
opt_a = option_factory(slug="nonmatching-slug", label="hello world")
opt_b = option_factory(slug="another-slug", label="irrelevant")
question_option_factory(question=question_a, option=opt_a)
question_option_factory(question=question_a, option=opt_b)
question_b = question_factory(type=models.Question.TYPE_TEXT)

doc_a.answers.create(question=question_a, value="nonmatching-slug")
doc_a.answers.create(question=question_b, value="whatsup planet")
if dynamic:
opt_a = option_factory(
question=question_a,
document=doc_a,
slug="nonmatching-slug",
label="hello world",
)
opt_b = option_factory(
question=question_a, document=doc_a, slug="another-slug", label="irrelevant"
)

doc_b.answers.create(question=question_a, value="another-slug")
doc_b.answers.create(question=question_b, value="seeya world")

query = """
query ($search: [SearchAnswersFilterType!]) {
allDocuments (searchAnswers: $search) {
edges {
node {
id
}
}
}
}
"""

def _search(slugs, word, expect_count):
variables = {"search": [{"questions": slugs, "value": word}]}
result = schema_executor(query, variable_values=variables)

assert not result.errors
edges = result.data["allDocuments"]["edges"]
assert len(edges) == expect_count
return edges

edges = _search(
[question_a.slug], search_text, 0 if search_text == "unrelated" else 1
)
if search_text != "unrelated":
assert extract_global_id(edges[0]["node"]["id"]) == str(doc_a.id)


def test_search_multiple(
schema_executor,
db,
Expand Down

0 comments on commit 456bf08

Please sign in to comment.