From 67ab05055cc3d0b56b55018f55198893bd28128d Mon Sep 17 00:00:00 2001 From: Herald Date: Fri, 24 Apr 2026 15:37:46 +0200 Subject: [PATCH] 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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