uzsms 2.0.0__tar.gz
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.
- uzsms-2.0.0/CHANGELOG.md +186 -0
- uzsms-2.0.0/LICENSE +16 -0
- uzsms-2.0.0/MANIFEST.in +4 -0
- uzsms-2.0.0/PKG-INFO +450 -0
- uzsms-2.0.0/README.md +399 -0
- uzsms-2.0.0/UPGRADE.md +94 -0
- uzsms-2.0.0/pyproject.toml +87 -0
- uzsms-2.0.0/setup.cfg +4 -0
- uzsms-2.0.0/tests/test_admin.py +33 -0
- uzsms-2.0.0/tests/test_api.py +584 -0
- uzsms-2.0.0/tests/test_app_config.py +35 -0
- uzsms-2.0.0/tests/test_async_services.py +520 -0
- uzsms-2.0.0/tests/test_backend_loader.py +85 -0
- uzsms-2.0.0/tests/test_backend_playmobile.py +303 -0
- uzsms-2.0.0/tests/test_backend_playmobile_async.py +342 -0
- uzsms-2.0.0/tests/test_backends_base.py +148 -0
- uzsms-2.0.0/tests/test_compat.py +77 -0
- uzsms-2.0.0/tests/test_conf.py +221 -0
- uzsms-2.0.0/tests/test_dto.py +49 -0
- uzsms-2.0.0/tests/test_exceptions.py +55 -0
- uzsms-2.0.0/tests/test_harness.py +31 -0
- uzsms-2.0.0/tests/test_models.py +101 -0
- uzsms-2.0.0/tests/test_public_api.py +100 -0
- uzsms-2.0.0/tests/test_repository.py +179 -0
- uzsms-2.0.0/tests/test_services.py +429 -0
- uzsms-2.0.0/tests/test_tasks.py +110 -0
- uzsms-2.0.0/tests/test_validators.py +88 -0
- uzsms-2.0.0/uzsms/__init__.py +66 -0
- uzsms-2.0.0/uzsms/admin.py +30 -0
- uzsms-2.0.0/uzsms/api/__init__.py +9 -0
- uzsms-2.0.0/uzsms/api/responses.py +36 -0
- uzsms-2.0.0/uzsms/api/serializers.py +34 -0
- uzsms-2.0.0/uzsms/api/urls.py +18 -0
- uzsms-2.0.0/uzsms/api/views.py +195 -0
- uzsms-2.0.0/uzsms/apps.py +8 -0
- uzsms-2.0.0/uzsms/backends/__init__.py +64 -0
- uzsms-2.0.0/uzsms/backends/base.py +79 -0
- uzsms-2.0.0/uzsms/backends/console.py +33 -0
- uzsms-2.0.0/uzsms/backends/dummy.py +19 -0
- uzsms-2.0.0/uzsms/backends/locmem.py +39 -0
- uzsms-2.0.0/uzsms/backends/playmobile.py +343 -0
- uzsms-2.0.0/uzsms/compat.py +67 -0
- uzsms-2.0.0/uzsms/conf.py +102 -0
- uzsms-2.0.0/uzsms/dto.py +30 -0
- uzsms-2.0.0/uzsms/exceptions.py +47 -0
- uzsms-2.0.0/uzsms/migrations/0001_initial.py +70 -0
- uzsms-2.0.0/uzsms/migrations/__init__.py +0 -0
- uzsms-2.0.0/uzsms/models.py +75 -0
- uzsms-2.0.0/uzsms/py.typed +0 -0
- uzsms-2.0.0/uzsms/repository.py +66 -0
- uzsms-2.0.0/uzsms/services.py +184 -0
- uzsms-2.0.0/uzsms/tasks.py +72 -0
- uzsms-2.0.0/uzsms/urls.py +23 -0
- uzsms-2.0.0/uzsms/validators.py +54 -0
- uzsms-2.0.0/uzsms.egg-info/PKG-INFO +450 -0
- uzsms-2.0.0/uzsms.egg-info/SOURCES.txt +57 -0
- uzsms-2.0.0/uzsms.egg-info/dependency_links.txt +1 -0
- uzsms-2.0.0/uzsms.egg-info/requires.txt +24 -0
- uzsms-2.0.0/uzsms.egg-info/top_level.txt +1 -0
uzsms-2.0.0/CHANGELOG.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
## 2.0.0
|
|
6
|
+
|
|
7
|
+
A ground-up rewrite. See `UPGRADE.md` for the 1.x → 2.0 migration
|
|
8
|
+
procedure — **read it before running `migrate`**, the migration history
|
|
9
|
+
is data-destroying for existing installs.
|
|
10
|
+
|
|
11
|
+
**The PyPI distribution is renamed** from `django-sms-uz` to `uzsms`,
|
|
12
|
+
matching the importable package name. `pip install --upgrade` will not
|
|
13
|
+
find 2.0 across a rename; uninstall `django-sms-uz` and install `uzsms`
|
|
14
|
+
instead. The app label (`SMS`), the database table (`SMS_smslog`) and
|
|
15
|
+
the settings key (`SMS_SETTINGS`) are unaffected. `django-sms-uz` stays
|
|
16
|
+
on PyPI at 1.0.1 and receives no further releases.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `SmsClient`/`AsyncSmsClient` (`uzsms/services.py`): the package's
|
|
21
|
+
primary API, with `send(phone_number, text, *, message_id=None)` and
|
|
22
|
+
`send_bulk(messages)`, validating every message and bulk-writing log
|
|
23
|
+
rows in a constant number of queries.
|
|
24
|
+
- `AsyncSmsClient` and `AsyncPlaymobileBackend` for async/ASGI Django,
|
|
25
|
+
behind the new `async` extra (`httpx`).
|
|
26
|
+
- Swappable SMS backends (`uzsms/backends/`): `PlaymobileBackend` /
|
|
27
|
+
`AsyncPlaymobileBackend`, `ConsoleBackend`, `LocMemBackend` /
|
|
28
|
+
`AsyncLocMemBackend`, `DummyBackend`, plus `BaseSmsBackend`/
|
|
29
|
+
`BaseAsyncSmsBackend` for writing your own, selected via
|
|
30
|
+
`SMS_SETTINGS["BACKEND"]`.
|
|
31
|
+
- `send_bulk()` for sending many messages in a single batched HTTP
|
|
32
|
+
request.
|
|
33
|
+
- Value objects `SmsMessage` and `SendResult` (`uzsms/dto.py`), both
|
|
34
|
+
frozen dataclasses; `SendResult` carries `log_id`.
|
|
35
|
+
- A dedicated exception hierarchy (`uzsms/exceptions.py`): `SmsError`,
|
|
36
|
+
`SmsConfigurationError`, `SmsValidationError`, `SmsTransportError`,
|
|
37
|
+
`SmsProviderError`, `SmsBackendError`.
|
|
38
|
+
- `SmsLogRecorder` (`uzsms/repository.py`), the single component
|
|
39
|
+
permitted to write `SmsLog` rows, batching creates and updates.
|
|
40
|
+
- Configurable timeout, retry, backoff, and connection pooling
|
|
41
|
+
(`SMS_SETTINGS["TIMEOUT"/"MAX_RETRIES"/"RETRY_BACKOFF"/"POOL_MAXSIZE"]`).
|
|
42
|
+
Retries are safe because every message carries a stable `message_id`
|
|
43
|
+
the broker deduplicates on replay.
|
|
44
|
+
- `SMS_SETTINGS["FAIL_SILENTLY"]`, `["MAX_MESSAGE_LENGTH"]`,
|
|
45
|
+
`["PERMISSION_CLASSES"]`, `["THROTTLE_RATE"]`, and `["ASYNC_BACKEND"]`
|
|
46
|
+
(the backend `AsyncSmsClient` resolves, kept separate from `["BACKEND"]`
|
|
47
|
+
so `SmsClient` and `AsyncSmsClient` can both be constructed from one
|
|
48
|
+
unmodified `SMS_SETTINGS` dict).
|
|
49
|
+
- Shared phone-number and message-text validators
|
|
50
|
+
(`uzsms/validators.py`), used by both the client and the DRF
|
|
51
|
+
serializer.
|
|
52
|
+
- A uniform HTTP API response envelope (`uzsms/api/responses.py`):
|
|
53
|
+
every response, success or failure, is
|
|
54
|
+
`{"success": bool, "data": object|null, "error": object|null}`, with
|
|
55
|
+
meaningful HTTP status codes preserved (`201`/`400`/`401`/`403`/`429`/`502`).
|
|
56
|
+
DRF's own auth/permission/throttling/parsing errors are re-shaped into
|
|
57
|
+
the same envelope.
|
|
58
|
+
- Throttling on the send endpoint (`SMS_SETTINGS["THROTTLE_RATE"]`,
|
|
59
|
+
default `20/min`).
|
|
60
|
+
- An optional Celery task, `uzsms.tasks.send_sms_task`
|
|
61
|
+
(`uzsms[celery]`), with automatic retry on
|
|
62
|
+
`SmsTransportError`.
|
|
63
|
+
- Backward-compatibility shims (`uzsms/compat.py`): `SMS_Sender`
|
|
64
|
+
reproduces 1.0.1's constructor and method names, delegating to
|
|
65
|
+
`SmsClient` internally, and emits a `DeprecationWarning` from every
|
|
66
|
+
entry point.
|
|
67
|
+
- A defined public API surface: `uzsms/__init__.py` exposes
|
|
68
|
+
`SmsClient`, `AsyncSmsClient`, `SmsMessage`, `SendResult`,
|
|
69
|
+
`SmsLogRecorder`, `SMS_Sender`, `get_backend`, `get_async_backend`, and
|
|
70
|
+
the exception hierarchy, resolved lazily so importing `uzsms` never
|
|
71
|
+
requires `SMS_SETTINGS` to be configured.
|
|
72
|
+
- `SmsLog.status` (`pending`/`sent`/`failed`), replacing the old
|
|
73
|
+
activity-only tracking.
|
|
74
|
+
- A CI workflow running the test suite and `ruff check` across the
|
|
75
|
+
supported Python/Django matrix.
|
|
76
|
+
|
|
77
|
+
### Changed
|
|
78
|
+
|
|
79
|
+
- **Package renamed**: importable package is now `uzsms` (was the
|
|
80
|
+
top-level `SMS` package). The Django app *label* stays pinned to
|
|
81
|
+
`"SMS"` and the database table stays `SMS_smslog`, so existing
|
|
82
|
+
`django_migrations` bookkeeping and table names are otherwise
|
|
83
|
+
undisturbed by the rename itself.
|
|
84
|
+
- **HTTP API URL path changed**: the send route is now `send/` (was
|
|
85
|
+
`send_sms/`) — with `path("sms/", include("uzsms.urls"))`, that's
|
|
86
|
+
`/sms/send/` (was `/sms/send_sms/`). **Breaking.**
|
|
87
|
+
- **`send()` return type changed**: `SmsClient.send()` (and the
|
|
88
|
+
`SMS_Sender` shim) now return `SendResult`, never a raw
|
|
89
|
+
`requests.Response`.
|
|
90
|
+
- **Settings keys renamed**: `SMS_URL`/`SMS_LOGIN`/`SMS_PASSWORD` →
|
|
91
|
+
`URL`/`LOGIN`/`PASSWORD` under `SMS_SETTINGS`. The old names still
|
|
92
|
+
work (see Deprecated below).
|
|
93
|
+
- **`SmsLog.is_active` default changed** from `True` to `False`: a
|
|
94
|
+
`pending` row must not claim delivery before it's actually sent.
|
|
95
|
+
- Migration history replaced by a single `0001_initial` (see the
|
|
96
|
+
**Removed** section and `UPGRADE.md`).
|
|
97
|
+
|
|
98
|
+
### Fixed
|
|
99
|
+
|
|
100
|
+
Four performance defects present in 1.0.1's `SMS/sms_utils.py`, all in the
|
|
101
|
+
broker HTTP call:
|
|
102
|
+
|
|
103
|
+
- **No request timeout.** A stalled broker could pin a worker thread
|
|
104
|
+
forever. Every request now passes `SMS_SETTINGS["TIMEOUT"]`.
|
|
105
|
+
- **A new TCP+TLS handshake per message, with no connection pooling.**
|
|
106
|
+
Requests now go through a cached, pooled `requests.Session`
|
|
107
|
+
(`SMS_SETTINGS["POOL_MAXSIZE"]`), or the async equivalent via
|
|
108
|
+
`httpx.AsyncClient`.
|
|
109
|
+
- **One HTTP request per message instead of batching.** Every message
|
|
110
|
+
passed to a single `send_bulk()`/`send_messages()` call is now sent
|
|
111
|
+
as one `messages` array in a single HTTP request.
|
|
112
|
+
- **A hardcoded constant `message-id` sent for every message.** Each
|
|
113
|
+
message's wire `message-id` is now its own `SmsMessage.message_id`
|
|
114
|
+
(generated once per message), never a shared constant and never
|
|
115
|
+
regenerated between retries.
|
|
116
|
+
|
|
117
|
+
Plus two correctness defects:
|
|
118
|
+
|
|
119
|
+
- **The old HTTP view crashed on every request.** `SMS/views.py` did
|
|
120
|
+
`return Response(result)` where `result` was a raw `requests.Response`
|
|
121
|
+
object, which DRF cannot serialize — every request to the send endpoint
|
|
122
|
+
crashed. Every response body this API now returns is built by
|
|
123
|
+
`uzsms/api/responses.py`, whose envelope contains only
|
|
124
|
+
JSON-serializable values.
|
|
125
|
+
- A pending log row that raised during `send()` (rather than returning
|
|
126
|
+
a normal failure result) is now marked `failed` with `error` set,
|
|
127
|
+
instead of being left `pending` forever.
|
|
128
|
+
|
|
129
|
+
### Security
|
|
130
|
+
|
|
131
|
+
- **Open relay fixed.** The send endpoint was hardcoded `AllowAny`,
|
|
132
|
+
letting anyone on the internet send SMS through the operator's
|
|
133
|
+
broker credentials. It now requires authentication by default
|
|
134
|
+
(`SMS_SETTINGS["PERMISSION_CLASSES"]`, default `IsAuthenticated`).
|
|
135
|
+
- **Unbounded message length fixed.** Outgoing message text is now
|
|
136
|
+
validated against `SMS_SETTINGS["MAX_MESSAGE_LENGTH"]` (default 918
|
|
137
|
+
characters) before being sent.
|
|
138
|
+
- **The broker's raw response body is no longer forwarded to API
|
|
139
|
+
callers.** A provider failure (`502`) now returns only a generic
|
|
140
|
+
message and the upstream status code; the broker's raw body
|
|
141
|
+
(which can carry broker-internal error text, account, or routing
|
|
142
|
+
details) is persisted to `SmsLog.provider_response` for operators to
|
|
143
|
+
inspect, but never returned in the API response.
|
|
144
|
+
- **A transport failure no longer leaks the broker's hostname, port, or
|
|
145
|
+
URL path.** A connection error's `str()` (e.g. from `requests`/`httpx`)
|
|
146
|
+
routinely embeds that information; the send endpoint now returns a
|
|
147
|
+
fixed, generic message on transport failure, matching the provider-error
|
|
148
|
+
branch above. The real detail is still persisted to `SmsLog.error`.
|
|
149
|
+
- **A send that fails under `FAIL_SILENTLY=True` no longer reports
|
|
150
|
+
success.** The backend RETURNS `SendResult(ok=False, ...)` instead of
|
|
151
|
+
raising in that mode; the send endpoint now checks `result.ok` and
|
|
152
|
+
returns `502`/`provider_error` instead of `201`/`success: true`.
|
|
153
|
+
|
|
154
|
+
### Deprecated
|
|
155
|
+
|
|
156
|
+
- `SMS_SETTINGS["SMS_URL"]`/`["SMS_LOGIN"]`/`["SMS_PASSWORD"]` — use
|
|
157
|
+
`["URL"]`/`["LOGIN"]`/`["PASSWORD"]`. The legacy names still work and
|
|
158
|
+
emit a `DeprecationWarning`.
|
|
159
|
+
- `uzsms.SMS_Sender` (and its `SendSmsOneContact`/`create_sms_log`
|
|
160
|
+
methods) — use `uzsms.SmsClient`.
|
|
161
|
+
- `SmsLog.is_active` — read `SmsLog.status` instead. Scheduled for
|
|
162
|
+
removal in 3.0.
|
|
163
|
+
|
|
164
|
+
### Removed
|
|
165
|
+
|
|
166
|
+
- The CodeQL GitHub Actions workflow (`.github/workflows/codeql-analysis.yml`).
|
|
167
|
+
- The old migration history (`0001_initial`, `0002_delete_smstoken`,
|
|
168
|
+
`0003_remove_smslog_code`), replaced by a single, from-scratch
|
|
169
|
+
`0001_initial`. **Data-destroying for existing installs** — see
|
|
170
|
+
`UPGRADE.md`.
|
|
171
|
+
- `SMS/sms_utils.py` (and the rest of the top-level `SMS` Python
|
|
172
|
+
package/module path). The Django app label `"SMS"` is kept for
|
|
173
|
+
migration/table-name compatibility, but there is no longer a `SMS`
|
|
174
|
+
Python package to import from — `from SMS.sms_utils import ...` now
|
|
175
|
+
raises `ImportError`. Use `from uzsms import ...` instead (see
|
|
176
|
+
`UPGRADE.md`).
|
|
177
|
+
|
|
178
|
+
### Known Limitations
|
|
179
|
+
|
|
180
|
+
- **Partial-success responses are not detected.** `PlaymobileBackend`/
|
|
181
|
+
`AsyncPlaymobileBackend` map a single HTTP outcome onto every message in
|
|
182
|
+
a batch: if the broker returns HTTP 200 for the whole batch but silently
|
|
183
|
+
rejects individual recipients within it, those messages are recorded as
|
|
184
|
+
`sent` anyway. The broker does not document a per-message failure format
|
|
185
|
+
within a 200 response, so this can't currently be detected. See
|
|
186
|
+
`README.md` → "Known limitations" for the full list.
|
uzsms-2.0.0/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT LICENSE
|
|
2
|
+
|
|
3
|
+
COPYRIGHT (C) 2022 by DAVRONBEK
|
|
4
|
+
|
|
5
|
+
This program is free software; you can redistribute it and/or modify
|
|
6
|
+
it under the terms of the GNU General Public License as published by
|
|
7
|
+
the Free Software Foundation; either version 2 of the License, or
|
|
8
|
+
(at your option) any later version.
|
|
9
|
+
|
|
10
|
+
This program is distributed in the hope that it will be useful,
|
|
11
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
GNU General Public License for more details.
|
|
14
|
+
|
|
15
|
+
You should have received a copy of the GNU General Public License
|
|
16
|
+
along with this program; if not, write to the Free Software
|
uzsms-2.0.0/MANIFEST.in
ADDED
uzsms-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uzsms
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: SMS sending api for django
|
|
5
|
+
Author-email: Boltayev Davronbek <tarjimatv1@gmail.com>
|
|
6
|
+
Maintainer-email: Boburbek <boburbek@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/firdavsDev/django-sms-uz
|
|
9
|
+
Project-URL: Repository, https://github.com/firdavsDev/django-sms-uz
|
|
10
|
+
Project-URL: Changelog, https://github.com/firdavsDev/django-sms-uz/blob/master/CHANGELOG.md
|
|
11
|
+
Project-URL: Issues, https://github.com/firdavsDev/django-sms-uz/issues
|
|
12
|
+
Keywords: sms,django,api,sending,sending api,sending sms,sending sms api,sending sms api django
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 5.1
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
28
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
29
|
+
Requires-Python: >=3.9
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Requires-Dist: django>=4.2
|
|
33
|
+
Requires-Dist: requests>=2.28
|
|
34
|
+
Requires-Dist: typing_extensions>=4; python_version < "3.11"
|
|
35
|
+
Provides-Extra: drf
|
|
36
|
+
Requires-Dist: djangorestframework>=3.14; extra == "drf"
|
|
37
|
+
Provides-Extra: async
|
|
38
|
+
Requires-Dist: httpx>=0.24; extra == "async"
|
|
39
|
+
Provides-Extra: celery
|
|
40
|
+
Requires-Dist: celery>=5.2; extra == "celery"
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: pytest; extra == "dev"
|
|
43
|
+
Requires-Dist: pytest-django; extra == "dev"
|
|
44
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
45
|
+
Requires-Dist: responses; extra == "dev"
|
|
46
|
+
Requires-Dist: respx; extra == "dev"
|
|
47
|
+
Requires-Dist: ruff; extra == "dev"
|
|
48
|
+
Requires-Dist: djangorestframework>=3.14; extra == "dev"
|
|
49
|
+
Requires-Dist: celery>=5.2; extra == "dev"
|
|
50
|
+
Dynamic: license-file
|
|
51
|
+
|
|
52
|
+
# uzsms
|
|
53
|
+
|
|
54
|
+
An SMS-sending package for Django, built for the Playmobile broker used by
|
|
55
|
+
Uzbek telecom operators. It provides a validated, pooled, retrying HTTP
|
|
56
|
+
client (sync and async), a Django model that logs every outgoing message,
|
|
57
|
+
an optional DRF endpoint, and an optional Celery task.
|
|
58
|
+
|
|
59
|
+
As of 2.0 the distribution and the importable package share one name:
|
|
60
|
+
`pip install uzsms`, `import uzsms`. 1.x was published as
|
|
61
|
+
`django-sms-uz`, which is now frozen at 1.0.1 and receives no further
|
|
62
|
+
releases.
|
|
63
|
+
|
|
64
|
+
> **Upgrading from 1.x?** Read [`UPGRADE.md`](UPGRADE.md) first. 2.0 is a
|
|
65
|
+
> breaking release, and its migration history is **data-destroying** for
|
|
66
|
+
> existing installs — do not run `migrate` before reading it.
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
Base install (sync sending only, no HTTP API, no Celery task):
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install uzsms
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
With extras, as needed:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install "uzsms[drf]" # HTTP API (Django REST Framework)
|
|
80
|
+
pip install "uzsms[async]" # AsyncSmsClient / AsyncPlaymobileBackend (httpx)
|
|
81
|
+
pip install "uzsms[celery]" # uzsms.tasks.send_sms_task
|
|
82
|
+
pip install "uzsms[drf,async,celery]" # any combination
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
For contributing to this package itself:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install "uzsms[dev]"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Quickstart
|
|
92
|
+
|
|
93
|
+
Add the app:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
INSTALLED_APPS = [
|
|
97
|
+
...,
|
|
98
|
+
"uzsms",
|
|
99
|
+
]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Configure the broker credentials (the only three required keys — see the
|
|
103
|
+
full settings table below for everything else):
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
SMS_SETTINGS = {
|
|
107
|
+
"URL": "http://91.204.239.44/broker-api/send",
|
|
108
|
+
"LOGIN": "your-login",
|
|
109
|
+
"PASSWORD": "your-password",
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Run migrations:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
python manage.py migrate
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Send a message:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from uzsms import SmsClient
|
|
123
|
+
|
|
124
|
+
client = SmsClient()
|
|
125
|
+
result = client.send("998901234567", "hello there")
|
|
126
|
+
result.ok # True/False
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
If you also want the HTTP endpoint, install the `drf` extra and include the
|
|
130
|
+
URLs:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
# urls.py
|
|
134
|
+
from django.urls import include, path
|
|
135
|
+
|
|
136
|
+
urlpatterns = [
|
|
137
|
+
...,
|
|
138
|
+
path("sms/", include("uzsms.urls")),
|
|
139
|
+
]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
This puts the send endpoint at `/sms/send/` (see "HTTP API" below).
|
|
143
|
+
|
|
144
|
+
## `SMS_SETTINGS`
|
|
145
|
+
|
|
146
|
+
All settings live under one dict, `SMS_SETTINGS`, in your Django settings
|
|
147
|
+
module. `URL`, `LOGIN`, and `PASSWORD` are required (no default); everything
|
|
148
|
+
else falls back to the default shown below. Defaults are read from
|
|
149
|
+
`uzsms/conf.py`.
|
|
150
|
+
|
|
151
|
+
| Key | Default | Meaning |
|
|
152
|
+
|----------------------|--------------------------------------------------|---------|
|
|
153
|
+
| `URL` | *(required)* | The broker's send endpoint URL. |
|
|
154
|
+
| `LOGIN` | *(required)* | Broker HTTP Basic Auth username. |
|
|
155
|
+
| `PASSWORD` | *(required)* | Broker HTTP Basic Auth password. |
|
|
156
|
+
| `BACKEND` | `"uzsms.backends.playmobile.PlaymobileBackend"` | Dotted path to the backend class `SmsClient` uses to actually send messages. |
|
|
157
|
+
| `ASYNC_BACKEND` | `"uzsms.backends.playmobile.AsyncPlaymobileBackend"` | Dotted path to the backend class `AsyncSmsClient` uses. A separate setting from `BACKEND`, so a project can use `SmsClient` and `AsyncSmsClient` side by side from one unmodified `SMS_SETTINGS` dict. |
|
|
158
|
+
| `ORIGINATOR` | `"3700"` | The `sms.originator` value sent in every message envelope. |
|
|
159
|
+
| `TIMEOUT` | `(5, 15)` | `(connect, read)` timeout in seconds, passed straight to `requests`/mapped onto `httpx.Timeout`. |
|
|
160
|
+
| `MAX_RETRIES` | `3` | Number of retries on a transient failure (connection error or a `429`/`500`/`502`/`503`/`504` response). Safe because every message carries a stable `message_id` the broker deduplicates on retry — see the docstring in `uzsms/backends/playmobile.py`. |
|
|
161
|
+
| `RETRY_BACKOFF` | `0.5` | Backoff factor between retries (exponential, per `urllib3.util.Retry`/the async backend's own backoff loop). |
|
|
162
|
+
| `POOL_MAXSIZE` | `10` | Max size of the pooled HTTP connection pool (`requests.Session`'s adapter, or `httpx.Limits`). |
|
|
163
|
+
| `LOG_MESSAGES` | `True` | Whether to persist a `SmsLog` row for every message sent. |
|
|
164
|
+
| `FAIL_SILENTLY` | `False` | When `True`, a send failure returns a `SendResult(ok=False, ...)` instead of raising. |
|
|
165
|
+
| `MAX_MESSAGE_LENGTH` | `918` | Maximum allowed message length in characters; longer text raises `SmsValidationError`. |
|
|
166
|
+
| `PERMISSION_CLASSES` | `["rest_framework.permissions.IsAuthenticated"]` | Dotted paths to DRF permission classes applied to the send endpoint. **The endpoint requires authentication by default.** |
|
|
167
|
+
| `THROTTLE_RATE` | `"20/min"` | DRF throttle rate string applied to the send endpoint. |
|
|
168
|
+
|
|
169
|
+
Legacy key names `SMS_URL`, `SMS_LOGIN`, and `SMS_PASSWORD` (1.0.1's
|
|
170
|
+
spelling of `URL`/`LOGIN`/`PASSWORD`) still work, but emit a
|
|
171
|
+
`DeprecationWarning`. The new name always wins if both are present.
|
|
172
|
+
|
|
173
|
+
## Sync usage
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from uzsms import SmsClient
|
|
177
|
+
|
|
178
|
+
client = SmsClient()
|
|
179
|
+
|
|
180
|
+
# Single message
|
|
181
|
+
result = client.send("998901234567", "hello there")
|
|
182
|
+
# result: SendResult(message=SmsMessage(...), ok=True, provider_message_id=None,
|
|
183
|
+
# status_code=200, raw=..., error="", log_id=42)
|
|
184
|
+
# provider_message_id is None because no shipped backend populates it (see
|
|
185
|
+
# "The SmsLog model" below) — the id that matters is message.message_id,
|
|
186
|
+
# the one actually sent on the wire.
|
|
187
|
+
|
|
188
|
+
# Multiple messages, sent as one batched HTTP request
|
|
189
|
+
from uzsms import SmsMessage
|
|
190
|
+
|
|
191
|
+
results = client.send_bulk([
|
|
192
|
+
SmsMessage(phone_number="998901234567", text="hi"),
|
|
193
|
+
SmsMessage(phone_number="998907654321", text="hello"),
|
|
194
|
+
])
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`send`/`send_bulk` validate every message (Uzbek phone format, non-empty
|
|
198
|
+
text under `MAX_MESSAGE_LENGTH`) before writing anything to the database,
|
|
199
|
+
create `SmsLog` rows in bulk (a single query for any number of messages,
|
|
200
|
+
unless `LOG_MESSAGES` is `False`), send through the configured backend, and
|
|
201
|
+
update those rows in bulk with the outcome.
|
|
202
|
+
|
|
203
|
+
## Async usage
|
|
204
|
+
|
|
205
|
+
Requires the `async` extra (`pip install "uzsms[async]"`, which
|
|
206
|
+
installs `httpx`):
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from uzsms import AsyncSmsClient
|
|
210
|
+
|
|
211
|
+
client = AsyncSmsClient()
|
|
212
|
+
result = await client.send("998901234567", "hello there")
|
|
213
|
+
results = await client.send_bulk([...])
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`AsyncSmsClient` has the same validation-before-write and constant-query
|
|
217
|
+
contract as `SmsClient`; its ORM calls go through
|
|
218
|
+
`asgiref.sync.sync_to_async` since Django's ORM is not async-safe.
|
|
219
|
+
|
|
220
|
+
## Backends
|
|
221
|
+
|
|
222
|
+
Select the sync backend with `SMS_SETTINGS["BACKEND"]`, and the async
|
|
223
|
+
backend `AsyncSmsClient` uses with `SMS_SETTINGS["ASYNC_BACKEND"]` — each a
|
|
224
|
+
dotted path to a class implementing `uzsms.backends.base.BaseSmsBackend`
|
|
225
|
+
(sync) or `BaseAsyncSmsBackend` (async), respectively:
|
|
226
|
+
|
|
227
|
+
| Backend | Path | Behavior |
|
|
228
|
+
|---|---|---|
|
|
229
|
+
| Playmobile (default) | `uzsms.backends.playmobile.PlaymobileBackend` | Sends over a pooled, retrying `requests.Session`. |
|
|
230
|
+
| Async Playmobile | `uzsms.backends.playmobile.AsyncPlaymobileBackend` | Same broker, over a cached `httpx.AsyncClient`. Used automatically by `AsyncSmsClient`. |
|
|
231
|
+
| Console | `uzsms.backends.console.ConsoleBackend` | Prints each message to stdout; always reports success. No network I/O. Useful for local development. |
|
|
232
|
+
| LocMem | `uzsms.backends.locmem.LocMemBackend` (sync) / `AsyncLocMemBackend` (async) | Appends each message to a module-level `outbox` list (`uzsms.backends.locmem.outbox`), like Django's `django.core.mail.outbox`. No network I/O. Useful for tests. |
|
|
233
|
+
| Dummy | `uzsms.backends.dummy.DummyBackend` | Discards every message; always reports success. No network I/O. |
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
SMS_SETTINGS = {
|
|
237
|
+
...,
|
|
238
|
+
"BACKEND": "uzsms.backends.console.ConsoleBackend",
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Writing a custom backend
|
|
243
|
+
|
|
244
|
+
Subclass `uzsms.backends.base.BaseSmsBackend` (or `BaseAsyncSmsBackend`) and
|
|
245
|
+
implement `send_messages`:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
from collections.abc import Sequence
|
|
249
|
+
from uzsms.backends.base import BaseSmsBackend
|
|
250
|
+
from uzsms.dto import SendResult, SmsMessage
|
|
251
|
+
|
|
252
|
+
class MyBackend(BaseSmsBackend):
|
|
253
|
+
def send_messages(self, messages: Sequence[SmsMessage]) -> list[SendResult]:
|
|
254
|
+
# Must return exactly one SendResult per message, in the same order.
|
|
255
|
+
...
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`get_backend()` (in `uzsms/backends/__init__.py`) resolves
|
|
259
|
+
`SMS_SETTINGS["BACKEND"]`, and `get_async_backend()` resolves
|
|
260
|
+
`SMS_SETTINGS["ASYNC_BACKEND"]`, each via
|
|
261
|
+
`django.utils.module_loading.import_string` and instantiates it; a backend
|
|
262
|
+
that doesn't subclass the expected base raises `SmsConfigurationError`.
|
|
263
|
+
`open()`/`close()` are no-op hooks you can override to acquire/release
|
|
264
|
+
resources (backends also work as a context manager via `with backend:` /
|
|
265
|
+
`async with backend:`).
|
|
266
|
+
|
|
267
|
+
## Celery task
|
|
268
|
+
|
|
269
|
+
Requires the `celery` extra (`pip install "uzsms[celery]"`).
|
|
270
|
+
Calling `uzsms.tasks.send_sms_task` without Celery installed raises
|
|
271
|
+
`SmsConfigurationError` naming the extra; importing the module is always
|
|
272
|
+
safe.
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
from uzsms.tasks import send_sms_task
|
|
276
|
+
|
|
277
|
+
send_sms_task.delay("998901234567", "hello there")
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The task retries automatically on `SmsTransportError` (up to 3 times) and
|
|
281
|
+
returns a JSON-serializable summary dict (`ok`, `message_id`, `log_id`,
|
|
282
|
+
`error`) rather than a `SendResult`, since `SendResult` isn't JSON
|
|
283
|
+
serializable on its own.
|
|
284
|
+
|
|
285
|
+
## HTTP API
|
|
286
|
+
|
|
287
|
+
Include the URLs under whatever prefix you like:
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
path("sms/", include("uzsms.urls"))
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
This resolves to `POST /sms/send/` (route name `send_sms`). Importing
|
|
294
|
+
`uzsms.urls` without DRF installed raises `SmsConfigurationError` naming the
|
|
295
|
+
`drf` extra.
|
|
296
|
+
|
|
297
|
+
**The endpoint requires authentication by default** (`SMS_SETTINGS["PERMISSION_CLASSES"]`
|
|
298
|
+
defaults to `["rest_framework.permissions.IsAuthenticated"]`). This is
|
|
299
|
+
deliberate: 1.0.1 shipped this endpoint as `AllowAny`, an open relay that let
|
|
300
|
+
anyone on the internet send SMS through your broker credentials. To relax
|
|
301
|
+
it, set `PERMISSION_CLASSES` explicitly, e.g. `["rest_framework.permissions.AllowAny"]`.
|
|
302
|
+
The endpoint is also throttled (`THROTTLE_RATE`, default `"20/min"`).
|
|
303
|
+
|
|
304
|
+
Request body:
|
|
305
|
+
|
|
306
|
+
```json
|
|
307
|
+
{"phone_number": "998901234567", "message": "hello there"}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Response envelope
|
|
311
|
+
|
|
312
|
+
Every response — success or failure — has exactly the same three top-level
|
|
313
|
+
keys: `success`, `data`, `error`. Exactly one of `data`/`error` is non-null.
|
|
314
|
+
HTTP status codes stay meaningful (`201` created, `400` validation error,
|
|
315
|
+
`401`/`403` unauthenticated/forbidden, `429` throttled, `502` upstream
|
|
316
|
+
broker failure).
|
|
317
|
+
|
|
318
|
+
**Success** (`201 Created`):
|
|
319
|
+
|
|
320
|
+
```json
|
|
321
|
+
{
|
|
322
|
+
"success": true,
|
|
323
|
+
"data": {
|
|
324
|
+
"message_id": "b3f1...e2a9",
|
|
325
|
+
"log_id": 42,
|
|
326
|
+
"phone_number": "998901234567",
|
|
327
|
+
"status": "sent"
|
|
328
|
+
},
|
|
329
|
+
"error": null
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`message_id` here is the client-generated correlation id (`SmsMessage.message_id`)
|
|
334
|
+
that identifies the request, not the broker's own id — it's assigned before
|
|
335
|
+
the send even happens. `log_id` is the `SmsLog` row's primary key, or `null`
|
|
336
|
+
when `LOG_MESSAGES` is `False`. `status` is `"sent"` or `"failed"`.
|
|
337
|
+
|
|
338
|
+
**Failure** (e.g. `400 Bad Request`, invalid phone number). `message` stays
|
|
339
|
+
the fixed string `"Invalid request."`; the field-level errors live in
|
|
340
|
+
`error.detail`, keyed by field name (DRF's own validation error shape):
|
|
341
|
+
|
|
342
|
+
```json
|
|
343
|
+
{
|
|
344
|
+
"success": false,
|
|
345
|
+
"data": null,
|
|
346
|
+
"error": {
|
|
347
|
+
"code": "validation_error",
|
|
348
|
+
"message": "Invalid request.",
|
|
349
|
+
"detail": {
|
|
350
|
+
"phone_number": [
|
|
351
|
+
"'12345' is not a valid Uzbek phone number; expected '998' followed by nine digits."
|
|
352
|
+
]
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
A broker/upstream failure (`502 Bad Gateway`) never forwards the broker's
|
|
359
|
+
raw response body to the caller — only the upstream status code:
|
|
360
|
+
|
|
361
|
+
```json
|
|
362
|
+
{
|
|
363
|
+
"success": false,
|
|
364
|
+
"data": null,
|
|
365
|
+
"error": {
|
|
366
|
+
"code": "provider_error",
|
|
367
|
+
"message": "The SMS provider rejected the request.",
|
|
368
|
+
"detail": {"status_code": 400}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
DRF's own authentication, permission, throttling, and parsing errors are
|
|
374
|
+
re-shaped into this same envelope rather than DRF's bare `{"detail": ...}`
|
|
375
|
+
shape.
|
|
376
|
+
|
|
377
|
+
## The `SmsLog` model
|
|
378
|
+
|
|
379
|
+
Every send (unless `LOG_MESSAGES` is `False`) writes a row to `SmsLog`
|
|
380
|
+
(table `SMS_smslog`, app label `SMS` — both pinned for backward
|
|
381
|
+
compatibility with 1.0.1's migration history location, even though the
|
|
382
|
+
Python package is now `uzsms`):
|
|
383
|
+
|
|
384
|
+
| Field | Type | Notes |
|
|
385
|
+
|---|---|---|
|
|
386
|
+
| `phone_number` | `CharField(35)` | |
|
|
387
|
+
| `text` | `TextField` | |
|
|
388
|
+
| `status` | `CharField`, choices | `"pending"`, `"sent"`, or `"failed"` — see `SmsLog.Status`. |
|
|
389
|
+
| `created_at` | `DateTimeField` | Auto-set on creation, indexed. |
|
|
390
|
+
| `sent_at` | `DateTimeField`, nullable | Set when marked sent. |
|
|
391
|
+
| `message_id` | `CharField(255)` | The client-generated correlation id (`SmsMessage.message_id`) that was sent on the wire. Only overwritten if the backend supplies its own `SendResult.provider_message_id` — no shipped backend currently does. Indexed. |
|
|
392
|
+
| `provider_response` | `JSONField`, nullable | The broker's raw response body, when available. |
|
|
393
|
+
| `error` | `TextField` | Error message on failure. |
|
|
394
|
+
| `is_active` | `BooleanField`, default `False` | **Deprecated**, mirrors `status == "sent"`. Scheduled for removal in 3.0 — read `status` instead. |
|
|
395
|
+
|
|
396
|
+
Rows go `pending` → `sent`/`failed`. A row that raises before the backend
|
|
397
|
+
even returns (e.g. a raised exception rather than a returned `SendResult`)
|
|
398
|
+
is still marked `failed`, with `error` set, rather than left `pending`
|
|
399
|
+
forever.
|
|
400
|
+
|
|
401
|
+
## Troubleshooting
|
|
402
|
+
|
|
403
|
+
- **`SmsConfigurationError: SMS_SETTINGS['URL'] is required but was not provided.`**
|
|
404
|
+
You haven't set `SMS_SETTINGS["URL"]` (or the legacy `SMS_URL`). Same for
|
|
405
|
+
`LOGIN`/`PASSWORD`.
|
|
406
|
+
- **`SmsConfigurationError: ... requires Django REST Framework ...`** when
|
|
407
|
+
importing `uzsms.urls`. Install the `drf` extra.
|
|
408
|
+
- **`SmsConfigurationError: ... requires httpx ...`** when using
|
|
409
|
+
`AsyncSmsClient`/`AsyncPlaymobileBackend`. Install the `async` extra.
|
|
410
|
+
- **`SmsConfigurationError: Celery is required ...`** when calling
|
|
411
|
+
`send_sms_task`. Install the `celery` extra.
|
|
412
|
+
- **401/403 from the send endpoint.** Authentication is required by
|
|
413
|
+
default; either authenticate the request or relax
|
|
414
|
+
`SMS_SETTINGS["PERMISSION_CLASSES"]`.
|
|
415
|
+
- **429 from the send endpoint.** You've exceeded `SMS_SETTINGS["THROTTLE_RATE"]`.
|
|
416
|
+
- **A send raises instead of returning `ok=False`.** Set
|
|
417
|
+
`SMS_SETTINGS["FAIL_SILENTLY"] = True` if you'd rather inspect
|
|
418
|
+
`SendResult.ok`/`.error` than catch exceptions.
|
|
419
|
+
- **`SmsBackendError: ... returned N result(s) for M message(s)`.** A custom
|
|
420
|
+
backend's `send_messages` didn't return exactly one `SendResult` per
|
|
421
|
+
message it was given — fix the backend.
|
|
422
|
+
|
|
423
|
+
## Known limitations
|
|
424
|
+
|
|
425
|
+
These are accepted, deliberate tradeoffs, not oversights:
|
|
426
|
+
|
|
427
|
+
- **Partial-success responses are not detected.** `PlaymobileBackend`/`AsyncPlaymobileBackend`
|
|
428
|
+
map a single HTTP outcome onto every message in a batch: if the broker
|
|
429
|
+
returns HTTP 200 for the whole batch but silently rejects individual
|
|
430
|
+
recipients within it, those messages are recorded as `sent` anyway. The
|
|
431
|
+
broker does not document a per-message failure format within a 200
|
|
432
|
+
response, so this can't currently be detected.
|
|
433
|
+
- **The cached async HTTP client has no event-loop affinity check.**
|
|
434
|
+
`get_async_client()` in `uzsms/backends/playmobile.py` caches one
|
|
435
|
+
`httpx.AsyncClient` per process, assuming a single, persistent event
|
|
436
|
+
loop. Calling it from repeated separate `asyncio.run()` invocations in
|
|
437
|
+
the same process can break the cached client.
|
|
438
|
+
- **Resetting `SMS_SETTINGS` leaks the async client.** `reset_async_client()`
|
|
439
|
+
drops the cached `httpx.AsyncClient` reference without calling
|
|
440
|
+
`aclose()` on it (closing it is an async operation, and the reset runs
|
|
441
|
+
from a synchronous Django signal handler) — every `SMS_SETTINGS` change
|
|
442
|
+
in a long-lived async process leaks one client.
|
|
443
|
+
|
|
444
|
+
(Upgrading from 1.x and still calling the deprecated `SMS_Sender.create_sms_log`?
|
|
445
|
+
See the `create_sms_log` row in `UPGRADE.md` → "Before / after" for a
|
|
446
|
+
behavior change you need to know about.)
|
|
447
|
+
|
|
448
|
+
## License
|
|
449
|
+
|
|
450
|
+
MIT. See `LICENSE`.
|