Skip to content

Commit c330014

Browse files
authored
Merge pull request #273 from unicef/feature/283921
283921: Enhance Mapping Importer functionality and admin interface
2 parents 4c89805 + a325e80 commit c330014

File tree

27 files changed

+563
-104
lines changed

27 files changed

+563
-104
lines changed

src/country_workspace/admin/mapping_importer.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
@admin.register(MappingImporter)
1111
class MappingImporterAdmin(BaseModelAdmin):
1212
readonly_fields = ("created_at", "last_modified", "created_by")
13-
list_display = ("name", "data_checker", "created_by", "created_at", "last_modified")
13+
list_display = ("name", "office", "data_checker", "created_by", "created_at", "last_modified")
1414
list_filter = (
15-
"data_checker",
15+
("office", AutoCompleteFilter),
16+
("data_checker", AutoCompleteFilter),
1617
("created_by", AutoCompleteFilter),
1718
)
18-
search_fields = ("name",)
19+
search_fields = ("name", "description")
20+
autocomplete_fields = ("office", "data_checker")
1921

2022
def save_model(self, request: HttpRequest, obj: MappingImporter, form: ModelForm, change: bool) -> None:
2123
if not change:

src/country_workspace/contrib/aurora/forms.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class ImportAuroraForm(BaseImportForm):
1414
)
1515

1616
def __init__(self, *args: Any, program: Program | None = None, **kwargs: Any) -> None:
17+
if program:
18+
kwargs["program"] = program
1719
super().__init__(*args, **kwargs)
1820
if program:
1921
self.fields["registration"].queryset = (

src/country_workspace/contrib/aurora/import_processing.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, NamedTuple
1+
from typing import Any, NamedTuple, NotRequired
22
from itertools import chain
33
from collections.abc import Mapping
44
from functools import partial
@@ -18,6 +18,8 @@
1818
class Config(BatchNameConfig, ValidateModeConfig):
1919
registration_reference_pk: str | None
2020
master_detail: bool
21+
household_mapping_id: NotRequired[int | None]
22+
individual_mapping_id: NotRequired[int | None]
2123

2224

2325
class ImportResult(NamedTuple):

src/country_workspace/contrib/kobo/forms.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django import forms
44

55
from country_workspace.contrib.kobo.sync import make_client
6+
from country_workspace.models import Program
67
from country_workspace.workspaces.admin.forms import BaseImportForm
78

89

@@ -14,7 +15,15 @@ class ImportKoboForm(BaseImportForm):
1415
help_text="Which field contains individual records",
1516
)
1617

17-
def __init__(self, *args: Any, kobo_country_code: str | None, **kwargs: Any) -> None:
18+
def __init__(
19+
self,
20+
*args: Any,
21+
kobo_country_code: str | None = None,
22+
program: Program | None = None,
23+
**kwargs: Any,
24+
) -> None:
25+
kwargs["program"] = program
26+
1827
super().__init__(*args, **kwargs)
1928
if kobo_country_code:
2029
client = make_client(kobo_country_code)

src/country_workspace/contrib/kobo/sync.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import re
22
from collections.abc import Callable, Iterable
33
from functools import partial
4-
from typing import Any, Final, TypedDict, cast, TYPE_CHECKING
4+
from typing import Any, Final, NotRequired, TypedDict, cast, TYPE_CHECKING
55
from constance import config as constance_config
66
from django.utils import timezone
77
from requests import Session
@@ -11,8 +11,6 @@
1111

1212
from country_workspace.contrib.kobo.exceptions import AlienFieldsError
1313

14-
if TYPE_CHECKING:
15-
from hope_flex_fields.models import DataChecker
1614

1715
from country_workspace.contrib.kobo.api.client.auth import Auth
1816
from country_workspace.contrib.kobo.api.client.main import Client
@@ -26,10 +24,15 @@
2624
from country_workspace.utils.sync_log import get_kobo_sync_log_name
2725
from country_workspace.workspaces.admin.cleaners.validate import create_validation_jobs
2826

27+
if TYPE_CHECKING:
28+
from hope_flex_fields.models import DataChecker
29+
2930

3031
class Config(BatchNameConfig, ValidateModeConfig):
3132
project_id: str
3233
individual_records_field: str
34+
household_mapping_id: NotRequired[int | None]
35+
individual_mapping_id: NotRequired[int | None]
3336

3437

3538
ACCEPT_JSON_HEADERS: Final[dict[str, str]] = {"Accept": "application/json"}
@@ -93,11 +96,12 @@ def get_fullname_key(individual: Iterable[str]) -> str | None:
9396

