Skip to content

Commit 371803c

Browse files
chg ! aurora import takes into account beneficiary groups
1 parent 9006b7c commit 371803c

File tree

4 files changed

+112
-73
lines changed

4 files changed

+112
-73
lines changed

src/country_workspace/contrib/aurora/forms.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,37 @@
66

77
class ImportAuroraForm(forms.Form):
88
batch_name = forms.CharField(required=False, help_text="Label for this batch.")
9-
109
registration = forms.ModelChoiceField(
1110
queryset=Registration.objects.none(),
1211
help_text="What type of registrations are being imported.",
1312
)
14-
1513
household_column_prefix = forms.CharField(
16-
initial="household_",
17-
help_text="Household's column group prefix",
14+
initial="household_", help_text="Household's column group prefix", required=False
1815
)
19-
2016
individuals_column_prefix = forms.CharField(
2117
initial="individuals_",
2218
help_text="Individuals' column group prefix",
2319
)
24-
2520
household_label_column = forms.CharField(
2621
required=False,
2722
initial="family_name",
2823
help_text="Which Individual's column should be used as label for the household.",
2924
)
30-
3125
check_before = forms.BooleanField(
3226
required=False, help_text="Prevent import if errors if data is not valid against data checker."
3327
)
34-
3528
fail_if_alien = forms.BooleanField(
3629
required=False, help_text="Fails if it finds fields which do not exists in data checker."
3730
)
3831

3932
def __init__(self, *args: tuple, program: Program | None = None, **kwargs: dict) -> None:
4033
super().__init__(*args, **kwargs)
34+
self.program = program
4135
if program:
4236
self.fields["registration"].queryset = Registration.objects.filter(project__program=program, active=True)
37+
if not getattr(program.beneficiary_group, "master_detail", False):
38+
self.fields = {
39+
key: value
40+
for key, value in self.fields.items()
41+
if key not in ("household_column_prefix", "household_label_column")
42+
}

src/country_workspace/contrib/aurora/pipeline.py

Lines changed: 65 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any
1+
from typing import Any, TypedDict, cast, Final, NotRequired
22

33
from django.db.transaction import atomic
44

@@ -7,52 +7,61 @@
77
from country_workspace.utils.fields import clean_field_name, uppercase_field_value
88

99

10+
class Config(TypedDict):
11+
batch_name: str
12+
registration_reference_pk: int | None
13+
master_detail: bool
14+
household_column_prefix: NotRequired[str]
15+
individuals_column_prefix: str
16+
household_label_column: NotRequired[str]
17+
check_before: bool
18+
fail_if_alien: bool
19+
20+
21+
HEAD_RELATIONSHIP: Final[str] = "HEAD"
22+
23+
1024
def import_from_aurora(job: AsyncJob) -> dict[str, int]:
1125
"""Import data from the Aurora system into the database within an atomic transaction.
1226
1327
Args:
1428
job (AsyncJob): The job instance containing the configuration and context for data import.
15-
Expected keys in `job.config`:
16-
- "batch_name" (str): The name for the newly created batch.
17-
- "registration_reference_pk" (int): The unique identifier of the registration to import.
18-
- "household_column_prefix" (str, optional): The prefix for household-related columns.
19-
- "individuals_column_prefix" (str, optional): The prefix for individual-related columns.
20-
- "household_label_column" (str, optional): The column name used to determine the household label.
29+
Expected keys in `job.config` correspond to the `Config` TypedDict.
2130
2231
Returns:
23-
dict[str, int]: A dictionary with the counts of successfully created records:
24-
- "households": The number of households imported.
25-
- "individuals": The total number of individuals imported.
32+
dict[str, int]: Counts of imported records:
33+
- "households": Number of households imported (0 if `master_detail` is False or None).
34+
- "individuals": Total number of individuals imported.
2635
2736
"""
28-
total_hh = total_ind = 0
37+
total = {"households": 0, "individuals": 0}
38+
cfg = cast(Config, job.config)
39+
2940
batch = Batch.objects.create(
30-
name=job.config["batch_name"],
41+
name=cfg["batch_name"],
3142
program=job.program,
3243
country_office=job.program.country_office,
3344
imported_by=job.owner,
3445
source=Batch.BatchSource.AURORA,
3546
)
47+
3648
client = AuroraClient()
3749
with atomic():
38-
for record in client.get(f"registration/{job.config['registration_reference_pk']}/records/"):
39-
inds_data = _collect_by_prefix(record["flatten"], job.config.get("individuals_column_prefix"))
40-
if inds_data:
41-
hh = create_household(batch, record["flatten"], job.config.get("household_column_prefix"))
42-
total_hh += 1
43-
total_ind += len(
44-
create_individuals(
45-
household=hh,
46-
data=inds_data,
47-
household_label_column=job.config.get("household_label_column"),
48-
)
49-
)
50-
return {"households": total_hh, "individuals": total_ind}
50+
for record in client.get(f"registration/{cfg['registration_reference_pk']}/records/"):
51+
individuals = create_individuals(
52+
batch=batch,
53+
data=record["flatten"],
54+
cfg=cfg,
55+
)
56+
if cfg.get("master_detail") and individuals and individuals[0].household_id:
57+
total["households"] += 1
58+
total["individuals"] += len(individuals)
59+
60+
return total
5161

