Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add background-geopoint question type which exposes xforms-value-changed event with odk:setgeopoint action #1

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions pyxform/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ def __init__(self, **kwargs):

# dictionary of setvalue target and value tuple indexed by triggering element
self.setvalues_by_triggering_ref = {}
# dictionary of setgeopoint target and value tuple indexed by triggering element
self.setgeopoint_by_triggering_ref = {}
# For tracking survey-level choices while recursing through the survey.
self._choices: dict[str, Any] = {}

Expand Down Expand Up @@ -117,6 +119,7 @@ def create_survey_element_from_dict(

if d[const.TYPE] == const.SURVEY:
section.setvalues_by_triggering_ref = self.setvalues_by_triggering_ref
section.setgeopoint_by_triggering_ref = self.setgeopoint_by_triggering_ref
section.choices = self._choices

return section
Expand All @@ -137,28 +140,27 @@ def create_survey_element_from_dict(
return ExternalInstance(**d)
elif d[const.TYPE] == "entity":
return EntityDeclaration(**d)
elif d[const.TYPE] == "background-geopoint":
self._save_trigger_and_remove_calculate(d, self.setgeopoint_by_triggering_ref)
return self._create_question_from_dict(
d, copy_json_dict(QUESTION_TYPE_DICT), self._add_none_option
)
else:
self._save_trigger_as_setvalue_and_remove_calculate(d)

self._save_trigger_and_remove_calculate(d, self.setvalues_by_triggering_ref)
return self._create_question_from_dict(
d, copy_json_dict(QUESTION_TYPE_DICT), self._add_none_option
)

def _save_trigger_as_setvalue_and_remove_calculate(self, d):
def _save_trigger_and_remove_calculate(self, d, target_dict):
if "trigger" in d:
triggering_ref = re.sub(r"\s+", "", d["trigger"])
value = ""
if const.BIND in d and "calculate" in d[const.BIND]:
value = d[const.BIND]["calculate"]

if triggering_ref in self.setvalues_by_triggering_ref:
self.setvalues_by_triggering_ref[triggering_ref].append(
(d[const.NAME], value)
)
if triggering_ref in target_dict:
target_dict[triggering_ref].append((d[const.NAME], value))
else:
self.setvalues_by_triggering_ref[triggering_ref] = [
(d[const.NAME], value)
]
target_dict[triggering_ref] = [(d[const.NAME], value)]

@staticmethod
def _create_question_from_dict(
Expand Down
59 changes: 43 additions & 16 deletions pyxform/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ def validate(self):
# question type dictionary.
if self.type not in QUESTION_TYPE_DICT:
raise PyXFormError(f"Unknown question type '{self.type}'.")
# ensure that background-geopoint questions have non-null triggers that correspond to an exsisting questions
if self.type == "background-geopoint":
if not self.trigger:
raise PyXFormError(
f"background-geopoint question '{self.name}' must have a non-null trigger."
)
trigger_cleaned = self.trigger.strip("${}")
if not self.get_root().question_exists(trigger_cleaned):
raise PyXFormError(
f"Trigger '{trigger_cleaned}' for background-geopoint question '{self.name}' "
"does not correspond to an existing question."
)

def xml_instance(self, **kwargs):
survey = self.get_root()
Expand All @@ -50,7 +62,15 @@ def xml_control(self):
if self.type == "calculate" or (
("calculate" in self.bind or self.trigger) and not (self.label or self.hint)
):
nested_setvalues = self.get_root().get_setvalues_for_question_name(self.name)
if self.type == "background-geopoint":
if "calculate" in self.bind:
raise PyXFormError(
f"'{self.name}' is triggered by a geopoint action, so the calculation must be null."
)

nested_setvalues = self.get_root().get_trigger_values_for_question_name(
self.name, "setvalue"
)
if nested_setvalues:
for setvalue in nested_setvalues:
msg = (
Expand All @@ -64,29 +84,36 @@ def xml_control(self):
xml_node = self.build_xml()

if xml_node:
self.nest_setvalues(xml_node)
# Get nested setvalue and setgeopoint items
setvalue_items = self.get_root().get_trigger_values_for_question_name(
self.name, "setvalue"
)
setgeopoint_items = self.get_root().get_trigger_values_for_question_name(
self.name, "setgeopoint"
)

return xml_node
# Only call nest_set_nodes if the respective nested items list is not empty
if setvalue_items:
self.nest_set_nodes(xml_node, "setvalue", setvalue_items)
if setgeopoint_items:
self.nest_set_nodes(xml_node, "odk:setgeopoint", setgeopoint_items)

def nest_setvalues(self, xml_node):
nested_setvalues = self.get_root().get_setvalues_for_question_name(self.name)
return xml_node

if nested_setvalues:
for setvalue in nested_setvalues:
setvalue_attrs = {
def nest_set_nodes(self, xml_node, tag, nested_items):
if nested_items:
for item in nested_items:
node_attrs = {
"ref": self.get_root()
.insert_xpaths(f"${{{setvalue[0]}}}", self.get_root())
.insert_xpaths(f"${{{item[0]}}}", self.get_root())
.strip(),
"event": "xforms-value-changed",
}
if not setvalue[1] == "":
setvalue_attrs["value"] = self.get_root().insert_xpaths(
setvalue[1], self
)

setvalue_node = node("setvalue", **setvalue_attrs)
if item[1]:
node_attrs["value"] = self.get_root().insert_xpaths(item[1], self)

xml_node.appendChild(setvalue_node)
set_node = node(tag, **node_attrs)
xml_node.appendChild(set_node)

def build_xml(self):
return None
Expand Down
4 changes: 4 additions & 0 deletions pyxform/question_type_dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,4 +382,8 @@ def generate_new_dict():
"bind": {"type": "binary"},
"action": {"name": "odk:recordaudio", "event": "odk-instance-load"},
},
"background-geopoint": {
"control": {"tag": "trigger"},
"bind": {"type": "geopoint"},
},
}
19 changes: 17 additions & 2 deletions pyxform/survey.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ class Survey(Section):
"_xpath": dict,
"_created": datetime.now, # This can't be dumped to json
"setvalues_by_triggering_ref": dict,
"setgeopoint_by_triggering_ref": dict,
"title": str,
"id_string": str,
"sms_keyword": str,
Expand Down Expand Up @@ -297,8 +298,22 @@ def xml(self):
**nsmap,
)

def get_setvalues_for_question_name(self, question_name):
return self.setvalues_by_triggering_ref.get(f"${{{question_name}}}")
def get_trigger_values_for_question_name(self, question_name, trigger_type):
trigger_map = {
"setvalue": self.setvalues_by_triggering_ref,
"setgeopoint": self.setgeopoint_by_triggering_ref,
}

return trigger_map.get(trigger_type, {}).get(f"${{{question_name}}}")

def question_exists(self, question_name: str) -> bool:
"""
Check if a question with the given name exists in the survey.
"""
for element in self.iter_descendants():
if isinstance(element, Question) and element.name == question_name:
return True
return False

def _generate_static_instances(self, list_name, choice_list) -> InstanceInfo:
"""
Expand Down
71 changes: 71 additions & 0 deletions tests/test_background_geopoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from tests.pyxform_test_case import PyxformTestCase


class BackgroundGeopointTest(PyxformTestCase):
"""Test background-geopoint question type."""

def test_background_geopoint(self):
self.assertPyxformXform(
name="data",
md="""
| survey | | | |
| | type | name | label | trigger |
| | integer | temp | Enter the current temperature | |
| | background-geopoint| temp_geo | | ${temp} |
| | note | show_temp_geo | location: ${temp_geo} | |
""",
xml__contains=[
'<bind nodeset="/data/temp_geo" type="geopoint"/>',
'<odk:setgeopoint event="xforms-value-changed" ref="/data/temp_geo"/>',
],
)

def test_background_geopoint_missing_trigger(self):
"""Test that background-geopoint question raises error when trigger is empty."""
self.assertPyxformXform(
name="data",
md="""
| survey | | | |
| | type | name | label | trigger |
| | integer | temp | Enter the current temperature | |
| | background-geopoint| temp_geo | | |
| | note | show_temp_geo | location: ${temp_geo} | |
""",
errored=True,
error__contains=[
"background-geopoint question 'temp_geo' must have a non-null trigger"
],
)

def test_invalid_trigger_background_geopoint(self):
self.assertPyxformXform(
name="data",
md="""
| survey | | | |
| | type | name | label | trigger |
| | integer | temp | Enter the current temperature | |
| | background-geopoint| temp_geo | | ${invalid_trigger} |
| | note | show_temp_geo | location: ${temp_geo} | |
""",
errored=True,
error__contains=[
"Trigger 'invalid_trigger' for background-geopoint question 'temp_geo' does not correspond to an existing question"
],
)

def test_background_geopoint_requires_null_calculation(self):
"""Test that background-geopoint raises an error if there is a calculation."""
self.assertPyxformXform(
name="data",
md="""
| survey | | | | |
| | type | name | label | trigger | calculation |
| | integer | temp | Enter the current temperature | | |
| | background-geopoint| temp_geo | | ${temp} | 5 * temp |
| | note | show_temp_geo | location: ${temp_geo} | | |
""",
errored=True,
error__contains=[
"'temp_geo' is triggered by a geopoint action, so the calculation must be null."
],
)