Skip to content

Commit 297d50c

Browse files
Feature/default values for flexfields (#276)
feat: add configurable default beneficiary fields - Add JSONField system_fields to Program model for internal metadata - Add helpers to manage default fields (backed by system_fields["default_fields"], configurable via admin UI) - Integrate default fields into import pipelines (Aurora, Kobo, RDI/Excel)
1 parent 0dbad8b commit 297d50c

File tree

19 files changed

+779
-121
lines changed

19 files changed

+779
-121
lines changed

src/country_workspace/contrib/aurora/import_processing.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def create_people(batch: Batch, record: dict[str, Any], config: Config) -> Indiv
8888
clean_field_names,
8989
partial(batch.program.apply_mapping_importer, Individual),
9090
make_full_name,
91+
partial(batch.program.apply_default_fields, Individual),
9192
)
9293
return Individual.objects.create(
9394
batch_id=batch.pk,

src/country_workspace/contrib/kobo/sync.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,14 @@ def normalize_json(data: dict[str, Any]) -> dict[str, Any]:
7171
type Raw = dict[str, Any]
7272

7373

74-
def preprocess(raw: Raw, fields_to_uppercase: tuple[str, ...], mapping_importer: Callable[[Raw], Raw]) -> Raw:
74+
def preprocess(
75+
raw: Raw,
76+
fields_to_uppercase: tuple[str, ...],
77+
mapping_importer: Callable[[Raw], Raw],
78+
default_fields_applier: Callable[[Raw], Raw],
79+
) -> Raw:
7580
clean: Callable[[Raw], Raw] = partial(clean_field_names, fields_to_uppercase=fields_to_uppercase)
76-
processor: Callable[[Raw], Raw] = compose(normalize_json, clean, mapping_importer)
81+
processor: Callable[[Raw], Raw] = compose(normalize_json, clean, mapping_importer, default_fields_applier)
7782
return processor(raw)
7883

7984

@@ -88,6 +93,7 @@ def create_individuals(batch: Batch, household: Household, submission: Submissio
8893
raw_individual,
8994
INDIVIDUAL_FIELDS_TO_UPPERCASE + TO_UPPERCASE_FIELDS,
9095
partial(batch.program.apply_mapping_importer, Individual),
96+
partial(batch.program.apply_default_fields, Individual),
9197
)
9298
fullname = get_fullname_key(individual_fields)
9399
individuals.append(
@@ -111,6 +117,7 @@ def create_household(
111117
raw_household_fields,
112118
HOUSEHOLD_FIELDS_TO_UPPERCASE,
113119
partial(batch.program.apply_mapping_importer, Household),
120+
partial(batch.program.apply_default_fields, Household),
114121
)
115122
household_fields["household_id"] = id_generator()
116123
return cast(

src/country_workspace/datasources/rdi/processors.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from base64 import b64encode
33
from collections import defaultdict
44
from collections.abc import Generator
5-
from copy import deepcopy
5+
from functools import partial
66
from typing import Any, cast, Mapping
77

88
from PIL import Image
@@ -18,6 +18,7 @@
1818
from country_workspace.utils.imports import validate_alien_fields
1919
from country_workspace.utils.functional import compose
2020
from country_workspace.workspaces.admin.cleaners.validate import create_validation_jobs
21+
2122
from .config import Config, SheetName, Sheet
2223
from .exceptions import ColumnConfigurationError, SheetProcessingError, SheetNotFoundError
2324
from .utils import date_to_iso_string, datetime_to_date
@@ -108,24 +109,23 @@ def read_sheets(config: Config, filepath: str, *sheet_names: str) -> Generator[S
108109

109110
def process_households(sheet: Sheet, job: AsyncJob, batch: Batch, config: Config) -> Mapping[int, Household]:
110111
mapping = {}
112+
transform_row = compose(
113+
clean_field_names,
114+
partial(job.program.apply_mapping_importer, Household),
115+
partial(job.program.apply_default_fields, Household),
116+
)
111117

112118
for row in sheet:
113-
household_key = get_value(row, config["household_id_column"])
114-
if household_key in mapping:
119+
if (household_key := get_value(row, config["household_id_column"])) in mapping:
115120
raise SheetProcessingError(SheetName.HOUSEHOLDS, household_key)
116-
117-
label = get_value(row, config["household_label"])
118-
raw_data = clean_field_names(row)
119-
flex_fields = job.program.apply_mapping_importer(Household, deepcopy(raw_data))
120-
121121
try:
122122
mapping[household_key] = cast(
123123
"Household",
124124
Household.objects.create(
125125
batch_id=batch.pk,
126-
name=str(label),
127-
flex_fields=flex_fields,
128-
raw_data=raw_data,
126+
name=str(get_value(row, config["household_label"])),
127+
flex_fields=transform_row(row),
128+
raw_data=row,
129129
),
130130
)
131131
except Exception as e:
@@ -141,6 +141,11 @@ def process_beneficiaries(
141141
people_prefix = config.get("people_prefix") if household_mapping is None else None
142142
household_id_column = config.get("household_id_column") if household_mapping is not None else None
143143
sheet_name = SheetName.PEOPLE if household_mapping is None else SheetName.INDIVIDUALS
144+
transform_row = compose(
145+
clean_field_names,
146+
partial(job.program.apply_mapping_importer, Individual),
147+
partial(job.program.apply_default_fields, Individual),
148+
)
144149

145150
for row in sheet:
146151
beneficiary_key = get_value(row, config["beneficiary_id_column"])
@@ -150,18 +155,15 @@ def process_beneficiaries(
150155
cleaned_row, name_column = normalize_row_structure(row, people_prefix)
151156
name = cleaned_row.get(name_column) if name_column else ""
152157
household = get_hh_for_ind(cleaned_row, household_id_column, household_mapping)
153-
raw_data = clean_field_names(cleaned_row)
154-
flex_fields = job.program.apply_mapping_importer(Individual, deepcopy(raw_data))
155-
156158
try:
157159
mapping[beneficiary_key] = cast(
158160
"Individual",
159161
Individual.objects.create(
160162
batch_id=batch.pk,
161163
name=name,
162164
household=household,
163-
flex_fields=flex_fields,
164-
raw_data=raw_data,
165+
flex_fields=transform_row(cleaned_row),
166+
raw_data=row,
165167
),
166168
)
167169
except Exception as e:
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 5.2.3 on 2025-11-20 10:28
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("country_workspace", "0031_add_parse_name_permission"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="program",
14+
name="system_fields",
15+
field=models.JSONField(
16+
blank=True,
17+
default=dict,
18+
help_text='Internal metadata (e.g. "default_fields" for household/individual defaults).',
19+
),
20+
),
21+
]

src/country_workspace/models/program.py

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from contextlib import suppress
2-
from typing import TYPE_CHECKING
2+
from typing import TYPE_CHECKING, Any
33
from collections.abc import Iterable
4+
from enum import StrEnum
45

56
from django.core.exceptions import ObjectDoesNotExist
67
from django.db import models
@@ -23,6 +24,11 @@
2324
from .individual import Individual
2425

2526

27+
class BeneficiaryScope(StrEnum):
28+
HOUSEHOLD = "household"
29+
INDIVIDUAL = "individual"
30+
31+
2632
class Program(BaseModel):
2733
DRAFT = "DRAFT"
2834
ACTIVE = "ACTIVE"
@@ -95,6 +101,17 @@ class Program(BaseModel):
95101
extra_fields = models.JSONField(default=dict, blank=True, null=False)
96102
enabled = models.BooleanField(default=True, db_index=True, help_text="Is this program enabled in the workspace?")
97103
serializer = models.ForeignKey(DataSerializer, on_delete=models.SET_NULL, null=True, blank=True)
104+
# Internal metadata used by the workspace.
105+
# Expected JSON structure (simplified):
106+
# - key "default_fields": an object with two optional keys:
107+
# - "household": mapping "<field_name>" -> <default_value>
108+
# - "individual": mapping "<field_name>" -> <default_value>
109+
# - other keys may be added in the future.
110+
system_fields = models.JSONField(
111+
default=dict,
112+
blank=True,
113+
help_text=_('Internal metadata (e.g. "default_fields" for household/individual defaults).'),
114+
)
98115

99116
def __str__(self) -> str:
100117
return self.name
@@ -116,26 +133,26 @@ def individuals(self) -> "QuerySet[Individual]":
116133

117134
return Individual.objects.filter(batch__program=self)
118135

119-
def get_checker_for(self, m: type[Validable] | Validable) -> DataChecker:
120-
from country_workspace.models import Household, Individual
121-
from country_workspace.workspaces.models import CountryHousehold, CountryIndividual
122-
123-
if isinstance(m, (Household | CountryHousehold)) or m in (Household, CountryHousehold):
124-
return self.household_checker
125-
if isinstance(m, (Individual | CountryIndividual)) or m in (Individual, CountryIndividual):
126-
return self.individual_checker
127-
raise ValueError(m)
128-
129-
def get_columns_for(self, model_cls: type[Validable]) -> list[str]:
136+
@staticmethod
137+
def _scope_for(m: type[Validable] | Validable) -> BeneficiaryScope:
138+
"""Return 'household' or 'individual' for a given model class or instance."""
130139
from country_workspace.models import Household, Individual
131140

132-
if issubclass(model_cls, Household):
133-
raw = self.household_columns
134-
elif issubclass(model_cls, Individual):
135-
raw = self.individual_columns
136-
else:
137-
raise TypeError(f"Unsupported model {model_cls!r}")
138-
141+
cls = m if isinstance(m, type) else type(m)
142+
if issubclass(cls, Household):
143+
return BeneficiaryScope.HOUSEHOLD
144+
if issubclass(cls, Individual):
145+
return BeneficiaryScope.INDIVIDUAL
146+
raise TypeError(f"Unsupported model {m!r}")
147+
148+
def get_checker_for(self, m: type[Validable] | Validable) -> DataChecker | None:
149+
scope = self._scope_for(m)
150+
return getattr(self, f"{scope.value}_checker")
151+
152+
def get_columns_for(self, m: type[Validable]) -> list[str]:
153+
"""Return list of column names for the given model class."""
154+
scope = self._scope_for(m)
155+
raw = getattr(self, f"{scope.value}_columns")
139156
return [c.strip() for c in raw.splitlines() if c.strip()]
140157

141158
def serialize(self, data: list[dict]) -> Iterable:
@@ -146,7 +163,38 @@ def serialize(self, data: list[dict]) -> Iterable:
146163
def apply_mapping_importer(
147164
self, m: type[Validable] | Validable, data: dict[str, str | int | bool]
148165
) -> dict[str, str | int | bool]:
149-
# skip if mapping importer not found
166+
"""Apply mapping importer from the checker's mappingimporter, if any."""
167+
if (checker := self.get_checker_for(m)) is None:
168+
return data
150169
with suppress(ObjectDoesNotExist):
151-
self.get_checker_for(m).mappingimporter.apply(data)
170+
checker.mappingimporter.apply(data)
171+
return data
172+
173+
def get_default_fields_for(self, m: type[Validable] | Validable) -> dict[str, Any]:
174+
"""Return defaults from system_fields['default_fields'][scope] for the given model."""
175+
scope = self._scope_for(m).value
176+
default_fields = (self.system_fields or {}).get("default_fields") or {}
177+
raw = default_fields.get(scope) or {}
178+
return dict(raw) if isinstance(raw, dict) else {}
179+
180+
def save_default_fields_for(self, m: type[Validable] | Validable, defaults: dict[str, Any]) -> None:
181+
"""Replace system_fields['default_fields'][scope] with the given defaults and save."""
182+
scope = self._scope_for(m).value
183+
184+
system_fields = dict(self.system_fields or {})
185+
default_fields = dict(system_fields.get("default_fields") or {})
186+
187+
default_fields[scope] = defaults
188+
system_fields["default_fields"] = default_fields
189+
190+
self.system_fields = system_fields
191+
self.save(update_fields=["system_fields"])
192+
193+
def apply_default_fields(self, m: type[Validable] | Validable, data: dict[str, Any]) -> dict[str, Any]:
194+
"""Apply default fields for the given model to the provided data dict."""
195+
if not (defaults := self.get_default_fields_for(m)):
196+
return data
197+
for field_name, default_value in defaults.items():
198+
if field_name not in data or data[field_name] is None:
199+
data[field_name] = default_value
152200
return data

src/country_workspace/workspaces/admin/forms.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,12 @@ def __init__(self, *args: Any, beneficiary_group: BeneficiaryGroup | None = None
124124

125125
[self.fields.pop(field_name, None) for field_name in fields_to_exclude]
126126
[setattr(self.fields["beneficiary_id_column"], attr, v) for attr, v in field_cfg.items()]
127+
128+
129+
class MassDefaultsForm(forms.Form):
130+
def __init__(self, *args: Any, checker: "DataChecker", **kwargs: Any) -> None:
131+
super().__init__(*args, **kwargs)
132+
source_form = checker.get_form()()
133+
for field in source_form.fields.values():
134+
field.required = False
135+
self.fields.update(source_form.fields)

0 commit comments

Comments
 (0)