Skip to content

Commit

Permalink
feat(data_source): add question and context arguments for data sources
Browse files Browse the repository at this point in the history
BREAKING CHANGE: The method `get_data` on data sources receives two new
arguments `question` (caluma question model) and `context` (dict). The
method `validate_answer_value` on data sources receives a new argument
`context` (dict).
  • Loading branch information
anehx committed Nov 17, 2022
1 parent b6687f9 commit cb2739e
Show file tree
Hide file tree
Showing 14 changed files with 574 additions and 41 deletions.
4 changes: 2 additions & 2 deletions caluma/caluma_data_source/data_source_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@ def get_data_sources(dic=False):
]


def get_data_source_data(user, name):
def get_data_source_data(user, name, question, context):
data_sources = get_data_sources(dic=True)
if name not in data_sources:
raise DataSourceException(f"No data_source found for name: {name}")

raw_data = data_sources[name]().try_get_data_with_fallback(user)
raw_data = data_sources[name]().try_get_data_with_fallback(user, question, context)
if not is_iterable_and_no_string(raw_data):
raise DataSourceException(f"Failed to parse data from source: {name}")

Expand Down
19 changes: 10 additions & 9 deletions caluma/caluma_data_source/data_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ class BaseDataSource:
two items. The first will be used for the option name, the second one for it's
value. If only one value is provided, this value will also be used as choice name.
The `validate_answer_value`-method checks if each value in `self.get_data(user)` equals the value
of the parameter `value`. If this is correct the method returns the label as a String
and otherwise the method returns `False`.
The `validate_answer_value`-method checks if each value in
`self.get_data(user, question, context)` equals the value of the parameter
`value`. If this is correct the method returns the label as a String and
otherwise the method returns `False`.
Examples:
[['my-option', {"en": "english description", "de": "deutsche Beschreibung"}, ...]
Expand All @@ -40,7 +41,7 @@ class BaseDataSource:
... info = 'User choices from "someapi"'
...
... @data_source_cache(timeout=3600)
... def get_data(self, user):
... def get_data(self, user, question, context):
... response = requests.get(f"https://someapi/?user={user.username}")
... return [result["value"] for result in response.json()["results"]]
```
Expand All @@ -53,11 +54,11 @@ class BaseDataSource:
def __init__(self):
pass

def get_data(self, user): # pragma: no cover
def get_data(self, user, question, context): # pragma: no cover
raise NotImplementedError()

def validate_answer_value(self, value, document, question, user):
for data in self.get_data(user):
def validate_answer_value(self, value, document, question, user, context):
for data in self.get_data(user, question, context):
label = data
if is_iterable_and_no_string(data):
label = data[-1]
Expand All @@ -73,9 +74,9 @@ def validate_answer_value(self, value, document, question, user):
return dynamic_option.label
return False

def try_get_data_with_fallback(self, user):
def try_get_data_with_fallback(self, user, question, context):
try:
new_data = self.get_data(user)
new_data = self.get_data(user, question, context)
except Exception as e:
logger.exception(
f"Executing {type(self).__name__}.get_data() failed:"
Expand Down
21 changes: 17 additions & 4 deletions caluma/caluma_data_source/schema.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from graphene import ConnectionField, String
from graphene import ConnectionField, JSONString, String
from graphene.types import ObjectType

from ..caluma_core.types import CountableConnectionBase
from ..caluma_form.models import Question
from .data_source_handlers import get_data_source_data, get_data_sources


Expand All @@ -27,10 +28,22 @@ class Meta:

class Query(ObjectType):
all_data_sources = ConnectionField(DataSourceConnection)
data_source = ConnectionField(DataSourceDataConnection, name=String(required=True))
data_source = ConnectionField(
DataSourceDataConnection,
name=String(required=True),
question=String(
description="Slug of the question passed as context to the data source"
),
context=JSONString(
description="JSON object passed as context to the data source"
),
)

def resolve_all_data_sources(self, info, **kwargs):
return get_data_sources()

def resolve_data_source(self, info, name, **kwargs):
return get_data_source_data(info.context.user, name)
def resolve_data_source(self, info, name, question=None, context=None, **kwargs):
if question:
question = Question.objects.get(pk=question)

return get_data_source_data(info.context.user, name, question, context)
Original file line number Diff line number Diff line change
@@ -1,3 +1,47 @@
# name: test_data_source_context[False-False]
dict({
'dataSource': dict({
'edges': list([
]),
}),
})
# ---
# name: test_data_source_context[False-True]
dict({
'dataSource': dict({
'edges': list([
dict({
'node': dict({
'label': 'q: environmental-ten',
'slug': 'option-without-context',
}),
}),
]),
}),
})
# ---
# name: test_data_source_context[True-False]
dict({
'dataSource': dict({
'edges': list([
]),
}),
})
# ---
# name: test_data_source_context[True-True]
dict({
'dataSource': dict({
'edges': list([
dict({
'node': dict({
'label': 'q: environmental-ten foo: bar',
'slug': 'option-with-context',
}),
}),
]),
}),
})
# ---
# name: test_data_source_defaults
dict({
'dataSource': dict({
Expand Down
29 changes: 23 additions & 6 deletions caluma/caluma_data_source/tests/data_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class MyDataSource(BaseDataSource):
default = [1, 2, 3]

@data_source_cache(timeout=3600)
def get_data(self, user):
def get_data(self, user, question, context):
return [
1,
(5.5,),
Expand All @@ -36,7 +36,7 @@ def get_data_expire(self, info):


class MyOtherDataSource(MyDataSource):
def validate_answer_value(self, value, document, question, info):
def validate_answer_value(self, value, document, question, info, context):
return "Test 123"


Expand All @@ -45,7 +45,7 @@ class MyFaultyDataSource(BaseDataSource):
default = None

@data_source_cache(timeout=3600)
def get_data(self, user):
def get_data(self, user, question, context):
return "just a string"


Expand All @@ -54,7 +54,7 @@ class MyOtherFaultyDataSource(BaseDataSource):
default = None

@data_source_cache(timeout=3600)
def get_data(self, user):
def get_data(self, user, question, context):
return [["just", "some", "strings"]]


Expand All @@ -63,7 +63,7 @@ class MyBrokenDataSource(BaseDataSource):
default = [1, 2, 3]

@data_source_cache(timeout=3600)
def get_data(self, user):
def get_data(self, user, question, context):
raise Exception()


Expand All @@ -72,5 +72,22 @@ class MyOtherBrokenDataSource(BaseDataSource):
default = None

@data_source_cache(timeout=3600)
def get_data(self, user):
def get_data(self, user, question, context):
raise Exception()


class MyDataSourceWithContext(BaseDataSource):
info = "Data source using context for testing"

def get_data(self, user, question, context):
if not question:
return []

slug = "option-without-context"
label = f"q: {question.slug}"

if context:
slug = "option-with-context"
label += f" foo: {context['foo']}"

return [[slug, label]]
41 changes: 41 additions & 0 deletions caluma/caluma_data_source/tests/test_data_source.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import json

import pytest
from django.core.cache import cache
from django.utils import translation
Expand Down Expand Up @@ -254,3 +256,42 @@ def __str__(self):
created_by_user="asdf",
created_by_group="foobar",
).exists()


@pytest.mark.parametrize("has_question", [True, False])
@pytest.mark.parametrize("has_context", [True, False])
def test_data_source_context(
db, snapshot, schema_executor, settings, question, has_question, has_context
):
settings.DATA_SOURCE_CLASSES = [
"caluma.caluma_data_source.tests.data_sources.MyDataSourceWithContext"
]

query = """
query($question: String, $context: JSONString) {
dataSource(
name: "MyDataSourceWithContext"
question: $question
context: $context
) {
edges {
node {
label
slug
}
}
}
}
"""

variables = {}

if has_question:
variables["question"] = question.slug

if has_context:
variables["context"] = json.dumps({"foo": "bar"})

result = schema_executor(query, variable_values=variables)
assert not result.errors
snapshot.assert_match(result.data)
4 changes: 2 additions & 2 deletions caluma/caluma_data_source/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
def data_source_cache(timeout=None):
def decorator(method):
@functools.wraps(method)
def handle_cache(self, info):
def handle_cache(self, *args, **kwargs):
key = f"data_source_{type(self).__name__}"

get_data_method = functools.partial(method, self, info)
get_data_method = functools.partial(method, self, *args, **kwargs)
return cache.get_or_set(key, get_data_method, timeout)

return handle_cache
Expand Down
12 changes: 10 additions & 2 deletions caluma/caluma_form/domain_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,11 @@ def update_answer_files(cls, answer: models.Answer, files: list):

@staticmethod
def validate_for_save(
data: dict, user: BaseUser, answer: models.Answer = None, origin: bool = False
data: dict,
user: BaseUser,
answer: models.Answer = None,
origin: bool = False,
data_source_context: dict = None,
) -> dict:
question = data["question"]

Expand All @@ -127,7 +131,11 @@ def validate_for_save(
del data["value"]

validators.AnswerValidator().validate(
**data, user=user, instance=answer, origin=origin
**data,
user=user,
instance=answer,
origin=origin,
data_source_context=data_source_context,
)

return data
Expand Down
11 changes: 8 additions & 3 deletions caluma/caluma_form/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,12 +328,17 @@ class Meta:


class DynamicQuestion(graphene.Interface):
options = ConnectionField(DataSourceDataConnection)
options = ConnectionField(
DataSourceDataConnection,
context=graphene.JSONString(
description="JSON object passed as context to the data source"
),
)
data_source = graphene.String(required=True)
hint_text = graphene.String()

def resolve_options(self, info, *args, **kwargs):
return get_data_source_data(info.context.user, self.data_source)
def resolve_options(self, info, context=None, *args, **kwargs):
return get_data_source_data(info.context.user, self.data_source, self, context)


class DynamicChoiceQuestion(QuestionQuerysetMixin, FormDjangoObjectType):
Expand Down
24 changes: 22 additions & 2 deletions caluma/caluma_form/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
DateField,
FloatField,
IntegerField,
JSONField,
ListField,
PrimaryKeyRelatedField,
)
Expand Down Expand Up @@ -524,9 +525,21 @@ class Meta:


class SaveAnswerSerializer(serializers.ModelSerializer):
data_source_context = JSONField(
encoder=None,
required=False,
allow_null=True,
write_only=True,
help_text="JSON object passed as context to the data source of dynamic questions",
)

def validate(self, data):
data = domain_logic.SaveAnswerLogic.validate_for_save(
data, self.context["request"].user, self.instance, True
data,
self.context["request"].user,
self.instance,
True,
data.pop("data_source_context", None),
)
return super().validate(data)

Expand All @@ -542,7 +555,14 @@ def update(self, instance, validated_data):

class Meta:
model = models.Answer
fields = ["question", "document", "meta", "value"]
fields = [
"question",
"document",
"meta",
"value",
"question",
"data_source_context",
]


class SaveDocumentStringAnswerSerializer(SaveAnswerSerializer):
Expand Down
Loading

0 comments on commit cb2739e

Please sign in to comment.