Skip to content

Commit f28c0f6

Browse files
authored
Insert itemset declaration for range labels (#836)
1 parent 18fcc4d commit f28c0f6

7 files changed

Lines changed: 82 additions & 36 deletions

File tree

pyxform/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ class EntityColumns(StrEnum):
145145

146146
EXTERNAL_INSTANCE_EXTENSIONS = {".xml", ".csv", ".geojson"}
147147

148-
EXTERNAL_CHOICES_ITEMSET_REF_LABEL = "label"
149-
EXTERNAL_CHOICES_ITEMSET_REF_VALUE = "name"
148+
DEFAULT_ITEMSET_LABEL_REF = "label"
149+
DEFAULT_ITEMSET_VALUE_REF = "name"
150150

151151
EXTERNAL_CHOICES_ITEMSET_REF_LABEL_GEOJSON = "title"
152152
EXTERNAL_CHOICES_ITEMSET_REF_VALUE_GEOJSON = "id"

pyxform/errors.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ class ErrorCode(Enum):
456456
name="Range type - tick_labelset choice is not a number",
457457
msg=(
458458
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
459-
"For the 'range' question type, the parameter '{tick_labelset}' choices must "
459+
"For the 'range' question type, the parameter '{tick_labelset}' choice values must "
460460
"all be numbers."
461461
),
462462
)
@@ -472,15 +472,15 @@ class ErrorCode(Enum):
472472
name="Range type - tick_labelset choice not a multiple of tick",
473473
msg=(
474474
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
475-
"For the 'range' question type, the parameter 'tick_labelset' choices' must "
475+
"For the 'range' question type, the parameter 'tick_labelset' choices' values must "
476476
"be equal to the start of the range plus a multiple of '{name}'."
477477
),
478478
)
479479
RANGE_012 = Detail(
480480
name="Range type - tick_labelset choices not start/end for no-ticks",
481481
msg=(
482482
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
483-
"For the 'range' question type, the parameter 'tick_labelset' choice list "
483+
"For the 'range' question type, the parameter 'tick_labelset' choice list values may only"
484484
"match the range 'start' and 'end' values when the 'appearance' is 'no-ticks'."
485485
),
486486
)

pyxform/question.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010
from pyxform import constants
1111
from pyxform.constants import (
12-
EXTERNAL_CHOICES_ITEMSET_REF_LABEL,
12+
DEFAULT_ITEMSET_LABEL_REF,
13+
DEFAULT_ITEMSET_VALUE_REF,
1314
EXTERNAL_CHOICES_ITEMSET_REF_LABEL_GEOJSON,
14-
EXTERNAL_CHOICES_ITEMSET_REF_VALUE,
1515
EXTERNAL_CHOICES_ITEMSET_REF_VALUE_GEOJSON,
1616
EXTERNAL_INSTANCE_EXTENSIONS,
1717
)
@@ -53,15 +53,15 @@
5353
)
5454
QUESTION_FIELDS = (*SURVEY_ELEMENT_FIELDS, *QUESTION_EXTRA_FIELDS)
5555

