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"