Skip to content

Commit 4b4475a

Browse files
chg ! push to hope core according to beneficiary groups
1 parent ebf2ef3 commit 4b4475a

File tree

12 files changed

+279
-111
lines changed

12 files changed

+279
-111
lines changed

src/country_workspace/contrib/hope/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
# NEVER CHANGE THIS VALUES
66
HOUSEHOLD_CHECKER_NAME: Final[str] = "HOPE Household core"
77
INDIVIDUAL_CHECKER_NAME: Final[str] = "HOPE Individual core"
8+
PEOPLE_CHECKER_NAME: Final[str] = "HOPE People core"

src/country_workspace/contrib/hope/push.py

Lines changed: 63 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,36 +12,48 @@
1212
from country_workspace.contrib.hope.constants import HOUSEHOLD_PUSH_BATCH_SIZE
1313
from country_workspace.exceptions import RemoteError
1414
from country_workspace.models import AsyncJob
15-
from country_workspace.workspaces.models import CountryHousehold
15+
from country_workspace.workspaces.models import CountryHousehold, CountryIndividual
1616

1717

1818
@dataclass
1919
class PushProcessor:
20-
"""Handles pushing household data to an external system through the HopeClient API."""
20+
"""Handles pushing beneficiaries data to an external system through the HopeClient API."""
2121

2222
co_slug: str
2323
batch_name: str
2424
program_id: str
25+
master_detail: bool
2526
queryset: QuerySet[CountryHousehold] = field(default_factory=lambda: CountryHousehold.objects.none())
2627
client: HopeClient = field(default_factory=HopeClient)
27-
total: dict[str, Any] = field(default_factory=lambda: {"households": 0, "errors": []})
28+
total: dict[str, Any] = field(default_factory=lambda: {"errors": []})
2829
rdi_id: str | None = field(default=None, init=False)
30+
model: type = field(init=False)
31+
push_endpoint: str = field(init=False)
32+
has_members: bool = field(init=False)
2933

3034
def __post_init__(self) -> None:
31-
"""Initialize the base path for API requests."""
3235
self.base_path = f"{self.co_slug}/rdi/"
36+
self.model, self.push_endpoint, self.has_members = (
37+
(CountryHousehold, "push/lax/", True) if self.master_detail else (CountryIndividual, "push/people/", False)
38+
)
3339

34-
def check_households_validity(self) -> None:
35-
"""Check the validity of each household and its members in the queryset.
40+
def set_queryset(self, pks: list[int]) -> None:
41+
"""Set the queryset based on master_detail and provided pks."""
42+
qs = self.model.objects.filter(pk__in=pks)
43+
self.queryset = qs.prefetch_related("members") if self.master_detail else qs
3644

37-
Adds errors to `self.total["errors"]` if any household or member is invalid.
45+
def check_beneficiaries_validity(self) -> None:
46+
"""Check the validity of each beneficiaries in the queryset.
47+
48+
Adds errors to `self.total["errors"]` if any beneficiaries is invalid.
3849
"""
39-
for hh in self.queryset:
40-
if not hh.is_valid():
41-
self.total["errors"].append(f"HH #{hh.pk} invalid.")
42-
for ind in hh.members.all():
43-
if not ind.is_valid():
44-
self.total["errors"].append(f"Ind #{ind.pk} invalid.")
50+
for item in self.queryset:
51+
if not item.is_valid():
52+
self.total["errors"].append(f"{self.model.__name__} #{item.pk} invalid")
53+
if self.has_members:
54+
for ind in item.members.all():
55+
if not ind.is_valid():
56+
self.total["errors"].append(f"Ind #{ind.pk} invalid")
4557

4658
def rdi_create(self) -> None:
4759
"""Create a new RDI record in the external system.
@@ -53,9 +65,9 @@ def rdi_create(self) -> None:
5365
if response := self.safe_post(path, data, "Error creating RDI"):
5466
self.rdi_id = response.get("id")
5567

