diff --git a/src/python/notificatiesbeheren/notification-preferences-example/README.md b/src/python/notificatiesbeheren/notification-preferences-example/README.md new file mode 100644 index 0000000..a2594dd --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/README.md @@ -0,0 +1,121 @@ +# Notification preferences transformation service - Python example + +Reference implementation for the Service Engine `GET /customers/notificationpreferences` endpoint. It composes raw CRUD API data into the frontend-friendly response model from `SE-notifications.yaml`. + +## Supplied contracts used + +- `notifications-crud.yaml` + - `GET /notificationsubscriptions?customerProfileId={id}&expand=eventTypeChannel` + - `GET /notificationcategories?notificationCategoryId={id}&expand=eventTypeChannel` +- `customers-crud-v2.yaml` + - `GET /ovpaytokens?ovPayTokenId={id}` + - `GET /devices?customerProfileId={id}` + - `GET /customers?customerProfileId={id}` for person/email resource data +- `SE-notifications.yaml` + - `GET /customers/notificationpreferences` + +## Runtime options + +### Local FastAPI with real CRUD APIs + +```powershell +.\.venv\Scripts\python.exe -m pip install -r requirements.txt +$env:NOTIFICATIONS_CRUD_BASE_URL="https://your-notifications-crud-api.example.nl" +$env:CUSTOMERS_CRUD_BASE_URL="https://your-customers-crud-api.example.nl" +.\.venv\Scripts\python.exe -m uvicorn app.entrypoints.fastapi_app:app --reload +``` + +Call: + +```powershell +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8000/customers/notificationpreferences?deviceId=device-1" ` + -Headers @{"X-HTM-CUSTOMER-PROFILE-ID-HEADER"="42"} +``` + +### Local FastAPI with mock data + +Use mock mode when you want to demo or test the transformation without live CRUD APIs. + +```powershell +$env:USE_MOCK_DATA="true" +$env:MOCK_SCENARIO="mixed" +.\.venv\Scripts\python.exe -m uvicorn app.entrypoints.fastapi_app:app --reload +``` + +Open: + +```text +http://127.0.0.1:8000/docs +``` + +Health check: + +```text +http://127.0.0.1:8000/health +``` + +Available mock scenarios: + +| Scenario | Purpose | +|---|---| +| `mixed` | Default-on push, default-off email, plus customer deviations | +| `defaults_only` | No customer preferences, so only defaults are applied | +| `global_override` | Preference with `resourceIdentifier = null` applies to all resources | +| `inactive_subscription` | Subscription is returned but marked inactive | + +### Azure Function + +Use `app/entrypoints/azure_function.py` as the HTTP-trigger body. Keep the `app/services` and `app/domain` modules unchanged. + +## Running automated tests + +```powershell +.\.venv\Scripts\python.exe -m pytest +``` + +The tests cover: + +- default-on channel without preference -> active +- default-on channel with preference -> inactive deviation +- default-off channel without preference -> inactive +- default-off channel with preference -> active deviation +- global preference where `resourceIdentifier = null` +- device filtering via `deviceId` +- inactive subscription still returned as inactive + +## Design choice + +The implementation deliberately separates: + +- HTTP entrypoints: request/response only +- CRUD client: raw REST calls only +- Mock CRUD client: deterministic local/demo data +- Service layer: composition and transformation logic +- Domain models: frontend/SE response shape + +This makes the same transformation reusable in Azure Functions, Container Apps, and tests. + +## Business rule interpretation + +For each customer subscription: + +1. Load customer `NotificationSubscriptions` with `NotificationPreferences` expanded to `EventTypeChannel`. +2. Load the default category definition with all `EventTypes` and `EventTypeChannels`. +3. Load customer resources for each channel resource type: + - `resourceNameId = 8` -> `GET /devices?customerProfileId={id}` + - `resourceNameId = 4` -> `GET /customers?customerProfileId={id}` and use the embedded `person` email data +4. For every default `EventTypeChannel`, write out resources explicitly. +5. If the channel is default-on and no preference exists, resource is active. +6. If the channel is default-off and no preference exists, resource is inactive. +7. A `NotificationPreference` row is interpreted as a deviation: + - default-on + preference -> inactive unless explicit `isActive` says otherwise + - default-off + preference -> active unless explicit `isActive` says otherwise +8. A null `resourceIdentifier` applies to all resources for that EventTypeChannel. + +## Production hardening points + +- Add authentication propagation from the SE API to both CRUD APIs. +- Decide whether category defaults should be cached across requests. +- Decide whether missing category defaults should become `502 Bad Gateway`, partial data, or an empty category. +- Confirm whether the real `NotificationPreference` has explicit `isActive`; this example supports it, but also works when preference existence alone means deviation. diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/service_factory.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/service_factory.cpython-314.pyc new file mode 100644 index 0000000..1f5b7a4 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/service_factory.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/settings.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/settings.cpython-314.pyc new file mode 100644 index 0000000..330c52c Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/settings.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/crud_client.cpython-313.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/crud_client.cpython-313.pyc new file mode 100644 index 0000000..53545a5 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/crud_client.cpython-313.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/crud_client.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/crud_client.cpython-314.pyc new file mode 100644 index 0000000..8543126 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/crud_client.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/mock_crud_client.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/mock_crud_client.cpython-314.pyc new file mode 100644 index 0000000..ca8fd0f Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/mock_crud_client.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/clients/crud_client.py b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/crud_client.py new file mode 100644 index 0000000..8731a53 --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/crud_client.py @@ -0,0 +1,89 @@ +from __future__ import annotations +from typing import Any, Optional +import httpx + + +class CrudApiClient: + """Thin async client around the ABT CRUD APIs. + + Keep business/transformation logic out of this class. The client only knows how + to call the source endpoints and unwrap the collection names from the CRUD + contracts. + """ + + def __init__( + self, + notifications_base_url: str, + customers_base_url: str, + timeout_seconds: float = 10.0, + ): + self.notifications_base_url = notifications_base_url.rstrip("/") + self.customers_base_url = customers_base_url.rstrip("/") + self.timeout_seconds = timeout_seconds + + async def _get( + self, + base_url: str, + path: str, + params: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + async with httpx.AsyncClient(base_url=base_url, timeout=self.timeout_seconds) as client: + response = await client.get(path, params=params) + response.raise_for_status() + return response.json() + + # notifications-crud.yaml + async def get_notification_subscriptions(self, customer_profile_id: int) -> list[dict[str, Any]]: + payload = await self._get( + self.notifications_base_url, + "/notificationsubscriptions", + params={"customerProfileId": customer_profile_id, "expand": "eventTypeChannel"}, + ) + return payload.get("notificationSubscriptions", []) + + async def get_notification_category_defaults(self, notification_category_id: int) -> Optional[dict[str, Any]]: + payload = await self._get( + self.notifications_base_url, + "/notificationcategories", + params={"notificationCategoryId": notification_category_id, "expand": "eventTypeChannel"}, + ) + categories = payload.get("notificationCategories", []) + return categories[0] if categories else None + + # customers-crud-v2.yaml + async def get_ovpay_token(self, ovpay_token_id: Optional[int]) -> Optional[dict[str, Any]]: + if ovpay_token_id is None: + return None + payload = await self._get( + self.customers_base_url, + "/ovpaytokens", + params={"ovPayTokenId": ovpay_token_id}, + ) + tokens = payload.get("ovPayTokens", []) + return tokens[0] if tokens else None + + async def get_devices(self, customer_profile_id: int) -> list[dict[str, Any]]: + payload = await self._get( + self.customers_base_url, + "/devices", + params={"customerProfileId": customer_profile_id}, + ) + return payload.get("devices", []) + + async def get_persons(self, customer_profile_id: int) -> list[dict[str, Any]]: + """Return customer/person resources used for email-like channels. + + customers-crud-v2.yaml exposes person details through GET /customers, + filtered by customerProfileId. For the transformation service we normalize + the matching customer profile into one resource with the customer's email. + """ + payload = await self._get( + self.customers_base_url, + "/customers", + params={"customerProfileId": customer_profile_id}, + ) + return payload.get("customers", []) + + +# Backwards-compatible alias used by the service type hints. +NotificationsCrudClient = CrudApiClient diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/clients/mock_crud_client.py b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/mock_crud_client.py new file mode 100644 index 0000000..253d00e --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/clients/mock_crud_client.py @@ -0,0 +1,162 @@ +from __future__ import annotations +from copy import deepcopy +from typing import Any, Optional + + +class MockCrudClient: + """Deterministic in-memory CRUD client for local demos and tests. + + It implements the same methods as CrudApiClient, so the service layer does + not know whether data comes from real CRUD APIs or from mock fixtures. + """ + + def __init__(self, scenario: str = "mixed"): + self.scenario = scenario + + async def get_notification_subscriptions(self, customer_profile_id: int) -> list[dict[str, Any]]: + subscriptions = deepcopy(MOCK_SUBSCRIPTIONS.get(self.scenario, MOCK_SUBSCRIPTIONS["mixed"])) + for subscription in subscriptions: + subscription["customerProfileId"] = customer_profile_id + return subscriptions + + async def get_notification_category_defaults(self, notification_category_id: int) -> Optional[dict[str, Any]]: + return deepcopy(MOCK_CATEGORIES.get(notification_category_id)) + + async def get_ovpay_token(self, ovpay_token_id: Optional[int]) -> Optional[dict[str, Any]]: + if ovpay_token_id is None: + return None + return deepcopy(MOCK_OVPAY_TOKENS.get(ovpay_token_id, {"ovPayTokenId": ovpay_token_id, "alias": None})) + + async def get_devices(self, customer_profile_id: int) -> list[dict[str, Any]]: + return deepcopy(MOCK_DEVICES) + + async def get_persons(self, customer_profile_id: int) -> list[dict[str, Any]]: + return deepcopy(MOCK_CUSTOMERS) + + +MOCK_CATEGORIES: dict[int, dict[str, Any]] = { + 1: { + "notificationCategoryId": 1, + "name": "Reizen", + "groupName": "Mijn passen", + "eventTypes": [ + { + "eventTypeId": 10, + "name": "OVPAY_CHECK_IN", + "subName": "Check-in", + "prettyName": "Check-in bevestiging", + "eventTypeChannels": [ + { + "eventTypeChannelId": "etc-push-default-on", + "channel": { + "channelId": 1, + "name": "Push", + "resourceName": {"resourceNameId": 8, "name": "devices"}, + }, + "isDefault": True, + "isMandatory": False, + }, + { + "eventTypeChannelId": "etc-email-default-off", + "channel": { + "channelId": 2, + "name": "E-mail", + "resourceName": {"resourceNameId": 4, "name": "customers"}, + }, + "isDefault": False, + "isMandatory": False, + }, + ], + }, + { + "eventTypeId": 20, + "name": "SERVICE_MESSAGE", + "subName": "Service", + "prettyName": "Servicebericht", + "eventTypeChannels": [ + { + "eventTypeChannelId": "etc-email-mandatory", + "channel": { + "channelId": 2, + "name": "E-mail", + "resourceName": {"resourceNameId": 4, "name": "customers"}, + }, + "isDefault": True, + "isMandatory": True, + } + ], + }, + ], + } +} + +MOCK_SUBSCRIPTIONS: dict[str, list[dict[str, Any]]] = { + # Mixed scenario demonstrates both default behavior and customer deviations. + "mixed": [ + { + "notificationSubscriptionId": "sub-mixed-1", + "notificationCategoryId": 1, + "notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"}, + "customerProfileId": 42, + "ovPayTokenId": 112, + "subscriptionActivities": [{"timestamp": "2026-04-01T10:00:00Z", "isActive": True}], + "notificationPreferences": [ + # Default push=true: this row opts out only device-2. + {"notificationPreferenceId": "pref-push-device-2-off", "eventTypeChannelId": "etc-push-default-on", "resourceIdentifier": "device-2"}, + # Default email=false: this row opts in the customer's email resource. + {"notificationPreferenceId": "pref-email-on", "eventTypeChannelId": "etc-email-default-off", "resourceIdentifier": "42"}, + ], + } + ], + # No preferences: output should be entirely based on category defaults. + "defaults_only": [ + { + "notificationSubscriptionId": "sub-defaults-1", + "notificationCategoryId": 1, + "notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"}, + "customerProfileId": 42, + "ovPayTokenId": 112, + "subscriptionActivities": [{"timestamp": "2026-04-01T10:00:00Z", "isActive": True}], + "notificationPreferences": [], + } + ], + # Global preference: null resourceIdentifier applies the deviation to all resources. + "global_override": [ + { + "notificationSubscriptionId": "sub-global-1", + "notificationCategoryId": 1, + "notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"}, + "customerProfileId": 42, + "ovPayTokenId": 112, + "subscriptionActivities": [{"timestamp": "2026-04-01T10:00:00Z", "isActive": True}], + "notificationPreferences": [ + {"notificationPreferenceId": "pref-all-push-off", "eventTypeChannelId": "etc-push-default-on", "resourceIdentifier": None} + ], + } + ], + # Subscription inactive while preferences are still written out for the frontend. + "inactive_subscription": [ + { + "notificationSubscriptionId": "sub-inactive-1", + "notificationCategoryId": 1, + "notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"}, + "customerProfileId": 42, + "ovPayTokenId": 112, + "subscriptionActivities": [{"timestamp": "2026-04-01T10:00:00Z", "isActive": False}], + "notificationPreferences": [], + } + ], +} + +MOCK_OVPAY_TOKENS = { + 112: {"ovPayTokenId": 112, "alias": "Mijn betaalpas"}, +} + +MOCK_DEVICES = [ + {"deviceId": "device-1", "alias": "Mijn iPhone"}, + {"deviceId": "device-2", "alias": "Werktelefoon"}, +] + +MOCK_CUSTOMERS = [ + {"customerProfileId": 42, "person": {"emailAddress": "klant@example.nl"}}, +] diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/domain/__pycache__/models.cpython-313.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/domain/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..8a7f437 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/domain/__pycache__/models.cpython-313.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/domain/__pycache__/models.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/domain/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..d1110ec Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/domain/__pycache__/models.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/domain/models.py b/src/python/notificatiesbeheren/notification-preferences-example/app/domain/models.py new file mode 100644 index 0000000..257a67f --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/domain/models.py @@ -0,0 +1,60 @@ +from __future__ import annotations +from typing import Any, Optional +from pydantic import BaseModel, Field + + +class Resource(BaseModel): + resourceName: str + resourceIdentifier: str + alias: Optional[str] = None + isActive: bool + + +class EventTypeChannelPreference(BaseModel): + eventTypeChannelId: str + channelId: int + name: str + isMandatory: bool = False + resources: list[Resource] = Field(default_factory=list) + + +class EventTypePreference(BaseModel): + eventTypeId: int + name: str + subName: Optional[str] = None + prettyName: str + eventTypeChannels: list[EventTypeChannelPreference] = Field(default_factory=list) + + +class OvPayToken(BaseModel): + ovPayTokenId: int + alias: Optional[str] = None + + +class NotificationSubscriptionPreference(BaseModel): + notificationSubscriptionId: str + customerProfileId: int + ovPayToken: Optional[OvPayToken] = None + isActive: bool + eventTypes: list[EventTypePreference] = Field(default_factory=list) + + +class NotificationCategoryPreference(BaseModel): + notificationCategoryId: int + name: str + groupName: Optional[str] = None + notificationSubscriptions: list[NotificationSubscriptionPreference] = Field(default_factory=list) + + +class CustomerNotificationPreferencesResponse(BaseModel): + notificationCategories: list[NotificationCategoryPreference] = Field(default_factory=list) + + +def latest_activity_is_active(subscription: dict[str, Any]) -> bool: + """CRUD returns subscriptionActivities; the most recent/current one determines active state.""" + activities = subscription.get("subscriptionActivities") or [] + if not activities: + return False + # CRUD activityLimit defaults to 1; when more are supplied, sort defensively on timestamp. + latest = sorted(activities, key=lambda a: a.get("timestamp", ""), reverse=True)[0] + return bool(latest.get("isActive")) diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/__pycache__/fastapi_app.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/__pycache__/fastapi_app.cpython-314.pyc new file mode 100644 index 0000000..ef09b8e Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/__pycache__/fastapi_app.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/azure_function.py b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/azure_function.py new file mode 100644 index 0000000..25d9a57 --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/azure_function.py @@ -0,0 +1,17 @@ +from __future__ import annotations +import azure.functions as func + +from app.service_factory import create_notification_preferences_service + + +async def main(req: func.HttpRequest) -> func.HttpResponse: + customer_profile_id = req.headers.get("X-HTM-CUSTOMER-PROFILE-ID-HEADER") + if not customer_profile_id: + return func.HttpResponse("Missing X-HTM-CUSTOMER-PROFILE-ID-HEADER", status_code=400) + + service = create_notification_preferences_service() + result = await service.get_customer_notification_preferences( + customer_profile_id=int(customer_profile_id), + device_id=req.params.get("deviceId"), + ) + return func.HttpResponse(result.model_dump_json(exclude_none=True), mimetype="application/json", status_code=200) diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/fastapi_app.py b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/fastapi_app.py new file mode 100644 index 0000000..909e3ca --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/fastapi_app.py @@ -0,0 +1,25 @@ +from __future__ import annotations +from fastapi import FastAPI, Header, Query + +from app.domain.models import CustomerNotificationPreferencesResponse +from app.service_factory import create_notification_preferences_service +from app.settings import settings + +app = FastAPI(title="SE Notifications example") + + +@app.get("/health") +async def health(): + return {"status": "ok", "useMockData": settings.use_mock_data, "mockScenario": settings.mock_scenario} + + +@app.get("/customers/notificationpreferences", response_model=CustomerNotificationPreferencesResponse) +async def get_customer_notification_preferences( + x_htm_customer_profile_id_header: int = Header(..., alias="X-HTM-CUSTOMER-PROFILE-ID-HEADER"), + device_id: str | None = Query(default=None, alias="deviceId"), +): + service = create_notification_preferences_service() + return await service.get_customer_notification_preferences( + customer_profile_id=x_htm_customer_profile_id_header, + device_id=device_id, + ) diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/service_factory.py b/src/python/notificatiesbeheren/notification-preferences-example/app/service_factory.py new file mode 100644 index 0000000..4cbe60b --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/service_factory.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from app.clients.crud_client import CrudApiClient +from app.clients.mock_crud_client import MockCrudClient +from app.services.notification_preferences_service import NotificationPreferencesService +from app.settings import settings + + +def create_notification_preferences_service() -> NotificationPreferencesService: + if settings.use_mock_data: + return NotificationPreferencesService(MockCrudClient(settings.mock_scenario)) + + return NotificationPreferencesService( + CrudApiClient( + notifications_base_url=settings.notifications_crud_base_url, + customers_base_url=settings.customers_crud_base_url, + ) + ) diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/services/__pycache__/notification_preferences_service.cpython-313.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/services/__pycache__/notification_preferences_service.cpython-313.pyc new file mode 100644 index 0000000..41e622f Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/services/__pycache__/notification_preferences_service.cpython-313.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/services/__pycache__/notification_preferences_service.cpython-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/services/__pycache__/notification_preferences_service.cpython-314.pyc new file mode 100644 index 0000000..b970be8 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/app/services/__pycache__/notification_preferences_service.cpython-314.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/services/notification_preferences_service.py b/src/python/notificatiesbeheren/notification-preferences-example/app/services/notification_preferences_service.py new file mode 100644 index 0000000..f82deb6 --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/services/notification_preferences_service.py @@ -0,0 +1,228 @@ +from __future__ import annotations +from collections import defaultdict +from typing import Any, Optional + +from app.clients.crud_client import NotificationsCrudClient +from app.domain.models import ( + CustomerNotificationPreferencesResponse, + EventTypeChannelPreference, + EventTypePreference, + NotificationCategoryPreference, + NotificationSubscriptionPreference, + OvPayToken, + Resource, + latest_activity_is_active, +) + +RESOURCE_DEVICES_ID = 8 +RESOURCE_PERSONS_ID = 4 + + +class NotificationPreferencesService: + def __init__(self, crud_client: NotificationsCrudClient): + self.crud_client = crud_client + + async def get_customer_notification_preferences( + self, + customer_profile_id: int, + device_id: Optional[str] = None, + ) -> CustomerNotificationPreferencesResponse: + subscriptions = await self.crud_client.get_notification_subscriptions(customer_profile_id) + categories_by_id: dict[int, NotificationCategoryPreference] = {} + + # Small in-request caches avoid repeated calls for the same category/resources. + default_category_cache: dict[int, dict[str, Any]] = {} + resource_cache: dict[int, list[dict[str, Any]]] = {} + + for subscription in subscriptions: + category_id = _extract_category_id(subscription) + default_category = default_category_cache.get(category_id) + if default_category is None: + default_category = await self.crud_client.get_notification_category_defaults(category_id) + if default_category is None: + # Defensive choice: skip orphaned subscriptions rather than returning incomplete UI data. + continue + default_category_cache[category_id] = default_category + + category_response = categories_by_id.setdefault( + category_id, + NotificationCategoryPreference( + notificationCategoryId=category_id, + name=default_category.get("name") or subscription.get("notificationCategory", {}).get("name"), + groupName=default_category.get("groupName") or subscription.get("notificationCategory", {}).get("groupName"), + ), + ) + + token_payload = await self.crud_client.get_ovpay_token(subscription.get("ovPayTokenId")) + token = _to_ovpay_token(token_payload, subscription.get("ovPayTokenId")) + + subscription_response = NotificationSubscriptionPreference( + notificationSubscriptionId=subscription["notificationSubscriptionId"], + customerProfileId=subscription["customerProfileId"], + ovPayToken=token, + isActive=latest_activity_is_active(subscription), + ) + + preferences_by_event_type_channel = _index_preferences(subscription) + + for event_type in default_category.get("eventTypes", []): + event_type_response = EventTypePreference( + eventTypeId=event_type["eventTypeId"], + name=event_type["name"], + subName=event_type.get("subName"), + prettyName=event_type.get("prettyName") or event_type["name"], + ) + + for event_type_channel in event_type.get("eventTypeChannels", []): + channel = event_type_channel.get("channel", {}) + resource_name = channel.get("resourceName", {}) + resource_name_id = resource_name.get("resourceNameId") + resource_name_text = resource_name.get("name") or channel.get("name") + + if resource_name_id not in resource_cache: + resource_cache[resource_name_id] = await self._load_resources(resource_name_id, customer_profile_id) + source_resources = resource_cache[resource_name_id] + + if device_id and resource_name_id == RESOURCE_DEVICES_ID: + source_resources = [r for r in source_resources if str(_resource_identifier(r)) == str(device_id)] + if not source_resources: + # SE swagger states a deviceId filter returns only ETCs with resources for that device. + continue + + matching_preferences = preferences_by_event_type_channel.get( + str(event_type_channel["eventTypeChannelId"]), [] + ) + + resources = self._normalize_resources( + resource_name=resource_name_text, + event_type_channel=event_type_channel, + source_resources=source_resources, + preferences=matching_preferences, + ) + + event_type_response.eventTypeChannels.append( + EventTypeChannelPreference( + eventTypeChannelId=event_type_channel["eventTypeChannelId"], + channelId=channel["channelId"], + name=channel["name"], + isMandatory=bool(event_type_channel.get("isMandatory")), + resources=resources, + ) + ) + + if event_type_response.eventTypeChannels: + subscription_response.eventTypes.append(event_type_response) + + category_response.notificationSubscriptions.append(subscription_response) + + return CustomerNotificationPreferencesResponse(notificationCategories=list(categories_by_id.values())) + + async def _load_resources(self, resource_name_id: int, customer_profile_id: int) -> list[dict[str, Any]]: + if resource_name_id == RESOURCE_DEVICES_ID: + return await self.crud_client.get_devices(customer_profile_id) + if resource_name_id == RESOURCE_PERSONS_ID: + return await self.crud_client.get_persons(customer_profile_id) + return [] + + def _normalize_resources( + self, + resource_name: str, + event_type_channel: dict[str, Any], + source_resources: list[dict[str, Any]], + preferences: list[dict[str, Any]], + ) -> list[Resource]: + default_active = bool(event_type_channel.get("isDefault")) + + if not preferences: + return [ + Resource( + resourceName=resource_name, + resourceIdentifier=str(_resource_identifier(resource)), + alias=_resource_alias(resource), + isActive=default_active, + ) + for resource in source_resources + ] + + # Preference rows are exceptions/deviations. A null resourceIdentifier means the exception applies to all resources. + preference_by_resource: dict[str, dict[str, Any]] = {} + global_preference: Optional[dict[str, Any]] = None + for preference in preferences: + pref_resource_id = preference.get("resourceIdentifier") + if pref_resource_id is None: + global_preference = preference + else: + preference_by_resource[str(pref_resource_id)] = preference + + normalized: list[Resource] = [] + for resource in source_resources: + resource_id = str(_resource_identifier(resource)) + preference = preference_by_resource.get(resource_id) or global_preference + if preference is None: + is_active = default_active + else: + # The CRUD example only shows the existence of a preference row, no explicit isActive. + # Therefore: for default-on channels a row means opted out; for default-off a row means opted in. + is_active = bool(preference.get("isActive", not default_active)) + + normalized.append( + Resource( + resourceName=resource_name, + resourceIdentifier=resource_id, + alias=_resource_alias(resource), + isActive=is_active, + ) + ) + return normalized + + +def _extract_category_id(subscription: dict[str, Any]) -> int: + if subscription.get("notificationCategoryId") is not None: + return int(subscription["notificationCategoryId"]) + return int(subscription["notificationCategory"]["notificationCategoryId"]) + + +def _index_preferences(subscription: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: + result: dict[str, list[dict[str, Any]]] = defaultdict(list) + for preference in subscription.get("notificationPreferences", []) or []: + event_type_channel_id = preference.get("eventTypeChannelId") + if isinstance(event_type_channel_id, dict): + event_type_channel_id = event_type_channel_id.get("eventTypeChannelId") + if event_type_channel_id is None and isinstance(preference.get("eventTypeChannel"), dict): + event_type_channel_id = preference["eventTypeChannel"].get("eventTypeChannelId") + if event_type_channel_id is not None: + result[str(event_type_channel_id)].append(preference) + return result + + +def _to_ovpay_token(payload: Optional[dict[str, Any]], fallback_id: Optional[int]) -> Optional[OvPayToken]: + if fallback_id is None: + return None + # Support both {ovPayToken: {...}} and direct {...} shapes. + token = (payload or {}).get("ovPayToken", payload or {}) + return OvPayToken(ovPayTokenId=token.get("ovPayTokenId", fallback_id), alias=token.get("alias")) + + +def _resource_identifier(resource: dict[str, Any]) -> Any: + # Device resources use deviceId. Customer/person email resources are normalized + # to customerProfileId because customers-crud-v2 exposes person under /customers. + return ( + resource.get("resourceIdentifier") + or resource.get("deviceId") + or resource.get("personId") + or resource.get("customerProfileId") + or resource.get("id") + ) + + +def _resource_alias(resource: dict[str, Any]) -> Optional[str]: + person = resource.get("person") or {} + return ( + resource.get("alias") + or resource.get("emailAddress") + or resource.get("emailAddresses") + or resource.get("email") + or person.get("emailAddress") + or person.get("emailAddresses") + or resource.get("name") + ) diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/settings.py b/src/python/notificatiesbeheren/notification-preferences-example/app/settings.py new file mode 100644 index 0000000..a347ded --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/settings.py @@ -0,0 +1,13 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + notifications_crud_base_url: str = "http://localhost:8001" + customers_crud_base_url: str = "http://localhost:8002" + + # local development aid: set USE_MOCK_DATA=true to run without CRUD APIs + use_mock_data: bool = False + mock_scenario: str = "mixed" + + +settings = Settings() diff --git a/src/python/notificatiesbeheren/notification-preferences-example/requirements.txt b/src/python/notificatiesbeheren/notification-preferences-example/requirements.txt new file mode 100644 index 0000000..d64d8e4 --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +httpx==0.28.1 +pydantic>=2.12.0 +pydantic-settings>=2.7.1 +azure-functions==1.21.3 +pytest==8.3.4 +pytest-asyncio==0.25.2 diff --git a/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_mock_scenarios.cpython-314-pytest-8.3.4.pyc b/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_mock_scenarios.cpython-314-pytest-8.3.4.pyc new file mode 100644 index 0000000..557f6b0 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_mock_scenarios.cpython-314-pytest-8.3.4.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_transformation.cpython-313-pytest-9.0.2.pyc b/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_transformation.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..fba7df5 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_transformation.cpython-313-pytest-9.0.2.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_transformation.cpython-314-pytest-8.3.4.pyc b/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_transformation.cpython-314-pytest-8.3.4.pyc new file mode 100644 index 0000000..9a22c80 Binary files /dev/null and b/src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_transformation.cpython-314-pytest-8.3.4.pyc differ diff --git a/src/python/notificatiesbeheren/notification-preferences-example/tests/test_mock_scenarios.py b/src/python/notificatiesbeheren/notification-preferences-example/tests/test_mock_scenarios.py new file mode 100644 index 0000000..15da9dc --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/tests/test_mock_scenarios.py @@ -0,0 +1,69 @@ +import pytest + +from app.clients.mock_crud_client import MockCrudClient +from app.services.notification_preferences_service import NotificationPreferencesService + + +def _channels(result): + subscription = result.notificationCategories[0].notificationSubscriptions[0] + return { + channel.eventTypeChannelId: channel + for event_type in subscription.eventTypes + for channel in event_type.eventTypeChannels + } + + +@pytest.mark.asyncio +async def test_mixed_scenario_applies_default_on_and_default_off_deviations(): + service = NotificationPreferencesService(MockCrudClient("mixed")) + result = await service.get_customer_notification_preferences(42) + channels = _channels(result) + + push_resources = channels["etc-push-default-on"].resources + assert [(r.resourceIdentifier, r.isActive) for r in push_resources] == [ + ("device-1", True), + ("device-2", False), + ] + + email_resources = channels["etc-email-default-off"].resources + assert [(r.resourceIdentifier, r.isActive) for r in email_resources] == [("42", True)] + + +@pytest.mark.asyncio +async def test_defaults_only_scenario_writes_all_resources_from_defaults(): + service = NotificationPreferencesService(MockCrudClient("defaults_only")) + result = await service.get_customer_notification_preferences(42) + channels = _channels(result) + + assert [r.isActive for r in channels["etc-push-default-on"].resources] == [True, True] + assert [r.isActive for r in channels["etc-email-default-off"].resources] == [False] + assert channels["etc-email-mandatory"].isMandatory is True + assert [r.isActive for r in channels["etc-email-mandatory"].resources] == [True] + + +@pytest.mark.asyncio +async def test_global_override_applies_to_all_resources_when_resource_identifier_is_null(): + service = NotificationPreferencesService(MockCrudClient("global_override")) + result = await service.get_customer_notification_preferences(42) + channels = _channels(result) + + assert [r.isActive for r in channels["etc-push-default-on"].resources] == [False, False] + + +@pytest.mark.asyncio +async def test_device_filter_returns_only_requested_device_for_device_channels(): + service = NotificationPreferencesService(MockCrudClient("mixed")) + result = await service.get_customer_notification_preferences(42, device_id="device-1") + channels = _channels(result) + + assert [(r.resourceIdentifier, r.isActive) for r in channels["etc-push-default-on"].resources] == [("device-1", True)] + + +@pytest.mark.asyncio +async def test_inactive_subscription_is_visible_but_marked_inactive(): + service = NotificationPreferencesService(MockCrudClient("inactive_subscription")) + result = await service.get_customer_notification_preferences(42) + subscription = result.notificationCategories[0].notificationSubscriptions[0] + + assert subscription.isActive is False + assert subscription.eventTypes diff --git a/src/python/notificatiesbeheren/notification-preferences-example/tests/test_transformation.py b/src/python/notificatiesbeheren/notification-preferences-example/tests/test_transformation.py new file mode 100644 index 0000000..5d0a509 --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/tests/test_transformation.py @@ -0,0 +1,83 @@ +import pytest + +from app.services.notification_preferences_service import NotificationPreferencesService + + +class FakeCrudClient: + async def get_notification_subscriptions(self, customer_profile_id: int): + return [ + { + "notificationSubscriptionId": "sub-1", + "notificationCategory": {"notificationCategoryId": 1, "name": "Reizen", "groupName": "Mijn passen"}, + "customerProfileId": customer_profile_id, + "ovPayTokenId": 112, + "subscriptionActivities": [{"timestamp": "2026-01-01T00:00:00Z", "isActive": True}], + "notificationPreferences": [ + # Default push=true, this row switches device-2 off only. + {"notificationPreferenceId": "pref-1", "eventTypeChannelId": "etc-push", "resourceIdentifier": "device-2"}, + # Default email=false, this row switches customer email on. + {"notificationPreferenceId": "pref-2", "eventTypeChannelId": "etc-email", "resourceIdentifier": "42"}, + ], + } + ] + + async def get_notification_category_defaults(self, notification_category_id: int): + return { + "notificationCategoryId": 1, + "name": "Reizen", + "groupName": "Mijn passen", + "eventTypes": [ + { + "eventTypeId": 2, + "name": "GBO", + "subName": "CI", + "prettyName": "Normal Check-in", + "eventTypeChannels": [ + { + "eventTypeChannelId": "etc-push", + "channel": {"channelId": 1, "name": "push", "resourceName": {"resourceNameId": 8, "name": "devices"}}, + "isDefault": True, + "isMandatory": False, + }, + { + "eventTypeChannelId": "etc-email", + "channel": {"channelId": 2, "name": "email", "resourceName": {"resourceNameId": 4, "name": "customers"}}, + "isDefault": False, + "isMandatory": False, + }, + ], + } + ], + } + + async def get_ovpay_token(self, ovpay_token_id): + return {"ovPayToken": {"ovPayTokenId": ovpay_token_id, "alias": "Mijn ING bankpas"}} + + async def get_devices(self, customer_profile_id: int): + return [ + {"deviceId": "device-1", "alias": "Mijn iPhone"}, + {"deviceId": "device-2", "alias": "Mijn Pixel"}, + ] + + async def get_persons(self, customer_profile_id: int): + return [{"customerProfileId": 42, "emailAddress": "customer@example.nl"}] + + +@pytest.mark.asyncio +async def test_normalizes_defaults_and_customer_exceptions(): + result = await NotificationPreferencesService(FakeCrudClient()).get_customer_notification_preferences(42) + event_type = result.notificationCategories[0].notificationSubscriptions[0].eventTypes[0] + + push_resources = event_type.eventTypeChannels[0].resources + assert [r.isActive for r in push_resources] == [True, False] + + email_resources = event_type.eventTypeChannels[1].resources + assert email_resources[0].isActive is True + + +@pytest.mark.asyncio +async def test_device_filter_only_returns_that_device_resource(): + result = await NotificationPreferencesService(FakeCrudClient()).get_customer_notification_preferences(42, device_id="device-1") + event_type = result.notificationCategories[0].notificationSubscriptions[0].eventTypes[0] + assert len(event_type.eventTypeChannels[0].resources) == 1 + assert event_type.eventTypeChannels[0].resources[0].resourceIdentifier == "device-1"