diff --git a/docs/src/flows/rdp_lifecycle.md b/docs/src/flows/rdp_lifecycle.md
index af9422b3..b03444c4 100644
--- a/docs/src/flows/rdp_lifecycle.md
+++ b/docs/src/flows/rdp_lifecycle.md
@@ -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.
@@ -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.
- CW pushed data to HOPE Core successfully.
- HOPE Core automerge is assumed.
+ note right of PUSHED
+ CW pushed data to HOPE Core successfully.
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.
- Can be set by admin cancel, DE set reject,
+ note right of MERGED
+ Final merged RDP status.
+ HOPE-side merge was confirmed.
+ end note
+
+ note right of REJECTED
+ Final rejected RDP status.
+ Can be set by DE set reject,
Clone replacing PENDING source, or Staff Reset.
end note
```
@@ -60,25 +64,24 @@ flowchart TB
D1 -- "error" --> PENDING
PENDING --> RDS["Reject Deduplication Set
DE REST API"]
- RDS -->|rejected| CANCELLED["CANCELLED
final cancelled state"]
+ RDS -->|rejected| REJECTED["REJECTED
final rejected state"]
RDS -->|error| PENDING
PENDING --> HOPE_PUSH["Push to HOPE
create RDI / push / complete"]
HOPE_PUSH -->|failed| FAILURE["FAILURE
push workflow failed"]
- HOPE_PUSH -->|succeeded| SUCCESS["SUCCESS
pushed to HOPE Core
automerge assumed"]
-
- SUCCESS -. "post-push" .-> DE_APPROVE["Approve Deduplication Set
DE REST API"]
- DE_APPROVE -. "status unchanged" .-> SUCCESS
+ HOPE_PUSH -->|succeeded| PUSHED["PUSHED
pushed to HOPE Core"]
- SUCCESS --> RESET["Staff admin Reset"]
- RESET -->|HOPE rejection confirmed| CANCELLED
+ PUSHED -. "post-push" .-> DE_APPROVE["Approve Deduplication Set
DE REST API"]
+ DE_APPROVE -. "status unchanged" .-> PUSHED
- PENDING -->|admin cancels RDP| CANCELLED
+ PUSHED --> MERGED["MERGED
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
child becomes PENDING"]
- CL1 -- "FAILURE / CANCELLED" --> CL3["source unchanged
child becomes PENDING"]
- CL1 -- "SUCCESS" --> CL4["clone blocked"]
+ CL1 -- "PENDING" --> CL2["source becomes REJECTED
child becomes PENDING"]
+ CL1 -- "FAILURE / REJECTED" --> CL3["source unchanged
child becomes PENDING"]
+ CL1 -- "PUSHED / MERGED" --> CL4["clone blocked"]
CL2 --> PENDING
CL3 --> PENDING
@@ -86,31 +89,31 @@ flowchart TB
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
@@ -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
@@ -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.
diff --git a/pyproject.toml b/pyproject.toml
index 645d524c..0728964c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ requires = [
[project]
name = "hope-country-workspace"
-version = "0.4.8"
+version = "0.4.9"
description = "HOPE Country Workspace (HCW)"
readme = "README.md"
license = { text = "MIT" }
@@ -51,8 +51,10 @@ 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-api-auth>=0.1",
"hope-flex-fields>=0.8.1",
"hope-smart-export>=0.3",
"hope-smart-import>=0.5",
diff --git a/src/country_workspace/admin/__init__.py b/src/country_workspace/admin/__init__.py
index 136c0737..52dbb077 100644
--- a/src/country_workspace/admin/__init__.py
+++ b/src/country_workspace/admin/__init__.py
@@ -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
@@ -32,6 +33,7 @@
site.register_panel(panel_redis)
__all__ = [
+ "APITokenAdmin",
"AreaAdmin",
"AreaTypeAdmin",
"AsyncJobAdmin",
diff --git a/src/country_workspace/admin/api_token.py b/src/country_workspace/admin/api_token.py
new file mode 100644
index 00000000..37df6cc7
--- /dev/null
+++ b/src/country_workspace/admin/api_token.py
@@ -0,0 +1,26 @@
+from django.contrib import admin
+from django.http import HttpRequest
+from hope_api_auth.admin import APITokenAdmin as BaseAPITokenAdmin
+from hope_api_auth.admin import APITokenForm as BaseAPITokenForm
+
+from country_workspace.models import APIToken
+
+
+class APITokenForm(BaseAPITokenForm):
+ class Meta(BaseAPITokenForm.Meta):
+ model = APIToken
+ fields = (*BaseAPITokenForm.Meta.fields, "offices")
+
+
+@admin.register(APIToken)
+class APITokenAdmin(BaseAPITokenAdmin):
+ form = APITokenForm
+ filter_horizontal = (
+ *getattr(BaseAPITokenAdmin, "filter_horizontal", ()),
+ "offices",
+ )
+ search_fields = (*BaseAPITokenAdmin.search_fields, "offices__name")
+
+ def get_fields(self, request: HttpRequest, obj: APIToken | None = None) -> tuple[str, ...]:
+ fields = super().get_fields(request, obj)
+ return (*fields, "offices") if "offices" not in fields else fields
diff --git a/src/country_workspace/admin/rdp.py b/src/country_workspace/admin/rdp.py
index 4a1af2ae..dc6399df 100644
--- a/src/country_workspace/admin/rdp.py
+++ b/src/country_workspace/admin/rdp.py
@@ -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(
@@ -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),
diff --git a/src/country_workspace/api/__init__.py b/src/country_workspace/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/country_workspace/api/grants.py b/src/country_workspace/api/grants.py
new file mode 100644
index 00000000..7f4607b1
--- /dev/null
+++ b/src/country_workspace/api/grants.py
@@ -0,0 +1,5 @@
+from enum import StrEnum
+
+
+class APIGrant(StrEnum):
+ HOPE_RDI_CALLBACK = "HOPE_RDI_CALLBACK"
diff --git a/src/country_workspace/api/serializers.py b/src/country_workspace/api/serializers.py
new file mode 100644
index 00000000..79632f42
--- /dev/null
+++ b/src/country_workspace/api/serializers.py
@@ -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)
diff --git a/src/country_workspace/api/urls.py b/src/country_workspace/api/urls.py
new file mode 100644
index 00000000..e4717e0e
--- /dev/null
+++ b/src/country_workspace/api/urls.py
@@ -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
diff --git a/src/country_workspace/api/views.py b/src/country_workspace/api/views.py
new file mode 100644
index 00000000..7318b72e
--- /dev/null
+++ b/src/country_workspace/api/views.py
@@ -0,0 +1,63 @@
+from http import HTTPMethod
+from typing import Any, TYPE_CHECKING, cast
+
+from hope_api_auth.auth import GrantedPermission, LoggingTokenAuthentication
+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 HopeRdiCallbackCode, HopeRdiCallbackPayload, apply_hope_rdi_final_status
+from .grants import APIGrant
+from .serializers import HopeRdiCallbackSerializer
+
+if TYPE_CHECKING:
+ from country_workspace.models import APIToken
+
+
+def _callback_payload(exc: HopeRdiCallbackError) -> dict[str, Any]:
+ payload = exc.args[0] if exc.args else None
+ if isinstance(payload, HopeRdiCallbackPayload):
+ return payload.as_dict()
+ if isinstance(payload, dict):
+ return payload
+ return HopeRdiCallbackPayload.error(
+ code=HopeRdiCallbackCode.CALLBACK_ERROR,
+ detail=str(exc),
+ ).as_dict()
+
+
+class HopeRdiViewSet(viewsets.GenericViewSet):
+ authentication_classes = (LoggingTokenAuthentication,)
+ permission_classes = (GrantedPermission,)
+ permission = APIGrant.HOPE_RDI_CALLBACK
+ 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 = serializer.validated_data["status"]
+
+ try:
+ payload = apply_hope_rdi_final_status(
+ hope_rdi_id=hope_rdi_id,
+ status=callback_status,
+ token=token,
+ )
+ except HopeRdiCallbackNotFoundError as exc:
+ return Response(_callback_payload(exc), status=status.HTTP_404_NOT_FOUND)
+ except HopeRdiCallbackConflictError as exc:
+ return Response(_callback_payload(exc), status=status.HTTP_409_CONFLICT)
+ except HopeRdiCallbackError as exc:
+ return Response(_callback_payload(exc), status=status.HTTP_400_BAD_REQUEST)
+
+ return Response(payload.as_dict())
diff --git a/src/country_workspace/config/fragments/api_auth.py b/src/country_workspace/config/fragments/api_auth.py
new file mode 100644
index 00000000..d0e175a7
--- /dev/null
+++ b/src/country_workspace/config/fragments/api_auth.py
@@ -0,0 +1,2 @@
+API_AUTH_GRANT_CLASS = "country_workspace.api.grants.APIGrant"
+HOPE_API_AUTH_APITOKEN_MODEL = "country_workspace.APIToken"
diff --git a/src/country_workspace/config/settings.py b/src/country_workspace/config/settings.py
index c293e4d2..5709af8b 100644
--- a/src/country_workspace/config/settings.py
+++ b/src/country_workspace/config/settings.py
@@ -25,6 +25,7 @@
"django.contrib.staticfiles",
"django.contrib.postgres",
"unicef_security",
+ "hope_api_auth",
"country_workspace.apps.HCWAdminConfig",
# ddt
"debug_toolbar",
@@ -41,10 +42,13 @@
"adminfilters",
"adminfilters.depot",
"constance",
+ "rest_framework",
"jsoneditor",
"django_celery_beat",
"django_celery_results",
"django_celery_boost",
+ "drf_spectacular",
+ "drf_spectacular_sidecar",
"hope_flex_fields",
"hope_smart_import",
"hope_smart_export",
@@ -213,6 +217,7 @@
SUPERUSERS = env("SUPERUSERS")
from .fragments.app import * # noqa: E402, F403
+from .fragments.api_auth import * # noqa: E402, F403
from .fragments.celery import * # noqa: E402, F403
from .fragments.constance import * # noqa: E402, F403
from .fragments.csp import * # noqa: E402, F403
diff --git a/src/country_workspace/config/urls.py b/src/country_workspace/config/urls.py
index 3e44cbc7..6bd9ab01 100644
--- a/src/country_workspace/config/urls.py
+++ b/src/country_workspace/config/urls.py
@@ -9,6 +9,7 @@
urlpatterns = [
path(r"admin/", admin.site.urls),
+ path("api/", include("country_workspace.api.urls", namespace="api")),
path(r"security/", include("unicef_security.urls", namespace="security")),
path(r"social/", include("social_django.urls", namespace="social")),
path(r"accounts/", include("django.contrib.auth.urls")),
diff --git a/src/country_workspace/contrib/hope/exceptions.py b/src/country_workspace/contrib/hope/exceptions.py
index 397f734a..9b27cea9 100644
--- a/src/country_workspace/contrib/hope/exceptions.py
+++ b/src/country_workspace/contrib/hope/exceptions.py
@@ -2,6 +2,18 @@ class HopePushError(Exception):
"""Exception raised for errors during the push process."""
+class HopeRdiCallbackError(Exception):
+ """Exception raised for errors during HOPE RDI callback processing."""
+
+
+class HopeRdiCallbackNotFoundError(HopeRdiCallbackError):
+ """Exception raised when no RDP matches the HOPE RDI ID."""
+
+
+class HopeRdiCallbackConflictError(HopeRdiCallbackError):
+ """Exception raised when the HOPE RDI callback conflicts with the current RDP state."""
+
+
class HopeSyncError(Exception):
"""Exception raised for errors during the synchronization process."""
diff --git a/src/country_workspace/contrib/hope/push/__init__.py b/src/country_workspace/contrib/hope/push/__init__.py
index 81ececa8..5854ee89 100644
--- a/src/country_workspace/contrib/hope/push/__init__.py
+++ b/src/country_workspace/contrib/hope/push/__init__.py
@@ -1,5 +1,7 @@
-from .config import CreateRdpConfig, PushExistingRdpConfig
+from .config import CreateRdpConfig, PushExistingRdpConfig, HopeRdiCallbackCode
from .orchestration import (
+ HopeRdiCallbackPayload,
+ apply_hope_rdi_final_status,
claim_rdp_deduplication,
clone_rdp_core,
create_rdp_core,
@@ -14,8 +16,11 @@
__all__ = [
"CreateRdpConfig",
"DedupEngineState",
+ "HopeRdiCallbackCode",
+ "HopeRdiCallbackPayload",
"PushExistingRdpConfig",
"PushProcessor",
+ "apply_hope_rdi_final_status",
"claim_rdp_deduplication",
"clone_rdp_core",
"create_rdp_core",
diff --git a/src/country_workspace/contrib/hope/push/config.py b/src/country_workspace/contrib/hope/push/config.py
index a0d2f5eb..d041a2b2 100644
--- a/src/country_workspace/contrib/hope/push/config.py
+++ b/src/country_workspace/contrib/hope/push/config.py
@@ -55,6 +55,16 @@ class Route(StrEnum):
}
+class HopeRdiCallbackCode(StrEnum):
+ FINALIZED = auto()
+ ALREADY_FINALIZED = auto()
+ MISSING_RDI_ID = auto()
+ UNSUPPORTED_STATUS = auto()
+ NOT_FOUND = auto()
+ INVALID_TRANSITION = auto()
+ CALLBACK_ERROR = auto()
+
+
class ErrorConfig(NamedTuple):
MAX_ERRORS: int = 300
MAX_ERROR_LEN: int = 2000
diff --git a/src/country_workspace/contrib/hope/push/orchestration.py b/src/country_workspace/contrib/hope/push/orchestration.py
index ea4cbe36..af519e4c 100644
--- a/src/country_workspace/contrib/hope/push/orchestration.py
+++ b/src/country_workspace/contrib/hope/push/orchestration.py
@@ -11,16 +11,22 @@
DeduplicationSetState,
make_dedup_client,
)
-from country_workspace.contrib.hope.exceptions import HopePushError
+from country_workspace.contrib.hope.exceptions import (
+ HopePushError,
+ HopeRdiCallbackConflictError,
+ HopeRdiCallbackError,
+ HopeRdiCallbackNotFoundError,
+)
from country_workspace.exceptions import RemoteError, RemoteUnavailableError
-from country_workspace.models import AsyncJob, Rdp
+from country_workspace.models import APIToken, AsyncJob, Rdp
-from .config import CreateRdpConfig, PushWorkflowConfig
+from .config import CreateRdpConfig, HopeRdiCallbackCode, PushWorkflowConfig
from .policy import ActionCheck, get_rdp_policy
from .processor import DedupProcessor, PushProcessor
from .repository import (
has_other_pending_rdp,
lock_rdp_for_update,
+ lock_rdp_for_hope_callback,
preflight_errors,
preflight_exclude_rdp_ids,
qs_households,
@@ -43,6 +49,47 @@ class CloneDeduplicationContext(NamedTuple):
snapshot: dict[str, Any]
+class HopeRdiCallbackPayload(NamedTuple):
+ rdp_id: int | None
+ rdi_id: str | None
+ status: str | None
+ changed: bool
+ code: HopeRdiCallbackCode
+ detail: str
+
+ @classmethod
+ def finalized(cls, *, rdp: Rdp, changed: bool) -> "HopeRdiCallbackPayload":
+ return cls(
+ rdp_id=rdp.pk,
+ rdi_id=rdp.hope_rdi_id,
+ status=rdp.status,
+ changed=changed,
+ code=HopeRdiCallbackCode.FINALIZED if changed else HopeRdiCallbackCode.ALREADY_FINALIZED,
+ detail=f"RDP status updated to {rdp.status}." if changed else f"RDP is already {rdp.status}.",
+ )
+
+ @classmethod
+ def error(
+ cls,
+ *,
+ code: HopeRdiCallbackCode,
+ detail: str,
+ rdp: Rdp | None = None,
+ rdi_id: str | None = None,
+ ) -> "HopeRdiCallbackPayload":
+ return cls(
+ rdp_id=rdp.pk if rdp else None,
+ rdi_id=rdp.hope_rdi_id if rdp else rdi_id,
+ status=rdp.status if rdp else None,
+ changed=False,
+ code=code,
+ detail=detail,
+ )
+
+ def as_dict(self) -> dict[str, Any]:
+ return self._asdict()
+
+
def _require_policy_check(check: Callable[[], ActionCheck]) -> None:
try:
check().require()
@@ -191,8 +238,8 @@ def _clone_rdp_in_transaction(
try:
with transaction.atomic():
source = lock_rdp_for_update(pk=source.pk)
- if source.status == Rdp.PushStatus.SUCCESS:
- raise HopePushError({"errors": ["RDP: can not clone a successful RDP."]})
+ if source.status == Rdp.PushStatus.MERGED:
+ raise HopePushError({"errors": ["RDP: can not clone a merged RDP."]})
owner = selection_owner_for_rdp(rdp=source)
if owner.pk != source.pk:
owner = lock_rdp_for_update(pk=owner.pk)
@@ -212,7 +259,7 @@ def _clone_rdp_in_transaction(
update_fields: list[str] = []
if source.status == Rdp.PushStatus.PENDING:
- source.status = Rdp.PushStatus.CANCELLED
+ source.status = Rdp.PushStatus.REJECTED
update_fields.append("status")
if source.is_dedup_settings_locked:
source.is_dedup_settings_locked = False
@@ -229,7 +276,6 @@ def _clone_rdp_in_transaction(
status=Rdp.PushStatus.PENDING,
deduplication_set_id=context.clone_deduplication_set_id,
is_dedup_settings_locked=False,
- hope_rdi_id="",
)
except IntegrityError as e:
raise HopePushError({"errors": [f"RDP: can not clone record: {e}"]}) from e
@@ -253,8 +299,8 @@ def reject_deduplication_set_existing_rdp_core(job: AsyncJob) -> dict[str, Any]:
locked = lock_rdp_for_update(pk=rdp.pk)
set_rdp_push_status(
rdp=locked,
- status=Rdp.PushStatus.CANCELLED,
- hope_rdi_id=locked.hope_rdi_id or "N/A",
+ status=Rdp.PushStatus.REJECTED,
+ hope_rdi_id=locked.hope_rdi_id,
is_dedup_settings_locked=False,
)
@@ -312,17 +358,20 @@ def push_existing_rdp_core(job: AsyncJob) -> dict[str, Any]:
set_rdp_push_status(
rdp=locked,
status=Rdp.PushStatus.FAILURE,
- hope_rdi_id=hope_processor.hope_rdi_id or "N/A",
+ hope_rdi_id=hope_processor.hope_rdi_id,
)
raise HopePushError(hope_processor.total)
+ if not (hope_rdi_id := hope_processor.hope_rdi_id):
+ raise HopePushError({"errors": ["RDI id is not set after successful push."]})
+
with transaction.atomic():
locked = lock_rdp_for_update(pk=rdp.pk)
_mark_rdp_beneficiaries_removed(locked, config["master_detail"])
set_rdp_push_status(
rdp=locked,
- status=Rdp.PushStatus.SUCCESS,
- hope_rdi_id=hope_processor.hope_rdi_id or "N/A",
+ status=Rdp.PushStatus.PUSHED,
+ hope_rdi_id=hope_rdi_id,
)
group_reference_id = locked.program.unicef_id
deduplication_set_id = locked.deduplication_set_id
@@ -353,3 +402,78 @@ def _approve_deduplication_set_after_successful_push(
client.approve()
except (RemoteError, RemoteUnavailableError) as e:
processor.fail("DedupEngine", f"approve failed. {e}")
+
+
+def _mark_rdp_beneficiaries_not_removed(rdp: Rdp) -> None:
+ """Mark selection-owner beneficiaries as not removed."""
+ owner = selection_owner_for_rdp(rdp=rdp)
+ hh_ids = list(owner.households.values_list("pk", flat=True))
+ if hh_ids:
+ owner.households.update(removed=False)
+ qs_individuals_by_household_pks(hh_ids).update(removed=False)
+ return
+ owner.individuals.update(removed=False)
+
+
+def apply_hope_rdi_final_status(
+ *,
+ hope_rdi_id: str,
+ status: Rdp.PushStatus,
+ token: APIToken,
+) -> HopeRdiCallbackPayload:
+ """Apply final HOPE Core RDI status to a pushed CW RDP."""
+ if not hope_rdi_id:
+ raise HopeRdiCallbackError(
+ HopeRdiCallbackPayload.error(
+ code=HopeRdiCallbackCode.MISSING_RDI_ID,
+ detail="RDI id is required.",
+ )
+ )
+
+ if status not in {Rdp.PushStatus.MERGED, Rdp.PushStatus.REJECTED}:
+ raise HopeRdiCallbackError(
+ HopeRdiCallbackPayload.error(
+ code=HopeRdiCallbackCode.UNSUPPORTED_STATUS,
+ detail="Unsupported RDI final status.",
+ rdi_id=hope_rdi_id,
+ )
+ )
+
+ with transaction.atomic():
+ try:
+ rdp = lock_rdp_for_hope_callback(
+ hope_rdi_id=hope_rdi_id,
+ token=token,
+ )
+ except Rdp.DoesNotExist as exc:
+ raise HopeRdiCallbackNotFoundError(
+ HopeRdiCallbackPayload.error(
+ code=HopeRdiCallbackCode.NOT_FOUND,
+ detail="RDP not found for RDI id.",
+ rdi_id=hope_rdi_id,
+ )
+ ) from exc
+
+ if rdp.status == status:
+ return HopeRdiCallbackPayload.finalized(rdp=rdp, changed=False)
+
+ if rdp.status != Rdp.PushStatus.PUSHED:
+ raise HopeRdiCallbackConflictError(
+ HopeRdiCallbackPayload.error(
+ code=HopeRdiCallbackCode.INVALID_TRANSITION,
+ detail=f"RDP can not be finalized from status={rdp.status}.",
+ rdp=rdp,
+ )
+ )
+
+ if status == Rdp.PushStatus.REJECTED:
+ _mark_rdp_beneficiaries_not_removed(rdp)
+
+ set_rdp_push_status(
+ rdp=rdp,
+ status=status,
+ hope_rdi_id=hope_rdi_id,
+ is_dedup_settings_locked=False if status == Rdp.PushStatus.REJECTED else None,
+ )
+
+ return HopeRdiCallbackPayload.finalized(rdp=rdp, changed=True)
diff --git a/src/country_workspace/contrib/hope/push/policy.py b/src/country_workspace/contrib/hope/push/policy.py
index d84d7fa0..dfcced7b 100644
--- a/src/country_workspace/contrib/hope/push/policy.py
+++ b/src/country_workspace/contrib/hope/push/policy.py
@@ -77,7 +77,7 @@ def update_dedup_settings_check(self) -> ActionCheck:
if self._has_locked_dedup_settings():
return ActionCheck(
False,
- "Deduplication settings cannot be updated after a successful RDP "
+ "Deduplication settings cannot be updated after an RDP was pushed or merged "
"or while a pending RDP has requested a new deduplication run.",
)
@@ -86,7 +86,10 @@ def update_dedup_settings_check(self) -> ActionCheck:
def _has_locked_dedup_settings(self) -> bool:
return (
Rdp.objects.filter(program=self.program)
- .filter(Q(status=Rdp.PushStatus.SUCCESS) | Q(status=Rdp.PushStatus.PENDING, is_dedup_settings_locked=True))
+ .filter(
+ Q(status__in=[Rdp.PushStatus.MERGED, Rdp.PushStatus.PUSHED])
+ | Q(status=Rdp.PushStatus.PENDING, is_dedup_settings_locked=True)
+ )
.exists()
)
@@ -222,8 +225,8 @@ def _clone_deduplication_check(self) -> ActionCheck:
def clone_check(self) -> ActionCheck:
if not self.is_biometric_deduplication_enabled:
return ActionCheck(False, "DedupEngine: biometric deduplication is not enabled for this program.")
- if self.rdp.status == Rdp.PushStatus.SUCCESS:
- return ActionCheck(False, "RDP: can not clone a successful RDP.")
+ if self.rdp.status in [Rdp.PushStatus.PUSHED, Rdp.PushStatus.MERGED]:
+ return ActionCheck(False, f"RDP: can not clone in status={self.rdp.status}")
exclude_ids = (self.rdp.pk,) if self.is_pending else ()
if has_other_pending_rdp(owner=self.owner, exclude_ids=exclude_ids):
diff --git a/src/country_workspace/contrib/hope/push/repository.py b/src/country_workspace/contrib/hope/push/repository.py
index 438008b8..5fea9449 100644
--- a/src/country_workspace/contrib/hope/push/repository.py
+++ b/src/country_workspace/contrib/hope/push/repository.py
@@ -4,7 +4,7 @@
from django.db.models import Exists, OuterRef, Prefetch, QuerySet
from country_workspace.contrib.hope.constants import PUSH_BATCH_SIZE
-from country_workspace.models import Program, Rdp
+from country_workspace.models import APIToken, Program, Rdp
from country_workspace.workspaces.models import CountryHousehold, CountryIndividual
from .config import PushWorkflowConfig, Serializer
@@ -123,7 +123,7 @@ def preflight_errors(
if not pks:
return ["RDP: no beneficiaries selected"]
- rdp_qs = Rdp.objects.filter(status__in=[Rdp.PushStatus.PENDING, Rdp.PushStatus.SUCCESS])
+ rdp_qs = Rdp.objects.filter(status__in=[Rdp.PushStatus.PENDING, Rdp.PushStatus.PUSHED, Rdp.PushStatus.MERGED])
if excluded := tuple(exclude_rdp_ids):
rdp_qs = rdp_qs.exclude(pk__in=excluded)
@@ -134,7 +134,7 @@ def collect(rows: QuerySet, tag: str) -> list[str]:
if not _is_valid_row(last_checked=last_checked, errors=obj_errors):
errors.append(f"{base} invalid")
if has_rdp:
- errors.append(f"{base} already in another RDP(s) (pending/success)")
+ errors.append(f"{base} already in another RDP(s) (pending/pushed/merged)")
return errors
def individual_rows() -> QuerySet:
@@ -163,7 +163,11 @@ def individual_rows() -> QuerySet:
def set_rdp_push_status(
- *, rdp: Rdp, status: Rdp.PushStatus, hope_rdi_id: str, is_dedup_settings_locked: bool | None = None
+ *,
+ rdp: Rdp,
+ status: Rdp.PushStatus,
+ hope_rdi_id: str | None,
+ is_dedup_settings_locked: bool | None = None,
) -> None:
"""Persist push status fields for an already-locked RDP."""
rdp.status = status
@@ -190,3 +194,21 @@ def has_other_pending_rdp(*, owner: Rdp, exclude_ids: Iterable[int] = ()) -> boo
if excluded := tuple(exclude_ids):
qs = qs.exclude(pk__in=excluded)
return qs.exists()
+
+
+def has_other_active_rdp(*, owner: Rdp, exclude_ids: Iterable[int] = ()) -> bool:
+ qs = Rdp.objects.filter(
+ program_id=owner.program_id,
+ status__in=[Rdp.PushStatus.PENDING, Rdp.PushStatus.PUSHED],
+ )
+ if excluded := tuple(exclude_ids):
+ qs = qs.exclude(pk__in=excluded)
+ return qs.exists()
+
+
+def lock_rdp_for_hope_callback(*, hope_rdi_id: str, token: APIToken) -> Rdp:
+ """Return an office-scoped RDP locked for a HOPE callback."""
+ return Rdp.objects.select_for_update().get(
+ hope_rdi_id=hope_rdi_id,
+ country_office__api_tokens=token,
+ )
diff --git a/src/country_workspace/migrations/0055_alter_rdp_status.py b/src/country_workspace/migrations/0055_alter_rdp_status.py
new file mode 100644
index 00000000..2fca4470
--- /dev/null
+++ b/src/country_workspace/migrations/0055_alter_rdp_status.py
@@ -0,0 +1,60 @@
+from collections.abc import Mapping
+from django.db import migrations, models
+from django.db.migrations.state import StateApps
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+
+def _remap_rdp_statuses(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor, mapping: Mapping[str, str]) -> None:
+ Rdp = apps.get_model("country_workspace", "Rdp")
+ db_alias = schema_editor.connection.alias
+ for old, new in mapping.items():
+ Rdp.objects.using(db_alias).filter(status=old).update(status=new)
+
+
+def forward_rdp_statuses(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ _remap_rdp_statuses(
+ apps,
+ schema_editor,
+ {
+ "SUCCESS": "PUSHED",
+ "CANCELLED": "REJECTED",
+ },
+ )
+
+
+def backward_rdp_statuses(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ _remap_rdp_statuses(
+ apps,
+ schema_editor,
+ {
+ "PUSHED": "SUCCESS",
+ "MERGED": "SUCCESS",
+ "REJECTED": "CANCELLED",
+ },
+ )
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("country_workspace", "0054_rdp_deduplication_snapshots_and_more"),
+ ]
+
+ operations = [
+ migrations.RunPython(forward_rdp_statuses, backward_rdp_statuses),
+ migrations.AlterField(
+ model_name="rdp",
+ name="status",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("PENDING", "Pending"),
+ ("FAILURE", "Failure"),
+ ("PUSHED", "Pushed"),
+ ("MERGED", "Merged"),
+ ("REJECTED", "Rejected"),
+ ],
+ default="PENDING",
+ max_length=10,
+ ),
+ ),
+ ]
diff --git a/src/country_workspace/migrations/0056_rdp_uniq_rdp_hope_rdi_id.py b/src/country_workspace/migrations/0056_rdp_uniq_rdp_hope_rdi_id.py
new file mode 100644
index 00000000..aee50f81
--- /dev/null
+++ b/src/country_workspace/migrations/0056_rdp_uniq_rdp_hope_rdi_id.py
@@ -0,0 +1,39 @@
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.db.migrations.state import StateApps
+
+
+def validate_unique_hope_rdi_id(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ Rdp = apps.get_model("country_workspace", "Rdp")
+ db_alias = schema_editor.connection.alias
+
+ duplicates = list(
+ Rdp.objects.using(db_alias)
+ .filter(hope_rdi_id__isnull=False)
+ .values("hope_rdi_id")
+ .annotate(count=models.Count("pk"))
+ .filter(count__gt=1)
+ .order_by("hope_rdi_id")
+ )
+ if duplicates:
+ details = ", ".join(f"{item['hope_rdi_id']} ({item['count']})" for item in duplicates[:10])
+ raise RuntimeError(f"Cannot add unique RDP hope_rdi_id constraint. Duplicates found: {details}")
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("country_workspace", "0055_alter_rdp_status"),
+ ]
+
+ operations = [
+ migrations.RunPython(validate_unique_hope_rdi_id, migrations.RunPython.noop),
+ migrations.AddConstraint(
+ model_name="rdp",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("hope_rdi_id__isnull", False)),
+ fields=("hope_rdi_id",),
+ name="uniq_rdp_hope_rdi_id",
+ violation_error_message="There is already an RDP for this HOPE RDI.",
+ ),
+ ),
+ ]
diff --git a/src/country_workspace/migrations/0057_apitoken.py b/src/country_workspace/migrations/0057_apitoken.py
new file mode 100644
index 00000000..d378d41c
--- /dev/null
+++ b/src/country_workspace/migrations/0057_apitoken.py
@@ -0,0 +1,43 @@
+# Generated by Django 5.2.14 on 2026-06-08 08:36
+
+import django.db.models.deletion
+import django.utils.timezone
+import hope_api_auth.fields
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("country_workspace", "0056_rdp_uniq_rdp_hope_rdi_id"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="APIToken",
+ fields=[
+ ("key", models.CharField(max_length=40, primary_key=True, serialize=False, verbose_name="Key")),
+ ("created", models.DateTimeField(auto_now_add=True, verbose_name="Created")),
+ ("allowed_ips", models.CharField(blank=True, max_length=200, null=True, verbose_name="IPs")),
+ ("valid_from", models.DateField(default=django.utils.timezone.now)),
+ ("valid_to", models.DateField(blank=True, null=True)),
+ (
+ "grants",
+ hope_api_auth.fields.ChoiceArrayField(base_field=models.CharField(max_length=255), size=None),
+ ),
+ ("offices", models.ManyToManyField(related_name="api_tokens", to="country_workspace.office")),
+ (
+ "user",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="auth_token",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="User",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/src/country_workspace/models/__init__.py b/src/country_workspace/models/__init__.py
index cf1e5485..737996e0 100644
--- a/src/country_workspace/models/__init__.py
+++ b/src/country_workspace/models/__init__.py
@@ -1,3 +1,4 @@
+from .api_token import APIToken
from .batch import Batch
from .beneficiary_group import BeneficiaryGroup
from .data_serializer import DataSerializer
@@ -16,6 +17,7 @@
from .user import User
__all__ = [
+ "APIToken",
"Area",
"AreaType",
"AsyncJob",
diff --git a/src/country_workspace/models/api_token.py b/src/country_workspace/models/api_token.py
new file mode 100644
index 00000000..35c8f4bf
--- /dev/null
+++ b/src/country_workspace/models/api_token.py
@@ -0,0 +1,13 @@
+from django.db import models
+
+from hope_api_auth.models import AbstractAPIToken
+
+from .office import Office
+
+
+class APIToken(AbstractAPIToken):
+ offices = models.ManyToManyField(Office, related_name="api_tokens")
+
+ def __str__(self) -> str:
+ grants = ", ".join(self.grants) if self.grants else "no grants"
+ return f"API token for {self.user} ({grants})"
diff --git a/src/country_workspace/models/rdp.py b/src/country_workspace/models/rdp.py
index 3d2c49f4..dad55cca 100644
--- a/src/country_workspace/models/rdp.py
+++ b/src/country_workspace/models/rdp.py
@@ -11,9 +11,10 @@ class Rdp(BaseModel):
class PushStatus(models.TextChoices):
PENDING = "PENDING", _("Pending")
- SUCCESS = "SUCCESS", _("Success")
FAILURE = "FAILURE", _("Failure")
- CANCELLED = "CANCELLED", _("Cancelled")
+ PUSHED = "PUSHED", _("Pushed")
+ MERGED = "MERGED", _("Merged")
+ REJECTED = "REJECTED", _("Rejected")
class DedupTrackingState(models.TextChoices):
NOT_RUN = "NOT_RUN", _("Not run yet")
@@ -58,6 +59,12 @@ class Meta:
name="uniq_pending_rdp_per_program",
violation_error_message=_("There is already an active (PENDING) RDP for this program."),
),
+ models.UniqueConstraint(
+ fields=["hope_rdi_id"],
+ condition=Q(hope_rdi_id__isnull=False),
+ name="uniq_rdp_hope_rdi_id",
+ violation_error_message=_("There is already an RDP for this HOPE RDI."),
+ ),
]
permissions = [
("reset_rdp", _("Can reset RDP")),
diff --git a/src/country_workspace/workspaces/admin/rdp.py b/src/country_workspace/workspaces/admin/rdp.py
index 3089ad9e..65c503f2 100644
--- a/src/country_workspace/workspaces/admin/rdp.py
+++ b/src/country_workspace/workspaces/admin/rdp.py
@@ -300,7 +300,7 @@ def push(self, request: HttpRequest, pk: str) -> HttpResponse:
@link(change_list=False, html_attrs={"title": "Shows related beneficiary records."})
def records(self, btn: LinkButton) -> None:
obj = btn.context["original"]
- if obj.status == CountryRdp.PushStatus.SUCCESS:
+ if obj.status == CountryRdp.PushStatus.MERGED:
btn.visible = False
return
item = "countryhousehold" if obj.program.beneficiary_group.master_detail else "countryindividual"
diff --git a/uv.lock b/uv.lock
index e8e44492..3d845ce3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1325,6 +1325,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
]
+[[package]]
+name = "drf-spectacular"
+version = "0.29.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+ { name = "djangorestframework" },
+ { name = "inflection" },
+ { name = "jsonschema" },
+ { name = "pyyaml" },
+ { name = "uritemplate" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/0e/a4f50d83e76cbe797eda88fc0083c8ca970cfa362b5586359ef06ec6f70a/drf_spectacular-0.29.0.tar.gz", hash = "sha256:0a069339ea390ce7f14a75e8b5af4a0860a46e833fd4af027411a3e94fc1a0cc", size = 241722, upload-time = "2025-11-02T03:40:26.348Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/d9/502c56fc3ca960075d00956283f1c44e8cafe433dada03f9ed2821f3073b/drf_spectacular-0.29.0-py3-none-any.whl", hash = "sha256:d1ee7c9535d89848affb4427347f7c4a22c5d22530b8842ef133d7b72e19b41a", size = 105433, upload-time = "2025-11-02T03:40:24.823Z" },
+]
+
+[package.optional-dependencies]
+sidecar = [
+ { name = "drf-spectacular-sidecar" },
+]
+
+[[package]]
+name = "drf-spectacular-sidecar"
+version = "2026.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7f/d8/735b129e7d55c4f6c682ecedfcc0438816e1d859e5e1837f914ac4544f6d/drf_spectacular_sidecar-2026.6.1.tar.gz", hash = "sha256:e159874fa85ccee39b801e260f2a3585fbe36a0c79bf811824eef9010ab98ea9", size = 2589761, upload-time = "2026-06-01T16:45:30.851Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4f/46/10bcaf965edcb70e7647e62b88b9e0de266f8bea7a7a3224e8f977f77eab/drf_spectacular_sidecar-2026.6.1-py3-none-any.whl", hash = "sha256:4560572773c7e5f636d36cd2903204e2c59560af0548da254e311f94458c51a2", size = 2613235, upload-time = "2026-06-01T16:45:29.031Z" },
+]
+
[[package]]
name = "dukpy"
version = "0.5.1"
@@ -1556,9 +1590,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
+[[package]]
+name = "hope-api-auth"
+version = "0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+ { name = "django-admin-extra-buttons" },
+ { name = "django-adminfilters" },
+ { name = "django-smart-admin" },
+ { name = "djangorestframework" },
+ { name = "psycopg2-binary" },
+ { name = "swapper" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ef/86/e23c3f0893cdf65a26e1ae5a932f04a1777068c7622b12bc686b833f7e47/hope_api_auth-0.1.tar.gz", hash = "sha256:d62dccedfdb9ecb514954ae8e0949ae72a180b473282fa119a8a03412d7df188", size = 5881, upload-time = "2026-05-28T10:28:04.423Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/2c/5a56bbf61500af0a0fc2092f0bbbbc9a01fb9d28e2538c77bb96743c707a/hope_api_auth-0.1-py2.py3-none-any.whl", hash = "sha256:bb6102956ec5d8f2e04e131b28a0c36d67795f0aacb28f05f2f95c5fff951486", size = 8123, upload-time = "2026-05-28T10:28:03.382Z" },
+]
+
[[package]]
name = "hope-country-workspace"
-version = "0.4.8"
+version = "0.4.9"
source = { editable = "." }
dependencies = [
{ name = "bitcaster-sdk" },
@@ -1592,8 +1644,10 @@ dependencies = [
{ name = "django-sysinfo" },
{ name = "django-tailwind" },
{ name = "djangorestframework" },
+ { name = "drf-spectacular", extra = ["sidecar"] },
{ name = "dukpy" },
{ name = "flower" },
+ { name = "hope-api-auth" },
{ name = "hope-flex-fields" },
{ name = "hope-smart-export" },
{ name = "hope-smart-import" },
@@ -1689,8 +1743,10 @@ requires-dist = [
{ name = "django-sysinfo", specifier = ">=2.6.2" },
{ name = "django-tailwind", specifier = ">=3.8" },
{ name = "djangorestframework", specifier = ">=3.15.1" },
+ { name = "drf-spectacular", extras = ["sidecar"] },
{ name = "dukpy", specifier = ">=0.5" },
{ name = "flower", specifier = ">=2.0.1" },
+ { name = "hope-api-auth", specifier = ">=0.1" },
{ name = "hope-flex-fields", specifier = ">=0.8.1" },
{ name = "hope-smart-export", specifier = ">=0.3" },
{ name = "hope-smart-import", specifier = ">=0.5" },
@@ -4409,6 +4465,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
]
+[[package]]
+name = "swapper"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9b/3b/98ea1cfc04dc9805d58c5a96dd006f5d88a5a32b7b05e1f5a1c00363bb9a/swapper-1.4.0.tar.gz", hash = "sha256:9e083af114ee0593241a7b877e3e0e7d3a580454f5d59016c667a5563306f8fe", size = 12668, upload-time = "2024-08-14T19:36:07.539Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/53/c59363308ef97507a680372471e25e1ebab2e706a45a7c416eea6474c928/swapper-1.4.0-py2.py3-none-any.whl", hash = "sha256:57b8378aad234242542fe32dc6e8cff0ed24b63493d20b3c88ee01f894b9345e", size = 7106, upload-time = "2024-08-14T19:36:06.247Z" },
+]
+
[[package]]
name = "sympy"
version = "1.14.0"
@@ -4732,6 +4797,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" },
]
+[[package]]
+name = "uritemplate"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" },
+]
+
[[package]]
name = "urllib3"
version = "2.7.0"