56-
def rdi_push_lax(self) -> None:
68+
def rdi_push(self) -> None:
5769
"""
58-
Pushes a batch of household data to the external RDI system.
70+
Pushes a batch of beneficiaries data to the external RDI system.
5971
6072
Adds errors to `self.total["errors"]` if `rdi_id` is not set.
6173
Successfully pushed records are marked as removed.
@@ -64,7 +76,7 @@ def rdi_push_lax(self) -> None:
6476
self.total["errors"].append("Cannot push data: rdi_id is not set")
6577
return
6678
batch_ids, batch_data = self.prepare_batch()
67-
path = f"{self.base_path}{self.rdi_id}/push/lax/"
79+
path = f"{self.base_path}{self.rdi_id}/{self.push_endpoint}"
6880
if successful_ids := self.process_batch_response(
6981
self.safe_post(path, batch_data, "Error pushing data"),
7082
batch_ids,
@@ -103,17 +115,19 @@ def safe_post(self, path: str, data: Any, error_msg: str) -> dict[str, Any] | No
103115

104116
def prepare_batch(self) -> tuple[list[int], list[dict]]:
105117
"""
106-
Prepare a batch of household data for API submission.
118+
Prepare a batch of household/individual data for API submission.
107119
108120
Returns:
109-
tuple[list[int], list[dict]]: A tuple of household IDs and transformed data.
121+
tuple[list[int], list[dict]]: A tuple of household/individual IDs and transformed data.
110122
111123
"""
112124
ids, data = [], []
113125
for item in self.queryset:
114126
ids.append(item.id)
115127
data.append(
116128
{**map_fields(item.flex_fields), "members": [map_fields(m.flex_fields) for m in item.members.all()]}
129+
if self.has_members
130+
else map_fields(item.flex_fields)
117131
)
118132
return ids, data
119133

@@ -133,6 +147,11 @@ def process_batch_response(self, response: dict | None, batch_ids: list[int]) ->
133147
case {"processed": int(p), "accepted": int(a)} if p == a == len(batch_ids):
134148
self.total["households"] = self.total.get("households", 0) + a
135149
return batch_ids
150+
case {"id": str(_rdi_id), "people": list(_batch_ids)} if _rdi_id == self.rdi_id and len(_batch_ids) == len(
151+
batch_ids
152+
):
153+
self.total["people"] = self.total.get("people", 0) + len(_batch_ids)
154+
return batch_ids
136155
case {"errors": int(e)} if e > 0:
137156
self.total["errors"].append(f"Error pushing data for IDs: {batch_ids} - {response}")
138157
case None:
@@ -143,27 +162,30 @@ def process_batch_response(self, response: dict | None, batch_ids: list[int]) ->
143162

144163
def mark_batch_removed(self, successful_ids: list[int]) -> None:
145164
"""
146-
Mark successfully pushed households and members as removed in the database.
165+
Mark successfully pushed beneficiaries as removed in the database.
147166
148167
Args:
149-
successful_ids (list[int]): List of successfully pushed household IDs.
168+
successful_ids (list[int]): List of successfully pushed beneficiaries IDs.
150169
151170
"""
152171
try:
153172
with transaction.atomic():
154-
households = CountryHousehold.objects.filter(id__in=successful_ids).prefetch_related("members")
155-
for hh in households:
156-
if hh.removed:
157-
self.total["errors"].append(f"Household #{hh.id} already marked as removed")
173+
items = self.model.objects.filter(id__in=successful_ids)
174+
if self.has_members:
175+
items = items.prefetch_related("members")
176+
for item in items:
177+
if item.removed:
178+
self.total["errors"].append(f"{self.model.__name__} #{item.id} already marked as removed")
158179
else:
159-
hh.removed = True
160-
hh.save(update_fields=["removed"])
161-
for ind in hh.members.all():
162-
if ind.removed:
163-
self.total["errors"].append(f"Individual #{ind.id} already marked as removed")
164-
else:
165-
ind.removed = True
166-
ind.save(update_fields=["removed"])
180+
item.removed = True
181+
item.save(update_fields=["removed"])
182+
if self.has_members:
183+
for ind in item.members.all():
184+
if ind.removed:
185+
self.total["errors"].append(f"Individual #{ind.id} already marked as removed")
186+
else:
187+
ind.removed = True
188+
ind.save(update_fields=["removed"])
167189
except (DatabaseError, Exception) as e:
168190
self.total["errors"].append(f"Failed to mark IDs {successful_ids} as removed: {e}")
169191

@@ -176,23 +198,28 @@ def push_to_hope_core(job: AsyncJob) -> dict[str, Any]:
176198
job (AsyncJob): The job configuration containing relevant identifiers and parameters.
177199
178200
Returns:
179-
dict[str, Any]: Summary of the operation including processed households and errors.
201+
dict[str, Any]: Summary of the operation including processed beneficiaries and errors.
180202
181203
"""
182204

