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
101 changes: 97 additions & 4 deletions kobo/apps/subsequences/actions/qual.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,103 @@ def result_schema(self):
}
return schema

def get_output_fields(self):
return []
def get_output_fields(self) -> list[dict]:
output_fields = []
for qual_item in self.params:
field = {
'labels': qual_item['labels'],
'source': self.source_question_xpath,
'name': f"{self.source_question_xpath}/{qual_item['uuid']}",
'type': qual_item['type'],
}

if qual_item['type'] in ('qualSelectOne', 'qualSelectMultiple'):
field['choices'] = [
{
'uuid': choice['uuid'],
'labels': choice['labels'],
}
for choice in qual_item.get('choices', [])
]
output_fields.append(field)
return output_fields

def transform_data_for_output(
self, action_data: list[dict]
self, action_data: dict
) -> dict[str, dict[str, Any]]:
return []
output_data = {}

qual_questions_by_uuid = {q['uuid']: q for q in self.params}

# Choice lookup tables for select questions
choices_by_uuid = {}
for qual_question in self.params:
if qual_question['type'] in ('qualSelectOne', 'qualSelectMultiple'):
choices_by_uuid[qual_question['uuid']] = {
choice['uuid']: choice
for choice in qual_question.get('choices', [])
}

for qual_uuid, qual_data in action_data.items():
if qual_uuid not in qual_questions_by_uuid:
continue

qual_question = qual_questions_by_uuid[qual_uuid]

# Get the most recent accepted version
versions = qual_data.get(self.VERSION_FIELD, [])
if not versions:
continue

# Find most recent accepted version
accepted_version = None
for version in versions:
if self.DATE_ACCEPTED_FIELD in version:
accepted_version = version
break

# Skip if no accepted version exists
if not accepted_version:
continue

# Extract the data and metadata
version_data = accepted_version.get(self.VERSION_DATA_FIELD, {})
date_accepted = accepted_version.get(self.DATE_ACCEPTED_FIELD)

# Skip if no actual data
if not version_data:
continue

field_name = f'{self.source_question_xpath}/{qual_uuid}'
value = version_data.get('value')
question_type = qual_question['type']
if question_type == 'qualSelectOne':
if value and qual_uuid in choices_by_uuid:
choice = choices_by_uuid[qual_uuid].get(value)
output_value = {
'uuid': value,
'labels': choice.get('labels') if choice else {}
}
else:
output_value = None
elif question_type == 'qualSelectMultiple':
if value and isinstance(value, list) and qual_uuid in choices_by_uuid:
output_value = []
for choice_uuid in value:
choice = choices_by_uuid[qual_uuid].get(choice_uuid)
output_value.append({
'uuid': choice_uuid,
'labels': choice.get('labels') if choice else {}
})
else:
output_value = []
else:
# Unchanged value for other types
output_value = value

output_data[field_name] = {
'value': output_value,
'_dateAccepted': date_accepted,
}

return output_data
261 changes: 260 additions & 1 deletion kobo/apps/subsequences/tests/test_qual.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from copy import deepcopy
from unittest import mock
from unittest import mock, TestCase
import uuid

from freezegun import freeze_time
Expand Down Expand Up @@ -642,3 +642,262 @@ def test_result_content():
accumulated_result
== Fix.expected_result_after_filled_and_empty_responses
)


class TestQualActionMethods(TestCase):
source_xpath = 'group_name/question_name'
action_params = [
{
'type': 'qualInteger',
'uuid': 'qual-integer-uuid',
'labels': {'_default': 'Number of themes', 'fr': 'Nombre de thèmes'},
},
{
'type': 'qualText',
'uuid': 'qual-text-uuid',
'labels': {'_default': 'Summary Notes'},
},
{
'type': 'qualSelectOne',
'uuid': 'qual-select-one-uuid',
'labels': {'_default': 'Urgency Level', 'es': 'Nivel de Urgencia'},
'choices': [
{
'uuid': 'choice-high-uuid',
'labels': {'_default': 'High', 'fr': 'Élevé', 'es': 'Alto'}
},
{
'uuid': 'choice-medium-uuid',
'labels': {'_default': 'Medium', 'fr': 'Moyen', 'es': 'Medio'}
},
{
'uuid': 'choice-low-uuid',
'labels': {'_default': 'Low', 'fr': 'Bas', 'es': 'Bajo'}
},
]
},
{
'type': 'qualSelectMultiple',
'uuid': 'qual-select-multi-uuid',
'labels': {'_default': 'Tags'},
'choices': [
{
'uuid': 'tag-shelter-uuid',
'labels': {'_default': 'Shelter', 'ar': 'مأوى'}
},
{
'uuid': 'tag-food-uuid',
'labels': {'_default': 'Food', 'ar': 'طعام'}
},
{
'uuid': 'tag-medical-uuid',
'labels': {'_default': 'Medical', 'ar': 'طبي'}
},
]
},
]

def test_get_output_fields(self):
"""
Test for `get_output_fields()` covering:
- Correct structure and required fields
- Integer and text questions (no choices)
- Select one with choices
- Select multiple with choices
- Field naming convention
"""
action = QualAction(self.source_xpath, self.action_params)
output_fields = action.get_output_fields()

# Should return one field per qual question
assert len(output_fields) == 4

# All fields should have required keys
for field in output_fields:
assert 'labels' in field
assert 'source' in field
assert 'name' in field
assert 'type' in field
assert field['source'] == self.source_xpath
# Name should follow pattern: source_xpath/qual_uuid
assert field['name'].startswith(f'{self.source_xpath}/')

# Test integer question (no choices)
integer_field = next(f for f in output_fields if f['type'] == 'qualInteger')
assert integer_field['labels'] == {
'_default': 'Number of themes',
'fr': 'Nombre de thèmes'
}
assert integer_field['name'] == f'{self.source_xpath}/qual-integer-uuid'
assert 'choices' not in integer_field

