diff --git a/pyxform/question.py b/pyxform/question.py index 945af4bd4..03460d4f6 100644 --- a/pyxform/question.py +++ b/pyxform/question.py @@ -3,6 +3,7 @@ XForm Survey element classes for different question types. """ import os.path +import re from pyxform.errors import PyXFormError from pyxform.question_type_dictionary import QUESTION_TYPE_DICT @@ -186,6 +187,7 @@ def build_xml(self): result.appendChild(element) choices = survey.get("choices") + multi_language = False if choices is not None and len(choices) > 0: first_choices = next(iter(choices.values())) multi_language = isinstance(first_choices[0].get("label"), dict) @@ -196,6 +198,7 @@ def build_xml(self): choice_filter = self.get("choice_filter") itemset, file_extension = os.path.splitext(self["itemset"]) has_media = False + is_previous_question = bool(re.match(r"^\${.*}$", self.get("itemset"))) if choices.get(itemset): has_media = bool(choices[itemset][0].get("media")) @@ -210,8 +213,28 @@ def build_xml(self): else: itemset = self["itemset"] itemset_label_ref = "jr:itext(itextId)" - nodeset = "instance('" + itemset + "')/root/item" - choice_filter = survey.insert_xpaths(choice_filter, self, True) + + choice_filter = survey.insert_xpaths(choice_filter, self, True, True) + if is_previous_question: + path = ( + survey.insert_xpaths(self["itemset"], self, reference_parent=True) + .strip() + .split("/") + ) + nodeset = "/".join(path[:-1]) + itemset_label_ref = path[-1] + if choice_filter: + choice_filter = choice_filter.replace( + "current()/" + nodeset, "." + ).replace(nodeset, ".") + else: + # Choices must have a value. Filter out repeat instances without + # an answer for the linked question + name = path[-1] + choice_filter = f"./{name} != ''" + else: + nodeset = "instance('" + itemset + "')/root/item" + if choice_filter: nodeset += "[" + choice_filter + "]" @@ -239,8 +262,9 @@ def build_xml(self): ] result.appendChild(node("itemset", *itemset_children, nodeset=nodeset)) else: - for n in [o.xml() for o in self.children]: - result.appendChild(n) + for child in self.children: + result.appendChild(child.xml()) + return result diff --git a/pyxform/survey.py b/pyxform/survey.py index 4ec4100d9..de0f5f9cf 100644 --- a/pyxform/survey.py +++ b/pyxform/survey.py @@ -7,6 +7,7 @@ import codecs import os import re +from re import split import tempfile import xml.etree.ElementTree as ETree from collections import defaultdict @@ -67,7 +68,7 @@ def is_parent_a_repeat(survey, xpath): @lru_cache(maxsize=None) -def share_same_repeat_parent(survey, xpath, context_xpath): +def share_same_repeat_parent(survey, xpath, context_xpath, reference_parent=False): """ Returns a tuple of the number of steps from the context xpath to the shared repeat parent and the xpath to the target xpath from the shared repeat @@ -79,14 +80,20 @@ def share_same_repeat_parent(survey, xpath, context_xpath): returns (2, '/group_a/name')' """ - context_parent = is_parent_a_repeat(survey, context_xpath) - xpath_parent = is_parent_a_repeat(survey, xpath) - if context_parent and xpath_parent and xpath_parent in context_parent: - context_parts = context_xpath[len(xpath_parent) + 1 :].split("/") + + def _get_steps_and_target_xpath(context_parent, xpath_parent, include_parent=False): parts = [] steps = 1 - remainder_xpath = xpath[len(xpath_parent) :] - xpath_parts = xpath[len(xpath_parent) + 1 :].split("/") + if not include_parent: + remainder_xpath = xpath[len(xpath_parent) :] + context_parts = context_xpath[len(xpath_parent) + 1 :].split("/") + xpath_parts = xpath[len(xpath_parent) + 1 :].split("/") + else: + split_idx = len(xpath_parent.split("/")) + context_parts = context_xpath.split("/")[split_idx - 1 :] + xpath_parts = xpath.split("/")[split_idx - 1 :] + remainder_xpath = "/".join(xpath_parts) + for index, item in enumerate(context_parts[:-1]): try: if xpath[len(context_parent) + 1 :].split("/")[index] != item: @@ -99,9 +106,36 @@ def share_same_repeat_parent(survey, xpath, context_xpath): steps = len(context_parts[index - 1 :]) parts = xpath_parts[index - 1 :] break - return (steps, "/" + "/".join(parts) if parts else remainder_xpath) + context_parent = is_parent_a_repeat(survey, context_xpath) + xpath_parent = is_parent_a_repeat(survey, xpath) + if context_parent and xpath_parent and xpath_parent in context_parent: + include_parent = False + if not context_parent == xpath_parent and reference_parent: + context_shared_ancestor = is_parent_a_repeat(survey, context_parent) + if context_shared_ancestor == xpath_parent: + # Check if context_parent is a child repeat of the xpath_parent + # If the context_parent is a child of the xpath_parent reference the entire + # xpath_parent in the generated nodeset + include_parent = True + context_parent = context_shared_ancestor + return _get_steps_and_target_xpath(context_parent, xpath_parent, include_parent) + elif context_parent and xpath_parent: + # Check if context_parent and xpath_parent share a common + # repeat ancestor + context_shared_ancestor = is_parent_a_repeat(survey, context_parent) + xpath_shared_ancestor = is_parent_a_repeat(survey, xpath_parent) + + if ( + xpath_shared_ancestor + and context_shared_ancestor + and xpath_shared_ancestor == context_shared_ancestor + ): + return _get_steps_and_target_xpath( + context_shared_ancestor, xpath_shared_ancestor + ) + return (None, None) @@ -836,7 +870,9 @@ def _setup_xpath_dictionary(self): else: self._xpath[element.name] = element.get_xpath() - def _var_repl_function(self, matchobj, context, use_current=False): + def _var_repl_function( + self, matchobj, context, use_current=False, reference_parent=False + ): """ Given a dictionary of xpaths, return a function we can use to replace ${varname} with the xpath to varname. @@ -869,7 +905,9 @@ def _var_repl_function(self, matchobj, context, use_current=False): ): # if context xpath and target xpath fall under the same # repeat use relative xpath referencing. - steps, ref_path = share_same_repeat_parent(self, xpath, context_xpath) + steps, ref_path = share_same_repeat_parent( + self, xpath, context_xpath, reference_parent + ) if steps: ref_path = ref_path if ref_path.endswith(name) else "/%s" % name prefix = " current()/" if use_current else " " @@ -881,13 +919,15 @@ def _var_repl_function(self, matchobj, context, use_current=False): ) return " " + last_saved_prefix + self._xpath[name] + " " - def insert_xpaths(self, text, context, use_current=False): + def insert_xpaths(self, text, context, use_current=False, reference_parent=False): """ Replace all instances of ${var} with the xpath to var. """ def _var_repl_function(matchobj): - return self._var_repl_function(matchobj, context, use_current) + return self._var_repl_function( + matchobj, context, use_current, reference_parent + ) return re.sub(BRACKETED_TAG_REGEX, _var_repl_function, unicode(text)) diff --git a/pyxform/tests_v1/test_repeat.py b/pyxform/tests_v1/test_repeat.py index bbade9b60..92f808921 100644 --- a/pyxform/tests_v1/test_repeat.py +++ b/pyxform/tests_v1/test_repeat.py @@ -233,13 +233,13 @@ def test_output_with_guidance_hint_translation_relative_path(self): def test_output_with_multiple_translations_relative_path(self): md = """ - | survey | | | | | | + | survey | | | | | | | | type | name | label::English | label::Indonesia | calculation | | | begin repeat | member | | | | - | | calculate | pos | | | position(..) | + | | calculate | pos | | | position(..) | | | text | member_name | Name of ${pos} | Nama ${pos} | | | | text | member_address | | Alamat | | - | | end repeat | | | | | + | | end repeat | | | | | """ self.assertPyxformXform( @@ -330,3 +330,131 @@ def test_hints_are_present_within_groups(self): """ # noqa self.assertPyxformXform(md=md, xml__contains=[expected], run_odk_validate=True) + + def test_choice_from_previous_repeat_answers(self): + """Select one choices from previous repeat answers.""" + xlsform_md = """ + | survey | | | | + | | type | name | label | + | | begin repeat | rep | Repeat | + | | text | name | Enter name | + | | end repeat | | | + | | select one fruits | fruit | Choose a fruit | + | | select one ${name} | choice | Choose name | + | choices | | | | + | | list name | name | label | + | | fruits | banana | Banana | + | | fruits | mango | Mango | + """ + self.assertPyxformXform( + md=xlsform_md, + xml__contains=[ + "" + ], + run_odk_validate=False, + ) + + def test_choice_from_previous_repeat_answers_with_choice_filter(self): + """Select one choices from previous repeat answers with choice filter""" + xlsform_md = """ + | survey | | | | | + | | type | name | label | choice_filter | + | | begin repeat | rep | Repeat | | + | | text | name | Enter name | | + | | begin group | demographics | Demographics | | + | | integer | age | Enter age | | + | | end group | demographics | | | + | | end repeat | | | | + | | select one fruits | fruit | Choose a fruit | | + | | select one ${name} | choice | Choose name | starts-with(${name}, "b") | + | | select one ${name} | choice_18_over | Choose name | ${age} > 18 | + | choices | | | | | + | | list name | name | label | | + | | fruits | banana | Banana | | + | | fruits | mango | Mango | | + """ + self.assertPyxformXform( + name="data", + id_string="some-id", + md=xlsform_md, + xml__contains=[ + '', + '', + ], + run_odk_validate=False, + ) + + def test_choice_from_previous_repeat_answers_in_child_repeat(self): + """ + Select one choice from previous repeat answers when within a child of a repeat + """ + xlsform_md = """ + | survey | | | | | + | | type | name | label | choice_filter | + | | begin repeat | household | Household Repeat | | + | | begin repeat | member | Household member repeat | | + | | text | name | Enter name of a household member | | + | | integer | age | Enter age of the household member | | + | | begin repeat | adult | Select a representative | | + | | select one ${name} | adult_name | Choose a name | ${age} > 18 | + | | end repeat | adult | | | + | | end repeat | member | | | + | | end repeat | household | | | + """ + self.assertPyxformXform( + name="data", + id_string="some-id", + md=xlsform_md, + xml__contains=['',], + ) + + def test_choice_from_previous_repeat_answers_in_nested_repeat(self): + """Select one choices from previous repeat answers within a nested repeat""" + xlsform_md = """ + | survey | | | | | + | | type | name | label | choice_filter | + | | begin repeat | household | Household Repeat | | + | | begin repeat | person | Household member repeat | | + | | text | name | Enter name of a household member | | + | | integer | age | Enter age of the household member | | + | | end repeat | person | | | + | | begin repeat | adult | Select a representative | | + | | select one ${name} | adult_name | Choose a name | ${age} > 18 | + | | end repeat | adult | | | + | | end repeat | household | | | + """ + self.assertPyxformXform( + name="data", + id_string="some-id", + md=xlsform_md, + xml__contains=['',], + ) + + def test_choice_from_previous_repeat_answers_in_nested_repeat_uses_current(self): + """ + Select one choices from previous repeat answers within a nested repeat should use current if a sibling node of a select is used + """ + xlsform_md = """ + | survey | | | | | + | | type | name | label | choice_filter | + | | text | enumerators_name | Enter enumerators name | | + | | begin repeat | household_rep | Household Repeat | | + | | integer | household_id | Enter household ID | | + | | begin repeat | household_mem_rep | Household member repeat | | + | | text | name | Enter name of a household member | | + | | integer | age | Enter age of the household member | | + | | end repeat | household_mem_rep | | | + | | begin repeat | selected | Select a representative | | + | | integer | target_min_age | Minimum age requirement | | + | | select one ${name} | selected_name | Choose a name | ${age} > ${target_min_age} | + | | end repeat | selected | | | + | | end repeat | household_rep | | | + """ + self.assertPyxformXform( + name="data", + id_string="some-id", + md=xlsform_md, + xml__contains=[ + '', + ], + ) diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index b7e18775b..c4e2bc666 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -1077,6 +1077,7 @@ def replace_prefix(d, prefix): list_name not in choices and select_type != "select one external" and file_extension not in [".csv", ".xml"] + and not re.match(r"\$\{(.*?)\}", list_name) ): if not choices: raise PyXFormError( @@ -1185,7 +1186,9 @@ def replace_prefix(d, prefix): ): new_json_dict["itemset"] = list_name json_dict["choices"] = choices - elif file_extension in [".csv", ".xml"]: + elif file_extension in [".csv", ".xml"] or re.match( + r"\$\{(.*?)\}", list_name + ): new_json_dict["itemset"] = list_name else: new_json_dict["list_name"] = list_name