9497
def create_individuals(batch: Batch, household: Household, submission: Submission, config: Config) -> list[Individual]:
9598
individuals = []
99+
individual_mapping_id = config.get("individual_mapping_id")
96100
for raw_individual in submission.get(config["individual_records_field"], []):
97101
individual_fields = preprocess(
98102
raw_individual,
99103
INDIVIDUAL_FIELDS_TO_UPPERCASE + TO_UPPERCASE_FIELDS,
100-
partial(batch.program.apply_mapping_importer, Individual),
104+
partial(batch.program.apply_mapping_importer, Individual, mapping_id=individual_mapping_id),
101105
partial(batch.program.apply_default_fields, Individual),
102106
)
103107
fullname = get_fullname_key(individual_fields)
@@ -117,11 +121,12 @@ def create_individuals(batch: Batch, household: Household, submission: Submissio
117121
def create_household(
118122
batch: Batch, submission: Submission, config: Config, id_generator: Callable[[], int]
119123
) -> Household:
124+
household_mapping_id = config.get("household_mapping_id")
120125
raw_household_fields = extract_household_data(submission, config["individual_records_field"])
121126
household_fields = preprocess(
122127
raw_household_fields,
123128
HOUSEHOLD_FIELDS_TO_UPPERCASE,
124-
partial(batch.program.apply_mapping_importer, Household),
129+
partial(batch.program.apply_mapping_importer, Household, mapping_id=household_mapping_id),
125130
partial(batch.program.apply_default_fields, Household),
126131
)
127132
household_fields["household_id"] = id_generator()

src/country_workspace/datasources/rdi/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ class Config(BatchNameConfig, ValidateModeConfig):
3333
household_label: NotRequired[str]
3434
people_prefix: NotRequired[str]
3535
first_line: int
36+
household_mapping_id: NotRequired[int | None]
37+
individual_mapping_id: NotRequired[int | None]
3638

3739

3840
class SheetName(StrEnum):

src/country_workspace/datasources/rdi/processors.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,17 @@ def read_sheets(config: Config, filepath: str, *sheet_names: str) -> Generator[S
109109

110110
def process_households(sheet: Sheet, job: AsyncJob, batch: Batch, config: Config) -> Mapping[int, Household]:
111111
mapping = {}
112+
household_mapping_id = config.get("household_mapping_id")
112113
transform_row = compose(
113114
clean_field_names,
114-
partial(job.program.apply_mapping_importer, Household),
115+
partial(job.program.apply_mapping_importer, Household, mapping_id=household_mapping_id),
115116
partial(job.program.apply_default_fields, Household),
116117
)
117118

118119
for row in sheet:
119120
if (household_key := get_value(row, config["household_id_column"])) in mapping:
120121
raise SheetProcessingError(SheetName.HOUSEHOLDS, household_key)
122+
121123
try:
122124
mapping[household_key] = cast(
123125
"Household",
@@ -141,9 +143,10 @@ def process_beneficiaries(
141143
people_prefix = config.get("people_prefix") if household_mapping is None else None
142144
household_id_column = config.get("household_id_column") if household_mapping is not None else None
143145
sheet_name = SheetName.PEOPLE if household_mapping is None else SheetName.INDIVIDUALS
146+
individual_mapping_id = config.get("individual_mapping_id")
144147
transform_row = compose(
145148
clean_field_names,
146-
partial(job.program.apply_mapping_importer, Individual),
149+
partial(job.program.apply_mapping_importer, Individual, mapping_id=individual_mapping_id),
147150
partial(job.program.apply_default_fields, Individual),
148151
)
149152

@@ -155,6 +158,7 @@ def process_beneficiaries(
155158
cleaned_row, name_column = normalize_row_structure(row, people_prefix)
156159
name = cleaned_row.get(name_column) if name_column else ""
157160
household = get_hh_for_ind(cleaned_row, household_id_column, household_mapping)
161+
158162
try:
159163
mapping[beneficiary_key] = cast(
160164
"Individual",
@@ -235,7 +239,7 @@ def _sync_ind_pks(households_mapping: dict, individuals_mapping: dict) -> None:
235239
pk_mapping = {v.flex_fields.get("individual_id"): v.pk for _, v in individuals_mapping.items()}
236240

237241
for v in households_mapping.values():
238-
hh_flex_fields = v.flex_fields
242+
hh_flex_fields = v.flex_fields.copy()
239243
hh_flex_fields["head_of_household"] = pk_mapping.get(v.flex_fields.get("head_of_household"))
240244
hh_flex_fields["primary_collector"] = pk_mapping.get(v.flex_fields.get("primary_collector"))
241245
if alt_id := v.flex_fields.get("alternate_collector"): # is optional
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import django.db.models.deletion
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
dependencies = [
7+
("country_workspace", "0032_program_system_fields"),
8+
("hope_flex_fields", "0013_fielddefinition_validated_alter_datachecker_id_and_more"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="mappingimporter",
14+
name="office",
15+
field=models.ForeignKey(
16+
null=True,
17+
blank=True,
18+
on_delete=django.db.models.deletion.CASCADE,
19+
related_name="mapping_importers",
20+
to="country_workspace.office",
21+
help_text="Business Area (Office) this mapping belongs to",
22+
),
23+
),
24+
# Change data_checker from OneToOneField to ForeignKey
25+
migrations.AlterField(
26+
model_name="mappingimporter",
27+
name="data_checker",
28+
field=models.ForeignKey(
29+
on_delete=django.db.models.deletion.CASCADE,
30+
related_name="mapping_importers",
31+
to="hope_flex_fields.datachecker",
32+
help_text="DataChecker (Household/Individual) this mapping is valid for",
33+
),
34+
),
35+
migrations.AlterUniqueTogether(
36+
name="mappingimporter",
37+
unique_together={("office", "name")},
38+
),
39+
]

src/country_workspace/models/mapping_importer.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,20 @@
99

1010

1111
class MappingImporter(BaseModel):
12-
data_checker = models.OneToOneField(DataChecker, on_delete=models.CASCADE, related_name="%(class)s")
1312
name = models.CharField(max_length=255)
1413
description = models.CharField(max_length=255, blank=True)
14+
office = models.ForeignKey(
15+
"Office",
16+
on_delete=models.CASCADE,
17+
related_name="mapping_importers",
18+
help_text=_("Business Area (Office) this mapping belongs to"),
19+
)
20+
data_checker = models.ForeignKey(
21+
DataChecker,
22+
on_delete=models.CASCADE,
23+
related_name="mapping_importers",
24+
help_text=_("DataChecker (Household/Individual) this mapping is valid for"),
25+
)
1526
rules = models.TextField(
1627
blank=True,
1728
default="",
@@ -27,6 +38,7 @@ class MappingImporter(BaseModel):
2738
class Meta:
2839
verbose_name = _("Mapping Importer")
2940
verbose_name_plural = _("Mapping Importers")
41+
unique_together = [["office", "name"]]
3042

3143
def __str__(self) -> str:
3244
return self.name

src/country_workspace/models/program.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
from contextlib import suppress
21
from typing import TYPE_CHECKING, Any
32
from collections.abc import Iterable
43
from enum import StrEnum
54

6-
from django.core.exceptions import ObjectDoesNotExist
75
from django.db import models
6+
from django.db.models import Q
87
from django.utils.translation import gettext as _
98
from hope_flex_fields.models import DataChecker
109
from strategy_field.fields import StrategyField
@@ -161,13 +160,28 @@ def serialize(self, data: list[dict]) -> Iterable:
161160
return data
162161

163162
def apply_mapping_importer(
164-
self, m: type[Validable] | Validable, data: dict[str, str | int | bool]
163+
self,
164+
m: type[Validable] | Validable,
165+
data: dict[str, str | int | bool],
166+
mapping_id: int | None = None,
165167
) -> dict[str, str | int | bool]:
166168
"""Apply mapping importer from the checker's mappingimporter, if any."""
167-
if (checker := self.get_checker_for(m)) is None:
168-
return data
169-
with suppress(ObjectDoesNotExist):
170-
checker.mappingimporter.apply(data)
169+
from country_workspace.models import MappingImporter
170+
171+
mapping_importers = []
172+
if mapping_id:
173+
mapping_importer = MappingImporter.objects.filter(id=mapping_id).first()
174+
if mapping_importer:
175+
mapping_importers = [mapping_importer]
176+
177+
elif (checker := self.get_checker_for(m)) is not None:
178+
mapping_importers = list(
179+
checker.mapping_importers.filter(Q(office=self.country_office) | Q(office__isnull=True))
180+
)
181+
182+
for mapping_importer in mapping_importers:
183+
mapping_importer.apply(data)
184+
171185
return data
172186

173187
def get_default_fields_for(self, m: type[Validable] | Validable) -> dict[str, Any]:

0 commit comments

Comments
 (0)