56-
SELECT_QUESTION_EXTRA_FIELDS = (
56+
ITEM_QUESTION_EXTRA_FIELDS = (
5757
constants.CHOICES,
5858
constants.ITEMSET,
5959
constants.LIST_NAME_U,
6060
)
61-
SELECT_QUESTION_FIELDS = (*QUESTION_FIELDS, *SELECT_QUESTION_EXTRA_FIELDS)
61+
SELECT_QUESTION_FIELDS = (*QUESTION_FIELDS, *ITEM_QUESTION_EXTRA_FIELDS)
6262

6363
OSM_QUESTION_EXTRA_FIELDS = (constants.CHILDREN,)
64-
OSM_QUESTION_FIELDS = (*QUESTION_FIELDS, *SELECT_QUESTION_EXTRA_FIELDS)
64+
OSM_QUESTION_FIELDS = (*QUESTION_FIELDS, *ITEM_QUESTION_EXTRA_FIELDS)
6565

6666
OPTION_EXTRA_FIELDS = (
6767
"_choice_itext_ref",
@@ -354,7 +354,7 @@ def get_options(self, choices: Iterable[dict]) -> Generator[Option, None, None]:
354354

355355

356356
class MultipleChoiceQuestion(Question):
357-
__slots__ = SELECT_QUESTION_EXTRA_FIELDS
357+
__slots__ = ITEM_QUESTION_EXTRA_FIELDS
358358

359359
@staticmethod
360360
def get_slot_names() -> tuple[str, ...]:
@@ -398,8 +398,8 @@ def build_xml(self, survey: "Survey"):
398398
itemset_value_ref = EXTERNAL_CHOICES_ITEMSET_REF_VALUE_GEOJSON
399399
itemset_label_ref = EXTERNAL_CHOICES_ITEMSET_REF_LABEL_GEOJSON
400400
else:
401-
itemset_value_ref = EXTERNAL_CHOICES_ITEMSET_REF_VALUE
402-
itemset_label_ref = EXTERNAL_CHOICES_ITEMSET_REF_LABEL
401+
itemset_value_ref = DEFAULT_ITEMSET_VALUE_REF
402+
itemset_label_ref = DEFAULT_ITEMSET_LABEL_REF
403403
if self.parameters is not None:
404404
itemset_value_ref = self.parameters.get("value", itemset_value_ref)
405405
itemset_label_ref = self.parameters.get("label", itemset_label_ref)
@@ -549,10 +549,43 @@ def build_xml(self, survey: "Survey"):
549549

550550

551551
class RangeQuestion(Question):
552+
__slots__ = ITEM_QUESTION_EXTRA_FIELDS
553+
554+
def __init__(
555+
self, itemset: str | None = None, list_name: str | None = None, **kwargs
556+
):
557+
self.itemset: str | None = itemset
558+
self.list_name: str | None = list_name
559+
560+
super().__init__(**kwargs)
561+
552562
def build_xml(self, survey: "Survey"):
563+
if self.bind["type"] not in {"int", "decimal"}:
564+
raise PyXFormError(
565+
f"""Invalid value for `self.bind["type"]`: {self.bind["type"]}"""
566+
)
567+
553568
result = self._build_xml(survey=survey)
569+
554570
params = self.parameters
555571
if params:
556572
for k, v in params.items():
557573
result.setAttribute(k, v)
574+
575+
if survey.choices and self.itemset:
576+
if survey.choices.get(self.itemset, None).requires_itext:
577+
itemset_label_ref = "jr:itext(itextId)"
578+
else:
579+
itemset_label_ref = DEFAULT_ITEMSET_LABEL_REF
580+
581+
nodeset = f"instance('{self.itemset}')/root/item"
582+
result.appendChild(
583+
node(
584+
"itemset",
585+
node("value", ref=DEFAULT_ITEMSET_VALUE_REF),
586+
node("label", ref=itemset_label_ref),
587+
nodeset=nodeset,
588+
)
589+
)
590+
558591
return result

pyxform/validators/pyxform/question_types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ def process_parameter(name: str) -> Decimal | None:
229229
if no_ticks_labels != {start, end}:
230230
raise PyXFormError(ErrorCode.RANGE_012.value.format(row=row_number))
231231

232-
parameters["odk:tick-labelset"] = parameters.pop("tick_labelset")
232+
parameters.pop("tick_labelset")
233233

234234
# Default is integer, but if the values have decimals then change the bind type.
235235
if any(

pyxform/xls2json.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,13 +1153,25 @@ def workbook_to_json(
11531153

11541154
# range question_type
11551155
if question_type == "range":
1156+
tick_labelset = parameters.get("tick_labelset")
1157+
11561158
new_dict = qt.process_range_question_type(
11571159
row_number=row_number,
11581160
row=row,
11591161
parameters=parameters,
11601162
appearance=appearance,
11611163
choices=choices,
11621164
)
1165+
1166+
if tick_labelset is not None:
1167+
add_choices_info_to_question(
1168+
question=new_dict,
1169+
list_name=tick_labelset,
1170+
choices=choices,
1171+
choice_filter=None,
1172+
file_extension=None,
1173+
)
1174+
11631175
parent_children_array.append(new_dict)
11641176
continue
11651177

tests/test_range.py

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,9 @@ def test_parameter_list__ok(self):
100100
"step": "1",
101101
"odk:tick-interval": "2",
102102
"odk:placeholder": "6",
103-
"odk:tick-labelset": "c1",
104103
},
105104
),
105+
xpq.range_itemset("q1", "c1"),
106106
],
107107
)
108108

@@ -130,9 +130,9 @@ def test_parameter_list__mixed_case__ok(self):
130130
"step": "1",
131131
"odk:tick-interval": "2",
132132
"odk:placeholder": "6",
133-
"odk:tick-labelset": "c1",
134133
},
135134
),
135+
xpq.range_itemset("q1", "c1"),
136136
],
137137
)
138138

