Skip to content

Commit 6c623f3

Browse files
committed
feat: 315764 Add push action when RDP is created, create async flow for create & push rdp
1 parent 1673d80 commit 6c623f3

24 files changed

Lines changed: 1499 additions & 49 deletions

File tree

src/country_workspace/config/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ class Group(Enum):
179179
"MAILJET_SECRET_KEY": (str, "", "", False, "Mailjet API secret key"),
180180
"DEDUP_API_URL": (str, "", "", False, "Dedup Engine API base URL"),
181181
"DEDUP_API_TOKEN": (str, "", "", False, "Dedup Engine API token"),
182+
"APP_BASE_URL": (str, "", "", False, "Base URL of this application (used to build absolute callback URLs)"),
182183
"EMAIL_BACKEND": (
183184
str,
184185
"anymail.backends.mailjet.EmailBackend",

src/country_workspace/config/fragments/constance.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from .app import AURORA_API_TOKEN, AURORA_API_URL, HOPE_API_TOKEN, HOPE_API_URL, NEW_USER_DEFAULT_GROUP
2-
from .dedup import DEDUP_API_URL, DEDUP_API_TOKEN
2+
from .dedup import DEDUP_API_URL, DEDUP_API_TOKEN, APP_BASE_URL
33
from .kobo import KOBO_API_TOKEN, KOBO_KF_URL, KOBO_MASTER_API_TOKEN, KOBO_PROJECT_VIEW_ID
44
from .mail import MAILJET_API_KEY, MAILJET_SECRET_KEY
55

@@ -68,6 +68,7 @@
6868
"MAILJET_SECRET_KEY": (MAILJET_SECRET_KEY, "Mailjet secret key", "write_only_text_input"),
6969
"DEDUP_API_URL": (DEDUP_API_URL, "Dedup Engine server address", str),
7070
"DEDUP_API_TOKEN": (DEDUP_API_TOKEN, "Dedup Engine API access token", "write_only_text_input"),
71+
"APP_BASE_URL": (APP_BASE_URL, "Current server address", str),
7172
"CHUNK_SIZE_FOR_VALIDATION_TASK": (500, "Number of records to process per chunk in validation tasks", int),
7273
"PICTURE_IMPORT_MAX_ZIP_UPLOAD_MB": (
7374
20,
@@ -173,6 +174,7 @@
173174
"NEW_USER_IS_STAFF",
174175
"NEW_USER_DEFAULT_GROUP",
175176
"CONCURRENCY_GUARD",
177+
"APP_BASE_URL",
176178
),
177179
}
178180

src/country_workspace/config/fragments/dedup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22

33
DEDUP_API_URL = env("DEDUP_API_URL")
44
DEDUP_API_TOKEN = env("DEDUP_API_TOKEN")
5+
APP_BASE_URL = env("APP_BASE_URL")

src/country_workspace/config/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.contrib import admin
66
from django.urls import path
77

8+
from country_workspace.contrib.hope.views import DeduplicationCallbackView
89
from country_workspace.workspaces.sites import workspace
910

1011
urlpatterns = [
@@ -16,6 +17,11 @@
1617
path(r"sentry_debug/", lambda _: 1 / 0),
1718
path("select2/", include(django_select2.urls)),
1819
path(r"__debug__/", include(debug_toolbar.urls)),
20+
path(
21+
"api/dedup/callback/<str:signed_token>/",
22+
DeduplicationCallbackView.as_view(),
23+
name="dedup_callback",
24+
),
1925
]
2026

2127
if "django_browser_reload" in settings.INSTALLED_APPS: # pragma: no cover

src/country_workspace/contrib/dedup_engine/client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,17 @@ def _request[T](self, operation: str, fn: Callable[[], T]) -> T:
6969
except RequestException as exc:
7070
raise self._err(operation, exc, getattr(exc, "response", None), error_cls=RemoteUnavailableError) from exc
7171

72-
def create_deduplication_set(self) -> response.CreatedDeduplicationSet:
72+
def create_deduplication_set(self, notification_url: str | None = None) -> response.CreatedDeduplicationSet:
7373
collection = resource.DeduplicationSetCollection(
7474
self.session,
7575
self.api_root.deduplication_sets,
7676
)
7777
payload: request.CreateDeduplicationSet = {"reference_pk": self.group_reference_id}
7878
if self.deduplication_set_id:
7979
payload["id"] = self.deduplication_set_id
80+
if notification_url:
81+
payload["notification_url"] = notification_url
82+
payload["notify"] = True
8083

