Skip to content

Commit 6834cb7

Browse files
chg ! apply mapping at import instead of validation
1 parent 4fafd22 commit 6834cb7

File tree

12 files changed

+186
-167
lines changed

12 files changed

+186
-167
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: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from country_workspace.contrib.kobo.api.data.helpers import VALUE_FORMAT
1515
from country_workspace.datasources.utils import datetime_to_date, date_to_iso_string
16-
from country_workspace.models import AsyncJob, Batch, Household, Individual, MappingImporter
16+
from country_workspace.models import AsyncJob, Batch, Household, Individual
1717
from country_workspace.utils.config import BatchNameConfig, FailIfAlienConfig
1818
from country_workspace.utils.fields import Record, clean_field_names
1919
from country_workspace.utils.functional import compose
@@ -99,14 +99,15 @@ def process_households(sheet: Sheet, job: AsyncJob, batch: Batch, config: Config
9999
for i, row in enumerate(sheet, 1):
100100
household_key = get_value(row, config["household_pk_col"])
101101
label = get_value(row, config["detail_column_label"])
102+
flex_fields = job.program.apply_mapping_importer(Household, clean_field_names(row))
102103

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

117118

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

124134

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

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

148149
try:
149150
mapping[i] = cast(
@@ -152,11 +153,10 @@ def process_beneficiaries(
152153
batch=batch,
153154
name=name,
154155
household=household,
155-
flex_fields=clean_field_names(cleaned_row),
156+
flex_fields=flex_fields,
156157
),
157158
)
158159
except Exception as e:
159-
sheet_name = PEOPLE if is_people_only_mode else INDIVIDUAL
160160
raise SheetProcessingError(sheet_name, i) from e
161161

162162
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

Lines changed: 0 additions & 43 deletions
This file was deleted.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from typing import Self
2+
from dataclasses import dataclass
3+
from packaging.version import Version
4+
from django.db import transaction
5+
6+
from hope_flex_fields.models import FieldDefinition, FlexField, DataChecker
7+
from country_workspace.models import MappingImporter
8+
from country_workspace.contrib.hope.constants import INDIVIDUAL_CHECKER_NAME, PEOPLE_CHECKER_NAME
9+
10+
_script_for_version = Version("0.1.0")
11+
12+
13+
@dataclass(frozen=True)
14+
class FieldRename:
15+
old_definition: str
16+
new_definition: str
17+
old_field: str
18+
new_field: str
19+
20+
def reverse(self) -> Self:
21+
return FieldRename(self.new_definition, self.old_definition, self.new_field, self.old_field)
22+
23+
24+
RENAME = FieldRename("HOPE IND Gender", "HOPE IND Sex", "gender", "sex")
25+
CHECKERS = (INDIVIDUAL_CHECKER_NAME, PEOPLE_CHECKER_NAME)
26+
MI_NAME = "gender_to_sex"
27+
28+
29+
def _rename_field(rename: FieldRename) -> None:
30+
field_def_qs = FieldDefinition.objects.filter(name=rename.old_definition)
31+
if field_def_qs.exists():
32+
FlexField.objects.filter(definition__in=field_def_qs).update(name=rename.new_field)
33+
field_def_qs.update(name=rename.new_definition)
34+
35+
36+
def _create_mapping_rules(rename: FieldRename) -> None:
37+
for checker_name in CHECKERS:
38+
try:
39+
dc = DataChecker.objects.get(name=checker_name)
40+
MappingImporter.objects.get_or_create(
41+
name=MI_NAME,
42+
data_checker=dc,
43+
defaults={
44+
"rules": f"{rename.old_field}={rename.new_field}",
45+
},
46+
)
47+
except DataChecker.DoesNotExist:
48+
continue
49+
50+
51+
def _remove_mapping_rules(rename: FieldRename) -> None:
52+
MappingImporter.objects.filter(name=MI_NAME).delete()
53+
54+
55+
def forward() -> None:
56+
with transaction.atomic():
57+
_rename_field(RENAME)
58+
_create_mapping_rules(RENAME)
59+
60+
61+
def backward() -> None:
62+
with transaction.atomic():
63+
_remove_mapping_rules(RENAME)
64+
_rename_field(RENAME.reverse())
65+
66+
67+
class Scripts:
68+
requires = []
69+
operations = [(forward, backward)]

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(

0 commit comments

Comments
 (0)