Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 43 additions & 39 deletions docs/src/flows/rdp_lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ stateDiagram-v2

PENDING --> PENDING: Dedup via DE REST
PENDING --> FAILURE: Push failed
PENDING --> SUCCESS: Push succeeded
PENDING --> CANCELLED: Admin cancel
PENDING --> CANCELLED: Reject DS
PENDING --> CANCELLED: Clone replaces source
PENDING --> PUSHED: Push succeeded
PENDING --> REJECTED: Reject DS
PENDING --> REJECTED: Clone replaces source

SUCCESS --> CANCELLED: Staff Reset after HOPE rejection
PUSHED --> MERGED: HOPE merge confirmed
PUSHED --> REJECTED: Staff Reset after HOPE rejection

FAILURE --> [*]: Final failed
CANCELLED --> [*]: Final cancelled
SUCCESS --> [*]: Final successful
REJECTED --> [*]: Final rejected
PUSHED --> [*]: Final pushed
MERGED --> [*]: Final merged

note right of PENDING
Local active RDP status.<br>
Expand All @@ -31,17 +32,20 @@ stateDiagram-v2
RDP was not successfully handed off to HOPE Core.
end note

note right of SUCCESS
Final successful RDP status.<br>
CW pushed data to HOPE Core successfully.
HOPE Core automerge is assumed.
note right of PUSHED
CW pushed data to HOPE Core successfully.<br>
If a DE set exists, CW calls DE approve after successful push.
DE approve failure is recorded but does not change RDP status.
end note

note right of CANCELLED
Final cancelled RDP status.<br>
Can be set by admin cancel, DE set reject,
note right of MERGED
Final merged RDP status.<br>
HOPE-side merge was confirmed.
end note