5262

5363
def create_household(batch: Batch, data: dict[str, Any], prefix: str) -> Household:
54-
"""
55-
Create a Household object from the provided data and associate it with a batch.
64+
"""Create a Household object from the provided data and associate it with a batch.
5665
5766
Args:
5867
batch (Batch): The batch to which the household will be linked.
@@ -69,36 +78,47 @@ def create_household(batch: Batch, data: dict[str, Any], prefix: str) -> Househo
6978
flex_fields = _collect_by_prefix(data, prefix)
7079
if len(flex_fields) > 1:
7180
raise ValueError("Multiple households found")
81+
flex_fields = next(iter(flex_fields.values()), {})
7282
return batch.program.households.create(batch=batch, flex_fields=flex_fields)
7383

7484

75-
def create_individuals(household: Household, data: dict[str, Any], household_label_column: str) -> list[Individual]:
76-
"""Create and associate Individual objects with a given Household.
85+
def create_individuals(
86+
batch: Batch,
87+
data: dict[str, Any],
88+
cfg: Config,
89+
) -> list[Individual]:
90+
"""Create and associate Individual objects with an optional Household.
7791
7892
Args:
79-
household (Household): The household to which the individuals will be linked.
80-
data (dict[str, Any]): A dictionary mapping indices to individual details.
81-
household_label_column (str): The key in the individual data used to determine the household label.
93+
batch (Batch): The batch to which individuals will be linked.
94+
data (dict[str, Any]): A dictionary containing related information.
95+
cfg (Config): Configuration dictionary containing various settings for the import process.
8296
8397
Returns:
8498
list[Individual]: A list of successfully created Individual instances.
8599
86100
"""
87-
individuals = []
101+
household, individuals = None, []
88102
head_found = False
89103

90-
for individual in data.values():
91-
if not head_found:
104+
inds_data = _collect_by_prefix(data, cfg.get("individuals_column_prefix"))
105+
106+
if all((inds_data, cfg["master_detail"], cfg.get("household_column_prefix"))):
107+
household = create_household(batch, data, cfg.get("household_column_prefix"))
108+
109+
for individual in inds_data.values():
110+
household_label_column = cfg.get("household_label_column")
111+
if household and household_label_column and not head_found:
92112
head_found = _update_household_label_from_individual(household, individual, household_label_column)
93113
individuals.append(
94114
Individual(
95-
batch=household.batch,
96-
household_id=household.pk,
115+
batch=batch,
116+
household_id=household.pk if household else None,
97117
name=individual.get("given_name", ""),
98118
flex_fields=individual,
99-
),
119+
)
100120
)
101-
return household.program.individuals.bulk_create(individuals)
121+
return batch.program.individuals.bulk_create(individuals, batch_size=1000)
102122

103123

104124
def _collect_by_prefix(data: dict[str, Any], prefix: str) -> dict[str, dict[str, Any]]:
@@ -123,11 +143,12 @@ def _collect_by_prefix(data: dict[str, Any], prefix: str) -> dict[str, dict[str,
123143
124144
"""
125145
result = {}
126-
for k, v in data.items():
127-
if (stripped := k.removeprefix(prefix)) != k:
128-
index, field = stripped.split("_", 1)
129-
field_clean = clean_field_name(field)
130-
result.setdefault(index, {})[field_clean] = uppercase_field_value(field_clean, v)
146+
for key, value in data.items():
147+
if not key.startswith(prefix):
148+
continue
149+
index, field = key.removeprefix(prefix).split("_", 1)
150+
clean_field = clean_field_name(field)
151+
result.setdefault(index, {})[clean_field] = uppercase_field_value(clean_field, value)
131152
return result
132153

133154

src/country_workspace/workspaces/admin/program.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import TYPE_CHECKING, Any
1+
from typing import TYPE_CHECKING, Any, cast
22

33
from admin_extra_buttons.api import button
44
from django import forms
@@ -13,7 +13,7 @@
1313
from django.utils.translation import gettext as _
1414
from strategy_field.utils import fqn
1515

16-
from country_workspace.contrib.aurora.pipeline import import_from_aurora
16+
from country_workspace.contrib.aurora.pipeline import import_from_aurora, Config as AuroraConfig
1717
from country_workspace.state import state
1818

