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.
- hackbou_push_notification-0.1.0.dist-info/METADATA +122 -0
- hackbou_push_notification-0.1.0.dist-info/RECORD +11 -0
- hackbou_push_notification-0.1.0.dist-info/WHEEL +5 -0
- hackbou_push_notification-0.1.0.dist-info/top_level.txt +1 -0
- push_notification/__init__.py +27 -0
- push_notification/_parse.py +127 -0
- push_notification/client.py +459 -0
- push_notification/django.py +101 -0
- push_notification/exceptions.py +55 -0
- push_notification/models.py +83 -0
- push_notification/py.typed +1 -0
|
@@ -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 @@
|
|
|
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
|