8184
result = self._request(
8285
"create_deduplication_set",

src/country_workspace/contrib/hope/forms.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any
2+
13
from django import forms
24

35
from country_workspace.workspaces.admin.cleaners.base import BaseActionForm
@@ -7,3 +9,30 @@ class CreateRDPForm(BaseActionForm):
79
batch_name = forms.CharField(
810
required=False, help_text="Label for this RDP creation. Defaults is the current date and time."
911
)
12+
push_to_hope = forms.BooleanField(
13+
required=False,
14+
label="Push to HOPE after creation",
15+
help_text="Automatically push beneficiaries to HOPE when RDP creation succeeds.",
16+
)
17+
18+
def __init__(self, *args: Any, show_push_option: bool = False, **kwargs: Any) -> None:
19+
super().__init__(*args, **kwargs)
20+
if not show_push_option:
21+
self.fields.pop("push_to_hope")
22+
23+
24+
class CreateRDPushThresholdForm(BaseActionForm):
25+
batch_name = forms.CharField(widget=forms.HiddenInput, required=False)
26+
push_to_hope = forms.CharField(widget=forms.HiddenInput)
27+
max_dedup_findings_percent = forms.IntegerField(
28+
min_value=0,
29+
max_value=100,
30+
initial=0,
31+
required=False,
32+
label="Max duplicate findings (%)",
33+
help_text=(
34+
"Maximum share of individuals allowed to have duplicate matches before push is blocked. "
35+
"Only applies when biometric deduplication is enabled. "
36+
"0 means any duplicate finding will block the push."
37+
),
38+
)

src/country_workspace/contrib/hope/push/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
cancel_existing_rdp_core,
44
claim_rdp_deduplication,
55
claim_rdp_push,
6+
create_and_push_rdp_core,
7+
create_rdp_and_start_dedup_core,
68
create_rdp_core,
9+
dedup_callback_handle,
710
dedup_existing_rdp_core,
811
push_existing_rdp_core,
912
)
@@ -18,7 +21,10 @@
1821
"cancel_existing_rdp_core",
1922
"claim_rdp_deduplication",
2023
"claim_rdp_push",
24+
"create_and_push_rdp_core",
25+
"create_rdp_and_start_dedup_core",
2126
"create_rdp_core",
27+
"dedup_callback_handle",
2228
"dedup_existing_rdp_core",
2329
"get_program_dedup_settings_policy",
2430
"get_rdp_policy",

src/country_workspace/contrib/hope/push/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class CreateRdpConfig(SelectionConfig):
2323
country_office_id: ReadOnly[int]
2424
program_id: ReadOnly[int]
2525
pushed_by_id: ReadOnly[int]
26+
max_dedup_findings_percent: NotRequired[int]
2627

2728

2829
class PushWorkflowConfig(SelectionConfig):

src/country_workspace/contrib/hope/push/orchestration.py

Lines changed: 222 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1+
import logging
12
from collections.abc import Callable, Iterator
23
from functools import partial
34
from typing import Any
5+
from constance import config
46
from uuid import UUID, uuid4
57

8+
from django.core import signing
69
from django.db import IntegrityError, transaction
7-
8-
from country_workspace.contrib.dedup_engine import REJECTABLE_DEDUPLICATION_SET_STATES, make_dedup_client
10+
from django.urls import reverse
11+
from strategy_field.utils import fqn
12+
13+
from country_workspace.contrib.dedup_engine import (
14+
REJECTABLE_DEDUPLICATION_SET_STATES,
15+
DedupResponseStatus,
16+
DeduplicationSetState,
17+
make_dedup_client,
18+
)
919
from country_workspace.contrib.hope.exceptions import HopePushError
1020
from country_workspace.exceptions import RemoteError, RemoteUnavailableError
1121
from country_workspace.models import AsyncJob, Rdp
@@ -20,6 +30,7 @@
2030
qs_households,
2131
qs_individuals_by_household_pks,
2232
qs_individuals_by_pks,
33+
qs_individuals_for_rdp,
2334
rdp_for_dedup,
2435
rdp_for_push,
2536
release_rdp_dedup_settings_lock,
@@ -28,6 +39,11 @@
2839
workflow_config_for_rdp,
2940
)
3041

42+
_DEDUP_CALLBACK_SIGN_KEY = "dedup_callback"
43+
_DEDUP_CALLBACK_MAX_AGE = 60 * 60 * 96 # 96 hours
44+
45+
logger = logging.getLogger(__name__)
46+
3147

3248
def _require_policy_check(check: Callable[[], ActionCheck]) -> None:
3349
try:
@@ -78,6 +94,210 @@ def create_rdp_core(job: AsyncJob) -> dict[str, Any]:
7894
return {"rdp_id": rdp.id, "rdp_str": str(rdp)}
7995