@@ -502,9 +502,7 @@ def test_tick_labelset_not_found__ok(self):
502502
"""
503503
self.assertPyxformXform(
504504
md=md,
505-
xml__xpath_match=[
506-
xpq.body_range("q1", {"odk:tick-labelset": "c1"}),
507-
],
505+
xml__xpath_match=[xpq.body_range("q1"), xpq.range_itemset("q1", "c1")],
508506
)
509507

510508
def test_tick_labelset_empty__error(self):
@@ -562,9 +560,8 @@ def test_tick_labelset_no_ticks_too_many_choices__ok(self):
562560
self.assertPyxformXform(
563561
md=md,
564562
xml__xpath_match=[
565-
xpq.body_range(
566-
"q1", {"odk:tick-labelset": "c1", "appearance": "no-ticks"}
567-
),
563+
xpq.body_range("q1", {"appearance": "no-ticks"}),
564+
xpq.range_itemset("q1", "c1"),
568565
],
569566
)
570567

@@ -609,9 +606,8 @@ def test_tick_labelset_no_ticks_too_many_choices__allow_duplicates__ok(self):
609606
self.assertPyxformXform(
610607
md=md,
611608
xml__xpath_match=[
612-
xpq.body_range(
613-
"q1", {"odk:tick-labelset": "c1", "appearance": "no-ticks"}
614-
),
609+
xpq.body_range("q1", {"appearance": "no-ticks"}),
610+
xpq.range_itemset("q1", "c1"),
615611
],
616612
)
617613

@@ -673,14 +669,14 @@ def test_parameters_not_compatible_with_appearance__ok(self):
673669
params = (
674670
("tick_interval=2", {"odk:tick-interval": "2"}),
675671
("placeholder=3", {"odk:placeholder": "3"}),
676-
("tick_labelset=c1", {"odk:tick-labelset": "c1"}),
672+
("tick_labelset=c1", {}),
677673
)
678674
cases = ("", "vertical", "no-ticks")
679675
for param, attr in params:
680-
for value in cases:
681-
with self.subTest((param, attr, value)):
676+
for appearance in cases:
677+
with self.subTest((param, attr, appearance)):
682678
self.assertPyxformXform(
683-
md=md.format(param=param, value=value),
679+
md=md.format(param=param, value=appearance),
684680
xml__xpath_match=[
685681
xpq.body_range("q1", attr),
686682
],
@@ -726,7 +722,7 @@ def test_tick_labelset_choice_is_not_a_number__ok(self):
726722
md=md.format(value=value),
727723
xml__xpath_match=[
728724
xpq.model_instance_bind("q1", "int"),
729-
xpq.body_range("q1", {"odk:tick-labelset": "c1"}),
725+
xpq.range_itemset("q1", "c1"),
730726
],
731727
)
732728

@@ -796,9 +792,9 @@ def test_tick_labelset_choice_outside_range__ok(self):
796792
"start": "0",
797793
"end": "7",
798794
"step": "1",
799-
"odk:tick-labelset": "c1",
800795
},
801796
),
797+
xpq.range_itemset("q1", "c1"),
802798
],
803799
)
804800

@@ -826,9 +822,9 @@ def test_tick_labelset_choice_outside_inverted_range__ok(self):
826822
"start": "7",
827823
"end": "3",
828824
"step": "2",
829-
"odk:tick-labelset": "c1",
830825
},
831826
),
827+
xpq.range_itemset("q1", "c1"),
832828
],
833829
)
834830

@@ -881,9 +877,9 @@ def test_tick_labelset_choice_not_a_multiple_of_step__ok(self):
881877
"start": "0",
882878
"end": "7",
883879
"step": "1",
884-
"odk:tick-labelset": "c1",
885880
},
886881
),
882+
xpq.range_itemset("q1", "c1"),
887883
],
888884
)
889885

@@ -911,9 +907,9 @@ def test_tick_labelset_choice_not_aligned_with_tick_interval__both__ok(self):
911907
"end": "12",
912908
"step": "2",
913909
"odk:tick-interval": "4",
914-
"odk:tick-labelset": "c1",
915910
},
916911
),
912+
xpq.range_itemset("q1", "c1"),
917913
],
918914
)
919915

@@ -987,9 +983,9 @@ def test_parameters__numeric__int(self):
987983
"step": "2",
988984
"odk:tick-interval": "2",
989985
"odk:placeholder": "7",
990-
"odk:tick-labelset": "c1",
991986
},
992987
),
988+
xpq.range_itemset("q1", "c1"),
993989
],
994990
)
995991

@@ -1019,8 +1015,8 @@ def test_parameters__numeric__decimal(self):
10191015
"step": "0.5",
10201016
"odk:tick-interval": "1.5",
10211017
"odk:placeholder": "2.5",
1022-
"odk:tick-labelset": "c1",
10231018
},
10241019
),
1020+
xpq.range_itemset("q1", "c1"),
10251021
],
10261022
)

tests/xpath_helpers/questions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,16 @@ def body_range(qname: str, attrs: dict[str, str] | None = None) -> str:
189189
if attrs is not None:
190190
parameters.update(attrs)
191191
attrs = " and ".join(f"@{k}='{v}'" for k, v in parameters.items())
192+
192193
return f"""
193194
/h:html/h:body/x:range[
194195
@ref='/test_name/{qname}' and {attrs}
195196
]
196197
"""
197198

199+
@staticmethod
200+
def range_itemset(qname: str, labelset: str) -> str:
201+
return f"/h:html/h:body/x:range[@ref='/test_name/{qname}']/x:itemset[@nodeset=\"instance('{labelset}')/root/item\"]"
202+
198203

199204
xpq = XPathHelper()

0 commit comments

Comments
 (0)