Skip to content

Commit 7c01525

Browse files
add ! import mapping rules, profiles
1 parent 6211250 commit 7c01525

File tree

11 files changed

+622
-10
lines changed

11 files changed

+622
-10
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ dependencies = [
4646
"hope-flex-fields>=0.6.2",
4747
"hope-smart-export>=0.3",
4848
"hope-smart-import>=0.3",
49+
"jmespath>=1.0.1",
4950
"openpyxl>=3.1.5",
5051
"pillow>=11.2.1",
5152
"psycopg2-binary>=2.9.9",

src/country_workspace/admin/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .individual import IndividualAdmin
1313
from .job import AsyncJobAdmin
1414
from .locations import AreaAdmin, AreaTypeAdmin, CountryAdmin
15+
from .mapping import FieldMappingRuleAdmin, MappingProfileAdmin
1516
from .office import OfficeAdmin
1617
from .program import ProgramAdmin
1718
from .rdp import RdpAdmin
@@ -36,8 +37,10 @@
3637
"BeneficiaryGroupAdmin",
3738
"ConstanceAdmin",
3839
"CountryAdmin",
40+
"FieldMappingRuleAdmin",
3941
"HouseholdAdmin",
4042
"IndividualAdmin",
43+
"MappingProfileAdmin",
4144
"OfficeAdmin",
4245
"ProgramAdmin",
4346
"RdpAdmin",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from jsoneditor.forms import JSONEditor
2+
from django.contrib import admin
3+
from django.db import models
4+
from django.http import HttpRequest
5+
6+
from ..models import MappingProfile, FieldMappingRule
7+
from .base import BaseModelAdmin
8+
9+
10+
@admin.register(MappingProfile)
11+
class MappingProfileAdmin(BaseModelAdmin):
12+
list_display = ("name", "source_type", "import_schema", "is_active", "inheritance")
13+
list_filter = ("source_type", "import_schema", "program", "is_active")
14+
search_fields = ("name", "description")
15+
filter_horizontal = ("program",)
16+
fields = (
17+
"name",
18+
"description",
19+
"parent",
20+
"source_type",
21+
"import_schema",
22+
"program",
23+
"is_active",
24+
"created_by",
25+
"created_at",
26+
"updated_at",
27+
)
28+
readonly_fields = ("created_by", "created_at", "updated_at")
29+
30+
@admin.display(description="Inheritance Chain")
31+
def inheritance(self, obj: MappingProfile) -> str:
32+
return obj.get_inheritance_chain()
33+
34+
def get_queryset(self, request: HttpRequest) -> models.QuerySet[MappingProfile]:
35+
return super().get_queryset(request).select_related("parent", "created_by").prefetch_related("program")
36+
37+
38+
@admin.register(FieldMappingRule)
39+
class FieldMappingRuleAdmin(BaseModelAdmin):
40+
formfield_overrides = {
41+
models.JSONField: {
42+
"widget": JSONEditor(
43+
init_options={"mode": "code", "modes": ["text", "code", "tree"]},
44+
ace_options={"readOnly": False},
45+
),
46+
}
47+
}
48+
list_display = ("name", "profile", "source_fields", "target_fields", "order", "is_active")
49+
list_filter = ("profile", "is_active")
50+
search_fields = ("name", "description", "profile__name")
51+
ordering = ("profile", "order", "name")
52+
fields = (
53+
"name",
54+
"description",
55+
"profile",
56+
"source_fields",
57+
"target_fields",
58+
"expression",
59+
"default_values",
60+
"order",
61+
"is_active",
62+
"created_by",
63+
"created_at",
64+
"updated_at",
65+
)
66+
67+
readonly_fields = ("created_by", "created_at", "updated_at")
68+
69+
def get_queryset(self, request: HttpRequest) -> models.QuerySet[FieldMappingRule]:
70+
return super().get_queryset(request).select_related("profile__parent", "created_by")

src/country_workspace/admin/program.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ def population(self, btn: "LinkButton") -> None:
6464
obj = btn.context["original"]
6565
btn.href = f"{base}?program__exact={obj.pk}&country_office__exact={obj.country_office.pk}"
6666

67+
@link(change_list=False)
68+
def mapping_profiles(self, btn: "LinkButton") -> None:
69+
obj = btn.context["original"]
70+
base = reverse("admin:country_workspace_mappingprofile_changelist")
71+
btn.href = f"{base}?program__id__exact={obj.pk}"
72+
6773
@button()
6874
def zap(self, request: HttpRequest, pk: str) -> None:
6975
obj: Program = self.get_object(request, pk)

src/country_workspace/datasources/rdi.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
from base64 import b64encode
33
from collections import defaultdict
44
from collections.abc import Iterable, Generator
5-
from typing import Any, Mapping, cast
5+
from contextlib import suppress
6+
from typing import Any, Mapping, cast, NotRequired
67

78
import openpyxl
89
from PIL import Image
@@ -11,7 +12,7 @@
1112
from openpyxl.drawing.image import Image as RDIImage
1213

1314
from country_workspace.contrib.kobo.api.data.helpers import VALUE_FORMAT
14-
from country_workspace.models import AsyncJob, Batch, Household
15+
from country_workspace.models import AsyncJob, Batch, Household, MappingProfile
1516
from country_workspace.utils.config import BatchNameConfig, FailIfAlienConfig
1617
from country_workspace.utils.fields import Record, clean_field_names
1718
from country_workspace.validators.beneficiaries import validate_beneficiaries
@@ -31,6 +32,7 @@ class Config(BatchNameConfig, FailIfAlienConfig):
3132
household_pk_col: str
3233
master_column_label: str
3334
detail_column_label: str
35+
mapping_profile_id: NotRequired[int]
3436

3537

3638
class ColumnConfigurationError(Exception):
@@ -74,7 +76,23 @@ def postprocess_cell(sheets: MultiSheet) -> MultiSheet:
7476
yield sheet_idx, formated_rows
7577

7678

77-
def process_households(sheet: Sheet, job: AsyncJob, batch: Batch, config: Config) -> Mapping[int, Household]:
79+
def apply_mapping_rules(row: Record, mapping_profile: MappingProfile | None) -> dict[str, Any]:
80+
result = clean_field_names(row)
81+
if not mapping_profile:
82+
return result
83+
84+
rules = mapping_profile.get_all_rules()
85+
for rule in sorted(rules, key=lambda r: r.order):
86+
if rule.is_active:
87+
mapped_data = rule.apply(result)
88+
result.update(mapped_data)
89+
90+
return result
91+
92+
93+
def process_households(
94+
sheet: Sheet, job: AsyncJob, batch: Batch, config: Config, mapping_profile: MappingProfile | None = None
95+
) -> Mapping[int, Household]:
7896
mapping = {}
7997

8098
for i, row in enumerate(sheet, 1):
@@ -87,7 +105,7 @@ def process_households(sheet: Sheet, job: AsyncJob, batch: Batch, config: Config
87105
job.program.households.create(
88106
batch=batch,
89107
name=label,
90-
flex_fields=clean_field_names(row),
108+
flex_fields=apply_mapping_rules(row, mapping_profile),
91109
),
92110
)
93111
except Exception as e:
@@ -104,22 +122,25 @@ def full_name_column(row: Record) -> str | None:
104122

105123

106124
def process_individuals(
107-
sheet: Sheet, household_mapping: Mapping[int, Household], job: AsyncJob, batch: Batch, config: Config
125+
sheet: Sheet,
126+
household_mapping: Mapping[int, Household],
127+
job: AsyncJob,
128+
mapping_profile: MappingProfile | None = None,
108129
) -> int:
109130
processed = 0
110131

111132
for i, row in enumerate(sheet, 1):
112133
name_column = full_name_column(row)
113134
name = get_value(row, name_column) if name_column else None
114-
household_key = get_value(row, config["master_column_label"])
135+
household_key = get_value(row, job.config["master_column_label"])
115136
household = household_mapping.get(household_key)
116137

117138
try:
118139
job.program.individuals.create(
119-
batch=batch,
140+
batch=job.batch,
120141
name=name,
121142
household_id=household.pk,
122-
flex_fields=clean_field_names(row),
143+
flex_fields=apply_mapping_rules(row, mapping_profile),
123144
)
124145
except Exception as e:
125146
raise SheetProcessingError(INDIVIDUAL, i) from e
@@ -173,6 +194,13 @@ def import_from_rdi(job: AsyncJob) -> dict[str, int]:
173194
with atomic():
174195
config: Config = job.config
175196
rdi = job.file
197+
198+
config["mapping_profile_id"] = 1
199+
mapping_profile = None
200+
if config.get("mapping_profile_id"):
201+
with suppress(MappingProfile.DoesNotExist):
202+
mapping_profile = MappingProfile.objects.get(id=config["mapping_profile_id"], is_active=True)
203+
176204
batch = Batch.objects.create(
177205
name=config["batch_name"],
178206
program=job.program,
@@ -183,8 +211,10 @@ def import_from_rdi(job: AsyncJob) -> dict[str, int]:
183211

184212
household_sheet, individual_sheet = read_sheets(config, rdi, 0, 1)
185213

186-
household_mapping = process_households(household_sheet, job, batch, config)
187-
individuals_number = process_individuals(individual_sheet, household_mapping, job, batch, config)
214+
household_mapping = process_households(household_sheet, job, batch, config, mapping_profile)
215+
individuals_number = process_individuals(
216+
individual_sheet, household_mapping, job, batch, config, mapping_profile
217+
)
188218

189219
validate_beneficiaries(config, household_mapping)
190220

0 commit comments

Comments
 (0)