1919
from ...contrib.aurora.forms import ImportAuroraForm
@@ -306,21 +306,33 @@ def import_rdi(self, request: HttpRequest, program: CountryProgram) -> "ImportFi
306306
def import_aurora(self, request: HttpRequest, program: "CountryProgram") -> "ImportAuroraForm|None":
307307
form = ImportAuroraForm(request.POST, prefix="aurora", program=program)
308308
if form.is_valid():
309-
registration_reference_pk = getattr(form.cleaned_data["registration"], "reference_pk", None)
309+
config = cast(
310+
AuroraConfig,
311+
{
312+
"batch_name": form.cleaned_data.get("batch_name") or batch_name_default(),
313+
"registration_reference_pk": getattr(form.cleaned_data.get("registration"), "reference_pk", None),
314+
"individuals_column_prefix": form.cleaned_data["individuals_column_prefix"],
315+
"check_before": form.cleaned_data.get("check_before", False),
316+
"fail_if_alien": form.cleaned_data.get("fail_if_alien", False),
317+
"master_detail": (master_detail := getattr(program.beneficiary_group, "master_detail", False)),
318+
**(
319+
{
320+
"household_column_prefix": form.cleaned_data.get("household_column_prefix"),
321+
"household_label_column": form.cleaned_data.get("household_label_column"),
322+
}
323+
if master_detail
324+
else {}
325+
),
326+
},
327+
)
310328
job: AsyncJob = AsyncJob.objects.create(
311329
description="Aurora importing",
312330
type=AsyncJob.JobType.TASK,
313331
action=fqn(import_from_aurora),
314332
file=None,
315333
program=program,
316334
owner=request.user,
317-
config={
318-
"batch_name": form.cleaned_data["batch_name"] or batch_name_default(),
319-
"registration_reference_pk": registration_reference_pk,
320-
"household_column_prefix": form.cleaned_data["household_column_prefix"],
321-
"individuals_column_prefix": form.cleaned_data["individuals_column_prefix"],
322-
"household_label_column": form.cleaned_data["household_label_column"],
323-
},
335+
config=config,
324336
)
325337
job.queue()
326338
self.message_user(request, _("Import scheduled"), messages.SUCCESS)

tests/workspace/test_ws_import.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ def office():
2727
return co
2828

2929

30-
@pytest.fixture
31-
def program(office, force_migrated_records, household_checker, individual_checker):
30+
@pytest.fixture(params=[True, False], ids=["master_detail_true", "master_detail_false"])
31+
def program(request, office, force_migrated_records, household_checker, individual_checker):
3232
from testutils.factories import CountryProgramFactory, ProjectFactory, RegistrationFactory
3333

3434
program = CountryProgramFactory(
@@ -37,6 +37,7 @@ def program(office, force_migrated_records, household_checker, individual_checke
3737
individual_checker=individual_checker,
3838
household_columns="name\nid\nxx",
3939
individual_columns="name\nid\nxx",
40+
beneficiary_group__master_detail=request.param,
4041
)
4142
project = ProjectFactory(program=program)
4243
RegistrationFactory.create_batch(3, project=project, active=True)
@@ -85,8 +86,8 @@ def test_import_data_rdi(force_migrated_records, app, program):
8586
("stub_data", "error_expected", "hh_count", "ind_count", "error_message"),
8687
[
8788
(stub.imported["correct"], False, 2, 3, None), # 2 hh: 1st with 1 ind, 2nd with 2 inds
88-
(stub.imported["no_individuals"], False, 0, 0, None), # No individuals, no Household
89-
(stub.imported["multiple_households"], True, 0, 0, "Multiple households found"), # Multiple households error
89+
(stub.imported["no_individuals"], False, 0, 0, None), # No individuals
90+
(stub.imported["multiple_households"], True, 0, 1, "Multiple households found"), # Multiple households error
9091
(stub.imported["empty_household_data"], False, 1, 1, None), # Only ind without hh data
9192
(stub.imported["update_head_name"], False, 1, 1, None), # Household name updated from head
9293
],
@@ -107,7 +108,6 @@ def test_import_data_aurora(
107108
res.forms["select-tenant"].submit()
108109

109110
url = reverse("workspace:workspaces_countryprogram_import_data", args=[program.pk])
110-
111111
mocked_responses.add(
112112
responses.GET,
113113
re.compile(re.escape(config.AURORA_API_URL) + ".*"),
@@ -118,14 +118,20 @@ def test_import_data_aurora(
118118
res.forms["import-aurora"]["_selected_tab"] = "aurora"
119119
res.forms["import-aurora"]["aurora-registration"] = program.projects.registrations.first().pk
120120

121-
if error_expected:
121+
master_detail = program.beneficiary_group.master_detail
122+
123+
if error_expected and master_detail:
122124
with pytest.raises(ValueError, match=error_message):
123125
res.forms["import-aurora"].submit()
124-
else:
125-
res = res.forms["import-aurora"].submit()
126-
households = program.households.all()
127-
assert households.count() == hh_count
128-
assert sum(hh.members.count() for hh in households) == ind_count
126+
return
127+
128+
res = res.forms["import-aurora"].submit()
129+
households = program.households.all()
130+
individuals = program.individuals.all()
131+
assert individuals.count() == ind_count
132+
assert households.count() == (hh_count if master_detail else 0)
133+
134+
if master_detail:
129135
if hh_count == 2:
130136
assert {hh.members.count() for hh in households} == {1, 2}
131137
assert {hh.heads().first().name for hh in households} == {"John", "Jane"}

0 commit comments

Comments
 (0)