diff --git a/pyxform/builder.py b/pyxform/builder.py
index 6675df70..a9089bd3 100644
--- a/pyxform/builder.py
+++ b/pyxform/builder.py
@@ -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] = {}
@@ -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
@@ -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(
diff --git a/pyxform/question.py b/pyxform/question.py
index 9c4810c6..91255e2a 100644
--- a/pyxform/question.py
+++ b/pyxform/question.py
@@ -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()
@@ -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 = (
@@ -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
diff --git a/pyxform/question_type_dictionary.py b/pyxform/question_type_dictionary.py
index 669504f1..b22e0b7d 100644
--- a/pyxform/question_type_dictionary.py
+++ b/pyxform/question_type_dictionary.py
@@ -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"},
+ },
}
diff --git a/pyxform/survey.py b/pyxform/survey.py
index 8f1a69c8..136e4623 100644
--- a/pyxform/survey.py
+++ b/pyxform/survey.py
@@ -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,
@@ -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:
"""
diff --git a/tests/test_background_geopoint.py b/tests/test_background_geopoint.py
new file mode 100644
index 00000000..f7c2b56b
--- /dev/null
+++ b/tests/test_background_geopoint.py
@@ -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=[
+ '',
+ '',
+ ],
+ )
+
+ 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."
+ ],
+ )