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.
Files changed (59) hide show
  1. uzsms-2.0.0/CHANGELOG.md +186 -0
  2. uzsms-2.0.0/LICENSE +16 -0
  3. uzsms-2.0.0/MANIFEST.in +4 -0
  4. uzsms-2.0.0/PKG-INFO +450 -0
  5. uzsms-2.0.0/README.md +399 -0
  6. uzsms-2.0.0/UPGRADE.md +94 -0
  7. uzsms-2.0.0/pyproject.toml +87 -0
  8. uzsms-2.0.0/setup.cfg +4 -0
  9. uzsms-2.0.0/tests/test_admin.py +33 -0
  10. uzsms-2.0.0/tests/test_api.py +584 -0
  11. uzsms-2.0.0/tests/test_app_config.py +35 -0
  12. uzsms-2.0.0/tests/test_async_services.py +520 -0
  13. uzsms-2.0.0/tests/test_backend_loader.py +85 -0
  14. uzsms-2.0.0/tests/test_backend_playmobile.py +303 -0
  15. uzsms-2.0.0/tests/test_backend_playmobile_async.py +342 -0
  16. uzsms-2.0.0/tests/test_backends_base.py +148 -0
  17. uzsms-2.0.0/tests/test_compat.py +77 -0
  18. uzsms-2.0.0/tests/test_conf.py +221 -0
  19. uzsms-2.0.0/tests/test_dto.py +49 -0
  20. uzsms-2.0.0/tests/test_exceptions.py +55 -0
  21. uzsms-2.0.0/tests/test_harness.py +31 -0
  22. uzsms-2.0.0/tests/test_models.py +101 -0
  23. uzsms-2.0.0/tests/test_public_api.py +100 -0
  24. uzsms-2.0.0/tests/test_repository.py +179 -0
  25. uzsms-2.0.0/tests/test_services.py +429 -0
  26. uzsms-2.0.0/tests/test_tasks.py +110 -0
  27. uzsms-2.0.0/tests/test_validators.py +88 -0
  28. uzsms-2.0.0/uzsms/__init__.py +66 -0
  29. uzsms-2.0.0/uzsms/admin.py +30 -0
  30. uzsms-2.0.0/uzsms/api/__init__.py +9 -0
  31. uzsms-2.0.0/uzsms/api/responses.py +36 -0
  32. uzsms-2.0.0/uzsms/api/serializers.py +34 -0
  33. uzsms-2.0.0/uzsms/api/urls.py +18 -0
  34. uzsms-2.0.0/uzsms/api/views.py +195 -0
  35. uzsms-2.0.0/uzsms/apps.py +8 -0
  36. uzsms-2.0.0/uzsms/backends/__init__.py +64 -0
  37. uzsms-2.0.0/uzsms/backends/base.py +79 -0
  38. uzsms-2.0.0/uzsms/backends/console.py +33 -0
  39. uzsms-2.0.0/uzsms/backends/dummy.py +19 -0
  40. uzsms-2.0.0/uzsms/backends/locmem.py +39 -0
  41. uzsms-2.0.0/uzsms/backends/playmobile.py +343 -0
  42. uzsms-2.0.0/uzsms/compat.py +67 -0
  43. uzsms-2.0.0/uzsms/conf.py +102 -0
  44. uzsms-2.0.0/uzsms/dto.py +30 -0
  45. uzsms-2.0.0/uzsms/exceptions.py +47 -0
  46. uzsms-2.0.0/uzsms/migrations/0001_initial.py +70 -0
  47. uzsms-2.0.0/uzsms/migrations/__init__.py +0 -0
  48. uzsms-2.0.0/uzsms/models.py +75 -0
  49. uzsms-2.0.0/uzsms/py.typed +0 -0
  50. uzsms-2.0.0/uzsms/repository.py +66 -0
  51. uzsms-2.0.0/uzsms/services.py +184 -0
  52. uzsms-2.0.0/uzsms/tasks.py +72 -0
  53. uzsms-2.0.0/uzsms/urls.py +23 -0
  54. uzsms-2.0.0/uzsms/validators.py +54 -0
  55. uzsms-2.0.0/uzsms.egg-info/PKG-INFO +450 -0
  56. uzsms-2.0.0/uzsms.egg-info/SOURCES.txt +57 -0
  57. uzsms-2.0.0/uzsms.egg-info/dependency_links.txt +1 -0
  58. uzsms-2.0.0/uzsms.egg-info/requires.txt +24 -0
  59. uzsms-2.0.0/uzsms.egg-info/top_level.txt +1 -0
@@ -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
@@ -0,0 +1,4 @@
1
+ include LICENSE
2
+ include README.md
3
+ include UPGRADE.md
4
+ include CHANGELOG.md
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`.