Skip to content

Commit 2aadc2b

Browse files
wip: new range parameters
1 parent 98954a1 commit 2aadc2b

4 files changed

Lines changed: 614 additions & 47 deletions

File tree

pyxform/errors.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,92 @@ class ErrorCode(Enum):
390390
"'{q}' appears more than once."
391391
),
392392
)
393+
RANGE_001 = Detail(
394+
name="Range type - parameter is not a number",
395+
msg=(
396+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
397+
"For the 'range' question type, the parameter '{name}' must be a number."
398+
),
399+
)
400+
RANGE_002 = Detail(
401+
name="Range type - parameter is zero",
402+
msg=(
403+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
404+
"For the 'range' question type, the parameter '{name}' must not be '0' (zero)."
405+
),
406+
)
407+
RANGE_003 = Detail(
408+
name="Range type - parameter larger than range",
409+
msg=(
410+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
411+
"For the 'range' question type, the parameter '{name}' must not be larger than "
412+
"the range (the difference between 'start' and 'end')."
413+
),
414+
)
415+
RANGE_004 = Detail(
416+
name="Range type - parameter not a multiple of tick",
417+
msg=(
418+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
419+
"For the 'range' question type, the parameter '{name}' must be a multiple of 'step'."
420+
),
421+
)
422+
RANGE_005 = Detail(
423+
name="Range type - parameter outside range",
424+
msg=(
425+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
426+
"For the 'range' question type, the parameter '{name}' must be a within "
427+
"the range (between the 'start' and 'end' values, inclusive)."
428+
),
429+
)
430+
RANGE_006 = Detail(
431+
name="Range type - tick_labelset not found",
432+
msg=(
433+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
434+
"For the 'range' question type, the parameter 'tick_labelset' must be a "
435+
"choice list name from the 'list_name' column on the choices sheet."
436+
),
437+
)
438+
RANGE_007 = Detail(
439+
name="Range type - tick_labelset too many choices with no-ticks",
440+
msg=(
441+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
442+
"For the 'range' question type, the parameter 'tick_labelset' choice list "
443+
"must have only 2 items when the 'appearance' is 'no-ticks'."
444+
),
445+
)
446+
RANGE_008 = Detail(
447+
name="Range type - parameter not compatible with appearance",
448+
msg=(
449+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
450+
"For the 'range' question type, the parameters 'tick_interval', 'placeholder', "
451+
"and 'tick_labelset' are only supported for the appearances 'vertical', 'no-ticks' "
452+
"and the default (empty) horizontal."
453+
),
454+
)
455+
RANGE_009 = Detail(
456+
name="Range type - tick_labelset choice is not a number",
457+
msg=(
458+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
459+
"For the 'range' question type, the parameter '{tick_labelset}' choices must "
460+
"all be numbers."
461+
),
462+
)
463+
RANGE_010 = Detail(
464+
name="Range type - tick_labelset choice outside range",
465+
msg=(
466+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
467+
"For the 'range' question type, the parameter 'tick_labelset' choices must "
468+
"be a within the range (between the 'start' and 'end' values, inclusive)."
469+
),
470+
)
471+
RANGE_011 = Detail(
472+
name="Range type - tick_labelset choice not a multiple of tick",
473+
msg=(
474+
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
475+
"For the 'range' question type, the parameter 'tick_labelset' choices' must "
476+
"be a multiple of '{name}'."
477+
),
478+
)
393479
SURVEY_001 = Detail(
394480
name="Survey sheet - unmatched group/repeat/loop end",
395481
msg=(

pyxform/validators/pyxform/question_types.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
"""
44

55
from collections.abc import Collection, Iterable
6+
from math import isinf
7+
from typing import Any
68

79
from pyxform import aliases
810
from pyxform.errors import ErrorCode, PyXFormError
11+
from pyxform.validators.pyxform import parameters_generic
912
from pyxform.validators.pyxform.pyxform_reference import (
1013
is_pyxform_reference_candidate,
1114
parse_pyxform_references,
@@ -100,3 +103,128 @@ def validate_geo_parameter_incremental(value: str) -> None:
100103
raise PyXFormError(
101104
code=ErrorCode.SURVEY_003,
102105
)
106+
107+
108+
def process_range_question_type(
109+
row_number: int,
110+
row: dict[str, Any],
111+
parameters: parameters_generic.PARAMETERS_TYPE,
112+
appearance: str,
113+
choices: dict[str, Any],
114+
) -> dict[str, Any]:
115+
"""
116+
Returns a new row that includes the Range parameters start, end and step.
117+
118+
Raises PyXFormError when invalid range parameters are used.
119+
"""
120+
parameters = parameters_generic.validate(
121+
parameters=parameters,
122+
allowed={"start", "end", "step", "tick_interval", "placeholder", "tick_labelset"},
123+
)
124+
if (
125+
appearance
126+
and appearance not in {"vertical", "no-ticks"}
127+
and any(
128+
k in parameters for k in ("tick_interval", "placeholder", "tick_labelset")
129+
)
130+
):
131+
raise PyXFormError(ErrorCode.RANGE_008.value.format(row=row_number))
132+
133+
parameters_map = {"start": "start", "end": "end", "step": "step"}
134+
defaults = {"start": "1", "end": "10", "step": "1"}
135+
136+
# set defaults
137+
for key in parameters_map.values():
138+
if key not in parameters:
139+
parameters[key] = defaults[key]
140+
141+
def process_parameter(name: str) -> float | None:
142+
value = parameters.get(name)
143+
if value is None:
144+
return value
145+
err = False
146+
try:
147+
value = float(value)
148+
except ValueError:
149+
err = True
150+
151+
if err or isinf(value):
152+
raise PyXFormError(
153+
ErrorCode.RANGE_001.value.format(row=row_number, name=name)
154+
)
155+
return value
156+
157+
start = process_parameter(name="start")
158+
end = process_parameter(name="end")
159+
step = process_parameter(name="step")
160+
tick_interval = process_parameter(name="tick_interval")
161+
placeholder = process_parameter(name="placeholder")
162+
tick_labelset = parameters.get("tick_labelset")
163+
range_width = end - start
164+
165+
if step == 0:
166+
raise PyXFormError(ErrorCode.RANGE_002.value.format(row=row_number, name="step"))
167+
if step > range_width:
168+
raise PyXFormError(ErrorCode.RANGE_003.value.format(row=row_number, name="step"))
169+
170+
if tick_interval is not None:
171+
if tick_interval == 0:
172+
raise PyXFormError(
173+
ErrorCode.RANGE_002.value.format(row=row_number, name="tick_interval")
174+
)
175+
if tick_interval > range_width:
176+
raise PyXFormError(
177+
ErrorCode.RANGE_003.value.format(row=row_number, name="tick_interval")
178+
)
179+
if (step % tick_interval) != 0:
180+
raise PyXFormError(
181+
ErrorCode.RANGE_004.value.format(row=row_number, name="tick_interval")
182+
)
183+
184+
if placeholder is not None:
185+
if (step % placeholder) != 0:
186+
raise PyXFormError(
187+
ErrorCode.RANGE_004.value.format(row=row_number, name="placeholder")
188+
)
189+
if placeholder < start or placeholder > end:
190+
raise PyXFormError(
191+
ErrorCode.RANGE_005.value.format(row=row_number, name="placeholder")
192+
)
193+
194+
if tick_labelset:
195+
tick_list = choices.get(tick_labelset)
196+
if tick_list is None:
197+
raise PyXFormError(ErrorCode.RANGE_006.value.format(row=row_number))
198+
199+
if appearance and appearance == "no-ticks" and len(tick_list) > 2:
200+
raise PyXFormError(ErrorCode.RANGE_007.value.format(row=row_number))
201+
202+
for label in tick_list:
203+
errored = False
204+
try:
205+
label = float(label.get("name"))
206+
except ValueError:
207+
errored = True
208+
209+
if errored or isinf(label):
210+
raise PyXFormError(ErrorCode.RANGE_009.value.format(row=row_number))
211+
212+
if label < start or label > end:
213+
raise PyXFormError(ErrorCode.RANGE_010.value.format(row=row_number))
214+
if tick_interval and (float(tick_interval) % label) != 0:
215+
raise PyXFormError(
216+
ErrorCode.RANGE_011.value.format(row=row_number, name="tick_interval")
217+
)
218+
elif (step % label) != 0:
219+
raise PyXFormError(
220+
ErrorCode.RANGE_011.value.format(row=row_number, name="step")
221+
)
222+
223+
# Default is integer, but if the floats have decimals then change the bind type.
224+
if any(not i.is_integer() for i in (start, end, step)):
225+
row["bind"] = row.get("bind", {})
226+
row["bind"].update({"type": "decimal"})
227+
228+
row["parameters"] = parameters
229+
230+
return row

pyxform/xls2json.py

Lines changed: 10 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -132,47 +132,6 @@ def add_flat_annotations(prompt_list, parent_relevant="", name_prefix=""):
132132
# prompt['name'] = name_prefix + prompt['name']
133133

134134

135-
def process_range_question_type(
136-
row: dict[str, Any], parameters: parameters_generic.PARAMETERS_TYPE
137-
) -> dict[str, Any]:
138-
"""
139-
Returns a new row that includes the Range parameters start, end and step.
140-
141-
Raises PyXFormError when invalid range parameters are used.
142-
"""
143-
new_dict = row.copy()
144-
parameters = parameters_generic.validate(
145-
parameters=parameters, allowed=("start", "end", "step")
146-
)
147-
parameters_map = {"start": "start", "end": "end", "step": "step"}
148-
defaults = {"start": "1", "end": "10", "step": "1"}
149-
150-
# set defaults
151-
for key in parameters_map.values():
152-
if key not in parameters:
153-
parameters[key] = defaults[key]
154-
155-
has_float = False
156-
try:
157-
# Check all parameters.
158-
for x in parameters.values():
159-
if float(x) and "." in str(x):
160-
has_float = True
161-
except ValueError as range_err:
162-
raise PyXFormError(
163-
"Range parameters 'start', 'end' or 'step' must all be numbers."
164-
) from range_err
165-
else:
166-
# is integer by default, convert to decimal if it has any float values
167-
if has_float:
168-
new_dict["bind"] = new_dict.get("bind", {})
169-
new_dict["bind"].update({"type": "decimal"})
170-
171-
new_dict["parameters"] = parameters
172-
173-
return new_dict
174-
175-
176135
def process_image_default(default_value):
177136
# prepend image files with the correct prefix, if they don't have it.
178137
image_jr_prefix = "jr://images/"
@@ -519,6 +478,7 @@ def workbook_to_json(
519478

520479
# Get question type
521480
question_type = row.get(constants.TYPE)
481+
appearance = row.get("control", {}).get("appearance")
522482

523483
if not question_type:
524484
# if name and label are also missing,
@@ -888,10 +848,8 @@ def workbook_to_json(
888848

889849
# Code to deal with table_list appearance flags
890850
# (for groups of selects)
891-
ctrl_ap = new_json_dict.get("control", {}).get("appearance")
892-
893-
if ctrl_ap:
894-
appearance_mods_as_list = ctrl_ap.split()
851+
if appearance:
852+
appearance_mods_as_list = appearance.split()
895853
if constants.TABLE_LIST in appearance_mods_as_list:
896854
# Table List modifier should add field list to the new dict,
897855
# as well as appending other appearance modifiers.
@@ -1195,7 +1153,13 @@ def workbook_to_json(
11951153

11961154
# range question_type
11971155
if question_type == "range":
1198-
new_dict = process_range_question_type(row=row, parameters=parameters)
1156+
new_dict = qt.process_range_question_type(
1157+
row_number=row_number,
1158+
row=row,
1159+
parameters=parameters,
1160+
appearance=appearance,
1161+
choices=choices,
1162+
)
11991163
parent_children_array.append(new_dict)
12001164
continue
12011165

@@ -1247,7 +1211,6 @@ def workbook_to_json(
12471211
)
12481212

12491213
if "app" in parameters:
1250-
appearance = row.get("control", {}).get("appearance")
12511214
if appearance is None or appearance == "annotate":
12521215
app_package_name = str(parameters["app"])
12531216
validation_result = validate_android_package_name(app_package_name)

0 commit comments

Comments
 (0)