# Test text question (no choices)
text_field = next(f for f in output_fields if f['type'] == 'qualText')
assert text_field['labels'] == {'_default': 'Summary Notes'}
assert text_field['name'] == f'{self.source_xpath}/qual-text-uuid'
assert 'choices' not in text_field

# Test select one (with choices)
select_one_field = next(
f for f in output_fields if f['type'] == 'qualSelectOne'
)
assert select_one_field['labels'] == {
'_default': 'Urgency Level',
'es': 'Nivel de Urgencia'
}
assert select_one_field['name'] == f'{self.source_xpath}/qual-select-one-uuid'
assert 'choices' in select_one_field
assert len(select_one_field['choices']) == 3

# Verify choice structure
high_choice = select_one_field['choices'][0]
assert high_choice['uuid'] == 'choice-high-uuid'
assert high_choice['labels'] == {
'_default': 'High',
'fr': 'Élevé',
'es': 'Alto'
}

# Test select multiple (with choices)
select_multi_field = next(
f for f in output_fields if f['type'] == 'qualSelectMultiple'
)
assert 'choices' in select_multi_field
assert len(select_multi_field['choices']) == 3

# Verify multilingual choice labels
shelter_choice = next(
c for c in select_multi_field['choices']
if c['uuid'] == 'tag-shelter-uuid'
)
assert shelter_choice['labels'] == {
'_default': 'Shelter',
'ar': 'مأوى'
}

def test_transform_data_for_output_all_question_types(self):
"""
Test for `transform_data_for_output()` covering:
- Integer question (direct value)
- Text question (direct value)
- Select one (UUID → object with labels)
- Select multiple (UUID array → object array with labels)
- Multiple questions processed together
- Field naming and structure
"""
action = QualAction(self.source_xpath, self.action_params)

action_data = {
# Integer question
'qual-integer-uuid': {
'_versions': [{
'_data': {'uuid': 'qual-integer-uuid', 'value': 5},
'_dateCreated': '2025-11-24T10:00:00Z',
'_dateAccepted': '2025-11-24T10:00:00Z',
'_uuid': 'v1'
}],
'_dateCreated': '2025-11-24T10:00:00Z',
'_dateModified': '2025-11-24T10:00:00Z'
},
# Text question
'qual-text-uuid': {
'_versions': [{
'_data': {
'uuid': 'qual-text-uuid',
'value': 'Family needs immediate shelter and medical care'
},
'_dateCreated': '2025-11-24T10:05:00Z',
'_dateAccepted': '2025-11-24T10:05:00Z',
'_uuid': 'v2'
}],
'_dateCreated': '2025-11-24T10:05:00Z',
'_dateModified': '2025-11-24T10:05:00Z'
},
# Select one question
'qual-select-one-uuid': {
'_versions': [{
'_data': {
'uuid': 'qual-select-one-uuid',
'value': 'choice-high-uuid'
},
'_dateCreated': '2025-11-24T10:10:00Z',
'_dateAccepted': '2025-11-24T10:10:00Z',
'_uuid': 'v3'
}],
'_dateCreated': '2025-11-24T10:10:00Z',
'_dateModified': '2025-11-24T10:10:00Z'
},
# Select multiple question
'qual-select-multi-uuid': {
'_versions': [{
'_data': {
'uuid': 'qual-select-multi-uuid',
'value': ['tag-shelter-uuid', 'tag-medical-uuid']
},
'_dateCreated': '2025-11-24T10:15:00Z',
'_dateAccepted': '2025-11-24T10:15:00Z',
'_uuid': 'v4'
}],
'_dateCreated': '2025-11-24T10:15:00Z',
'_dateModified': '2025-11-24T10:15:00Z'
}
}

output = action.transform_data_for_output(action_data)

# Should have 4 fields in output
assert len(output) == 4

# Test integer question - direct value
integer_field = f'{self.source_xpath}/qual-integer-uuid'
assert integer_field in output
assert output[integer_field]['value'] == 5
assert output[integer_field]['_dateAccepted'] == '2025-11-24T10:00:00Z'

# Test text question - direct value
text_field = f'{self.source_xpath}/qual-text-uuid'
assert text_field in output
assert (
output[text_field]['value']
== 'Family needs immediate shelter and medical care'
)
assert output[text_field]['_dateAccepted'] == '2025-11-24T10:05:00Z'

# Test select one - UUID transformed to object with labels
select_one_field = f'{self.source_xpath}/qual-select-one-uuid'
assert select_one_field in output
select_one_value = output[select_one_field]['value']
assert select_one_value['uuid'] == 'choice-high-uuid'
assert select_one_value['labels'] == {
'_default': 'High',
'fr': 'Élevé',
'es': 'Alto'
}
assert output[select_one_field]['_dateAccepted'] == '2025-11-24T10:10:00Z'

# Test select multiple - array of UUIDs transformed to array of objects
select_multi_field = f'{self.source_xpath}/qual-select-multi-uuid'
assert select_multi_field in output
select_multi_value = output[select_multi_field]['value']
assert len(select_multi_value) == 2

# Verify first choice
shelter_item = next(
item for item in select_multi_value
if item['uuid'] == 'tag-shelter-uuid'
)
assert shelter_item['labels'] == {
'_default': 'Shelter',
'ar': 'مأوى'
}

# Verify second choice
medical_item = next(
item for item in select_multi_value
if item['uuid'] == 'tag-medical-uuid'
)
assert medical_item['labels'] == {
'_default': 'Medical',
'ar': 'طبي'
}
assert output[select_multi_field]['_dateAccepted'] == '2025-11-24T10:15:00Z'
Loading