note right of REJECTED
Final rejected RDP status.<br>
Can be set by DE set reject,
Clone replacing PENDING source, or Staff Reset.
end note
```
Expand All @@ -60,57 +64,56 @@ flowchart TB
D1 -- "error" --> PENDING

PENDING --> RDS["Reject Deduplication Set<br>DE REST API"]
RDS -->|rejected| CANCELLED["CANCELLED<br>final cancelled state"]
RDS -->|rejected| REJECTED["REJECTED<br>final rejected state"]
RDS -->|error| PENDING

PENDING --> HOPE_PUSH["Push to HOPE<br>create RDI / push / complete"]
HOPE_PUSH -->|failed| FAILURE["FAILURE<br>push workflow failed"]
HOPE_PUSH -->|succeeded| SUCCESS["SUCCESS<br>pushed to HOPE Core<br>automerge assumed"]

SUCCESS -. "post-push" .-> DE_APPROVE["Approve Deduplication Set<br>DE REST API"]
DE_APPROVE -. "status unchanged" .-> SUCCESS
HOPE_PUSH -->|succeeded| PUSHED["PUSHED<br>pushed to HOPE Core"]

SUCCESS --> RESET["Staff admin Reset"]
RESET -->|HOPE rejection confirmed| CANCELLED
PUSHED -. "post-push" .-> DE_APPROVE["Approve Deduplication Set<br>DE REST API"]
DE_APPROVE -. "status unchanged" .-> PUSHED

PENDING -->|admin cancels RDP| CANCELLED
PUSHED --> MERGED["MERGED<br>merged in HOPE Core"]
PUSHED --> RESET["Staff admin Reset"]
RESET -->|HOPE rejection confirmed| REJECTED

CLONE["Clone RDP"] --> CL1{"source status"}
CL1 -- "PENDING" --> CL2["source becomes CANCELLED<br>child becomes PENDING"]
CL1 -- "FAILURE / CANCELLED" --> CL3["source unchanged<br>child becomes PENDING"]
CL1 -- "SUCCESS" --> CL4["clone blocked"]
CL1 -- "PENDING" --> CL2["source becomes REJECTED<br>child becomes PENDING"]
CL1 -- "FAILURE / REJECTED" --> CL3["source unchanged<br>child becomes PENDING"]
CL1 -- "PUSHED / MERGED" --> CL4["clone blocked"]

CL2 --> PENDING
CL3 --> PENDING

subgraph STATUS_RULES["Status-based policy rules"]
subgraph CREATE_RULES["Regular Create RDP"]
A["BLOCK: Program has PENDING RDP"]
B["BLOCK: selected records are already linked to PENDING/SUCCESS RDP"]
B["BLOCK: selected records are already linked to PENDING/PUSHED/MERGED RDP"]
end

subgraph DEDUP_RULES["Run Deduplication"]
H["ENSURE: CW-owned deduplication_set_id before new DE set creation"]
J["BLOCK: Deduplicate cannot be started twice for the same RDP"]
J["LOCK: Program dedup settings after deduplication is requested"]
end

subgraph CLONE_RULES["Clone RDP"]
C["ALLOW: clone PENDING; source becomes CANCELLED, child becomes PENDING"]
D["ALLOW: clone FAILURE/CANCELLED; source unchanged, child becomes PENDING"]
E["BLOCK: clone SUCCESS"]
C["ALLOW: clone PENDING; source becomes REJECTED, child becomes PENDING"]
D["ALLOW: clone FAILURE/REJECTED; source unchanged, child becomes PENDING"]
E["BLOCK: clone PUSHED/MERGED"]
F["REQUIRE: DE state allows clone: failed encoding, failed dedup, deduplicated, or rejected"]
K["REUSE: child may keep source deduplication_set_id when source DE set is deduplicated"]
end

subgraph SETTINGS_RULES["Update Dedup Settings"]
G["BLOCK: PENDING RDP after deduplication was requested, or SUCCESS RDP"]
G["BLOCK: PENDING locked by dedup request, PUSHED, or MERGED RDP"]
end

subgraph PUSH_RULES["Push to HOPE"]
L["SEND: RDI country_workspace_id = deduplication_set_id when present"]
M["SEND: beneficiary country_workspace_id as required by HOPE Core"]
N["APPROVE: DE set after successful HOPE push, if present"]
O["KEEP SUCCESS: DE approve error is recorded only"]
O["KEEP PUSHED: DE approve error is recorded only"]
end
end

Expand All @@ -124,14 +127,14 @@ flowchart TB
classDef success fill:#E8F6EC,stroke:#2E8540,stroke-width:1.5px,color:#1F3D2A;
classDef failed fill:#FBEAEA,stroke:#C43B3B,stroke-width:1.5px,color:#4A1F1F;
classDef neutral fill:#F3F4F6,stroke:#6B7280,stroke-width:1.5px,color:#1F2937;
classDef cancelled fill:#F3E8FF,stroke:#7E22CE,stroke-width:1.5px,color:#3B0764;
classDef rejected fill:#F3E8FF,stroke:#7E22CE,stroke-width:1.5px,color:#3B0764;

class PENDING,CREATE,CLONE,CL1,CL2,CL3,RESET,DEDUP_CLAIM local;
class DEDUP,D1,RDS,HOPE_PUSH,DE_APPROVE,X1,X2 external;
class SUCCESS success;
class PUSHED,MERGED success;
class FAILURE failed;
class CANCELLED cancelled;
class CL4,A,B,C,D,E,F,G,H,I,J,K,L,M,N,O neutral;
class REJECTED rejected;
class CL4,A,B,C,D,E,F,G,H,J,K,L,M,N,O neutral;
```