183205
def steps() -> Iterator[Callable[[], None]]:
184-
"""Yield steps for pushing household data in batches."""
206+
"""Yield steps for pushing beneficiaries data in batches."""
185207
yield processor.rdi_create
186208
for batch_pks in batched(job.config["pks"], HOUSEHOLD_PUSH_BATCH_SIZE):
187-
processor.queryset = CountryHousehold.objects.filter(pk__in=batch_pks).prefetch_related("members")
188-
yield from (processor.check_households_validity, processor.rdi_push_lax)
209+
processor.set_queryset(batch_pks)
210+
yield from (processor.check_beneficiaries_validity, processor.rdi_push)
189211
yield processor.rdi_complete
190212

213+
if job.program.beneficiary_group is None:
214+
return {"errors": ["Cannot proceed: beneficiary_group is not set"]}
215+
191216
processor = PushProcessor(
192217
co_slug=job.program.country_office.slug,
193218
batch_name=job.config.get("batch_name"),
194219
program_id=job.program.hope_id,
220+
master_detail=job.program.beneficiary_group.master_detail,
195221
)
222+
196223
for step in steps():
197224
step()
198225
if processor.total["errors"]:

src/country_workspace/models/program.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ class Program(BaseModel):
9292
household_search = models.TextField(default="name", help_text="Fields to use for searches")
9393
individual_search = models.TextField(default="name", help_text="Fields to use for searches")
9494
household_columns = models.TextField(default="name\nid", help_text="Columns to display in the Admin table")
95-
individual_columns = models.TextField(default="name\nid", help_text="Columns to display i the Admin table")
95+
individual_columns = models.TextField(default="name\nid", help_text="Columns to display in the Admin table")
9696
extra_fields = models.JSONField(default=dict, blank=True, null=False)
9797

9898
def __str__(self) -> str:

src/country_workspace/versioning/checkers.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from django import forms
22
from hope_flex_fields.models import DataChecker, FieldDefinition, Fieldset
33

4-
from country_workspace.contrib.hope.constants import HOUSEHOLD_CHECKER_NAME, INDIVIDUAL_CHECKER_NAME
4+
from country_workspace.contrib.hope.constants import (
5+
HOUSEHOLD_CHECKER_NAME,
6+
INDIVIDUAL_CHECKER_NAME,
7+
PEOPLE_CHECKER_NAME,
8+
)
59

610

711
def create_hope_checkers() -> None: # noqa: PLR0915
@@ -17,6 +21,8 @@ def create_hope_checkers() -> None: # noqa: PLR0915
1721
_i_role = FieldDefinition.objects.get(slug="hope-ind-role")
1822
_i_relationship = FieldDefinition.objects.get(slug="hope-ind-relationship")
1923

24+
_p_type = FieldDefinition.objects.get(slug="hope-people-type")
25+
2026
hh_fs, __ = Fieldset.objects.get_or_create(name=HOUSEHOLD_CHECKER_NAME)
2127
hh_fs.fields.get_or_create(name="address", definition=_char)
2228
hh_fs.fields.get_or_create(name="admin1", definition=_char)
@@ -86,14 +92,28 @@ def create_hope_checkers() -> None: # noqa: PLR0915
8692
)
8793
ind_fs.fields.get_or_create(name="role", attrs={"label": "Role"}, definition=_i_role)
8894

95+
pp_fs, __ = Fieldset.objects.get_or_create(name=PEOPLE_CHECKER_NAME)
96+
pp_fs.fields.get_or_create(name="type", attrs={"label": "People Type", "required": True}, definition=_p_type)
97+
pp_fs.fields.get_or_create(name="full_name", attrs={"label": "Full Name", "required": True}, definition=_char)
98+
pp_fs.fields.get_or_create(name="country", attrs={"label": "Country", "required": True}, definition=_h_country)
99+
pp_fs.fields.get_or_create(
100+
name="residence_status", attrs={"label": "Residence Status", "required": True}, definition=_h_residence
101+
)
102+
pp_fs.fields.get_or_create(name="gender", definition=_i_gender)
103+
pp_fs.fields.get_or_create(name="birth_date", attrs={"label": "Birth Date", "required": True}, definition=_date)
104+
89105
hh_dc, __ = DataChecker.objects.get_or_create(name=HOUSEHOLD_CHECKER_NAME)
90106
hh_dc.fieldsets.add(hh_fs)
91107
ind_dc, __ = DataChecker.objects.get_or_create(name=INDIVIDUAL_CHECKER_NAME)
92108
ind_dc.fieldsets.add(ind_fs)
109+
pp_dc, __ = DataChecker.objects.get_or_create(name=PEOPLE_CHECKER_NAME)
110+
pp_dc.fieldsets.add(pp_fs)
93111

