From 3b8f15a99ded7c1ff91633f4d6ce4e1ff8b8f5ff Mon Sep 17 00:00:00 2001 From: Raj Patel Date: Tue, 25 Nov 2025 22:12:58 +0530 Subject: [PATCH 1/2] Add implmentation for QualAction methods --- kobo/apps/subsequences/actions/qual.py | 101 ++++++++- kobo/apps/subsequences/tests/test_qual.py | 259 +++++++++++++++++++++- 2 files changed, 355 insertions(+), 5 deletions(-) diff --git a/kobo/apps/subsequences/actions/qual.py b/kobo/apps/subsequences/actions/qual.py index de9dfab86d..933bfa2743 100644 --- a/kobo/apps/subsequences/actions/qual.py +++ b/kobo/apps/subsequences/actions/qual.py @@ -254,10 +254,103 @@ def result_schema(self): } return schema - def get_output_fields(self): - raise NotImplementedError('Sorry!') + 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]]: - raise NotImplementedError('Sorry!') + 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 diff --git a/kobo/apps/subsequences/tests/test_qual.py b/kobo/apps/subsequences/tests/test_qual.py index a77020a243..f14b2dd090 100644 --- a/kobo/apps/subsequences/tests/test_qual.py +++ b/kobo/apps/subsequences/tests/test_qual.py @@ -1,5 +1,5 @@ from copy import deepcopy -from unittest import mock +from unittest import mock, TestCase import uuid from freezegun import freeze_time @@ -642,3 +642,260 @@ 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' From 4388983221ac8b07de72adc67b831446576c483c Mon Sep 17 00:00:00 2001 From: Raj Patel Date: Tue, 25 Nov 2025 22:21:01 +0530 Subject: [PATCH 2/2] Fix linter issues --- kobo/apps/subsequences/actions/qual.py | 2 +- kobo/apps/subsequences/tests/test_qual.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/kobo/apps/subsequences/actions/qual.py b/kobo/apps/subsequences/actions/qual.py index 933bfa2743..54d80eeb37 100644 --- a/kobo/apps/subsequences/actions/qual.py +++ b/kobo/apps/subsequences/actions/qual.py @@ -321,7 +321,7 @@ def transform_data_for_output( if not version_data: continue - field_name = f"{self.source_question_xpath}/{qual_uuid}" + field_name = f'{self.source_question_xpath}/{qual_uuid}' value = version_data.get('value') question_type = qual_question['type'] if question_type == 'qualSelectOne': diff --git a/kobo/apps/subsequences/tests/test_qual.py b/kobo/apps/subsequences/tests/test_qual.py index f14b2dd090..d490e66bd6 100644 --- a/kobo/apps/subsequences/tests/test_qual.py +++ b/kobo/apps/subsequences/tests/test_qual.py @@ -720,7 +720,7 @@ def test_get_output_fields(self): 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}/") + 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') @@ -738,7 +738,9 @@ def test_get_output_fields(self): 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') + 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'