Skip to content
Merged
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
4 changes: 2 additions & 2 deletions pyxform/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions pyxform/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
),
)
Expand All @@ -472,15 +472,15 @@ 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}'."
),
)
RANGE_012 = Detail(
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'."
),
)
Expand Down
49 changes: 41 additions & 8 deletions pyxform/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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, ...]:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion pyxform/validators/pyxform/question_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions pyxform/xls2json.py
Original file line number Diff line number Diff line change
Expand Up @@ -1153,13 +1153,25 @@ 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,
parameters=parameters,
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

Expand Down
40 changes: 18 additions & 22 deletions tests/test_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
],
)

Expand Down Expand Up @@ -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"),
],
)

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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"),
],
)

Expand Down Expand Up @@ -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"),
],
)

Expand Down Expand Up @@ -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),
],
Expand Down Expand Up @@ -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"),
],
)

Expand Down Expand Up @@ -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"),
],
)

Expand Down Expand Up @@ -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"),
],
)

Expand Down Expand Up @@ -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"),
],
)

Expand Down Expand Up @@ -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"),
],
)

Expand Down Expand Up @@ -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"),
],
)

Expand Down Expand Up @@ -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"),
],
)
5 changes: 5 additions & 0 deletions tests/xpath_helpers/questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading