trial-implementation-Not-beheren #59
@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||||
@ -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"}},
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
@ -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"))
|
||||||
Binary file not shown.
@ -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)
|
||||||
@ -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,
|
||||||
|
)
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
Binary file not shown.
Binary file not shown.
@ -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")
|
||||||
|
)
|
||||||
@ -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()
|
||||||
@ -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
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||||
@ -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"
|
||||||
Loading…
Reference in New Issue
Block a user