94112

95113
def removes_hope_checkers() -> None:
96114
DataChecker.objects.filter(name=HOUSEHOLD_CHECKER_NAME).delete()
97115
DataChecker.objects.filter(name=INDIVIDUAL_CHECKER_NAME).delete()
98116
Fieldset.objects.filter(name=HOUSEHOLD_CHECKER_NAME).delete()
99117
Fieldset.objects.filter(name=INDIVIDUAL_CHECKER_NAME).delete()
118+
Fieldset.objects.filter(name=PEOPLE_CHECKER_NAME).delete()
119+
Fieldset.objects.filter(name=PEOPLE_CHECKER_NAME).delete()

src/country_workspace/versioning/hope_fields.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,18 @@ def create_hope_field_definitions() -> None:
6969
},
7070
)
7171

72+
FieldDefinition.objects.get_or_create(
73+
name="HOPE People Type",
74+
slug=slugify("HOPE People Type"),
75+
field_type=forms.ChoiceField,
76+
attrs={
77+
"choices": [
78+
["", ""],
79+
["NON_BENEFICIARY", "Non Beneficiary"],
80+
]
81+
},
82+
)
83+
7284
SyncLog.objects.create_lookups()
7385

7486

src/country_workspace/workspaces/admin/cleaners/actions.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99

1010
from country_workspace.contrib.hope.forms import PushToHopeForm
1111
from country_workspace.contrib.hope.push import push_to_hope_core
12-
from country_workspace.models import AsyncJob, Household
12+
from country_workspace.models import AsyncJob
1313
from country_workspace.state import state
1414
from country_workspace.utils.fields import rdi_name_default
1515
from country_workspace.workspaces.admin.forms import BulkUpdateExportForm
1616

17+
from ...models import CountryIndividual, CountryHousehold
1718
from .bulk_update import bulk_update_export_template
1819
from .calculate_checksum import calculate_checksum_impl
1920
from .mass_update import MassUpdateForm, mass_update_impl
@@ -25,7 +26,6 @@
2526

2627
from country_workspace.types import Beneficiary
2728
from country_workspace.workspaces.admin.hh_ind import BeneficiaryBaseAdmin
28-
from country_workspace.workspaces.admin.household import CountryHouseholdAdmin
2929

3030

3131
@admin.action(description="Validate selected records", permissions=["validate"])
@@ -189,14 +189,23 @@ def calculate_checksum(
189189

190190
@admin.action(description="Push to HOPE core", permissions=["push_to_hope"])
191191
def push_to_hope(
192-
model_admin: "CountryHouseholdAdmin",
192+
model_admin: "BeneficiaryBaseAdmin",
193193
request: HttpRequest,
194-
queryset: "QuerySet[Household]",
194+
queryset: "QuerySet[Beneficiary]",
195195
) -> HttpResponse:
196196
ctx = model_admin.get_common_context(request, title=_(push_to_hope.short_description))
197197
form = PushToHopeForm(request.POST)
198198
ctx["form"] = form
199199
if "_push" in request.POST and form.is_valid():
200+
if (program := model_admin.get_selected_program(request)) and program.beneficiary_group:
201+
expected_model = CountryHousehold if program.beneficiary_group.master_detail else CountryIndividual
202+
if not isinstance(queryset.model, expected_model.__class__):
203+
model_admin.message_user(
204+
request,
205+
"Program, beneficiary group not set, or action unavailable for this model.",
206+
messages.ERROR,
207+
)
208+
return redirect(request.path)
200209
job = AsyncJob.objects.create(
201210
description=push_to_hope.short_description,
202211
type=AsyncJob.JobType.TASK,
@@ -205,6 +214,7 @@ def push_to_hope(
205214
program=state.program,
206215
config={
207216
"batch_name": form.cleaned_data["batch_name"] or rdi_name_default(),
217+
"master_detail": program.beneficiary_group.master_detail,
208218
"pks": list(queryset.values_list("pk", flat=True)),
209219
},
210220
)

0 commit comments

Comments
 (0)