Skip to content
Open
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
94 changes: 94 additions & 0 deletions pyxform/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,100 @@ class ErrorCode(Enum):
"'{q}' appears more than once."
),
)
RANGE_001 = Detail(
name="Range type - parameter is not a number",
msg=(
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
"For the 'range' question type, the parameter '{name}' must be a number."
),
)
RANGE_002 = Detail(
name="Range type - parameter is zero",
msg=(
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
"For the 'range' question type, the parameter '{name}' must not be '0' (zero)."
),
)
RANGE_003 = Detail(
name="Range type - parameter larger than range",
msg=(
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
"For the 'range' question type, the parameter '{name}' must not be larger than "
"the range (the difference between 'start' and 'end')."
),
)
RANGE_004 = Detail(
name="Range type - parameter not a multiple of tick",
msg=(
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
"For the 'range' question type, the parameter '{name}' must be a multiple of 'step'."
),
)
RANGE_005 = Detail(
name="Range type - parameter outside range",
msg=(
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
"For the 'range' question type, the parameter '{name}' must be a within "
"the range (between the 'start' and 'end' values, inclusive)."
),
)
RANGE_006 = Detail(
name="Range type - tick_labelset not found",
msg=(
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
"For the 'range' question type, the parameter 'tick_labelset' must be a "
"choice list name from the 'list_name' column on the choices sheet."
),
)
RANGE_007 = Detail(
name="Range type - tick_labelset too many choices with 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 "
"must have only 2 items when the 'appearance' is 'no-ticks'."
),
)
RANGE_008 = Detail(
name="Range type - parameter not compatible with appearance",
msg=(
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
"For the 'range' question type, the parameters 'tick_interval', 'placeholder', "
"and 'tick_labelset' are only supported for the appearances 'vertical', 'no-ticks' "
"and the default (empty) horizontal."
),
)
RANGE_009 = Detail(
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 "
"all be numbers."
),
)
RANGE_010 = Detail(
name="Range type - tick_labelset choice outside range",
msg=(
"[row : {row}] On the 'survey' sheet, the 'parameters' value is invalid. "
"For the 'range' question type, the parameter 'tick_labelset' choices must "
"be a within the range (between the 'start' and 'end' values, inclusive)."
),
)
RANGE_011 = Detail(
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 "
"be 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 "
"match the range 'start' and 'end' values when the 'appearance' is 'no-ticks'."
),
)
SURVEY_001 = Detail(
name="Survey sheet - unmatched group/repeat/loop end",
msg=(
Expand Down
6 changes: 5 additions & 1 deletion pyxform/question_type_dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,11 @@
"control": {"tag": "upload", "mediatype": "application/*"},
"bind": {"type": "binary"},
},
"range": {"control": {"tag": "range"}, "bind": {"type": "int"}},
"range": {
"control": {"tag": "range"},
"bind": {"type": "int"},
"parameters": {"start": "1", "end": "10", "step": "1"},
},
"audit": {"bind": {"type": "binary"}},
"xml-external": {
# Only effect is to add an external instance.
Expand Down
5 changes: 3 additions & 2 deletions pyxform/validators/pyxform/parameters_generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ def parse(raw_parameters: str) -> PARAMETERS_TYPE:

params = {}
for param in parts:
if "=" not in param:
kv_split = param.split("=")
if "=" not in param or len(kv_split) != 2:
raise PyXFormError(
"Expecting parameters to be in the form of "
"'parameter1=value parameter2=value'."
)
k, v = param.split("=")[:2]
k, v = kv_split
key = maybe_strip(k.lower())
params[key] = v if key in CASE_SENSITIVE_VALUES else maybe_strip(v.lower())

Expand Down
141 changes: 141 additions & 0 deletions pyxform/validators/pyxform/question_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
"""

from collections.abc import Collection, Iterable
from math import isinf
from typing import Any

from pyxform import aliases
from pyxform.errors import ErrorCode, PyXFormError
from pyxform.question_type_dictionary import QUESTION_TYPE_DICT
from pyxform.validators.pyxform import parameters_generic
from pyxform.validators.pyxform.pyxform_reference import (
is_pyxform_reference_candidate,
parse_pyxform_references,
Expand Down Expand Up @@ -100,3 +104,140 @@ def validate_geo_parameter_incremental(value: str) -> None:
raise PyXFormError(
code=ErrorCode.SURVEY_003,
)


def process_range_question_type(
row_number: int,
row: dict[str, Any],
parameters: parameters_generic.PARAMETERS_TYPE,
appearance: str,
choices: dict[str, Any],
) -> dict[str, Any]:
"""
Returns a new row that includes the Range parameters start, end and step.

Raises PyXFormError when invalid range parameters are used.
"""
parameters = parameters_generic.validate(
parameters=parameters,
allowed={"start", "end", "step", "tick_interval", "placeholder", "tick_labelset"},
)
if (
appearance
and appearance not in {"vertical", "no-ticks"}
and any(
k in parameters for k in ("tick_interval", "placeholder", "tick_labelset")
)
):
raise PyXFormError(ErrorCode.RANGE_008.value.format(row=row_number))
no_ticks_appearance = appearance and appearance == "no-ticks"

defaults = QUESTION_TYPE_DICT["range"]["parameters"]
# set defaults
for key in defaults:
if key not in parameters:
parameters[key] = defaults[key]

def process_parameter(name: str) -> float | None:
value = parameters.get(name)
if value is None:
return value
err = False
try:
value = float(value)
except ValueError:
err = True

if err or isinf(value):
raise PyXFormError(
ErrorCode.RANGE_001.value.format(row=row_number, name=name)
)
return value

start = process_parameter(name="start")
end = process_parameter(name="end")
step = process_parameter(name="step")
tick_interval = process_parameter(name="tick_interval")
placeholder = process_parameter(name="placeholder")
tick_labelset = parameters.get("tick_labelset")
range_width = abs(end - start)

if step == 0:
raise PyXFormError(ErrorCode.RANGE_002.value.format(row=row_number, name="step"))
if step > range_width:
raise PyXFormError(ErrorCode.RANGE_003.value.format(row=row_number, name="step"))

if tick_interval is not None:
if tick_interval == 0:
raise PyXFormError(
ErrorCode.RANGE_002.value.format(row=row_number, name="tick_interval")
)
if tick_interval > range_width:
raise PyXFormError(
ErrorCode.RANGE_003.value.format(row=row_number, name="tick_interval")
)
if (tick_interval % step) != 0:
raise PyXFormError(
ErrorCode.RANGE_004.value.format(row=row_number, name="tick_interval")
)
parameters["odk:tick-interval"] = parameters.pop("tick_interval")

if placeholder is not None:
if (placeholder % step) != 0:
raise PyXFormError(
ErrorCode.RANGE_004.value.format(row=row_number, name="placeholder")
)
if placeholder < start or placeholder > end:
raise PyXFormError(
ErrorCode.RANGE_005.value.format(row=row_number, name="placeholder")
)
parameters["odk:placeholder"] = parameters.pop("placeholder")

if tick_labelset:
tick_list = choices.get(tick_labelset)
if tick_list is None:
raise PyXFormError(ErrorCode.RANGE_006.value.format(row=row_number))

no_ticks_labels = set()
for label in tick_list:
errored = False
try:
label = float(label.get("name"))
except ValueError:
errored = True

if errored or isinf(label):
raise PyXFormError(ErrorCode.RANGE_009.value.format(row=row_number))

if label < start or label > end:
raise PyXFormError(ErrorCode.RANGE_010.value.format(row=row_number))
if tick_interval is not None and (label % tick_interval) != 0:
raise PyXFormError(
ErrorCode.RANGE_011.value.format(row=row_number, name="tick_interval")
)
elif (label % step) != 0:
raise PyXFormError(
ErrorCode.RANGE_011.value.format(row=row_number, name="step")
)
if no_ticks_appearance:
no_ticks_labels.add(label)

if no_ticks_appearance:
if len(no_ticks_labels) > 2:
raise PyXFormError(ErrorCode.RANGE_007.value.format(row=row_number))
if no_ticks_labels != {start, end}:
raise PyXFormError(ErrorCode.RANGE_012.value.format(row=row_number))

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

# Default is integer, but if the floats have decimals then change the bind type.
if any(
i is not None and not i.is_integer()
for i in (start, end, step, tick_interval, placeholder)
):
row["bind"] = row.get("bind", {})
row["bind"].update({"type": "decimal"})

row["parameters"] = parameters

return row
57 changes: 10 additions & 47 deletions pyxform/xls2json.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,47 +132,6 @@ def add_flat_annotations(prompt_list, parent_relevant="", name_prefix=""):
# prompt['name'] = name_prefix + prompt['name']


def process_range_question_type(
row: dict[str, Any], parameters: parameters_generic.PARAMETERS_TYPE
) -> dict[str, Any]:
"""
Returns a new row that includes the Range parameters start, end and step.

Raises PyXFormError when invalid range parameters are used.
"""
new_dict = row.copy()
parameters = parameters_generic.validate(
parameters=parameters, allowed=("start", "end", "step")
)
parameters_map = {"start": "start", "end": "end", "step": "step"}
defaults = {"start": "1", "end": "10", "step": "1"}

# set defaults
for key in parameters_map.values():
if key not in parameters:
parameters[key] = defaults[key]

has_float = False
try:
# Check all parameters.
for x in parameters.values():
if float(x) and "." in str(x):
has_float = True
except ValueError as range_err:
raise PyXFormError(
"Range parameters 'start', 'end' or 'step' must all be numbers."
) from range_err
else:
# is integer by default, convert to decimal if it has any float values
if has_float:
new_dict["bind"] = new_dict.get("bind", {})
new_dict["bind"].update({"type": "decimal"})

new_dict["parameters"] = parameters

return new_dict


def process_image_default(default_value):
# prepend image files with the correct prefix, if they don't have it.
image_jr_prefix = "jr://images/"
Expand Down Expand Up @@ -519,6 +478,7 @@ def workbook_to_json(

# Get question type
question_type = row.get(constants.TYPE)
appearance = row.get("control", {}).get("appearance")

if not question_type:
# if name and label are also missing,
Expand Down Expand Up @@ -888,10 +848,8 @@ def workbook_to_json(

# Code to deal with table_list appearance flags
# (for groups of selects)
ctrl_ap = new_json_dict.get("control", {}).get("appearance")

if ctrl_ap:
appearance_mods_as_list = ctrl_ap.split()
if appearance:
appearance_mods_as_list = appearance.split()
if constants.TABLE_LIST in appearance_mods_as_list:
# Table List modifier should add field list to the new dict,
# as well as appending other appearance modifiers.
Expand Down Expand Up @@ -1195,7 +1153,13 @@ def workbook_to_json(

# range question_type
if question_type == "range":
new_dict = process_range_question_type(row=row, parameters=parameters)
new_dict = qt.process_range_question_type(
row_number=row_number,
row=row,
parameters=parameters,
appearance=appearance,
choices=choices,
)
parent_children_array.append(new_dict)
continue

Expand Down Expand Up @@ -1247,7 +1211,6 @@ def workbook_to_json(
)

if "app" in parameters:
appearance = row.get("control", {}).get("appearance")
if appearance is None or appearance == "annotate":
app_package_name = str(parameters["app"])
validation_result = validate_android_package_name(app_package_name)
Expand Down
Loading
Loading