From 67ab05055cc3d0b56b55018f55198893bd28128d Mon Sep 17 00:00:00 2001 From: Herald Date: Fri, 24 Apr 2026 15:37:46 +0200 Subject: [PATCH 1/2] First vibe coded example --- .../README.md | 72 ++++++ .../__pycache__/crud_client.cpython-313.pyc | Bin 0 -> 4823 bytes .../app/clients/crud_client.py | 89 +++++++ .../domain/__pycache__/models.cpython-313.pyc | Bin 0 -> 3889 bytes .../app/domain/models.py | 60 +++++ .../app/entrypoints/azure_function.py | 19 ++ .../app/entrypoints/fastapi_app.py | 24 ++ ...cation_preferences_service.cpython-313.pyc | Bin 0 -> 10519 bytes .../notification_preferences_service.py | 228 ++++++++++++++++++ .../app/settings.py | 9 + .../requirements.txt | 8 + ...ransformation.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 7157 bytes .../tests/test_transformation.py | 83 +++++++ 13 files changed, 592 insertions(+) create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/README.md create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/crud_client.cpython-313.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/clients/crud_client.py create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/domain/__pycache__/models.cpython-313.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/domain/models.py create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/azure_function.py create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/fastapi_app.py create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/services/__pycache__/notification_preferences_service.cpython-313.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/services/notification_preferences_service.py create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/settings.py create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/requirements.txt create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_transformation.cpython-313-pytest-9.0.2.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/tests/test_transformation.py 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..07436ed --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/README.md @@ -0,0 +1,72 @@ +# 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 + +### Container App / local FastAPI + +```bash +pip install -r requirements.txt +export NOTIFICATIONS_CRUD_BASE_URL=https://your-notifications-crud-api.example.nl +export CUSTOMERS_CRUD_BASE_URL=https://your-customers-crud-api.example.nl +uvicorn app.entrypoints.fastapi_app:app --reload +``` + +Call: + +```bash +curl -H "X-HTM-CUSTOMER-PROFILE-ID-HEADER: 42" \ + "http://localhost:8000/customers/notificationpreferences?deviceId=5bedce29-af0c-4f3c-b182-2caa8a1f9377" +``` + +### Azure Function + +Use `app/entrypoints/azure_function.py` as the HTTP-trigger body. Keep the `app/services` and `app/domain` modules unchanged. + +## Design choice + +The implementation deliberately separates: + +- HTTP entrypoints: request/response only +- CRUD client: raw REST calls only +- 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/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 0000000000000000000000000000000000000000..53545a51e55159975a8fbbf9269ba6578e12de48 GIT binary patch literal 4823 zcmc&&-ER}w6~E*8uwze1h(kVrz>O0=3`tB`V4+C>lU+*4%1bkGMaiPk#2zODlNs;5 z<3O@)RZ*!5Pg_(W*uE@Mmo~ihk%v|){R^DUR&l55cBMX{z8Jv@wdzC9ov|G|WOs|I zs$=>1+;h(Tn0tQroO}GdxjBsB>3RR}#zZqhe`Ag6BlryW{46kck%AO%0$tH&*11_Jm z7TwUhdDhmmF-H<<3dWC67skv&^CEo7`U*Js!FCRM8x#iAC>_F6Fj8e~+cFpNb<4g%@tl1l?ASP? znWi@vwF@MpW8KQ;ZNqXX)?g6@>ju&Co<3unrk-ItjV*0nr#MIKc~8Su7|z%htS95Z zGBXwDdk$c3fG_ua&T~VWo8&xs15vo+6=@ooHGMkpjkXn_AawbNS#dZ*vnU=&xm+SH zxDufbG4gH)pvK4<86P?7j7D{}KuotSQ=pDLuM@f;ms$TlF9y(NaneLFF4If<<1Xo>o12J=ME=|s0OIvvz+YV&zhPiXmk!uJJDWB?AT z-x$gRj#bsL3`bSnjs~C%Rps8SM_|?;qq2++jIQq=TirkQ`?0nC7nX(3LVcy!vGv&S zYHaxN*jnr?6YVYS9r|Nv_jBljQVnhig5)R6EeBz{`w)f`GR>^KXR@}fRSAN#dAW76 z-mw*x!kr3mXdKSg95`TsyUV9U8CgPFm}GNodM@`Vfm4BThye}OVp^PR+GGmN;}sD` zppSgDo(3SMz^58F#-Uw5K1Tg0gi`Oo_f7aV^`Zgf*}(hwD-IzU9jr`{g&oNSFxm^d zf&omU{9csf-sE#!oKHc}3DwXOtRUqP=Sq2vX!A5Kx#FDTBsKu8=SDGLx zM$`YIQ!v-9zH@EdLoRt#`V|k{qiFIZ|64A3O!{3Pa8Ixd!Y2b<@`UtcP-6Tsj>%7O zj6W%zYY9F%&7I@qCud~f{}2g+JX4Rx8g73Cv-!A9qpiXONbCE7HG32;%_#we9||bK zDgG541|Z~!zGXdC#6%5S9+EmdG)SqXoed|R7G`J$!i|@n+*W-7!u-@?ULTu-w56MX zo$b|lbBWkF!_>#KuB6}0L#QR4Fr(XDA3&7);U4j%7cBfBvX2&QA5%55 zZZNMcnzojuEGX8nvIk(H*3Y0eu6UpkMPF~-4>T|H-~R%v{vW=#_%vUQ5*pwn%_|avOFzUO4;TF&E`q70&IVjS+R
  • ~66<+XkZ0A zJW%i9eaUUyueqcwtT1kC5C34p!>^SF{yWdsB>^$D6iQ_E1p{t*WFM@?cUZ70I>|(F zAdR6o!uGym<95Hg?-f;&b-V~<^KtUGx}(&7pa^j`I=a;eN?|Tll1NOOsXGN?;cAXc#eG_FIj7hMa4|WeQh@%jynvrclj5?KL}x zzw`DKPSm;%gdtTsaFfsCnZ?>tY8iY8pNHvLxwfewqdH-*z&CVku`10pmh>>QgMuvs zW#4RVEL=(57z!^N+m>E?L43n-=KT7rQmGuE{;Lj!m%#7ycc)vS zuc8`f=zFJvzVbHI-BU~QKK{D?6Z{RYK#o6L3?26IM;NB#ykAH$EPA)&ILEH4@bsUB zyD$^GJd|#})p=<8cW5yOw;CtTrQ#v)u1q}D9;Wv=cIo|RSM?8p>8z1)reT-?41W|{ zy$a=^2dAZNjS7gJhrAhK`w4sLrFPSx&gSl5_ANFOJCB|jxd0qUPq8k|wOL`5t9ANj zR@t$V87jN5s?OWlg2{MURo^dYW<}GYsyPFyRTFA^%La8&RkLIN$pMD(1^dqr%4(V5JM2{15)NWj{6IW{T1Oq ZBl)HDF2@Z#LyVSl0krGjD(dtG`8VKvl9d1e literal 0 HcmV?d00001 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/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 0000000000000000000000000000000000000000..8a7f4372429d29c82c7b81247e5ecf004a3dae8c GIT binary patch literal 3889 zcmbtXO>7&-72f48mwzH9>c_U^s9MW%WY&(<05w`$j$KENWl;z%cqPMzrEFK+k+KcB z%g!u~Qa}p<1{(L~9*p{!qkT$_J?7XW1qKQ=Xqy7;0Y1*W zdHZJO``)~HdzelqC3t>U`KR+RCP{zCPWy=l4f*gZXuOaN$&i<%yi8>s+mWS6UZF}} zrK;#FOVPYWwS0`mbm@#VDH-aRWJK4rD@x#p#)Vb`Z7ifs2yGm+iI6rav`NsWLfVwj z_JKAX()I~$2DJSlZCYptK${I|GsdtnI41oNo zxiucYSHZqj0!89|i;)jKo0NF;jzdazG1yZeTN{)Q--rGSX-%TCA<>8-Q{@5xwWb=% zG>nLrDq2w*H8jx2L@y4lglHwHwiV5#c>F$LUX>Qffd(&fh53~C(Uz`Yl?fkg_ZDs9 z`p%|9XyHHU_L3jN3+p^;l^lz4b;I*YJnpbtMc;WsUQ4fIJZ_pUxG_zhG|jSSS4-IL zGtFOCtx{{G-!wNJ%KVb!64!%aP=s(YP1=u(8!=6W&=pO~_o=f{^$CNqK^QxPpTuv@ zmR*0=wtQzQDA^>GxJAO=A-}T9l@gh?DwSE=D_f2`3#%d}HdCq51b7hw z!C>PcK9!n-BMo(=dFE_GJ=;7xvGc*R_ZsR%Gc)+x0v&7HA7e=cEj;q!Ly#x1qyih! z4quvR#elC4OKaMsv=%dxS7jQ9@KlXHVUwnbtxT?;kA3h27+I-R$o$6u7bz`w_IZ$o zsc8xXA0%&BE!tdhT%RY3!CTN*k%&8V0PRx_`_OU$M32^9%e>zvn^v{to10b0wL?M@?!#Mc}ic=^~qd0@&Jc<|yuA&$EHugtRAc+B-XR(FQ3xWG! zmp}j!0R{boPYcat`srfx%=pgI^Wla%zOP^S!rs;Ab{3!gW>1|9{Gf~flNJrghymd+G6L!hCbd5?(9?VS*KXFAS9Y(h*42^v>7VSWs{teE;20wcDBzI~(c?)61$c-8`t2j4ZK=69 zPu_d7Y}Ho0Pl$UU;zb89ywB@s7wrI5kv)P48v>IzW9>(%LxD~h24d&X2giL+od`C7 zM#nbrzn}EuA(enlB@y~oNTtL{?*naGgj@zrx&|kGvJgsVMs{`LyJ(8+K(jcD2*}an0oh#%yw!=s z?+?MBqQe*p5rS8-)hh&li~}fepY0IT&o|Wb&C_EIb*wpkX(wMFm};n(AU8iP>|cKO z%c_G7Zf|-pQ@u&g znhMur)QAB1nxVi&*)DnFaA;zDdcGCqVRX8PFu1}hP=%3$cfofM(^KC;G; zD~GhNzeLaLuZ!M1&L(cdUJ%~GQ53i+h8O9l(*FG2`lI#T`H$-ASpBV^?WrFHXrWnA z2GM@7YEdwszJ$gLX;r!(9&K3}@|BkMJ^5lwXGbJSmu4kbUhV3@YAf;>`~}adY$#CU z!ZEn27#f!BZsc*Ra+jplNYA-q?YY|jQ&KLz{b>IF>TMk^xGHs-&O-T26poG$XwK>W z$3!oC%m>3F)H$=oDuo)(*FBf$Ht`89L%GOwC`+K$)Uh%oGyGIJ$qX9=~3)${V(IgI_Aaf|zfL z^*MgcbXd?PGuKOA(JHYU&6=24&6B1PvE-WLXeCtE83P`MT?iXp4I-dgkMcdnEgV0Q(6YyrfM z^tbHz*V%Ks*>m;DYmMyO*V$Wtq&xY)rbe3m!~aMRJ-Tsrkn>OkztGnq3J9qw^ z>VK~NA^H2{%WI8+w;QSHx;ia3RmiDy8GLSMY^UhsSHxuz){*sMaF zz()hcMRU!sRUCJVB8L>ikH;%D+k!8XBE|R*@>Bv_qA0*DByM+cd5G*J&X?HQ8>IlS z7kr`Ipbz0Sq?LIf{-wyW{1<8R8%h60x^Sea^3b!|kpzE7nW%jIv(Y07{*DHs@&oy0 z4m%Izqk)L5KZgr20 CwVurY literal 0 HcmV?d00001 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/azure_function.py b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/azure_function.py new file mode 100644 index 0000000..9822074 --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/azure_function.py @@ -0,0 +1,19 @@ +from __future__ import annotations +import azure.functions as func + +from app.clients.crud_client import NotificationsCrudClient +from app.services.notification_preferences_service import NotificationPreferencesService +from app.settings import settings + + +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 = NotificationPreferencesService(NotificationsCrudClient(settings.notifications_crud_base_url, settings.customers_crud_base_url)) + 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..567dc8d --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/fastapi_app.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from fastapi import FastAPI, Header, Query + +from app.clients.crud_client import NotificationsCrudClient +from app.domain.models import CustomerNotificationPreferencesResponse +from app.services.notification_preferences_service import NotificationPreferencesService +from app.settings import settings + +app = FastAPI(title="SE Notifications example") + + +def get_service() -> NotificationPreferencesService: + return NotificationPreferencesService(NotificationsCrudClient(settings.notifications_crud_base_url, settings.customers_crud_base_url)) + + +@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"), +): + return await get_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/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 0000000000000000000000000000000000000000..41e622fb5c4c0f10d0d23afbee94c92ec0302ac0 GIT binary patch literal 10519 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 m0}){m literal 0 HcmV?d00001 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..da3cd45 --- /dev/null +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/settings.py @@ -0,0 +1,9 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + notifications_crud_base_url: str = "http://localhost:8001" + customers_crud_base_url: str = "http://localhost:8002" + + +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..238304d --- /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.10.4 +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_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 0000000000000000000000000000000000000000..fba7df5df36f3b71961f26cb8359dfea7cb9d6d5 GIT binary patch literal 7157 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 Date: Fri, 24 Apr 2026 16:51:48 +0200 Subject: [PATCH 2/2] updated to python v13.3 and added mockable tests --- .../README.md | 67 +++++++- .../service_factory.cpython-314.pyc | Bin 0 -> 1251 bytes .../app/__pycache__/settings.cpython-314.pyc | Bin 0 -> 1050 bytes .../__pycache__/crud_client.cpython-314.pyc | Bin 0 -> 5929 bytes .../mock_crud_client.cpython-314.pyc | Bin 0 -> 5980 bytes .../app/clients/mock_crud_client.py | 162 ++++++++++++++++++ .../domain/__pycache__/models.cpython-314.pyc | Bin 0 -> 4109 bytes .../__pycache__/fastapi_app.cpython-314.pyc | Bin 0 -> 1797 bytes .../app/entrypoints/azure_function.py | 6 +- .../app/entrypoints/fastapi_app.py | 11 +- .../app/service_factory.py | 18 ++ ...cation_preferences_service.cpython-314.pyc | Bin 0 -> 12177 bytes .../app/settings.py | 4 + .../requirements.txt | 4 +- ...ock_scenarios.cpython-314-pytest-8.3.4.pyc | Bin 0 -> 9860 bytes ...ransformation.cpython-314-pytest-8.3.4.pyc | Bin 0 -> 8127 bytes .../tests/test_mock_scenarios.py | 69 ++++++++ 17 files changed, 321 insertions(+), 20 deletions(-) create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/service_factory.cpython-314.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/__pycache__/settings.cpython-314.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/crud_client.cpython-314.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/clients/__pycache__/mock_crud_client.cpython-314.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/clients/mock_crud_client.py create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/domain/__pycache__/models.cpython-314.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/__pycache__/fastapi_app.cpython-314.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/service_factory.py create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/app/services/__pycache__/notification_preferences_service.cpython-314.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_mock_scenarios.cpython-314-pytest-8.3.4.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/tests/__pycache__/test_transformation.cpython-314-pytest-8.3.4.pyc create mode 100644 src/python/notificatiesbeheren/notification-preferences-example/tests/test_mock_scenarios.py diff --git a/src/python/notificatiesbeheren/notification-preferences-example/README.md b/src/python/notificatiesbeheren/notification-preferences-example/README.md index 07436ed..a2594dd 100644 --- a/src/python/notificatiesbeheren/notification-preferences-example/README.md +++ b/src/python/notificatiesbeheren/notification-preferences-example/README.md @@ -16,32 +16,81 @@ Reference implementation for the Service Engine `GET /customers/notificationpref ## Runtime options -### Container App / local FastAPI +### Local FastAPI with real CRUD APIs -```bash -pip install -r requirements.txt -export NOTIFICATIONS_CRUD_BASE_URL=https://your-notifications-crud-api.example.nl -export CUSTOMERS_CRUD_BASE_URL=https://your-customers-crud-api.example.nl -uvicorn app.entrypoints.fastapi_app:app --reload +```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: -```bash -curl -H "X-HTM-CUSTOMER-PROFILE-ID-HEADER: 42" \ - "http://localhost:8000/customers/notificationpreferences?deviceId=5bedce29-af0c-4f3c-b182-2caa8a1f9377" +```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 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 0000000000000000000000000000000000000000..1f5b7a4e77aead1461a354033700c96ee03da0ff GIT binary patch literal 1251 zcmah|PiqrF6rag za3TR;2)@_^-A8+}jdfaI_ zwmh>%NsCb8+JtQrdgRyy&ghJA?zr!mjYa+9OCW-dBIwM4(MNC5UgGgbgqncOq1X5T zH*h2I5JPdwnTQQEN_dyLR0HR;s8wruv}185GA;y%>radwMkq552(>zfcjQ|=gHhY? zd;Gw24d4~lN!UJtAQHzsx8hHWuaNgv$8QtE@_hr63MOXDvbjflRlgTxO*3p1F-fKyYiw80y(SH`64pKaMj4kA!JTQ>VWP}^ixIO+ z+rdq{%edEp>zZI?oeDldt_v|87&EhJaZ6q|6_kUV1er}-i#i^i5!k(}`E8iYNy4Bo z{m7LKDmc@;P&tug(Wb=W#Ej=MW7A-U6F>_PEid{77=1KM<_F1n_#G$bhxy7E_uKAZ zb^By>`?UJzG{1AK@BGY^hx+YdX=zZ}I4Nxmi;IKe+DUQkud1Zd7mAwI&XKBWvX;$q zCP);;R{bwS@wkHQ_{{jx x%1e>VueVKq+VPs*HhD(p;gpy$b{EvSf-xSUg=5T3oeq-mO{mDVb?q_IVb(jRJ-5=#*UQNe;&?2Boa&1GW_FL&qeMq2S9 zR6)VNMEqx(x4Phi_$0nGrl60`rb!Lr!p_coH?!Z&cXwA#WYd7_)9w#HAOPP?&|it3 z)47RG6Slx558ysgBnRV=2iKYa*WM&c_EDa)Hmy7?^@^13Ix|p6d^d4{=*%4^`8rq1 z51&b|m()G1UeH-Vhau4>DB^;$wj5=jfe2}p<60`=+A8TfYG?zl6E`sdK}x0Eq#E7; zm3D`aETvO@hF9Y?rQ+hkLJ)Z@s6|p0mlqeW{vW<}PbcdBHm`Un0hn!;gWHVOd5C6( zG+_g_tmU@=6-3X&V^Sjvuuh)BEz2brF{kHSJ)aQt9G{4`$E_}|< zww%?r4TOB1nr=D8b^_gY8ph7HoLqav1Q{5cXgSkAvlw)+5I(|MVMM1W4O!g~r75aZ zX#~c{7G5-1(0g){(yA|{3jC0V5v5`n%Z2$1#E3B&Gcnm?&)x|7aggp)R5|I+H7liQyu?m3eONtm2& zKH5Kr=9d}k4T!OR*;>KTqw!9Kg@(T`uP|R{_gbAg#>NfSEi}wJwQ?lGs>oqzT9Q*} e4s1fm7r1d`!r<6J`{IT-SR*6VH}A7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8543126caca7f0c74f48398cb361a9efcfa6c0d1 GIT binary patch literal 5929 zcmc&&T}&L;6~41GvwtivLbMEild(L-$?m1dl8zu1k_R8PPS856QD^|*nULWK?j3E~Eov zfl<*BMq6Sxp`L{)GkwNVYg*Tfc*ZO96atj1Lt z>OsFAR6|e;V-5BY0XoX*uv!CjjMEXd7U()oN7Z_u8#rB~?sH=dFz5EEvkr@T4-LPQ9cIO^mVrX!I4s$SJdVW?BYg zDTnG7o3rU6KfaQ+=gqWY=N-kKgV`p71$D;yl}T91U)#2_1?6qazQ~jX`(o6wm9(DC z@^3LaPt%5CSecw{S`Jfm*hJpCNc9}=Putn7kw(TUmcD2(Wscg5ya#s}P1_diC+)yC z(*iIUfA|F92b>o+dw{r1rpblC^FJpf18p7TjIbc6f*Mdo_(`gq@??whcAG)2=A4=K zWUI~U%+T^Q>x#^wZeu#n9DC8AY^S5%F&7OuzQzpLOoqAgT-Mf|>j;ye$0mlm>UQms zAI(fLm^`y!P|vV-8!>2J15}>5I9n zF{9^lGaj4SOd5o&d6oWL!HsE}k5fa_+yhk*=&$UngZ;55IH$HqSWazV@tu+=$?z@( zNvBc@Ar&SEx;7#}ZNgZRmaz06ggN{on{NRD3>PNKq9~7mw@Qxrt<%KX?w<}kaICM2 zguMQwgM$l>E_heqxk8u`P>>Y>pU>FaP}z5Cc&_rRU*ftw?%-6yfB zYrU)gPvH*Q1e3OaMSWu_gjAU9OO_%?MM<=EqXsB`@+ySj!QVrKoCe8vlP*|v2AaHQ zVq3!H76OL&^@a(Q)UHP*&-tSV1Wn8b@}d?v=8riAiv1R__mXf~JS|5_PMVe`Lc11# z>gx$LP+sK}xP_`Y^0YjG?B4a1ztKgYANjxQDfj1LPhei)@0gF(Q&6w`LKjJp5IGGS zJN-QTA__T5_!u~?xAPE*lEeP`^KjY@Vg|ZMCvXk_5cvaQCozR*L{mtL$pGzu!EVU6 zMdQ~6`WQ4MgRZ>bIJrx1%@Er95pL?aX1LLHD@sE!G>cm!vG))UI?d$l|H!Xu(qF;rU)%euE7^3S+|8S3RLl_pqzZZe}qIozZ z---&uV)#}~0_px*XudTd49me=gF&EgivqOYmIb6k!Qq3E+qJ@QL*4DVDAH{KEZZZ{ zTcy^BQ?Awu5chF7QYw%j*JV$g<7I!M<)}SpW)02ExFWbgimNO)WliSnR^|sPZ4XRk z?R*zoBq$GVVDY6`!dG!3!>gWlR_ffgcD@fYJ?)$(d)Z}Zt(*s=@lgSj)7+(|z@R(1&BunZqf&CYt^wKSTg2qS^)G?^pYICvda@v>v(9c zIriIz1)yH8-HxolHlP0i@J5kr+v14ahXGXIjh~kARebpZuilGp^s4V+r1S+4KDvcD zust1MXA^Ll`2ltrZ1S{_5>&CmCX$d8?RviaHM$r z46oh?bXQ>dqncCzTA%`G;tvH{h4Yt}!>Nq1WWrsS;uXXqpxy!ZL6u)n zm2yaz%0Ze0Qa*6e;Q1k@E!vqpT!J*rT)mUmvwly# zrp=j<-^oG((z2mDq-hyDt!cCkR`LS(7mz}&rl{5QRV=2UaQAEcYHOx7-GL0zY~C># z%tRx~mBUMUV2XfZfxefepm?8b1Y|k9RR`&=ttQB5+--#m)ZGS%Pwzst?`|`g)2)WG z-g#@`3dt}YWiAgH7dQ$<>*NNVLJojHC8$um0MQ;SaJbttUYT)3i{)R_tGDQe2R-(@ tNZ*8MpfiM_A1#T3ApC`Ne@m1({Tng>K1Kil literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ca8fd0f73b7200dda0f1dcd230b9673d09a6b5ea GIT binary patch literal 5980 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 Ih1re%A6f`IG5`Po literal 0 HcmV?d00001 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-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/domain/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1110ec0bb193842bd435ea07c2385cd25893c15 GIT binary patch literal 4109 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 ad2sl%*Mt5 literal 0 HcmV?d00001 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 index 9822074..25d9a57 100644 --- a/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/azure_function.py +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/azure_function.py @@ -1,9 +1,7 @@ from __future__ import annotations import azure.functions as func -from app.clients.crud_client import NotificationsCrudClient -from app.services.notification_preferences_service import NotificationPreferencesService -from app.settings import settings +from app.service_factory import create_notification_preferences_service async def main(req: func.HttpRequest) -> func.HttpResponse: @@ -11,7 +9,7 @@ async def main(req: func.HttpRequest) -> func.HttpResponse: if not customer_profile_id: return func.HttpResponse("Missing X-HTM-CUSTOMER-PROFILE-ID-HEADER", status_code=400) - service = NotificationPreferencesService(NotificationsCrudClient(settings.notifications_crud_base_url, settings.customers_crud_base_url)) + 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"), 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 index 567dc8d..909e3ca 100644 --- a/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/fastapi_app.py +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/entrypoints/fastapi_app.py @@ -1,16 +1,16 @@ from __future__ import annotations from fastapi import FastAPI, Header, Query -from app.clients.crud_client import NotificationsCrudClient from app.domain.models import CustomerNotificationPreferencesResponse -from app.services.notification_preferences_service import NotificationPreferencesService +from app.service_factory import create_notification_preferences_service from app.settings import settings app = FastAPI(title="SE Notifications example") -def get_service() -> NotificationPreferencesService: - return NotificationPreferencesService(NotificationsCrudClient(settings.notifications_crud_base_url, settings.customers_crud_base_url)) +@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) @@ -18,7 +18,8 @@ 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"), ): - return await get_service().get_customer_notification_preferences( + 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-314.pyc b/src/python/notificatiesbeheren/notification-preferences-example/app/services/__pycache__/notification_preferences_service.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b970be837f1f53d3c2e1f88d9353136bdd7d642c GIT binary patch literal 12177 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 X+rUBjxE@WBkDZ`xHtOIQp!5De1U3p^ literal 0 HcmV?d00001 diff --git a/src/python/notificatiesbeheren/notification-preferences-example/app/settings.py b/src/python/notificatiesbeheren/notification-preferences-example/app/settings.py index da3cd45..a347ded 100644 --- a/src/python/notificatiesbeheren/notification-preferences-example/app/settings.py +++ b/src/python/notificatiesbeheren/notification-preferences-example/app/settings.py @@ -5,5 +5,9 @@ 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 index 238304d..d64d8e4 100644 --- a/src/python/notificatiesbeheren/notification-preferences-example/requirements.txt +++ b/src/python/notificatiesbeheren/notification-preferences-example/requirements.txt @@ -1,8 +1,8 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 httpx==0.28.1 -pydantic==2.10.4 -pydantic-settings==2.7.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 0000000000000000000000000000000000000000..557f6b0d9bfe2e4f7480f097cf07f160471c36ba GIT binary patch literal 9860 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 b{m&c>*I7oc+|^Lav)5T#{ggwNUeWytStTqP literal 0 HcmV?d00001 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 -- 2.45.2