Skip to content

Commit 66eee6d

Browse files
committed
Merge branch 'develop' into feature/history
* develop: chg ! labels according to beneficiary group (#61) Use ValidatorMixin.validate fail_if_alien argument Add some tests Fix data format issue Fix failing test Fix failing test Fix tests Implement check if alien feature
2 parents 5c3038f + bb1f89f commit 66eee6d

File tree

30 files changed

+365
-224
lines changed

30 files changed

+365
-224
lines changed

src/country_workspace/admin/beneficiary_group.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,3 @@ class BeneficiaryGroupAdmin(BaseModelAdmin):
2020

2121
def has_add_permission(self, request: HttpRequest) -> bool:
2222
return False
23-
24-
def has_delete_permission(self, request: HttpRequest, obj: BeneficiaryGroup = None) -> bool:
25-
return False
26-
27-
def has_change_permission(self, request: HttpRequest, obj: BeneficiaryGroup = None) -> bool:
28-
return False

src/country_workspace/admin/program.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ class ProgramAdmin(BaseModelAdmin):
2020
list_display = (
2121
"name",
2222
"sector",
23-
"beneficiary_group",
2423
"status",
2524
"active",
25+
"beneficiary_group",
2626
"beneficiary_validator",
2727
"household_checker",
2828
"individual_checker",

src/country_workspace/contrib/aurora/pipeline.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
from typing import Any
1+
from typing import Any, Mapping
22

33
from django.db.transaction import atomic
44

55
from country_workspace.contrib.aurora.client import AuroraClient
66
from country_workspace.models import AsyncJob, Batch, Household, Individual
7-
from country_workspace.utils.fields import clean_field_name, uppercase_field_value
7+
from country_workspace.utils.config import BatchNameConfig, FailIfAlienConfig
8+
from country_workspace.utils.fields import clean_field_names, uppercase_field_value
9+
10+
11+
class Config(BatchNameConfig, FailIfAlienConfig):
12+
registration_reference_pk: str | None
13+
household_column_prefix: str
14+
individuals_column_prefix: str
15+
household_label_column: str
816

917

1018
def import_from_aurora(job: AsyncJob) -> dict[str, int]:
@@ -25,26 +33,27 @@ def import_from_aurora(job: AsyncJob) -> dict[str, int]:
2533
- "individuals": The total number of individuals imported.
2634
2735
"""
36+
config: Config = job.config
2837
total_hh = total_ind = 0
2938
batch = Batch.objects.create(
30-
name=job.config["batch_name"],
39+
name=config["batch_name"],
3140
program=job.program,
3241
country_office=job.program.country_office,
3342
imported_by=job.owner,
3443
source=Batch.BatchSource.AURORA,
3544
)
3645
client = AuroraClient()
3746
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"))
47+
for record in client.get(f"registration/{config['registration_reference_pk']}/records/"):
48+
inds_data = _collect_by_prefix(record["flatten"], config.get("individuals_column_prefix"))
4049
if inds_data:
41-
hh = create_household(batch, record["flatten"], job.config.get("household_column_prefix"))
50+
hh = create_household(batch, record["flatten"], config.get("household_column_prefix"))
4251
total_hh += 1
4352
total_ind += len(
4453
create_individuals(
4554
household=hh,
4655
data=inds_data,
47-
household_label_column=job.config.get("household_label_column"),
56+
household_label_column=config.get("household_label_column"),
4857
)
4958
)
5059
return {"households": total_hh, "individuals": total_ind}
@@ -69,7 +78,8 @@ 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")
72-
return batch.program.households.create(batch=batch, flex_fields=flex_fields)
81+
flex_fields = next(iter(flex_fields.values()), {})
82+
return batch.program.households.create(batch=batch, flex_fields=clean_field_names(flex_fields))
7383

7484

7585
def create_individuals(household: Household, data: dict[str, Any], household_label_column: str) -> list[Individual]:
@@ -87,7 +97,8 @@ def create_individuals(household: Household, data: dict[str, Any], household_lab
8797
individuals = []
8898
head_found = False
8999

90-
for individual in data.values():
100+
for raw_individual in data.values():
101+
individual = clean_field_names(raw_individual)
91102
if not head_found:
92103
head_found = _update_household_label_from_individual(household, individual, household_label_column)
93104
individuals.append(
@@ -126,13 +137,12 @@ def _collect_by_prefix(data: dict[str, Any], prefix: str) -> dict[str, dict[str,
126137
for k, v in data.items():
127138
if (stripped := k.removeprefix(prefix)) != k:
128139
index, field = stripped.split("_", 1)
129-
field_clean = clean_field_name(field)
130-
result.setdefault(index, {})[field_clean] = uppercase_field_value(field_clean, v)
140+
result.setdefault(index, {})[field] = uppercase_field_value(field, v)
131141
return result
132142

133143

134144
def _update_household_label_from_individual(
135-
household: Household, individual: dict[str, Any], household_label_column: str
145+
household: Household, individual: Mapping[str, Any], household_label_column: str
136146
) -> bool:
137147
"""Update the household's name based on an individual's role and specified name field.
138148
@@ -147,7 +157,7 @@ def _update_household_label_from_individual(
147157
bool: True if the household name was updated (individual is head and name provided), False otherwise.
148158
149159
"""
150-
is_head = any(individual.get(k) == "HEAD" for k in individual if k.startswith("relationship"))
160+
is_head = any(individual.get(k, "").upper() == "HEAD" for k in individual if k.startswith("relationship"))
151161
name = individual.get(household_label_column)
152162
if is_head and name:
153163
household.name = name

src/country_workspace/contrib/kobo/sync.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
from typing import Any, TypedDict, cast
22

3-
from constance import config
3+
from constance import config as constance_config
44
from django.core.cache import cache
55

66
from country_workspace.contrib.kobo.api.client.main import Client
77
from country_workspace.contrib.kobo.api.data.asset import Asset
88
from country_workspace.contrib.kobo.api.data.submission import Submission
99
from country_workspace.contrib.kobo.models import KoboSubmission
1010
from country_workspace.models import AsyncJob, Batch, Household, Individual
11-
from country_workspace.utils.fields import clean_field_name
11+
from country_workspace.utils.config import BatchNameConfig, FailIfAlienConfig
12+
from country_workspace.utils.fields import clean_field_names
13+
14+
15+
class Config(BatchNameConfig, FailIfAlienConfig):
16+
project_id: str
17+
individual_records_field: str
1218

1319

1420
def make_client(country_code: str | None) -> Client:
15-
token = config.KOBO_MASTER_API_TOKEN or config.KOBO_API_TOKEN
16-
project_view_id = config.KOBO_PROJECT_VIEW_ID if config.KOBO_MASTER_API_TOKEN else None
21+
token = constance_config.KOBO_MASTER_API_TOKEN or constance_config.KOBO_API_TOKEN
22+
project_view_id = constance_config.KOBO_PROJECT_VIEW_ID if constance_config.KOBO_MASTER_API_TOKEN else None
1723
return Client(
18-
base_url=config.KOBO_KF_URL,
24+
base_url=constance_config.KOBO_KF_URL,
1925
token=token,
2026
country_code=country_code,
2127
project_view_id=project_view_id,
@@ -26,31 +32,32 @@ def extract_household_data(submission: Submission, individual_records_field: str
2632
return {key: value for key, value in submission.items() if key != individual_records_field}
2733

2834

29-
def create_individuals(
30-
batch: Batch, household: Household, submission: Submission, individual_records_field: str
31-
) -> int:
35+
def create_individuals(batch: Batch, household: Household, submission: Submission, config: Config) -> int:
3236
individuals = []
33-
for raw_individual in submission.get(individual_records_field, []):
34-
individual = {key.lstrip(f"{individual_records_field}/"): value for key, value in raw_individual.items()}
37+
for raw_individual in submission.get(config["individual_records_field"], []):
38+
individual = {
39+
key.replace(f"{config['individual_records_field']}/", ""): value for key, value in raw_individual.items()
40+
}
3541
fullname = next((key for key in individual if key.startswith("full_name")), None)
3642
individuals.append(
3743
Individual(
3844
batch=batch,
3945
household=household,
4046
name=individual.get(fullname, ""),
41-
flex_fields={clean_field_name(key): value for key, value in individual.items()},
47+
flex_fields=clean_field_names(individual),
4248
),
4349
)
4450
household.program.individuals.bulk_create(individuals)
4551
return len(individuals)
4652

4753

48-
def create_household(batch: Batch, submission: Submission, individual_records_field: str) -> Household:
49-
household_fields = extract_household_data(submission, individual_records_field)
54+
def create_household(batch: Batch, submission: Submission, config: Config) -> Household:
55+
household_fields = extract_household_data(submission, config["individual_records_field"])
5056
return cast(
5157
Household,
5258
batch.program.households.create(
53-
batch=batch, flex_fields={clean_field_name(key): value for key, value in household_fields.items()}
59+
batch=batch,
60+
flex_fields=clean_field_names(household_fields),
5461
),
5562
)
5663

@@ -63,7 +70,7 @@ class ImportResult(TypedDict):
6370
individuals: int
6471

6572

66-
def import_asset(batch: Batch, asset: Asset, individual_records_field: str) -> ImportResult:
73+
def import_asset(batch: Batch, asset: Asset, config: Config) -> ImportResult:
6774
household_counter = 0
6875
individual_counter = 0
6976

@@ -72,31 +79,32 @@ def import_asset(batch: Batch, asset: Asset, individual_records_field: str) -> I
7279
for submission in asset.submissions:
7380
if submission.id in submission_ids:
7481
continue
75-
household = create_household(batch, submission, individual_records_field)
82+
household = create_household(batch, submission, config)
7683
household_counter += 1
77-
individual_counter += create_individuals(batch, household, submission, individual_records_field)
84+
individual_counter += create_individuals(batch, household, submission, config)
7885

7986
return ImportResult(households=household_counter, individuals=individual_counter)
8087

8188

8289
def import_data(job: AsyncJob) -> ImportResult:
90+
config: Config = job.config
91+
8392
batch = Batch.objects.create(
84-
name=job.config["batch_name"],
93+
name=config["batch_name"],
8594
program=job.program,
8695
country_office=job.program.country_office,
8796
imported_by=job.owner,
8897
source=Batch.BatchSource.KOBO,
8998
)
90-
individual_records_field = job.config["individual_records_field"]
9199
client = make_client(job.program.country_office.kobo_country_code)
92100

93101
household_counter = 0
94102
individual_counter = 0
95103

96104
for asset in client.assets:
97105
# TODO: fetch specific asset
98-
if job.config["project_id"] == asset.uid:
99-
import_result = import_asset(batch, asset, individual_records_field)
106+
if config["project_id"] == asset.uid:
107+
import_result = import_asset(batch, asset, config)
100108
household_counter += import_result["households"]
101109
individual_counter += import_result["individuals"]
102110

src/country_workspace/datasources/rdi.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
11
import io
22
from collections.abc import Iterable
3-
from typing import Any, Mapping, TypedDict, cast
3+
from typing import Any, Mapping, cast
44

55
from django.db.transaction import atomic
66
from hope_smart_import.readers import open_xls_multi
77

88
from country_workspace.models import AsyncJob, Batch, Household
9-
from country_workspace.utils.fields import clean_field_name
9+
from country_workspace.utils.config import BatchNameConfig, FailIfAlienConfig
10+
from country_workspace.utils.fields import Record, clean_field_names
1011

1112
RDI = str | io.BytesIO
12-
Row = Mapping[str, Any]
13-
Sheet = Iterable[Row]
13+
Sheet = Iterable[Record]
1414

1515
INDIVIDUAL = "individual"
1616
HOUSEHOLD = "household"
1717

1818

19-
class Config(TypedDict):
20-
batch_name: str
19+
class Config(BatchNameConfig, FailIfAlienConfig):
2120
household_pk_col: str
2221
master_column_label: str
2322
detail_column_label: str
@@ -62,11 +61,7 @@ def __str__(self) -> str:
6261
return f"Failed to validate household {self.household_key}."
6362

6463

65-
def normalize_row(row: Row) -> Mapping[str, Any]:
66-
return {clean_field_name(k): v for k, v in row.items()}
67-
68-
69-
def get_value(row: Row, column_name: str) -> Any:
64+
def get_value(row: Record, column_name: str) -> Any:
7065
if column_name in row:
7166
return row[column_name]
7267

@@ -76,7 +71,7 @@ def get_value(row: Row, column_name: str) -> Any:
7671
def filter_rows_with_household_pk(config: Config, *sheets: Sheet) -> Iterable[Sheet]:
7772
household_pk_col = config["household_pk_col"]
7873

79-
def has_household_pk(row: Row) -> bool:
74+
def has_household_pk(row: Record) -> bool:
8075
return bool(get_value(row, household_pk_col))
8176

8277
return (filter(has_household_pk, sheet) for sheet in sheets)
@@ -95,7 +90,7 @@ def process_households(sheet: Sheet, job: AsyncJob, batch: Batch, config: Config
9590
job.program.households.create(
9691
batch=batch,
9792
name=name,
98-
flex_fields=normalize_row(row),
93+
flex_fields=clean_field_names(row),
9994
),
10095
)
10196
except Exception as e:
@@ -122,7 +117,7 @@ def process_individuals(
122117
batch=batch,
123118
name=name,
124119
household_id=household.pk,
125-
flex_fields=normalize_row(row),
120+
flex_fields=clean_field_names(row),
126121
)
127122
except Exception as e:
128123
raise SheetProcessingError(INDIVIDUAL, i) from e
@@ -135,7 +130,7 @@ def process_individuals(
135130
def validate_households(config: Config, household_mapping: Mapping[int, Household]) -> None:
136131
if config["check_before"]:
137132
for household_key, household in household_mapping.items():
138-
if not household.validate_with_checker():
133+
if not household.validate_with_checker(fail_if_alien=config["fail_if_alien"]):
139134
raise HouseholdValidationError(household_key)
140135

141136

src/country_workspace/management/commands/demo.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def handle(self, *args: Any, **options: Any) -> None:
5252
sys.path.append(str(test_utils_dir.absolute()))
5353

5454
import vcr
55-
from testutils.factories import BatchFactory, HouseholdFactory
55+
from testutils.factories import BatchFactory, HouseholdFactory, IndividualFactory
5656
from vcr.record_mode import RecordMode
5757

5858
from country_workspace.contrib.hope.sync.office import sync_all
@@ -86,4 +86,7 @@ def handle(self, *args: Any, **options: Any) -> None:
8686
for co in Office.objects.filter(active=True):
8787
for p in co.programs.filter():
8888
b = BatchFactory(country_office=co, name=f"Batch {p}", program=p)
89-
HouseholdFactory.create_batch(10, batch=b)
89+
if p.beneficiary_group.master_detail:
90+
HouseholdFactory.create_batch(10, batch=b)
91+
else:
92+
IndividualFactory.create_batch(10, batch=b, household=None)

src/country_workspace/models/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ def save(
104104
def checker(self) -> "DataChecker":
105105
raise NotImplementedError
106106

107-
def validate_with_checker(self) -> bool:
108-
errors = self.checker.validate([self.flex_fields])
107+
def validate_with_checker(self, fail_if_alien: bool = False) -> bool:
108+
errors = self.checker.validate([self.flex_fields], fail_if_alien=fail_if_alien)
109109
if errors:
110110
self.errors = errors[1]
111111
else:

src/country_workspace/models/household.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,13 @@ def program(self) -> "Program":
3636
def country_office(self) -> "Office":
3737
return self.batch.program.country_office
3838

39-
def validate_with_checker(self) -> bool:
39+
def validate_with_checker(self, fail_if_alien: bool = False) -> bool:
4040
hh_valid = True
4141
for ind in self.members.all():
42-
if not ind.validate_with_checker():
42+
if not ind.validate_with_checker(fail_if_alien=fail_if_alien):
4343
hh_valid = False
4444
if hh_valid:
45-
super().validate_with_checker()
45+
super().validate_with_checker(fail_if_alien=fail_if_alien)
4646
errors = self.program.beneficiary_validator.validate(self)
4747
if errors:
4848
self.errors["dct"] = errors
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from typing import TypedDict
2+
3+
4+
class FailIfAlienConfig(TypedDict):
5+
fail_if_alien: bool
6+
7+
8+
class BatchNameConfig(TypedDict):
9+
batch_name: str

0 commit comments

Comments
 (0)