8096

97+
def _build_dedup_callback_url(rdp_id: int, job_id: int) -> str:
98+
"""Build an absolute, signed callback URL for the dedup engine to call when dedup finishes."""
99+
token = signing.dumps(
100+
{"rdp_id": rdp_id, "job_id": job_id},
101+
key=_DEDUP_CALLBACK_SIGN_KEY,
102+
)
103+
path = reverse("dedup_callback", kwargs={"signed_token": token})
104+
base = config.APP_BASE_URL.rstrip("/")
105+
return f"{base}{path}"
106+
107+
108+
def create_rdp_and_start_dedup_core(job: AsyncJob) -> dict[str, Any]:
109+
"""Create an RDP, upload images to the dedup engine, and start deduplication.
110+
111+
The dedup engine will call back the notification URL when deduplication finishes.
112+
The push to HOPE is deferred until the callback is received and the findings
113+
threshold is evaluated.
114+
"""
115+
create_result = create_rdp_core(job)
116+
rdp_id = create_result["rdp_id"]
117+
118+
with transaction.atomic():
119+
_check, rdp = claim_rdp_deduplication(rdp_id)
120+
if rdp is None:
121+
raise HopePushError({"errors": ["RDP: could not claim deduplication set."], "rdp_id": rdp_id})
122+
123+
job.refresh_from_db()
124+
125+
callback_url = _build_dedup_callback_url(rdp_id=rdp_id, job_id=job.pk)
126+
processor = DedupProcessor(rdp)
127+
processor.run(notification_url=callback_url)
128+
129+
if processor.has_errors:
130+
raise HopePushError({**processor.total, "rdp_id": rdp_id})
131+
132+
with transaction.atomic():
133+
locked = lock_rdp_for_update(pk=rdp_id)
134+
locked.status = Rdp.PushStatus.DEDUP_PENDING
135+
locked.save(update_fields=["status"])
136+
137+
return {**create_result, "dedup_pending": True}
138+
139+
140+
def create_and_push_rdp_core(job: AsyncJob) -> dict[str, Any]:
141+
"""Create an RDP and start deduplication; the push to HOPE is deferred.
142+
143+
Push to HOPE is only available for programs with biometric deduplication
144+
enabled. Deduplication is started first and the push is deferred until the
145+
dedup engine calls back the notification URL and the findings threshold is
146+
evaluated.
147+
"""
148+
rdp_program = job.program
149+
if not (rdp_program and rdp_program.biometric_deduplication_enabled):
150+
raise HopePushError({"errors": ["RDP: push to HOPE requires biometric deduplication for this program."]})
151+
return create_rdp_and_start_dedup_core(job)
152+
153+
154+
def _lock_and_fail_rdp(rdp_id: int, *, reason: str) -> None:
155+
with transaction.atomic():
156+
locked = lock_rdp_for_update(pk=rdp_id)
157+
if locked.status == Rdp.PushStatus.DEDUP_PENDING:
158+
set_rdp_push_status(rdp=locked, status=Rdp.PushStatus.FAILURE, hope_rdi_id="N/A")
159+
logger.warning(
160+
"dedup_callback_handle: rdp_id=%s marked FAILURE (%s)",
161+
rdp_id,
162+
reason,
163+
)
164+
else:
165+
logger.info(
166+
"dedup_callback_handle: rdp_id=%s skip FAILURE (%s); status=%s",
167+
rdp_id,
168+
reason,
169+
locked.status,
170+
)
171+
172+
173+
def _handle_deduplicated(rdp: Rdp, rdp_id: int, findings_count: int) -> None:
174+
origin_job = AsyncJob.objects.filter(rdp_id=rdp_id).order_by("-id").first()
175+
max_findings_percent: int = origin_job.config.get("max_dedup_findings_percent", 0) if origin_job else 0
176+
177+
total_individuals = qs_individuals_for_rdp(rdp=rdp).count()
178+
findings_rate = findings_count / total_individuals * 100 if total_individuals > 0 else float(findings_count)
179+
180+
logger.info(
181+
"dedup_callback_handle: rdp_id=%s DEDUPLICATED findings_count=%s total_individuals=%s "
182+
"findings_rate=%.2f%% max_dedup_findings_percent=%s",
183+
rdp_id,
184+
findings_count,
185+
total_individuals,
186+
findings_rate,
187+
max_findings_percent,
188+
)
189+
190+
if findings_rate > max_findings_percent:
191+
_lock_and_fail_rdp(
192+
rdp_id,
193+
reason=f"findings_rate {findings_rate:.2f}% exceeds threshold {max_findings_percent}%",
194+
)
195+
return
196+
197+
with transaction.atomic():
198+
locked = lock_rdp_for_update(pk=rdp_id)
199+
if locked.status != Rdp.PushStatus.DEDUP_PENDING:
200+
logger.warning(
201+
"dedup_callback_handle: rdp_id=%s skip push queue; status changed to %s",
202+
rdp_id,
203+
locked.status,
204+
)
205+
return
206+
locked.status = Rdp.PushStatus.PENDING
207+
locked.save(update_fields=["status"])
208+
209+
push_job = AsyncJob.objects.create(
210+
description=f"Push RDP {rdp_id} to HOPE (post-dedup)",
211+
type=AsyncJob.JobType.TASK,
212+
owner=rdp.pushed_by,
213+
action=fqn(push_existing_rdp_core),
214+
program=rdp.program,
215+
rdp=rdp,
216+
config={"rdp_id": rdp_id},
217+
)
218+
push_job.queue()
219+
logger.info(
220+
"dedup_callback_handle: rdp_id=%s status DEDUP_PENDING→PENDING; queued push job_id=%s",
221+
rdp_id,
222+
push_job.pk,
223+
)
224+
225+
226+
def dedup_callback_handle(rdp_id: int) -> None:
227+
"""Handle a dedup engine callback for the given RDP.
228+
229+
Fetches the current deduplication set state from the engine. Only acts on
230+
terminal states (``DEDUPLICATED``, ``DEDUPLICATION_FAILED``,
231+
``ENCODING_FAILED``). For intermediate states the function returns
232+
immediately so the engine can call again after the next state transition.
233+
234+
When dedup is successful and findings are within the configured threshold,
235+
a ``push_existing_rdp_core`` async job is queued. Otherwise the RDP is
236+
marked as ``FAILURE``.
237+
"""
238+
try:
239+
rdp = rdp_for_push(pk=rdp_id)
240+
except Rdp.DoesNotExist:
241+
logger.warning("dedup_callback_handle: rdp_id=%s not found", rdp_id)
242+
return
243+
244+
if rdp.status != Rdp.PushStatus.DEDUP_PENDING:
245+
logger.info(
246+
"dedup_callback_handle: rdp_id=%s skip; status=%s (expected DEDUP_PENDING)",
247+
rdp_id,
248+
rdp.status,
249+
)
250+
return
251+
252+
if not rdp.deduplication_set_id:
253+
logger.warning("dedup_callback_handle: rdp_id=%s missing deduplication_set_id", rdp_id)
254+
_lock_and_fail_rdp(rdp_id, reason="missing deduplication_set_id")
255+
return
256+
257+
logger.info(
258+
"dedup_callback_handle: rdp_id=%s deduplication_set_id=%s; fetching remote status",
259+
rdp_id,
260+
rdp.deduplication_set_id,
261+
)
262+
263+
status = get_rdp_policy(rdp).deduplication_status(rdp)
264+
if status is None:
265+
logger.warning(
266+
"dedup_callback_handle: rdp_id=%s dedup engine returned no status; leaving DEDUP_PENDING",
267+
rdp_id,
268+
)
269+
return
270+
271+
if status.response_status != DedupResponseStatus.OK:
272+
logger.warning(
273+
"dedup_callback_handle: rdp_id=%s dedup engine status unavailable (%s); leaving DEDUP_PENDING",
274+
rdp_id,
275+
status.response_status,
276+
)
277+
return
278+
279+
dedup_state = status.deduplication_set_status
280+
terminal_failure_states = {DeduplicationSetState.DEDUPLICATION_FAILED, DeduplicationSetState.ENCODING_FAILED}
281+
282+
logger.info(
283+
"dedup_callback_handle: rdp_id=%s remote_state=%s findings_count=%s",
284+
rdp_id,
285+
dedup_state,
286+
status.findings_count,
287+
)
288+
289+
if dedup_state in terminal_failure_states:
290+
_lock_and_fail_rdp(rdp_id, reason=f"dedup engine state {dedup_state}")
291+
elif dedup_state == DeduplicationSetState.DEDUPLICATED:
292+
_handle_deduplicated(rdp, rdp_id, status.findings_count)
293+
else:
294+
logger.info(
295+
"dedup_callback_handle: rdp_id=%s intermediate state %s; waiting for next callback",
296+
rdp_id,
297+
dedup_state,
298+
)
299+
300+
81301
def claim_rdp_deduplication(rdp_id: int) -> tuple[ActionCheck, Rdp | None]:
82302
rdp = rdp_for_dedup(pk=rdp_id)
83303
policy = get_rdp_policy(rdp)

0 commit comments

Comments
 (0)