Skip to content

Commit 04482eb

Browse files
chg ! apply mapping at import instead of validation
1 parent 30cfda0 commit 04482eb

File tree

11 files changed

+116
-123
lines changed

11 files changed

+116
-123
lines changed

src/country_workspace/contrib/hope/push.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from country_workspace.exceptions import RemoteError
1515
from country_workspace.models import AsyncJob, Rdp
1616
from country_workspace.workspaces.models import CountryHousehold, CountryIndividual
17-
from country_workspace.utils.fields import map_fields
1817

1918

2019
type Beneficiary = CountryHousehold | CountryIndividual
@@ -195,9 +194,9 @@ def prepare_batch(self) -> tuple[list[int], list[dict]]:
195194
for item in self.queryset:
196195
ids.append(item.id)
197196
data.append(
198-
{**map_fields(item.flex_fields), "members": [map_fields(m.flex_fields) for m in item.members.all()]}
197+
{**item.flex_fields, "members": [m.flex_fields for m in item.members.all()]}
199198
if self.master_detail
200-
else map_fields(item.flex_fields)
199+
else item.flex_fields
201200
)
202201
return ids, data
203202

src/country_workspace/datasources/rdi.py

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,15 @@ def process_households(sheet: Sheet, job: AsyncJob, batch: Batch, config: Config
100100
for i, row in enumerate(sheet, 1):
101101
household_key = get_value(row, config["household_pk_col"])
102102
label = get_value(row, config["detail_column_label"])
103+
flex_fields = job.program.apply_mapping_importer(Household, clean_field_names(row))
103104

104105
try:
105106
mapping[household_key] = cast(
106107
"Household",
107108
job.program.households.create(
108109
batch=batch,
109110
name=label,
110-
flex_fields=clean_field_names(row),
111+
flex_fields=flex_fields,
111112
),
112113
)
113114
except Exception as e:
@@ -116,35 +117,35 @@ def process_households(sheet: Sheet, job: AsyncJob, batch: Batch, config: Config
116117
return mapping
117118

118119

119-
def full_name_column(row: Record) -> str | None:
120-
for key in row:
121-
if key.startswith("full") and "name" in key:
122-
return key
123-
return None
120+
def normalize_row_structure(row: Record, people_column_prefix: str | None = None) -> tuple[Record, str | None]:
121+
if people_column_prefix:
122+
row = {k.removeprefix(people_column_prefix): v for k, v in row.items()}
123+
name_column = next((key for key in row if key.startswith("full") and "name" in key), None)
124+
return row, name_column
125+
126+
127+
def get_hh_for_ind(
128+
cleaned_row: dict, master_column_label: str, household_mapping: Mapping[int, Household] | None
129+
) -> Household | None:
130+
if not household_mapping or not master_column_label:
131+
return None
132+
household_key = get_value(cleaned_row, master_column_label)
133+
return household_mapping.get(household_key)
124134

125135

126136
def process_beneficiaries(
127137
sheet: Sheet, job: AsyncJob, batch: Batch, config: Config, household_mapping: Mapping[int, Household] | None = None
128138
) -> Mapping[int, Individual]:
129-
mapping, name_column = {}, None
130-
pp_column_prefix = config.get("people_column_prefix")
131-
is_people_only_mode = household_mapping is None
139+
mapping = {}
140+
people_column_prefix = config.get("people_column_prefix") if household_mapping is None else None
141+
master_column_label = config.get("master_column_label") if household_mapping is not None else None
142+
sheet_name = PEOPLE if household_mapping is None else INDIVIDUAL
132143

133144
for i, row in enumerate(sheet, 1):
134-
cleaned_row = (
135-
{k.removeprefix(pp_column_prefix): v for k, v in row.items()}
136-
if is_people_only_mode and pp_column_prefix
137-
else row
138-
)
139-
140-
if name_column is None:
141-
name_column = full_name_column(cleaned_row)
142-
name = get_value(cleaned_row, name_column) if name_column else None
143-
144-
household = None
145-
if not is_people_only_mode:
146-
household_key = get_value(cleaned_row, config["master_column_label"])
147-
household = household_mapping.get(household_key)
145+
cleaned_row, name_column = normalize_row_structure(row, people_column_prefix)
146+
name = cleaned_row.get(name_column) if name_column else ""
147+
household = get_hh_for_ind(cleaned_row, master_column_label, household_mapping)
148+
flex_fields = job.program.apply_mapping_importer(Individual, clean_field_names(cleaned_row))
148149

149150
try:
150151
mapping[i] = cast(
@@ -153,11 +154,10 @@ def process_beneficiaries(
153154
batch=batch,
154155
name=name,
155156
household=household,
156-
flex_fields=clean_field_names(cleaned_row),
157+
flex_fields=flex_fields,
157158
),
158159
)
159160
except Exception as e:
160-
sheet_name = PEOPLE if is_people_only_mode else INDIVIDUAL
161161
raise SheetProcessingError(sheet_name, i) from e
162162

163163
return mapping

src/country_workspace/models/base.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
from typing import TYPE_CHECKING, Any
22

33
from concurrency.fields import IntegerVersionField
4-
from contextlib import suppress
5-
from django.core.exceptions import ObjectDoesNotExist
64
from django.db import models
75
from django.urls import reverse
86
from django.utils import timezone
@@ -108,11 +106,6 @@ def checker(self) -> "DataChecker":
108106
raise NotImplementedError
109107

110108
def validate_with_checker(self, fail_if_alien: bool = False) -> bool:
111-
# skip if mappingimporter not found
112-
with suppress(ObjectDoesNotExist):
113-
self.checker.mappingimporter.apply(self.flex_fields)
114-
self.save(update_fields=["flex_fields"])
115-
116109
errors = self.checker.validate([self.flex_fields], fail_if_alien=fail_if_alien)
117110
if errors:
118111
self.errors = errors[1]

src/country_workspace/models/program.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from typing import TYPE_CHECKING
2+
from contextlib import suppress
23

4+
from django.core.exceptions import ObjectDoesNotExist
35
from django.db import models
46
from django.utils.translation import gettext as _
57
from hope_flex_fields.models import DataChecker
@@ -120,3 +122,11 @@ def get_checker_for(self, m: type[Validable] | Validable) -> DataChecker:
120122
if isinstance(m, (Individual | CountryIndividual)) or m in (Individual, CountryIndividual):
121123
return self.individual_checker
122124
raise ValueError(m)
125+
126+
def apply_mapping_importer(
127+
self, m: type[Validable] | Validable, data: dict[str, str | int | bool]
128+
) -> dict[str, str | int | bool]:
129+
# skip if mapping importer not found
130+
with suppress(ObjectDoesNotExist):
131+
self.get_checker_for(m).mappingimporter.apply(data)
132+
return data

src/country_workspace/utils/fields.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
TO_REMOVE_VALUES = "_h_c", "_h_f", "_i_c", "_i_f"
1717
TO_UPPERCASE_FIELDS = "relationship", "gender", "residence_status", "consent_sharing"
18-
TO_MAP_FIELDS = {"gender": "sex"}
1918

2019

2120
def clean_field_name(v: str) -> str:
@@ -61,20 +60,6 @@ def uppercase_field_value(k: str, v: Any, fields_to_uppercase: Iterable[str] = T
6160
return v.upper() if isinstance(v, str) and any(k.startswith(prefix) for prefix in fields_to_uppercase) else v
6261

6362

64-
def map_fields(fields: dict[str, str]) -> dict[str, str]:
65-
"""
66-
Map keys in a dictionary to alternative names based on a predefined mapping.
67-
68-
Args:
69-
fields (dict[str, str]): A dictionary containing field names as keys and their values.
70-
71-
Returns:
72-
dict[str, str]: A new dictionary with keys mapped according to the predefined mapping.
73-
74-
"""
75-
return {TO_MAP_FIELDS.get(k, k): v for k, v in fields.items() if v is not None}
76-
77-
7863
def extract_uuid(value: str, prefix: str | None = None) -> UUID:
7964
"""Extract a UUID from the given string.
8065

src/country_workspace/versioning/scripts/0013_create_field_sex_instead_of_gender.py renamed to src/country_workspace/versioning/scripts/0014_create_field_sex_instead_of_gender.py

File renamed without changes.

tests/contrib/hope/test_push_to_hope_core.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,18 +394,19 @@ def test_safe_post_failure(
394394

395395

396396
@pytest.mark.django_db
397-
def test_prepare_batch(mocker: MockerFixture, push_processor: PushProcessor, beneficiary_instance: Beneficiary) -> None:
397+
def test_prepare_batch(push_processor: PushProcessor, beneficiary_instance: Beneficiary) -> None:
398398
push_processor.set_queryset([beneficiary_instance.pk])
399-
mocker.patch("country_workspace.contrib.hope.push.map_fields", return_value={"field": "value"})
400399

401400
ids, data = push_processor.prepare_batch()
402401

403402
assert ids == [beneficiary_instance.pk]
403+
assert len(data) == 1
404+
404405
if push_processor.master_detail:
405-
assert len(data) == 1
406406
assert "members" in data[0]
407+
assert data[0]["members"] == [m.flex_fields for m in beneficiary_instance.members.all()]
407408
else:
408-
assert data == [{"field": "value"}]
409+
assert data[0] == beneficiary_instance.flex_fields
409410

410411

411412
@pytest.mark.parametrize(

tests/datasources/test_rdi.py

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,13 @@
2323
image_content,
2424
extract_images,
2525
merge_images,
26+
normalize_row_structure,
2627
read_sheets,
27-
full_name_column,
2828
INDIVIDUAL,
2929
PEOPLE,
3030
)
3131
from country_workspace.datasources.utils import datetime_to_date, date_to_iso_string
32-
from country_workspace.models import Household
32+
from country_workspace.models import Household, Individual
3333
from country_workspace.workspaces.exceptions import BeneficiaryValidationError
3434
from country_workspace.validators.beneficiaries import validate_beneficiaries
3535

@@ -169,6 +169,33 @@ def test_filter_rows_with_household_pk(
169169
)
170170

171171

172+
@pytest.mark.parametrize(
173+
("row", "prefix", "expected_row"),
174+
[
175+
({"full_name": "John"}, None, {"full_name": "John"}),
176+
({"pp_full_name": "Jane"}, "pp_", {"full_name": "Jane"}),
177+
({"no_prefix": "value"}, "wrong_", {"no_prefix": "value"}),
178+
],
179+
ids=["no_prefix", "with_prefix", "prefix_not_found"],
180+
)
181+
def test_normalize_row_structure_prefix_handling(row: Record, prefix: str | None, expected_row: Record) -> None:
182+
result_row, _ = normalize_row_structure(row, prefix)
183+
assert result_row == expected_row
184+
185+
186+
@pytest.mark.parametrize(
187+
("row", "expected_name_column"),
188+
[
189+
({"full_name": "John"}, "full_name"),
190+
({"age": 30}, None),
191+
],
192+
ids=["full_name", "no_name"],
193+
)
194+
def test_normalize_row_structure_name_column_detection(row: Record, expected_name_column: str | None) -> None:
195+
_, name_column = normalize_row_structure(row)
196+
assert name_column == expected_name_column
197+
198+
172199
def test_process_households(
173200
mocker: MockerFixture, config: Config, household_sheet: Sheet, skip_if_not_master_detail
174201
) -> None:
@@ -179,9 +206,17 @@ def test_process_households(
179206
assert result == {
180207
row[config["household_pk_col"]]: job.program.households.create.return_value for row in household_sheet
181208
}
209+
210+
job.program.apply_mapping_importer.assert_has_calls(
211+
[call(Household, clean_field_names_mock.return_value) for row in household_sheet]
212+
)
182213
job.program.households.create.assert_has_calls(
183214
[
184-
call(batch=batch, name=row[config["detail_column_label"]], flex_fields=clean_field_names_mock.return_value)
215+
call(
216+
batch=batch,
217+
name=row[config["detail_column_label"]],
218+
flex_fields=job.program.apply_mapping_importer.return_value,
219+
)
185220
for row in household_sheet
186221
]
187222
)
@@ -218,13 +253,16 @@ def test_process_beneficiaries_with_households(
218253
)
219254

220255
assert len(result) == len(list(individual_sheet))
256+
job_mock.program.apply_mapping_importer.assert_has_calls(
257+
[call(Individual, clean_field_names_mock.return_value) for row in individual_sheet]
258+
)
221259
job_mock.program.individuals.create.assert_has_calls(
222260
[
223261
call(
224262
batch=batch_mock,
225263
name=row[FULL_NAME_COLUMN],
226264
household=household_mapping[row[config["master_column_label"]]],
227-
flex_fields=clean_field_names_mock.return_value,
265+
flex_fields=job_mock.program.apply_mapping_importer.return_value,
228266
)
229267
for row in individual_sheet
230268
]
@@ -246,19 +284,21 @@ def test_process_beneficiaries_people_only(
246284
)
247285

248286
assert len(result) == len(list(people_sheet))
249-
expected_calls = []
287+
expected_calls, expected_apply_mapping_calls = [], []
250288
for __, row in enumerate(people_sheet, 1):
251289
prefix = config.get("people_column_prefix", "")
252290
cleaned_row = {k.removeprefix(prefix): v for k, v in row.items()}
291+
expected_apply_mapping_calls.append(call(Individual, clean_field_names_mock.return_value))
253292
expected_calls.append(
254293
call(
255294
batch=batch_mock,
256295
name=cleaned_row[FULL_NAME_COLUMN],
257296
household=None,
258-
flex_fields=clean_field_names_mock.return_value,
297+
flex_fields=job_mock.program.apply_mapping_importer.return_value,
259298
)
260299
)
261300
job_mock.program.individuals.create.assert_has_calls(expected_calls)
301+
job_mock.program.apply_mapping_importer.assert_has_calls(expected_apply_mapping_calls)
262302

263303

264304
def test_process_beneficiaries_failed_to_create(
@@ -462,18 +502,6 @@ def test_read_sheets_sheet_not_found_error(mocker: MockerFixture, config: Config
462502
assert str(sheet_index) in str(exc_info.value)
463503

464504

465-
@pytest.mark.parametrize(
466-
("record", "expected"),
467-
[
468-
({"full_name": "John Smith"}, "full_name"),
469-
({}, None),
470-
({"name_full": "John Smith"}, None),
471-
],
472-
)
473-
def test_full_name_column(record: Record, expected: str | None) -> None:
474-
assert full_name_column(record) == expected
475-
476-
477505
@pytest.mark.parametrize(
478506
("inp", "expected"),
479507
[

tests/utils/test_utils_fields.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
clean_field_name,
1313
TO_REMOVE_VALUES,
1414
clean_field_names,
15-
map_fields,
1615
extract_uuid,
1716
)
1817
from country_workspace.utils.flex_fields import (
@@ -44,20 +43,6 @@ def test_clean_field_names(mocker: MockerFixture) -> None:
4443
clean_field_name_mock.assert_called_once_with(key)
4544

4645

47-
@pytest.mark.parametrize(
48-
("input_fields", "expected_output"),
49-
[
50-
({"gender": "male"}, {"sex": "male"}),
51-
({"name": "John"}, {"name": "John"}),
52-
({}, {}),
53-
({"gender": "female", "age": "30"}, {"sex": "female", "age": "30"}),
54-
],
55-
)
56-
def test_map_fields(input_fields, expected_output):
57-
result = map_fields(input_fields)
58-
assert result == expected_output
59-
60-
6146
@pytest.mark.parametrize("value", [None, "", "test"])
6247
def test_base64_image_input(value: str | None) -> None:
6348
input_ = Base64ImageInput()

0 commit comments

Comments
 (0)