## Key rules
Expand All @@ -140,6 +143,7 @@ flowchart TB
- For a new DedupEngine set, `deduplication_set_id` is generated by CW before the create call.
- RDI `country_workspace_id` is sent only when the RDP has `deduplication_set_id`; its value is `str(deduplication_set_id)`.
- `deduplication_set_id` belongs to the selection owner / deduplication source, so clones may reuse it.
- `SUCCESS` means CW successfully completed the HOPE push; HOPE Core automerge is assumed.
- If a DedupEngine set exists, approve is called after successful HOPE push. Approval errors are recorded, but RDP status stays `SUCCESS`.
- Staff Reset can move `SUCCESS` to `CANCELLED` when HOPE-side rejection is confirmed manually.
- `PUSHED` means CW successfully completed the HOPE push.
- `MERGED` means HOPE-side merge was confirmed.
- If a DedupEngine set exists, approve is called after successful HOPE push. Approval errors are recorded, but RDP status stays `PUSHED`.
- Staff Reset can move `PUSHED` to `REJECTED` when HOPE-side rejection is confirmed manually.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ dependencies = [
"django-sysinfo>=2.6.2",
"django-tailwind>=3.8",
"djangorestframework>=3.15.1",
"drf-spectacular[sidecar]",
"dukpy>=0.5",
"flower>=2.0.1",
"hope-flex-fields>=0.8.1",
Expand Down
2 changes: 2 additions & 0 deletions src/country_workspace/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from smart_admin.smart_auth.admin import ContentTypeAdmin, PermissionAdmin

from ..cache.smart_panel import panel_cache
from .api_token import APITokenAdmin
from .batch import BatchAdmin
from .beneficiary_group import BeneficiaryGroupAdmin
from .constance import ConstanceAdmin
Expand Down Expand Up @@ -32,6 +33,7 @@
site.register_panel(panel_redis)

__all__ = [
"APITokenAdmin",
"AreaAdmin",
"AreaTypeAdmin",
"AsyncJobAdmin",
Expand Down
49 changes: 49 additions & 0 deletions src/country_workspace/admin/api_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from django.contrib.admin import ModelAdmin, display, register
from django.db.models import QuerySet
from django.http import HttpRequest
from django.utils import timezone

from country_workspace.models import APIToken


@register(APIToken)
class APITokenAdmin(ModelAdmin):
list_display = (
"masked_key",
"user",
"grant_type",
"office_names",
"valid_now",
"valid_from",
"valid_to",
"created",
)
list_filter = ("grant_type", "offices", "valid_from", "valid_to")
search_fields = ("key", "user__email", "offices__name")
filter_horizontal = ("offices",)
ordering = ("-created",)
date_hierarchy = "created"
autocomplete_fields = ("user",)

def get_queryset(self, request: HttpRequest) -> QuerySet[APIToken]:
return super().get_queryset(request).select_related("user").prefetch_related("offices")

def get_fields(self, request: HttpRequest, obj: APIToken | None = None) -> tuple[str, ...]:
fields = ("user", "grant_type", "offices", "valid_from", "valid_to")
return (*fields, "key", "valid_now", "created") if obj else fields

def get_readonly_fields(self, request: HttpRequest, obj: APIToken | None = None) -> tuple[str, ...]:
readonly = ("key", "valid_now", "created")
return (*readonly, "user", "grant_type", "valid_from") if obj else readonly

@display(description="Key", ordering="key")
def masked_key(self, obj: APIToken) -> str:
return f"{obj.key[:8]}…{obj.key[-4:]}"

@display(description="Offices")
def office_names(self, obj: APIToken) -> str:
return ", ".join(map(str, obj.offices.all())) or "—"

@display(boolean=True, description="Valid now")
def valid_now(self, obj: APIToken) -> bool:
return obj.is_valid_at(timezone.now())
14 changes: 7 additions & 7 deletions src/country_workspace/admin/rdp.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,21 @@ def view_in_workspace(self, btn: LinkButton) -> None:
change_list=False,
label="Reset",
html_attrs={"class": "btn-warning"},
enabled=lambda btn: btn.context["original"].status == Rdp.PushStatus.SUCCESS,
enabled=lambda btn: btn.context["original"].status == Rdp.PushStatus.PUSHED,
)
def reset(self, request: HttpRequest, pk: int) -> HttpResponse:
obj: Rdp = self.get_object(request, str(pk))

if obj.status != Rdp.PushStatus.SUCCESS:
self.message_user(request, "Reset is only allowed for SUCCESS status.", level="error")
if obj.status != Rdp.PushStatus.PUSHED:
self.message_user(request, "Reset is only allowed for PUSHED status.", level="error")
return HttpResponseRedirect(reverse("admin:country_workspace_rdp_change", args=[pk]))

def _action(_: HttpRequest) -> HttpResponseRedirect:
with transaction.atomic():
obj.households.all().update(removed=False)
obj.individuals.all().update(removed=False)
obj.status = Rdp.PushStatus.CANCELLED
obj.save()
obj.status = Rdp.PushStatus.REJECTED
obj.save(update_fields=["status"])
return HttpResponseRedirect(reverse("admin:country_workspace_rdp_change", args=[pk]))

return confirm_action(
Expand All @@ -97,8 +97,8 @@ def _action(_: HttpRequest) -> HttpResponseRedirect:
_action,
"Are you sure you want to reset this RDP?",
description=(
"This will set all related households and individuals to removed=False "
"and mark the RDP status as CANCELLED. This action cannot be undone."
"It will set all related households and individuals to removed=False "
"and mark the RDP status as REJECTED. This action cannot be undone."
),
success_message="RDP reset successfully. Related beneficiaries marked as not removed.",
pk=str(pk),
Expand Down
Empty file.
20 changes: 20 additions & 0 deletions src/country_workspace/api/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import Any, cast

from django.utils import timezone
from rest_framework.authentication import TokenAuthentication
from rest_framework.exceptions import AuthenticationFailed

from country_workspace.models import APIToken


class APITokenAuthentication(TokenAuthentication):
model = APIToken

def authenticate_credentials(self, key: str) -> tuple[Any, APIToken]:
user, token = super().authenticate_credentials(key)
token = cast("APIToken", token)

if not token.is_valid_at(timezone.now()):
raise AuthenticationFailed("Token is expired or not active yet.")

return user, token
12 changes: 12 additions & 0 deletions src/country_workspace/api/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.views import APIView

from country_workspace.models import APIToken


class CanCallHopeRdiCallback(BasePermission):
message = "Token does not grant access to the HOPE RDI callback."

def has_permission(self, request: Request, view: APIView) -> bool:
return isinstance(request.auth, APIToken) and request.auth.grant_type == APIToken.GrantType.HOPE_RDI_CALLBACK
15 changes: 15 additions & 0 deletions src/country_workspace/api/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from rest_framework import serializers

from country_workspace.models import Rdp


class HopeRdiCallbackSerializer(serializers.Serializer):
status = serializers.ChoiceField(
choices=(
Rdp.PushStatus.MERGED,
Rdp.PushStatus.REJECTED,
)
)

def validate_status(self, value: str) -> Rdp.PushStatus:
return Rdp.PushStatus(value)
11 changes: 11 additions & 0 deletions src/country_workspace/api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from rest_framework.routers import SimpleRouter

from .views import HopeRdiViewSet


app_name = "api"

router = SimpleRouter()
router.register("hope-rdis", HopeRdiViewSet, basename="hope-rdi")

urlpatterns = router.urls
61 changes: 61 additions & 0 deletions src/country_workspace/api/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from http import HTTPMethod
from typing import Any, cast, TYPE_CHECKING

from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response

from country_workspace.contrib.hope.exceptions import (
HopeRdiCallbackConflictError,
HopeRdiCallbackError,
HopeRdiCallbackNotFoundError,
)
from country_workspace.contrib.hope.push import apply_hope_rdi_final_status

from .authentication import APITokenAuthentication
from .permissions import CanCallHopeRdiCallback
from .serializers import HopeRdiCallbackSerializer

if TYPE_CHECKING:
from country_workspace.models import APIToken, Rdp


def _error_payload(exc: HopeRdiCallbackError) -> dict[str, Any]:
return exc.args[0] if exc.args and isinstance(exc.args[0], dict) else {"errors": [str(exc)]}


class HopeRdiViewSet(viewsets.GenericViewSet):
authentication_classes = (APITokenAuthentication,)
permission_classes = (CanCallHopeRdiCallback,)
serializer_class = HopeRdiCallbackSerializer
lookup_url_kwarg = "hope_rdi_id"

@action(detail=True, methods=(HTTPMethod.POST,), url_path="callback")
def callback(self, request: Request, hope_rdi_id: str) -> Response:
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)

token = cast("APIToken", request.auth)
callback_status: Rdp.PushStatus = serializer.validated_data["status"]

try:
rdp = apply_hope_rdi_final_status(
hope_rdi_id=hope_rdi_id,
status=callback_status,
token=token,
)
except HopeRdiCallbackNotFoundError as exc:
return Response(_error_payload(exc), status=status.HTTP_404_NOT_FOUND)
except HopeRdiCallbackConflictError as exc:
return Response(_error_payload(exc), status=status.HTTP_409_CONFLICT)
except HopeRdiCallbackError as exc:
return Response(_error_payload(exc), status=status.HTTP_400_BAD_REQUEST)

return Response(
{
"rdp_id": rdp.pk,
"rdi_id": rdp.hope_rdi_id,
"status": rdp.status,
}
)
Loading
Loading