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." + ], + )