diff --git a/pyxform/constants.py b/pyxform/constants.py index 01e687d0..88091de5 100644 --- a/pyxform/constants.py +++ b/pyxform/constants.py @@ -145,8 +145,8 @@ class EntityColumns(StrEnum): EXTERNAL_INSTANCE_EXTENSIONS = {".xml", ".csv", ".geojson"} -EXTERNAL_CHOICES_ITEMSET_REF_LABEL = "label" -EXTERNAL_CHOICES_ITEMSET_REF_VALUE = "name" +DEFAULT_ITEMSET_LABEL_REF = "label" +DEFAULT_ITEMSET_VALUE_REF = "name" EXTERNAL_CHOICES_ITEMSET_REF_LABEL_GEOJSON = "title" EXTERNAL_CHOICES_ITEMSET_REF_VALUE_GEOJSON = "id" diff --git a/pyxform/errors.py b/pyxform/errors.py index 25123527..60ecdb85 100644 --- a/pyxform/errors.py +++ b/pyxform/errors.py @@ -456,7 +456,7 @@ class ErrorCode(Enum): name="Range type - tick_labelset choice is not a number", msg=( "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " - "For the 'range' question type, the parameter '{tick_labelset}' choices must " + "For the 'range' question type, the parameter '{tick_labelset}' choice values must " "all be numbers." ), ) @@ -472,7 +472,7 @@ class ErrorCode(Enum): name="Range type - tick_labelset choice not a multiple of tick", msg=( "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " - "For the 'range' question type, the parameter 'tick_labelset' choices' must " + "For the 'range' question type, the parameter 'tick_labelset' choices' values must " "be equal to the start of the range plus a multiple of '{name}'." ), ) @@ -480,7 +480,7 @@ class ErrorCode(Enum): name="Range type - tick_labelset choices not start/end for no-ticks", msg=( "[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. " - "For the 'range' question type, the parameter 'tick_labelset' choice list " + "For the 'range' question type, the parameter 'tick_labelset' choice list values may only" "match the range 'start' and 'end' values when the 'appearance' is 'no-ticks'." ), ) diff --git a/pyxform/question.py b/pyxform/question.py index ccdbdb08..6dfb58e6 100644 --- a/pyxform/question.py +++ b/pyxform/question.py @@ -9,9 +9,9 @@ from pyxform import constants from pyxform.constants import ( - EXTERNAL_CHOICES_ITEMSET_REF_LABEL, + DEFAULT_ITEMSET_LABEL_REF, + DEFAULT_ITEMSET_VALUE_REF, EXTERNAL_CHOICES_ITEMSET_REF_LABEL_GEOJSON, - EXTERNAL_CHOICES_ITEMSET_REF_VALUE, EXTERNAL_CHOICES_ITEMSET_REF_VALUE_GEOJSON, EXTERNAL_INSTANCE_EXTENSIONS, ) @@ -53,15 +53,15 @@ ) QUESTION_FIELDS = (*SURVEY_ELEMENT_FIELDS, *QUESTION_EXTRA_FIELDS) -SELECT_QUESTION_EXTRA_FIELDS = ( +ITEM_QUESTION_EXTRA_FIELDS = ( constants.CHOICES, constants.ITEMSET, constants.LIST_NAME_U, ) -SELECT_QUESTION_FIELDS = (*QUESTION_FIELDS, *SELECT_QUESTION_EXTRA_FIELDS) +SELECT_QUESTION_FIELDS = (*QUESTION_FIELDS, *ITEM_QUESTION_EXTRA_FIELDS) OSM_QUESTION_EXTRA_FIELDS = (constants.CHILDREN,) -OSM_QUESTION_FIELDS = (*QUESTION_FIELDS, *SELECT_QUESTION_EXTRA_FIELDS) +OSM_QUESTION_FIELDS = (*QUESTION_FIELDS, *ITEM_QUESTION_EXTRA_FIELDS) OPTION_EXTRA_FIELDS = ( "_choice_itext_ref", @@ -354,7 +354,7 @@ def get_options(self, choices: Iterable[dict]) -> Generator[Option, None, None]: class MultipleChoiceQuestion(Question): - __slots__ = SELECT_QUESTION_EXTRA_FIELDS + __slots__ = ITEM_QUESTION_EXTRA_FIELDS @staticmethod def get_slot_names() -> tuple[str, ...]: @@ -398,8 +398,8 @@ def build_xml(self, survey: "Survey"): itemset_value_ref = EXTERNAL_CHOICES_ITEMSET_REF_VALUE_GEOJSON itemset_label_ref = EXTERNAL_CHOICES_ITEMSET_REF_LABEL_GEOJSON else: - itemset_value_ref = EXTERNAL_CHOICES_ITEMSET_REF_VALUE - itemset_label_ref = EXTERNAL_CHOICES_ITEMSET_REF_LABEL + itemset_value_ref = DEFAULT_ITEMSET_VALUE_REF + itemset_label_ref = DEFAULT_ITEMSET_LABEL_REF if self.parameters is not None: itemset_value_ref = self.parameters.get("value", itemset_value_ref) itemset_label_ref = self.parameters.get("label", itemset_label_ref) @@ -549,10 +549,43 @@ def build_xml(self, survey: "Survey"): class RangeQuestion(Question): + __slots__ = ITEM_QUESTION_EXTRA_FIELDS + + def __init__( + self, itemset: str | None = None, list_name: str | None = None, **kwargs + ): + self.itemset: str | None = itemset + self.list_name: str | None = list_name + + super().__init__(**kwargs) + def build_xml(self, survey: "Survey"): + if self.bind["type"] not in {"int", "decimal"}: + raise PyXFormError( + f"""Invalid value for `self.bind["type"]`: {self.bind["type"]}""" + ) + result = self._build_xml(survey=survey) + params = self.parameters if params: for k, v in params.items(): result.setAttribute(k, v) + + if survey.choices and self.itemset: + if survey.choices.get(self.itemset, None).requires_itext: + itemset_label_ref = "jr:itext(itextId)" + else: + itemset_label_ref = DEFAULT_ITEMSET_LABEL_REF + + nodeset = f"instance('{self.itemset}')/root/item" + result.appendChild( + node( + "itemset", + node("value", ref=DEFAULT_ITEMSET_VALUE_REF), + node("label", ref=itemset_label_ref), + nodeset=nodeset, + ) + ) + return result diff --git a/pyxform/validators/pyxform/question_types.py b/pyxform/validators/pyxform/question_types.py index f8be04d8..06386f79 100644 --- a/pyxform/validators/pyxform/question_types.py +++ b/pyxform/validators/pyxform/question_types.py @@ -229,7 +229,7 @@ def process_parameter(name: str) -> Decimal | None: if no_ticks_labels != {start, end}: raise PyXFormError(ErrorCode.RANGE_012.value.format(row=row_number)) - parameters["odk:tick-labelset"] = parameters.pop("tick_labelset") + parameters.pop("tick_labelset") # Default is integer, but if the values have decimals then change the bind type. if any( diff --git a/pyxform/xls2json.py b/pyxform/xls2json.py index 5d8c1bed..0f992b87 100644 --- a/pyxform/xls2json.py +++ b/pyxform/xls2json.py @@ -1153,6 +1153,8 @@ def workbook_to_json( # range question_type if question_type == "range": + tick_labelset = parameters.get("tick_labelset") + new_dict = qt.process_range_question_type( row_number=row_number, row=row, @@ -1160,6 +1162,16 @@ def workbook_to_json( appearance=appearance, choices=choices, ) + + if tick_labelset is not None: + add_choices_info_to_question( + question=new_dict, + list_name=tick_labelset, + choices=choices, + choice_filter=None, + file_extension=None, + ) + parent_children_array.append(new_dict) continue diff --git a/tests/test_range.py b/tests/test_range.py index 8af14e3f..d4396d4c 100644 --- a/tests/test_range.py +++ b/tests/test_range.py @@ -100,9 +100,9 @@ def test_parameter_list__ok(self): "step": "1", "odk:tick-interval": "2", "odk:placeholder": "6", - "odk:tick-labelset": "c1", }, ), + xpq.range_itemset("q1", "c1"), ], ) @@ -130,9 +130,9 @@ def test_parameter_list__mixed_case__ok(self): "step": "1", "odk:tick-interval": "2", "odk:placeholder": "6", - "odk:tick-labelset": "c1", }, ), + xpq.range_itemset("q1", "c1"), ], ) @@ -502,9 +502,7 @@ def test_tick_labelset_not_found__ok(self): """ self.assertPyxformXform( md=md, - xml__xpath_match=[ - xpq.body_range("q1", {"odk:tick-labelset": "c1"}), - ], + xml__xpath_match=[xpq.body_range("q1"), xpq.range_itemset("q1", "c1")], ) def test_tick_labelset_empty__error(self): @@ -562,9 +560,8 @@ def test_tick_labelset_no_ticks_too_many_choices__ok(self): self.assertPyxformXform( md=md, xml__xpath_match=[ - xpq.body_range( - "q1", {"odk:tick-labelset": "c1", "appearance": "no-ticks"} - ), + xpq.body_range("q1", {"appearance": "no-ticks"}), + xpq.range_itemset("q1", "c1"), ], ) @@ -609,9 +606,8 @@ def test_tick_labelset_no_ticks_too_many_choices__allow_duplicates__ok(self): self.assertPyxformXform( md=md, xml__xpath_match=[ - xpq.body_range( - "q1", {"odk:tick-labelset": "c1", "appearance": "no-ticks"} - ), + xpq.body_range("q1", {"appearance": "no-ticks"}), + xpq.range_itemset("q1", "c1"), ], ) @@ -673,14 +669,14 @@ def test_parameters_not_compatible_with_appearance__ok(self): params = ( ("tick_interval=2", {"odk:tick-interval": "2"}), ("placeholder=3", {"odk:placeholder": "3"}), - ("tick_labelset=c1", {"odk:tick-labelset": "c1"}), + ("tick_labelset=c1", {}), ) cases = ("", "vertical", "no-ticks") for param, attr in params: - for value in cases: - with self.subTest((param, attr, value)): + for appearance in cases: + with self.subTest((param, attr, appearance)): self.assertPyxformXform( - md=md.format(param=param, value=value), + md=md.format(param=param, value=appearance), xml__xpath_match=[ xpq.body_range("q1", attr), ], @@ -726,7 +722,7 @@ def test_tick_labelset_choice_is_not_a_number__ok(self): md=md.format(value=value), xml__xpath_match=[ xpq.model_instance_bind("q1", "int"), - xpq.body_range("q1", {"odk:tick-labelset": "c1"}), + xpq.range_itemset("q1", "c1"), ], ) @@ -796,9 +792,9 @@ def test_tick_labelset_choice_outside_range__ok(self): "start": "0", "end": "7", "step": "1", - "odk:tick-labelset": "c1", }, ), + xpq.range_itemset("q1", "c1"), ], ) @@ -826,9 +822,9 @@ def test_tick_labelset_choice_outside_inverted_range__ok(self): "start": "7", "end": "3", "step": "2", - "odk:tick-labelset": "c1", }, ), + xpq.range_itemset("q1", "c1"), ], ) @@ -881,9 +877,9 @@ def test_tick_labelset_choice_not_a_multiple_of_step__ok(self): "start": "0", "end": "7", "step": "1", - "odk:tick-labelset": "c1", }, ), + xpq.range_itemset("q1", "c1"), ], ) @@ -911,9 +907,9 @@ def test_tick_labelset_choice_not_aligned_with_tick_interval__both__ok(self): "end": "12", "step": "2", "odk:tick-interval": "4", - "odk:tick-labelset": "c1", }, ), + xpq.range_itemset("q1", "c1"), ], ) @@ -987,9 +983,9 @@ def test_parameters__numeric__int(self): "step": "2", "odk:tick-interval": "2", "odk:placeholder": "7", - "odk:tick-labelset": "c1", }, ), + xpq.range_itemset("q1", "c1"), ], ) @@ -1019,8 +1015,8 @@ def test_parameters__numeric__decimal(self): "step": "0.5", "odk:tick-interval": "1.5", "odk:placeholder": "2.5", - "odk:tick-labelset": "c1", }, ), + xpq.range_itemset("q1", "c1"), ], ) diff --git a/tests/xpath_helpers/questions.py b/tests/xpath_helpers/questions.py index 4b536d92..b5625b3d 100644 --- a/tests/xpath_helpers/questions.py +++ b/tests/xpath_helpers/questions.py @@ -189,11 +189,16 @@ def body_range(qname: str, attrs: dict[str, str] | None = None) -> str: if attrs is not None: parameters.update(attrs) attrs = " and ".join(f"@{k}='{v}'" for k, v in parameters.items()) + return f""" /h:html/h:body/x:range[ @ref='/test_name/{qname}' and {attrs} ] """ + @staticmethod + def range_itemset(qname: str, labelset: str) -> str: + return f"/h:html/h:body/x:range[@ref='/test_name/{qname}']/x:itemset[@nodeset=\"instance('{labelset}')/root/item\"]" + xpq = XPathHelper()