hackbou-push-notification 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: hackbou-push-notification
3
+ Version: 0.1.0
4
+ Summary: Client Python / Django pour l'API Push Notification
5
+ Author-email: hackbou <hackbou@users.noreply.github.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/hardhacklife/push-notification-python-sdk
8
+ Project-URL: Documentation, https://github.com/hardhacklife/push-notification-python-sdk#readme
9
+ Project-URL: Repository, https://github.com/hardhacklife/push-notification-python-sdk
10
+ Project-URL: Issues, https://github.com/hardhacklife/push-notification-python-sdk/issues
11
+ Keywords: push-notification,push,notifications,django,fcm,apns
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: Django
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Typing :: Typed
21
+ Classifier: Topic :: Communications
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: typing_extensions>=4.5; python_version < "3.11"
26
+ Provides-Extra: django
27
+ Requires-Dist: Django>=4.2; extra == "django"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0; extra == "dev"
30
+ Requires-Dist: build; extra == "dev"
31
+ Requires-Dist: twine; extra == "dev"
32
+
33
+ # hackbou-push-notification
34
+
35
+ Client Python / Django pour l’API **Push Notification**.
36
+
37
+ Typage : PEP 561 (`py.typed`) + `TypedDict` pour les réponses.
38
+
39
+ **Import :** `push_notification`
40
+ **Package PyPI :** `hackbou-push-notification`
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ pip install hackbou-push-notification
46
+
47
+ # avec helpers Django
48
+ pip install "hackbou-push-notification[django]"
49
+ ```
50
+
51
+ ## Usage Python
52
+
53
+ ```python
54
+ from push_notification import PushNotificationClient, Device, NotificationCreateResult
55
+
56
+ with PushNotificationClient(
57
+ api_key="pf_live_…",
58
+ base_url="https://push.example.com",
59
+ ) as push:
60
+ result: NotificationCreateResult = push.send_notification(
61
+ title="Nouveau cours",
62
+ body="Le support est disponible.",
63
+ user_ids=["student_123"],
64
+ data={"screen": "course"},
65
+ )
66
+ devices: list[Device] = push.list_devices(limit=50)
67
+ page = push.list_devices_page(q="expo", limit=25, offset=0)
68
+ ```
69
+
70
+ ## Usage Django
71
+
72
+ `settings.py` :
73
+
74
+ ```python
75
+ PUSH_NOTIFICATION_API_KEY = env("PUSH_NOTIFICATION_API_KEY")
76
+ PUSH_NOTIFICATION_BASE_URL = env("PUSH_NOTIFICATION_BASE_URL", default="http://127.0.0.1:8787")
77
+ PUSH_NOTIFICATION_TIMEOUT = 15
78
+ ```
79
+
80
+ ```python
81
+ from push_notification.django import send_to_user, get_client
82
+
83
+ send_to_user(
84
+ "student_123",
85
+ title="Nouveau cours",
86
+ body="Un support vient d’être publié.",
87
+ data={"course_id": "math_101"},
88
+ )
89
+
90
+ client = get_client()
91
+ client.login_user("student_123", device_ids=["…"])
92
+ client.set_user_tags("student_123", {"language": "fr"})
93
+ ```
94
+
95
+ ## API du client
96
+
97
+ | Méthode | Endpoint |
98
+ |---------|----------|
99
+ | `register_device(platform=, token=)` | `POST /v1/devices` |
100
+ | `list_devices(q=, limit=, offset=)` | `GET /v1/devices` → `list[Device]` |
101
+ | `list_devices_page(…)` | idem → `{items,total,limit,offset,has_more}` |
102
+ | `delete_device(id)` | `DELETE /v1/devices/{id}` |
103
+ | `login` / `login_user` | `POST /v1/users/…/login` |
104
+ | `set_tags` / `set_user_tags` | `PUT /v1/users/…/tags` |
105
+ | `list_users` / `list_users_page` | `GET /v1/users` |
106
+ | `send_notification(…)` | `POST /v1/notifications` |
107
+ | `get_notification(id)` | `GET /v1/notifications/{id}` |
108
+ | `list_notifications` / `_page` | `GET /v1/notifications` |
109
+ | `health_live()` / `health_ready()` | healthchecks |
110
+
111
+ Les listes acceptent l’enveloppe paginée **et** un ancien tableau brut (compat).
112
+
113
+ ## Tests
114
+
115
+ ```bash
116
+ pip install -e ".[dev]"
117
+ pytest
118
+ ```
119
+
120
+ ## Licence
121
+
122
+ Apache-2.0
@@ -0,0 +1,11 @@
1
+ push_notification/__init__.py,sha256=QgGvKMraJucRwl8mEibGu6K32ZOV24jPTQKJMOhCtwY,531
2
+ push_notification/_parse.py,sha256=uWFd8pa1m5TkHmv4lQT2SqURAQDWbnAnWhIDJv1RAQ0,4145
3
+ push_notification/client.py,sha256=GmatJAcpw3gcS-jesQLf9oBi6PUNo1y3RGjNjeCxSnM,15469
4
+ push_notification/django.py,sha256=evaderE60-8Y4WsGuy3sksP4_r5DWTk15fOONaJmLX0,2920
5
+ push_notification/exceptions.py,sha256=5Y1kpsM4W5Pf-isLdJYyy30K7SnGd_OT2mzWDWPAtMU,1437
6
+ push_notification/models.py,sha256=PxsD4JvfEBag-di-99DQO9CK1KR0xG7HN4OcQzeXixw,1680
7
+ push_notification/py.typed,sha256=rnYdiA2Ka23H1X4q_PHfFdD9_Z0QiH6cMspLo5NxsTw,21
8
+ hackbou_push_notification-0.1.0.dist-info/METADATA,sha256=2AerHJDAqt7KGQGfODTCznwH3vs5yMA6t0r7Kvf0vhQ,3862
9
+ hackbou_push_notification-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ hackbou_push_notification-0.1.0.dist-info/top_level.txt,sha256=S0ykUv766Y1cni7vHhVKWj5BA3wk6qisEP_yQPrbbfY,18
11
+ hackbou_push_notification-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ push_notification
@@ -0,0 +1,27 @@
1
+ """Client Python PushNotification."""
2
+
3
+ from .client import PushNotificationClient
4
+ from .exceptions import PushNotificationError
5
+ from .models import (
6
+ DeliverySummary,
7
+ Device,
8
+ NotificationCreateResult,
9
+ NotificationDetail,
10
+ Page,
11
+ TagsResponse,
12
+ User,
13
+ )
14
+
15
+ __all__ = [
16
+ "PushNotificationClient",
17
+ "PushNotificationError",
18
+ "Device",
19
+ "User",
20
+ "Page",
21
+ "TagsResponse",
22
+ "DeliverySummary",
23
+ "NotificationCreateResult",
24
+ "NotificationDetail",
25
+ "__version__",
26
+ ]
27
+ __version__ = "0.1.1"
@@ -0,0 +1,127 @@
1
+ """Helpers de parsing défensifs — ne jamais faire confiance au JSON brut."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+ from .exceptions import PushNotificationError
8
+ from .models import Device, JsonDict, Page, User
9
+
10
+
11
+ def _require_mapping(value: Any, label: str) -> JsonDict:
12
+ if not isinstance(value, dict):
13
+ raise PushNotificationError(
14
+ f"Réponse {label} invalide (objet attendu)",
15
+ code="invalid_response",
16
+ )
17
+ return cast(JsonDict, value)
18
+
19
+
20
+ def _require_str(obj: JsonDict, key: str, label: str) -> str:
21
+ val = obj.get(key)
22
+ if not isinstance(val, str) or not val:
23
+ raise PushNotificationError(
24
+ f"Réponse {label} sans champ '{key}'",
25
+ code="invalid_response",
26
+ )
27
+ return val
28
+
29
+
30
+ def parse_device(raw: Any) -> Device:
31
+ obj = _require_mapping(raw, "appareil")
32
+ device: Device = {
33
+ "id": _require_str(obj, "id", "appareil"),
34
+ "platform": str(obj.get("platform") or "unknown"),
35
+ "status": str(obj.get("status") or "unknown"),
36
+ "user_id": obj.get("user_id") if isinstance(obj.get("user_id"), str) else None,
37
+ "created_at": str(obj.get("created_at") or ""),
38
+ "updated_at": str(obj.get("updated_at") or ""),
39
+ }
40
+ if "last_seen_at" in obj:
41
+ device["last_seen_at"] = (
42
+ obj["last_seen_at"] if isinstance(obj["last_seen_at"], str) else None
43
+ )
44
+ if "external_user_id" in obj:
45
+ device["external_user_id"] = (
46
+ obj["external_user_id"]
47
+ if isinstance(obj["external_user_id"], str)
48
+ else None
49
+ )
50
+ if isinstance(obj.get("token_preview"), str):
51
+ device["token_preview"] = obj["token_preview"]
52
+ if isinstance(obj.get("token_kind"), str):
53
+ device["token_kind"] = obj["token_kind"]
54
+ if isinstance(obj.get("push_ready"), bool):
55
+ device["push_ready"] = obj["push_ready"]
56
+ return device
57
+
58
+
59
+ def parse_user(raw: Any) -> User:
60
+ obj = _require_mapping(raw, "utilisateur")
61
+ return {
62
+ "id": _require_str(obj, "id", "utilisateur"),
63
+ "external_id": _require_str(obj, "external_id", "utilisateur"),
64
+ "created_at": str(obj.get("created_at") or ""),
65
+ "updated_at": str(obj.get("updated_at") or ""),
66
+ }
67
+
68
+
69
+ def parse_tags(raw: Any) -> dict[str, str]:
70
+ obj = _require_mapping(raw, "tags")
71
+ tags_raw = obj.get("tags", {})
72
+ if not isinstance(tags_raw, dict):
73
+ return {}
74
+ out: dict[str, str] = {}
75
+ for key, value in tags_raw.items():
76
+ if isinstance(key, str) and isinstance(value, str):
77
+ out[key] = value
78
+ elif isinstance(key, str) and value is not None:
79
+ out[key] = str(value)
80
+ return out
81
+
82
+
83
+ def parse_page(raw: Any, *, item_label: str = "item") -> Page:
84
+ """
85
+ Accepte l’enveloppe Page moderne **ou** un tableau legacy.
86
+ Toujours retourne {items,total,limit,offset,has_more}.
87
+ """
88
+ if isinstance(raw, list):
89
+ items = list(raw)
90
+ return {
91
+ "items": items,
92
+ "total": len(items),
93
+ "limit": len(items) or 25,
94
+ "offset": 0,
95
+ "has_more": False,
96
+ }
97
+
98
+ obj = _require_mapping(raw, f"page {item_label}")
99
+ items_raw = obj.get("items")
100
+ if not isinstance(items_raw, list):
101
+ # Ancien shape accidentel : objet unique
102
+ raise PushNotificationError(
103
+ f"Réponse page {item_label} sans 'items'",
104
+ code="invalid_response",
105
+ )
106
+
107
+ total = obj.get("total")
108
+ limit = obj.get("limit")
109
+ offset = obj.get("offset")
110
+ has_more = obj.get("has_more")
111
+
112
+ total_i = int(total) if isinstance(total, (int, float)) else len(items_raw)
113
+ limit_i = int(limit) if isinstance(limit, (int, float)) else max(len(items_raw), 1)
114
+ offset_i = int(offset) if isinstance(offset, (int, float)) else 0
115
+ has_more_b = (
116
+ bool(has_more)
117
+ if isinstance(has_more, bool)
118
+ else offset_i + len(items_raw) < total_i
119
+ )
120
+
121
+ return {
122
+ "items": items_raw,
123
+ "total": total_i,
124
+ "limit": limit_i,
125
+ "offset": offset_i,
126
+ "has_more": has_more_b,
127
+ }
@@ -0,0 +1,459 @@
1
+ """Client HTTP PushNotification — payloads alignés OneSignal, typage strict, BC préservée."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping, cast
6
+ from uuid import uuid4
7
+
8
+ import httpx
9
+
10
+ from ._parse import parse_device, parse_page, parse_tags, parse_user
11
+ from .exceptions import PushNotificationError, raise_for_status
12
+ from .models import (
13
+ Device,
14
+ JsonDict,
15
+ NotificationCreateResult,
16
+ NotificationDetail,
17
+ Page,
18
+ User,
19
+ )
20
+
21
+
22
+ def cast_notification_result(raw: JsonDict) -> NotificationCreateResult:
23
+ """Cast défensif : exige au minimum un id string si présent."""
24
+ if "id" in raw and not isinstance(raw.get("id"), str):
25
+ raise PushNotificationError(
26
+ "Réponse notification sans id valide",
27
+ code="invalid_response",
28
+ )
29
+ return cast(NotificationCreateResult, raw)
30
+
31
+
32
+ class PushNotificationClient:
33
+ """
34
+ Client synchrone httpx.
35
+
36
+ Méthodes historiques conservées (`login`, `set_tags`, `list_devices` → list).
37
+ Alias documentés : `login_user`, `set_user_tags`.
38
+ Pagination : `list_*_page(...)` ou params `q`/`limit`/`offset` sur les listes.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ api_key: str,
44
+ *,
45
+ base_url: str = "http://127.0.0.1:8787",
46
+ timeout: float = 15.0,
47
+ auth_scheme: str = "Bearer",
48
+ ) -> None:
49
+ if not isinstance(api_key, str) or not api_key.strip():
50
+ raise PushNotificationError("api_key is required", code="validation")
51
+ if not isinstance(base_url, str) or not base_url.strip():
52
+ raise PushNotificationError("base_url is required", code="validation")
53
+
54
+ self.api_key = api_key.strip()
55
+ self.base_url = base_url.strip().rstrip("/")
56
+ # OneSignal utilise `Key` ; PushNotification accepte Bearer et Key.
57
+ self.auth_scheme = auth_scheme
58
+ self._client = httpx.Client(base_url=self.base_url, timeout=timeout)
59
+
60
+ def close(self) -> None:
61
+ self._client.close()
62
+
63
+ def __enter__(self) -> PushNotificationClient:
64
+ return self
65
+
66
+ def __exit__(self, *args: object) -> None:
67
+ self.close()
68
+
69
+ def _headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
70
+ headers: dict[str, str] = {
71
+ "Authorization": f"{self.auth_scheme} {self.api_key}",
72
+ "Content-Type": "application/json",
73
+ "Accept": "application/json",
74
+ }
75
+ if extra:
76
+ headers.update(dict(extra))
77
+ return headers
78
+
79
+ def _request(
80
+ self,
81
+ method: str,
82
+ path: str,
83
+ *,
84
+ json: JsonDict | None = None,
85
+ headers: Mapping[str, str] | None = None,
86
+ params: Mapping[str, str | int] | None = None,
87
+ ) -> Any:
88
+ response = self._client.request(
89
+ method,
90
+ path,
91
+ json=json,
92
+ headers=self._headers(headers),
93
+ params=params,
94
+ )
95
+ raise_for_status(response)
96
+ if not response.content:
97
+ return {}
98
+ try:
99
+ return response.json()
100
+ except ValueError as exc:
101
+ raise PushNotificationError(
102
+ "Réponse JSON invalide",
103
+ status=response.status_code,
104
+ code="invalid_response",
105
+ ) from exc
106
+
107
+ @staticmethod
108
+ def _list_params(
109
+ *,
110
+ q: str | None = None,
111
+ limit: int | None = None,
112
+ offset: int | None = None,
113
+ status: str | None = None,
114
+ platform: str | None = None,
115
+ ) -> dict[str, str | int]:
116
+ params: dict[str, str | int] = {}
117
+ if q is not None and q.strip():
118
+ params["q"] = q.strip()
119
+ if limit is not None:
120
+ params["limit"] = max(1, min(int(limit), 100))
121
+ if offset is not None:
122
+ params["offset"] = max(0, int(offset))
123
+ if status:
124
+ params["status"] = status
125
+ if platform:
126
+ params["platform"] = platform
127
+ return params
128
+
129
+ # --- Devices ---------------------------------------------------------
130
+
131
+ def register_device(self, *, platform: str, token: str) -> Device:
132
+ if platform not in ("android", "ios", "web"):
133
+ raise PushNotificationError(
134
+ "platform must be android | ios | web",
135
+ code="validation",
136
+ )
137
+ if not token or not str(token).strip():
138
+ raise PushNotificationError("token is required", code="validation")
139
+ raw = self._request(
140
+ "POST",
141
+ "/v1/devices",
142
+ json={"platform": platform, "token": str(token).strip()},
143
+ )
144
+ return parse_device(raw)
145
+
146
+ def list_devices_page(
147
+ self,
148
+ *,
149
+ q: str | None = None,
150
+ limit: int = 25,
151
+ offset: int = 0,
152
+ status: str | None = None,
153
+ platform: str | None = None,
154
+ ) -> Page:
155
+ raw = self._request(
156
+ "GET",
157
+ "/v1/devices",
158
+ params=self._list_params(
159
+ q=q, limit=limit, offset=offset, status=status, platform=platform
160
+ ),
161
+ )
162
+ page = parse_page(raw, item_label="devices")
163
+ return {
164
+ "items": [parse_device(item) for item in page["items"]],
165
+ "total": page["total"],
166
+ "limit": page["limit"],
167
+ "offset": page["offset"],
168
+ "has_more": page["has_more"],
169
+ }
170
+
171
+ def list_devices(
172
+ self,
173
+ *,
174
+ q: str | None = None,
175
+ limit: int = 25,
176
+ offset: int = 0,
177
+ status: str | None = None,
178
+ platform: str | None = None,
179
+ ) -> list[Device]:
180
+ """
181
+ Retourne les items de la page (BC historique).
182
+ Pour total / has_more, utilisez `list_devices_page`.
183
+ """
184
+ return self.list_devices_page(
185
+ q=q, limit=limit, offset=offset, status=status, platform=platform
186
+ )["items"]
187
+
188
+ def delete_device(self, device_id: str) -> Device:
189
+ if not device_id or not str(device_id).strip():
190
+ raise PushNotificationError("device_id is required", code="validation")
191
+ raw = self._request("DELETE", f"/v1/devices/{device_id}")
192
+ return parse_device(raw)
193
+
194
+ # --- Users -----------------------------------------------------------
195
+
196
+ def login(
197
+ self, external_id: str, device_ids: list[str] | None = None
198
+ ) -> User:
199
+ if not external_id or not str(external_id).strip():
200
+ raise PushNotificationError("external_id is required", code="validation")
201
+ raw = self._request(
202
+ "POST",
203
+ f"/v1/users/{str(external_id).strip()}/login",
204
+ json={"device_ids": list(device_ids or [])},
205
+ )
206
+ return parse_user(raw)
207
+
208
+ def login_user(
209
+ self, external_id: str, device_ids: list[str] | None = None
210
+ ) -> User:
211
+ """Alias documenté de `login`."""
212
+ return self.login(external_id, device_ids)
213
+
214
+ def set_tags(self, external_id: str, tags: dict[str, str]) -> dict[str, str]:
215
+ if not external_id or not str(external_id).strip():
216
+ raise PushNotificationError("external_id is required", code="validation")
217
+ if not isinstance(tags, dict):
218
+ raise PushNotificationError("tags must be a dict", code="validation")
219
+ raw = self._request(
220
+ "PUT",
221
+ f"/v1/users/{str(external_id).strip()}/tags",
222
+ json={"tags": tags},
223
+ )
224
+ return parse_tags(raw)
225
+
226
+ def set_user_tags(
227
+ self, external_id: str, tags: dict[str, str]
228
+ ) -> dict[str, str]:
229
+ """Alias documenté de `set_tags`."""
230
+ return self.set_tags(external_id, tags)
231
+
232
+ def list_users_page(
233
+ self,
234
+ *,
235
+ q: str | None = None,
236
+ limit: int = 25,
237
+ offset: int = 0,
238
+ ) -> Page:
239
+ raw = self._request(
240
+ "GET",
241
+ "/v1/users",
242
+ params=self._list_params(q=q, limit=limit, offset=offset),
243
+ )
244
+ page = parse_page(raw, item_label="users")
245
+ return {
246
+ "items": [parse_user(item) for item in page["items"]],
247
+ "total": page["total"],
248
+ "limit": page["limit"],
249
+ "offset": page["offset"],
250
+ "has_more": page["has_more"],
251
+ }
252
+
253
+ def list_users(
254
+ self,
255
+ *,
256
+ q: str | None = None,
257
+ limit: int = 25,
258
+ offset: int = 0,
259
+ ) -> list[User]:
260
+ return self.list_users_page(q=q, limit=limit, offset=offset)["items"]
261
+
262
+ # --- Notifications (OneSignal-compatible) ----------------------------
263
+
264
+ def create_notification(
265
+ self, payload: JsonDict
266
+ ) -> NotificationCreateResult:
267
+ """Envoi brut au format OneSignal / legacy PushNotification."""
268
+ if not isinstance(payload, dict):
269
+ raise PushNotificationError("payload must be a dict", code="validation")
270
+ headers: dict[str, str] = {}
271
+ idem = payload.get("idempotency_key") or payload.get("external_id")
272
+ if idem:
273
+ headers["Idempotency-Key"] = str(idem)
274
+ raw = self._request(
275
+ "POST",
276
+ "/v1/notifications",
277
+ json=payload,
278
+ headers=headers or None,
279
+ )
280
+ if not isinstance(raw, dict):
281
+ raise PushNotificationError(
282
+ "Réponse notification invalide",
283
+ code="invalid_response",
284
+ )
285
+ return cast_notification_result(raw)
286
+
287
+ def send_notification(
288
+ self,
289
+ *,
290
+ title: str = "",
291
+ body: str = "",
292
+ contents: dict[str, str] | None = None,
293
+ headings: dict[str, str] | None = None,
294
+ user_ids: list[str] | None = None,
295
+ include_external_user_ids: list[str] | None = None,
296
+ include_aliases: dict[str, list[str]] | None = None,
297
+ device_ids: list[str] | None = None,
298
+ include_subscription_ids: list[str] | None = None,
299
+ data: dict[str, Any] | None = None,
300
+ priority: str | int = "normal",
301
+ ttl_seconds: int | None = None,
302
+ ttl: int | None = None,
303
+ url: str | None = None,
304
+ name: str | None = None,
305
+ idempotency_key: str | None = None,
306
+ # legacy kwargs
307
+ target: dict[str, Any] | None = None,
308
+ notification: dict[str, Any] | None = None,
309
+ options: dict[str, Any] | None = None,
310
+ ) -> NotificationCreateResult:
311
+ """
312
+ Envoie une notification.
313
+
314
+ Par défaut construit un payload **OneSignal**. Le chemin legacy
315
+ (`target` / `notification`) reste supporté.
316
+ """
317
+ if target is not None or notification is not None:
318
+ legacy: JsonDict = {
319
+ "target": target
320
+ or {
321
+ "user_ids": user_ids or [],
322
+ "device_ids": device_ids or [],
323
+ },
324
+ "notification": notification
325
+ or {
326
+ "title": title,
327
+ "body": body,
328
+ "data": data or {},
329
+ },
330
+ "options": options
331
+ or {
332
+ "priority": priority
333
+ if isinstance(priority, str)
334
+ else ("high" if int(priority) >= 10 else "normal"),
335
+ "ttl_seconds": ttl_seconds if ttl_seconds is not None else ttl,
336
+ },
337
+ }
338
+ return self.create_notification(legacy)
339
+
340
+ contents_map = contents or {"en": body, "fr": body}
341
+ headings_map = headings or ({"en": title, "fr": title} if title else None)
342
+
343
+ aliases = include_aliases
344
+ if aliases is None and (user_ids or include_external_user_ids):
345
+ aliases = {
346
+ "external_id": list(
347
+ dict.fromkeys(
348
+ (user_ids or []) + (include_external_user_ids or [])
349
+ )
350
+ )
351
+ }
352
+
353
+ subs = include_subscription_ids or device_ids
354
+
355
+ if not aliases and not subs:
356
+ raise PushNotificationError(
357
+ "include_aliases.external_id / user_ids or include_subscription_ids required",
358
+ code="validation",
359
+ )
360
+
361
+ if isinstance(priority, str):
362
+ os_priority = 10 if priority.lower() == "high" else 5
363
+ else:
364
+ os_priority = int(priority)
365
+
366
+ payload: JsonDict = {
367
+ "target_channel": "push",
368
+ "contents": contents_map,
369
+ "data": data or {},
370
+ "priority": os_priority,
371
+ }
372
+ if headings_map:
373
+ payload["headings"] = headings_map
374
+ if aliases:
375
+ payload["include_aliases"] = aliases
376
+ if include_external_user_ids and "include_aliases" not in payload:
377
+ payload["include_external_user_ids"] = include_external_user_ids
378
+ if subs:
379
+ payload["include_subscription_ids"] = subs
380
+ if ttl is not None:
381
+ payload["ttl"] = ttl
382
+ elif ttl_seconds is not None:
383
+ payload["ttl"] = ttl_seconds
384
+ if url:
385
+ payload["url"] = url
386
+ if name:
387
+ payload["name"] = name
388
+ payload["idempotency_key"] = (
389
+ idempotency_key if idempotency_key else str(uuid4())
390
+ )
391
+
392
+ return self.create_notification(payload)
393
+
394
+ def get_notification(self, notification_id: str) -> NotificationDetail:
395
+ if not notification_id or not str(notification_id).strip():
396
+ raise PushNotificationError("notification_id is required", code="validation")
397
+ raw = self._request("GET", f"/v1/notifications/{notification_id}")
398
+ if not isinstance(raw, dict):
399
+ raise PushNotificationError(
400
+ "Réponse notification invalide",
401
+ code="invalid_response",
402
+ )
403
+ return cast(NotificationDetail, raw) # detail riche — champs NotRequired
404
+
405
+ def list_notifications_page(
406
+ self,
407
+ *,
408
+ q: str | None = None,
409
+ limit: int = 25,
410
+ offset: int = 0,
411
+ status: str | None = None,
412
+ ) -> Page:
413
+ raw = self._request(
414
+ "GET",
415
+ "/v1/notifications",
416
+ params=self._list_params(
417
+ q=q, limit=limit, offset=offset, status=status
418
+ ),
419
+ )
420
+ page = parse_page(raw, item_label="notifications")
421
+ items: list[NotificationDetail] = []
422
+ for item in page["items"]:
423
+ if isinstance(item, dict):
424
+ items.append(cast(NotificationDetail, item))
425
+ return {
426
+ "items": items,
427
+ "total": page["total"],
428
+ "limit": page["limit"],
429
+ "offset": page["offset"],
430
+ "has_more": page["has_more"],
431
+ }
432
+
433
+ def list_notifications(
434
+ self,
435
+ *,
436
+ q: str | None = None,
437
+ limit: int = 25,
438
+ offset: int = 0,
439
+ status: str | None = None,
440
+ ) -> list[NotificationDetail]:
441
+ return self.list_notifications_page(
442
+ q=q, limit=limit, offset=offset, status=status
443
+ )["items"]
444
+
445
+ # --- Health ----------------------------------------------------------
446
+
447
+ def health_live(self) -> bool:
448
+ try:
449
+ response = self._client.get("/health/live")
450
+ return response.status_code == 200
451
+ except httpx.HTTPError:
452
+ return False
453
+
454
+ def health_ready(self) -> bool:
455
+ try:
456
+ response = self._client.get("/health/ready")
457
+ return response.status_code == 200
458
+ except httpx.HTTPError:
459
+ return False
@@ -0,0 +1,101 @@
1
+ """Intégration Django : settings + helper `get_client()`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+ from typing import Any
7
+
8
+ from django.conf import settings
9
+
10
+ from .client import PushNotificationClient
11
+ from .exceptions import PushNotificationError
12
+ from .models import NotificationCreateResult
13
+
14
+
15
+ def _setting(name: str, default: Any = None) -> Any:
16
+ return getattr(settings, name, default)
17
+
18
+
19
+ @lru_cache(maxsize=1)
20
+ def get_client() -> PushNotificationClient:
21
+ """
22
+ Client singleton basé sur Django settings :
23
+
24
+ - PUSH_NOTIFICATION_API_KEY (requis)
25
+ - PUSH_NOTIFICATION_BASE_URL (défaut http://127.0.0.1:8787)
26
+ - PUSH_NOTIFICATION_TIMEOUT (défaut 15)
27
+ """
28
+ api_key = _setting("PUSH_NOTIFICATION_API_KEY") or _setting("PUSHFORGE_API_KEY")
29
+ if not api_key or not isinstance(api_key, str):
30
+ raise PushNotificationError(
31
+ "Définissez PUSH_NOTIFICATION_API_KEY dans vos settings Django",
32
+ code="missing_settings",
33
+ )
34
+
35
+ base_url = _setting(
36
+ "PUSH_NOTIFICATION_BASE_URL",
37
+ _setting("PUSHFORGE_BASE_URL", "http://127.0.0.1:8787"),
38
+ )
39
+ timeout_raw = _setting(
40
+ "PUSH_NOTIFICATION_TIMEOUT",
41
+ _setting("PUSHFORGE_TIMEOUT", 15),
42
+ )
43
+ try:
44
+ timeout = float(timeout_raw)
45
+ except (TypeError, ValueError) as exc:
46
+ raise PushNotificationError(
47
+ "PUSH_NOTIFICATION_TIMEOUT must be a number",
48
+ code="missing_settings",
49
+ ) from exc
50
+
51
+ return PushNotificationClient(
52
+ api_key=api_key,
53
+ base_url=str(base_url),
54
+ timeout=timeout,
55
+ )
56
+
57
+
58
+ def clear_client_cache() -> None:
59
+ """Invalide le singleton (tests / reload settings)."""
60
+ get_client.cache_clear()
61
+
62
+
63
+ def send_to_user(
64
+ external_id: str,
65
+ *,
66
+ title: str,
67
+ body: str,
68
+ data: dict[str, Any] | None = None,
69
+ priority: str = "normal",
70
+ ) -> NotificationCreateResult:
71
+ """Raccourci Django : envoi à un utilisateur externe."""
72
+ if not external_id or not str(external_id).strip():
73
+ raise PushNotificationError("external_id is required", code="validation")
74
+ return get_client().send_notification(
75
+ title=title,
76
+ body=body,
77
+ user_ids=[str(external_id).strip()],
78
+ data=data,
79
+ priority=priority,
80
+ )
81
+
82
+
83
+ def send_to_users(
84
+ external_ids: list[str],
85
+ *,
86
+ title: str,
87
+ body: str,
88
+ data: dict[str, Any] | None = None,
89
+ priority: str = "normal",
90
+ ) -> NotificationCreateResult:
91
+ """Demande à PushNotification d'envoyer aux utilisateurs (external_id)."""
92
+ cleaned = [str(x).strip() for x in external_ids if x and str(x).strip()]
93
+ if not cleaned:
94
+ raise PushNotificationError("external_ids must not be empty", code="validation")
95
+ return get_client().send_notification(
96
+ title=title,
97
+ body=body,
98
+ user_ids=cleaned,
99
+ data=data,
100
+ priority=priority,
101
+ )
@@ -0,0 +1,55 @@
1
+ """Erreurs PushNotification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ class PushNotificationError(Exception):
11
+ """Erreur API ou client PushNotification."""
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ *,
17
+ status: int | None = None,
18
+ code: str | None = None,
19
+ ) -> None:
20
+ super().__init__(message)
21
+ self.message = message
22
+ self.status = status
23
+ self.code = code
24
+
25
+ def __str__(self) -> str:
26
+ parts = [self.message]
27
+ if self.code:
28
+ parts.append(f"code={self.code}")
29
+ if self.status is not None:
30
+ parts.append(f"status={self.status}")
31
+ return " ".join(parts)
32
+
33
+
34
+ def raise_for_status(response: httpx.Response) -> None:
35
+ """Convertit une réponse HTTP d’erreur en PushNotificationError typée."""
36
+ if response.is_success:
37
+ return
38
+
39
+ code = "http_error"
40
+ message = response.reason_phrase or f"HTTP {response.status_code}"
41
+
42
+ try:
43
+ body: Any = response.json()
44
+ except ValueError:
45
+ body = None
46
+
47
+ if isinstance(body, dict):
48
+ err = body.get("error")
49
+ if isinstance(err, dict):
50
+ if isinstance(err.get("code"), str):
51
+ code = err["code"]
52
+ if isinstance(err.get("message"), str):
53
+ message = err["message"]
54
+
55
+ raise PushNotificationError(message, status=response.status_code, code=code)
@@ -0,0 +1,83 @@
1
+ """Modèles typés (TypedDict) — compatibles dict à l’exécution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, TypedDict
6
+
7
+ try:
8
+ from typing import NotRequired # py3.11+
9
+ except ImportError: # pragma: no cover
10
+ from typing_extensions import NotRequired
11
+
12
+
13
+ class Device(TypedDict):
14
+ id: str
15
+ platform: str
16
+ status: str
17
+ user_id: str | None
18
+ created_at: str
19
+ updated_at: str
20
+ last_seen_at: NotRequired[str | None]
21
+ external_user_id: NotRequired[str | None]
22
+ token_preview: NotRequired[str]
23
+ token_kind: NotRequired[str]
24
+ push_ready: NotRequired[bool]
25
+
26
+
27
+ class User(TypedDict):
28
+ id: str
29
+ external_id: str
30
+ created_at: str
31
+ updated_at: str
32
+
33
+
34
+ class TagsResponse(TypedDict):
35
+ tags: dict[str, str]
36
+
37
+
38
+ class DeliverySummary(TypedDict, total=False):
39
+ expo_tokens: int
40
+ expo_sent: int
41
+ skipped_reason: str
42
+ error: str
43
+ expo_response: Any
44
+
45
+
46
+ class NotificationCreateResult(TypedDict, total=False):
47
+ id: str
48
+ external_id: str | None
49
+ recipients: int
50
+ status: str
51
+ created_at: str
52
+ delivery: DeliverySummary
53
+ errors: Any
54
+ successful: int
55
+ failed: int
56
+ remaining: int
57
+
58
+
59
+ class NotificationDetail(TypedDict, total=False):
60
+ id: str
61
+ status: str
62
+ target: dict[str, Any]
63
+ notification: dict[str, Any]
64
+ options: dict[str, Any]
65
+ successful: int
66
+ failed: int
67
+ remaining: int
68
+ queued_at: str | None
69
+ created_at: str
70
+ updated_at: str
71
+
72
+
73
+ class Page(TypedDict):
74
+ """Enveloppe liste paginée (API stable). `items` typés au call-site."""
75
+
76
+ items: list[Any]
77
+ total: int
78
+ limit: int
79
+ offset: int
80
+ has_more: bool
81
+
82
+
83
+ JsonDict = dict[str, Any]
@@ -0,0 +1 @@
1
+ # Marker for PEP 561