nilva-django-sms 0.2.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.
- nilva_django_sms-0.2.0/LICENSE +21 -0
- nilva_django_sms-0.2.0/PKG-INFO +160 -0
- nilva_django_sms-0.2.0/README.md +136 -0
- nilva_django_sms-0.2.0/nilva_django_sms/__init__.py +52 -0
- nilva_django_sms-0.2.0/nilva_django_sms/api.py +45 -0
- nilva_django_sms-0.2.0/nilva_django_sms/base.py +67 -0
- nilva_django_sms-0.2.0/nilva_django_sms/engines/__init__.py +0 -0
- nilva_django_sms-0.2.0/nilva_django_sms/engines/console.py +23 -0
- nilva_django_sms-0.2.0/nilva_django_sms/engines/kavenegar.py +61 -0
- nilva_django_sms-0.2.0/nilva_django_sms/engines/magfa.py +51 -0
- nilva_django_sms-0.2.0/nilva_django_sms/engines/memory.py +38 -0
- nilva_django_sms-0.2.0/nilva_django_sms/engines/pardis.py +141 -0
- nilva_django_sms-0.2.0/nilva_django_sms/engines/pishgam.py +88 -0
- nilva_django_sms-0.2.0/nilva_django_sms/exceptions.py +31 -0
- nilva_django_sms-0.2.0/nilva_django_sms/kavenegar_sdk.py +172 -0
- nilva_django_sms-0.2.0/nilva_django_sms/otp.py +89 -0
- nilva_django_sms-0.2.0/nilva_django_sms/registry.py +43 -0
- nilva_django_sms-0.2.0/nilva_django_sms/settings.py +157 -0
- nilva_django_sms-0.2.0/nilva_django_sms/tasks.py +70 -0
- nilva_django_sms-0.2.0/nilva_django_sms.egg-info/PKG-INFO +160 -0
- nilva_django_sms-0.2.0/nilva_django_sms.egg-info/SOURCES.txt +35 -0
- nilva_django_sms-0.2.0/nilva_django_sms.egg-info/dependency_links.txt +1 -0
- nilva_django_sms-0.2.0/nilva_django_sms.egg-info/requires.txt +7 -0
- nilva_django_sms-0.2.0/nilva_django_sms.egg-info/top_level.txt +1 -0
- nilva_django_sms-0.2.0/pyproject.toml +63 -0
- nilva_django_sms-0.2.0/setup.cfg +4 -0
- nilva_django_sms-0.2.0/tests/test_api.py +44 -0
- nilva_django_sms-0.2.0/tests/test_engines_basic.py +28 -0
- nilva_django_sms-0.2.0/tests/test_kavenegar.py +91 -0
- nilva_django_sms-0.2.0/tests/test_magfa.py +35 -0
- nilva_django_sms-0.2.0/tests/test_otp.py +84 -0
- nilva_django_sms-0.2.0/tests/test_package.py +25 -0
- nilva_django_sms-0.2.0/tests/test_pardis.py +94 -0
- nilva_django_sms-0.2.0/tests/test_pishgam.py +84 -0
- nilva_django_sms-0.2.0/tests/test_registry.py +41 -0
- nilva_django_sms-0.2.0/tests/test_settings.py +34 -0
- nilva_django_sms-0.2.0/tests/test_tasks.py +22 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Nilva
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nilva-django-sms
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Nilva house SMS/OTP toolkit — one engine interface over Pishgam, Magfa, Kavenegar and MCI Pardis with fallback chains, typed errors, cooldown-throttled OTP and Celery delivery
|
|
5
|
+
Author-email: Nilva <dev@nilva.ai>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: homepage, https://gitlab.nilva.ai/nilva/dev-assets/django/nilva-django-sms
|
|
8
|
+
Project-URL: repository, https://gitlab.nilva.ai/nilva/dev-assets/django/nilva-django-sms
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 6.0
|
|
14
|
+
Classifier: Topic :: Communications :: Telephony
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: requests>=2.32
|
|
19
|
+
Provides-Extra: django
|
|
20
|
+
Requires-Dist: django>=6.0; extra == "django"
|
|
21
|
+
Provides-Extra: celery
|
|
22
|
+
Requires-Dist: celery>=5.4; extra == "celery"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# nilva-django-sms
|
|
26
|
+
|
|
27
|
+
Nilva's house SMS / OTP toolkit for Django and plain-Python services. One
|
|
28
|
+
engine interface over every provider used across the Nilva backends —
|
|
29
|
+
**Pishgam/Pishgaman Rayan, Magfa, Kavenegar, MCI Pardis** — plus **console**
|
|
30
|
+
and **memory** engines for development and tests. Replaces the copy-pasted
|
|
31
|
+
`SMSService` / `OTPService` clients that had drifted across Cheshmeh, Etka,
|
|
32
|
+
Selfcare, Ghadir, Shop and Tiran.
|
|
33
|
+
|
|
34
|
+
What it standardizes:
|
|
35
|
+
|
|
36
|
+
* **Typed errors** — `SMSTransientError` (retry can help) vs
|
|
37
|
+
`SMSPermanentError` (it can't); Celery retry policies key off the split.
|
|
38
|
+
* **Fallback chains** — primary engine plus ordered fallbacks.
|
|
39
|
+
* **OTP cooldown throttling** — previously only Tiran had it; now default.
|
|
40
|
+
* **Success normalization** — no `statusCode = 32` hacks leaking to callers.
|
|
41
|
+
* **One vendored Kavenegar SDK** (`nilva_django_sms.kavenegar_sdk`, the
|
|
42
|
+
timeout/proxy-patched fork) instead of a copy per repo.
|
|
43
|
+
|
|
44
|
+
Importing the package pulls neither Django, `requests` nor Celery — engines
|
|
45
|
+
and the OTP service load lazily. Only `requests` is a hard install
|
|
46
|
+
dependency.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv add nilva-django-sms # engines + registry + facade
|
|
52
|
+
uv add "nilva-django-sms[django]" # + OTP service (Django cache)
|
|
53
|
+
uv add "nilva-django-sms[celery]" # + queued delivery with retries
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Configure — `NILVA_SMS`
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# settings.py
|
|
60
|
+
NILVA_SMS = {
|
|
61
|
+
"ENGINE": "pishgam", # default engine ("console" if unset)
|
|
62
|
+
"FALLBACK_ENGINES": ("magfa",), # tried in order when pishgam raises
|
|
63
|
+
"PROXY": SETTINGS.SOCKS5_PROXY, # shared by all HTTP engines
|
|
64
|
+
"TIMEOUT": 5.0,
|
|
65
|
+
|
|
66
|
+
"PISHGAM": {
|
|
67
|
+
"GATEWAY_URL": "https://gw.pishgamrayan.com",
|
|
68
|
+
"TOKEN": "...", "GATEWAY_TOKEN": "...",
|
|
69
|
+
"SENDER": "3000...", "OTP_ID": 123,
|
|
70
|
+
# OTP template parameters; "{code}" is substituted:
|
|
71
|
+
"OTP_PARAMS": ("myapp", "{code}", "myapp.ir"),
|
|
72
|
+
},
|
|
73
|
+
"MAGFA": {"USERNAME": "...", "DOMAIN": "...", "PASSWORD": "...", "SENDER": "9800..."},
|
|
74
|
+
"KAVENEGAR": {"API_KEY": "...", "OTP_TEMPLATE": "login-otp"},
|
|
75
|
+
"PARDIS": {"BASE_URL": "...", "AUTH_HOST": "...", "BULK_HOST": "...",
|
|
76
|
+
"CLIENT_ID": "...", "USERNAME": "...", "PASSWORD": "...", "SOURCE": "98..."},
|
|
77
|
+
|
|
78
|
+
# OTP service
|
|
79
|
+
"OTP_LENGTH": 6,
|
|
80
|
+
"OTP_TTL": 300,
|
|
81
|
+
"OTP_COOLDOWN": 60,
|
|
82
|
+
"OTP_STATIC_CODE": None, # staging bypass code; None disables
|
|
83
|
+
|
|
84
|
+
# Celery delivery
|
|
85
|
+
"USE_CELERY": True,
|
|
86
|
+
"CELERY_QUEUE": "low",
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Engine blocks merge over their defaults, so set only what you have. Every
|
|
91
|
+
setting is read through `nilva_django_sms.settings.sms_settings` (the
|
|
92
|
+
`nilva-django-logger` settings-object pattern) and works without Django too.
|
|
93
|
+
|
|
94
|
+
## Send
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from nilva_django_sms import send_sms, send_otp
|
|
98
|
+
|
|
99
|
+
send_sms("پیام شما", "09123456789") # walks ENGINE + fallbacks
|
|
100
|
+
send_otp("123456", "09123456789") # OTP_ENGINE (or ENGINE) first
|
|
101
|
+
send_sms("...", "0912...", engine="kavenegar") # explicit engine
|
|
102
|
+
send_sms("...", "0912...", fail_silently=True) # SendResult(success=False) instead of raising
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Queued delivery with transient-error retries (backoff + jitter, 4 attempts):
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from nilva_django_sms.tasks import queue_send_sms, queue_send_otp
|
|
109
|
+
queue_send_sms("پیام", "09123456789", delay=10) # sync fallback without Celery
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## OTP
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from nilva_django_sms.otp import otp_service
|
|
116
|
+
|
|
117
|
+
otp_service.send("09123456789") # generate + cache + deliver
|
|
118
|
+
# raises OTPCooldownError(retry_after=…) inside the cooldown window
|
|
119
|
+
|
|
120
|
+
otp_service.verify("09123456789", "۱۲۳۴۵۶") # True once — single-use,
|
|
121
|
+
# Persian/Arabic digits accepted
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Codes are `secrets`-random, stored in the Django cache
|
|
125
|
+
(`OTP_CACHE_ALIAS`/`OTP_CACHE_PREFIX`), consumed on first successful verify.
|
|
126
|
+
`OTP_STATIC_CODE` enables the staging bypass the projects hacked in
|
|
127
|
+
individually.
|
|
128
|
+
|
|
129
|
+
## Engines
|
|
130
|
+
|
|
131
|
+
| name | provider | OTP mechanism |
|
|
132
|
+
|-------------|-------------------------|------------------------------------------------|
|
|
133
|
+
| `pishgam` | Pishgam / Pishgaman | `/Messages/SendOtp` template, or gateway `/Authenticate/Send` with `USE_GATEWAY_OTP` |
|
|
134
|
+
| `magfa` | Magfa v2 HTTP | plain send with `OTP_MESSAGE` template |
|
|
135
|
+
| `kavenegar` | Kavenegar | `verify_lookup` template (`url=` → `token2`); `HOST`/`VERIFY_SSL` support the kavenegar.nilva.ai mirror |
|
|
136
|
+
| `pardis` | MCI Bulk Sender P2P | plain send with `OTP_MESSAGE` (OAuth token cached) |
|
|
137
|
+
| `console` | logs only (default) | logs the code |
|
|
138
|
+
| `memory` | in-process outbox | for tests; `{"FAIL": True}` to exercise fallbacks |
|
|
139
|
+
|
|
140
|
+
Project-specific engines: subclass `nilva_django_sms.SmsEngine` and register
|
|
141
|
+
via `NILVA_SMS["EXTRA_ENGINES"] = {"myengine": "myapp.sms.MyEngine"}`.
|
|
142
|
+
|
|
143
|
+
## Testing your app
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from nilva_django_sms.engines.memory import MemoryEngine
|
|
147
|
+
|
|
148
|
+
@override_settings(NILVA_SMS={"ENGINE": "memory"})
|
|
149
|
+
def test_signup_sends_otp(self):
|
|
150
|
+
...
|
|
151
|
+
kind, recipient, code = MemoryEngine.outbox[-1]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
uv sync --group dev
|
|
158
|
+
./run_tests.sh # ./run_tests.sh -c for coverage
|
|
159
|
+
uv run ruff check .
|
|
160
|
+
```
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# nilva-django-sms
|
|
2
|
+
|
|
3
|
+
Nilva's house SMS / OTP toolkit for Django and plain-Python services. One
|
|
4
|
+
engine interface over every provider used across the Nilva backends —
|
|
5
|
+
**Pishgam/Pishgaman Rayan, Magfa, Kavenegar, MCI Pardis** — plus **console**
|
|
6
|
+
and **memory** engines for development and tests. Replaces the copy-pasted
|
|
7
|
+
`SMSService` / `OTPService` clients that had drifted across Cheshmeh, Etka,
|
|
8
|
+
Selfcare, Ghadir, Shop and Tiran.
|
|
9
|
+
|
|
10
|
+
What it standardizes:
|
|
11
|
+
|
|
12
|
+
* **Typed errors** — `SMSTransientError` (retry can help) vs
|
|
13
|
+
`SMSPermanentError` (it can't); Celery retry policies key off the split.
|
|
14
|
+
* **Fallback chains** — primary engine plus ordered fallbacks.
|
|
15
|
+
* **OTP cooldown throttling** — previously only Tiran had it; now default.
|
|
16
|
+
* **Success normalization** — no `statusCode = 32` hacks leaking to callers.
|
|
17
|
+
* **One vendored Kavenegar SDK** (`nilva_django_sms.kavenegar_sdk`, the
|
|
18
|
+
timeout/proxy-patched fork) instead of a copy per repo.
|
|
19
|
+
|
|
20
|
+
Importing the package pulls neither Django, `requests` nor Celery — engines
|
|
21
|
+
and the OTP service load lazily. Only `requests` is a hard install
|
|
22
|
+
dependency.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add nilva-django-sms # engines + registry + facade
|
|
28
|
+
uv add "nilva-django-sms[django]" # + OTP service (Django cache)
|
|
29
|
+
uv add "nilva-django-sms[celery]" # + queued delivery with retries
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configure — `NILVA_SMS`
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
# settings.py
|
|
36
|
+
NILVA_SMS = {
|
|
37
|
+
"ENGINE": "pishgam", # default engine ("console" if unset)
|
|
38
|
+
"FALLBACK_ENGINES": ("magfa",), # tried in order when pishgam raises
|
|
39
|
+
"PROXY": SETTINGS.SOCKS5_PROXY, # shared by all HTTP engines
|
|
40
|
+
"TIMEOUT": 5.0,
|
|
41
|
+
|
|
42
|
+
"PISHGAM": {
|
|
43
|
+
"GATEWAY_URL": "https://gw.pishgamrayan.com",
|
|
44
|
+
"TOKEN": "...", "GATEWAY_TOKEN": "...",
|
|
45
|
+
"SENDER": "3000...", "OTP_ID": 123,
|
|
46
|
+
# OTP template parameters; "{code}" is substituted:
|
|
47
|
+
"OTP_PARAMS": ("myapp", "{code}", "myapp.ir"),
|
|
48
|
+
},
|
|
49
|
+
"MAGFA": {"USERNAME": "...", "DOMAIN": "...", "PASSWORD": "...", "SENDER": "9800..."},
|
|
50
|
+
"KAVENEGAR": {"API_KEY": "...", "OTP_TEMPLATE": "login-otp"},
|
|
51
|
+
"PARDIS": {"BASE_URL": "...", "AUTH_HOST": "...", "BULK_HOST": "...",
|
|
52
|
+
"CLIENT_ID": "...", "USERNAME": "...", "PASSWORD": "...", "SOURCE": "98..."},
|
|
53
|
+
|
|
54
|
+
# OTP service
|
|
55
|
+
"OTP_LENGTH": 6,
|
|
56
|
+
"OTP_TTL": 300,
|
|
57
|
+
"OTP_COOLDOWN": 60,
|
|
58
|
+
"OTP_STATIC_CODE": None, # staging bypass code; None disables
|
|
59
|
+
|
|
60
|
+
# Celery delivery
|
|
61
|
+
"USE_CELERY": True,
|
|
62
|
+
"CELERY_QUEUE": "low",
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Engine blocks merge over their defaults, so set only what you have. Every
|
|
67
|
+
setting is read through `nilva_django_sms.settings.sms_settings` (the
|
|
68
|
+
`nilva-django-logger` settings-object pattern) and works without Django too.
|
|
69
|
+
|
|
70
|
+
## Send
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from nilva_django_sms import send_sms, send_otp
|
|
74
|
+
|
|
75
|
+
send_sms("پیام شما", "09123456789") # walks ENGINE + fallbacks
|
|
76
|
+
send_otp("123456", "09123456789") # OTP_ENGINE (or ENGINE) first
|
|
77
|
+
send_sms("...", "0912...", engine="kavenegar") # explicit engine
|
|
78
|
+
send_sms("...", "0912...", fail_silently=True) # SendResult(success=False) instead of raising
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Queued delivery with transient-error retries (backoff + jitter, 4 attempts):
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from nilva_django_sms.tasks import queue_send_sms, queue_send_otp
|
|
85
|
+
queue_send_sms("پیام", "09123456789", delay=10) # sync fallback without Celery
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## OTP
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from nilva_django_sms.otp import otp_service
|
|
92
|
+
|
|
93
|
+
otp_service.send("09123456789") # generate + cache + deliver
|
|
94
|
+
# raises OTPCooldownError(retry_after=…) inside the cooldown window
|
|
95
|
+
|
|
96
|
+
otp_service.verify("09123456789", "۱۲۳۴۵۶") # True once — single-use,
|
|
97
|
+
# Persian/Arabic digits accepted
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Codes are `secrets`-random, stored in the Django cache
|
|
101
|
+
(`OTP_CACHE_ALIAS`/`OTP_CACHE_PREFIX`), consumed on first successful verify.
|
|
102
|
+
`OTP_STATIC_CODE` enables the staging bypass the projects hacked in
|
|
103
|
+
individually.
|
|
104
|
+
|
|
105
|
+
## Engines
|
|
106
|
+
|
|
107
|
+
| name | provider | OTP mechanism |
|
|
108
|
+
|-------------|-------------------------|------------------------------------------------|
|
|
109
|
+
| `pishgam` | Pishgam / Pishgaman | `/Messages/SendOtp` template, or gateway `/Authenticate/Send` with `USE_GATEWAY_OTP` |
|
|
110
|
+
| `magfa` | Magfa v2 HTTP | plain send with `OTP_MESSAGE` template |
|
|
111
|
+
| `kavenegar` | Kavenegar | `verify_lookup` template (`url=` → `token2`); `HOST`/`VERIFY_SSL` support the kavenegar.nilva.ai mirror |
|
|
112
|
+
| `pardis` | MCI Bulk Sender P2P | plain send with `OTP_MESSAGE` (OAuth token cached) |
|
|
113
|
+
| `console` | logs only (default) | logs the code |
|
|
114
|
+
| `memory` | in-process outbox | for tests; `{"FAIL": True}` to exercise fallbacks |
|
|
115
|
+
|
|
116
|
+
Project-specific engines: subclass `nilva_django_sms.SmsEngine` and register
|
|
117
|
+
via `NILVA_SMS["EXTRA_ENGINES"] = {"myengine": "myapp.sms.MyEngine"}`.
|
|
118
|
+
|
|
119
|
+
## Testing your app
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from nilva_django_sms.engines.memory import MemoryEngine
|
|
123
|
+
|
|
124
|
+
@override_settings(NILVA_SMS={"ENGINE": "memory"})
|
|
125
|
+
def test_signup_sends_otp(self):
|
|
126
|
+
...
|
|
127
|
+
kind, recipient, code = MemoryEngine.outbox[-1]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv sync --group dev
|
|
134
|
+
./run_tests.sh # ./run_tests.sh -c for coverage
|
|
135
|
+
uv run ruff check .
|
|
136
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Nilva Django SMS — the Nilva house SMS / OTP toolkit.
|
|
2
|
+
|
|
3
|
+
One engine interface over every SMS provider used across the Nilva backends
|
|
4
|
+
(Pishgam/Pishgaman, Magfa, Kavenegar, MCI Pardis) plus console and in-memory
|
|
5
|
+
engines for development and tests. Ships a registry with fallback chains,
|
|
6
|
+
typed transient/permanent errors, a cache-backed OTP service with cooldown
|
|
7
|
+
throttling, and optional Celery delivery.
|
|
8
|
+
|
|
9
|
+
Configuration follows the house ``NILVA_*`` settings-dict pattern (see
|
|
10
|
+
``nilva_django_sms.settings``). Importing this package pulls neither Django
|
|
11
|
+
nor ``requests`` — engines and the OTP service load lazily.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
|
+
|
|
16
|
+
from .base import SendResult, SmsEngine
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
OTPCooldownError,
|
|
19
|
+
SMSConfigurationError,
|
|
20
|
+
SMSError,
|
|
21
|
+
SMSPermanentError,
|
|
22
|
+
SMSTransientError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"__version__",
|
|
27
|
+
"SendResult",
|
|
28
|
+
"SmsEngine",
|
|
29
|
+
"SMSError",
|
|
30
|
+
"SMSConfigurationError",
|
|
31
|
+
"SMSPermanentError",
|
|
32
|
+
"SMSTransientError",
|
|
33
|
+
"OTPCooldownError",
|
|
34
|
+
"get_engine",
|
|
35
|
+
"send_sms",
|
|
36
|
+
"send_otp",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
_LAZY = {
|
|
40
|
+
"get_engine": "registry",
|
|
41
|
+
"send_sms": "api",
|
|
42
|
+
"send_otp": "api",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def __getattr__(name):
|
|
47
|
+
module_name = _LAZY.get(name)
|
|
48
|
+
if module_name is None:
|
|
49
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
50
|
+
from importlib import import_module
|
|
51
|
+
|
|
52
|
+
return getattr(import_module(f".{module_name}", __name__), name)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Send facade — walks the engine fallback chain.
|
|
2
|
+
|
|
3
|
+
Each engine in the chain is tried in order; the last engine's error
|
|
4
|
+
propagates when all fail (with every attempt logged), preserving the
|
|
5
|
+
transient/permanent type for retry policies. ``fail_silently=True`` returns a
|
|
6
|
+
failed :class:`SendResult` instead of raising.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from .base import SendResult
|
|
12
|
+
from .exceptions import SMSError
|
|
13
|
+
from .registry import engine_chain, get_engine
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _send(method: str, args: tuple, kwargs: dict, engine: str | None, fail_silently: bool, for_otp: bool):
|
|
19
|
+
errors = []
|
|
20
|
+
for name in engine_chain(engine, for_otp=for_otp):
|
|
21
|
+
try:
|
|
22
|
+
result = getattr(get_engine(name), method)(*args, **kwargs)
|
|
23
|
+
if errors:
|
|
24
|
+
logger.warning("sms sent via fallback engine", extra={"engine": name, "failed": len(errors)})
|
|
25
|
+
return result
|
|
26
|
+
except SMSError as exc:
|
|
27
|
+
logger.error("sms engine failed", extra={"engine": name, "error_info": str(exc)})
|
|
28
|
+
errors.append((name, exc))
|
|
29
|
+
if fail_silently:
|
|
30
|
+
return SendResult(success=False, raw={"errors": [(n, str(e)) for n, e in errors]})
|
|
31
|
+
raise errors[-1][1]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def send_sms(message: str, recipient: str, engine: str | None = None, fail_silently: bool = False) -> SendResult:
|
|
35
|
+
"""Send a plain SMS through the configured engine chain."""
|
|
36
|
+
return _send("send_sms", (message, recipient), {}, engine, fail_silently, for_otp=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def send_otp(code: str, recipient: str, engine: str | None = None, fail_silently: bool = False, **kwargs) -> SendResult:
|
|
40
|
+
"""Send an OTP through the configured engine chain (``OTP_ENGINE`` first).
|
|
41
|
+
|
|
42
|
+
Extra ``kwargs`` reach the engine (e.g. Kavenegar's ``url`` second token,
|
|
43
|
+
Pishgam's ``is_dashboard``-style template switches via ``otp_id``).
|
|
44
|
+
"""
|
|
45
|
+
return _send("send_otp", (code, recipient), kwargs, engine, fail_silently, for_otp=True)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Engine interface (Cheshmeh's ``SmsEngine`` ABC, made config-injectable).
|
|
2
|
+
|
|
3
|
+
An engine either returns a :class:`SendResult` or raises a typed error from
|
|
4
|
+
:mod:`nilva_django_sms.exceptions` — success is never signalled by a magic
|
|
5
|
+
provider status code leaking to the caller.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SendResult:
|
|
15
|
+
success: bool = True
|
|
16
|
+
engine: str = ""
|
|
17
|
+
raw: Any = None
|
|
18
|
+
message_ids: list = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SmsEngine(ABC):
|
|
22
|
+
"""Base engine. ``config`` defaults to the engine's block in ``NILVA_SMS``
|
|
23
|
+
(e.g. ``NILVA_SMS["PISHGAM"]``); pass a dict explicitly in tests."""
|
|
24
|
+
|
|
25
|
+
#: registry key and the ``NILVA_SMS`` block name (upper-cased).
|
|
26
|
+
name = ""
|
|
27
|
+
|
|
28
|
+
def __init__(self, config: dict | None = None):
|
|
29
|
+
from .settings import sms_settings
|
|
30
|
+
|
|
31
|
+
if config is None:
|
|
32
|
+
block = self.name.upper()
|
|
33
|
+
# Engines outside DEFAULTS (memory, EXTRA_ENGINES) still get their
|
|
34
|
+
# NILVA_SMS[block] dict.
|
|
35
|
+
config = getattr(sms_settings, block, None) or sms_settings.user_settings.get(block) or {}
|
|
36
|
+
self.config = dict(config)
|
|
37
|
+
self.proxy = self.config.get("PROXY", sms_settings.PROXY)
|
|
38
|
+
self.timeout = self.config.get("TIMEOUT", sms_settings.TIMEOUT)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def proxies(self) -> dict | None:
|
|
42
|
+
if not self.proxy:
|
|
43
|
+
return None
|
|
44
|
+
return {"http": self.proxy, "https": self.proxy}
|
|
45
|
+
|
|
46
|
+
def require(self, key: str):
|
|
47
|
+
"""Config value or :class:`SMSConfigurationError` naming the setting."""
|
|
48
|
+
value = self.config.get(key)
|
|
49
|
+
if value in (None, ""):
|
|
50
|
+
from .exceptions import SMSConfigurationError
|
|
51
|
+
|
|
52
|
+
raise SMSConfigurationError(f"NILVA_SMS[{self.name.upper()!r}][{key!r}] is required for {self.name}")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
def otp_message(self, code: str) -> str:
|
|
56
|
+
"""Plain-text OTP body for providers without OTP templates."""
|
|
57
|
+
from .settings import sms_settings
|
|
58
|
+
|
|
59
|
+
return str(sms_settings.OTP_MESSAGE).format(code=code)
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def send_sms(self, message: str, recipient: str) -> SendResult:
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def send_otp(self, code: str, recipient: str, **kwargs) -> SendResult:
|
|
67
|
+
raise NotImplementedError
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Development engine: logs instead of sending (Tiran's console mode).
|
|
2
|
+
|
|
3
|
+
The default engine, so a fresh project never sends real SMS until it
|
|
4
|
+
explicitly configures a provider.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from ..base import SendResult, SmsEngine
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConsoleEngine(SmsEngine):
|
|
15
|
+
name = "console"
|
|
16
|
+
|
|
17
|
+
def send_sms(self, message: str, recipient: str) -> SendResult:
|
|
18
|
+
logger.info("SMS to %s: %s", recipient, message)
|
|
19
|
+
return SendResult(engine=self.name)
|
|
20
|
+
|
|
21
|
+
def send_otp(self, code: str, recipient: str, **kwargs) -> SendResult:
|
|
22
|
+
logger.info("OTP for %s: %s", recipient, code)
|
|
23
|
+
return SendResult(engine=self.name)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Kavenegar engine — wraps the vendored SDK (``nilva_django_sms.kavenegar_sdk``,
|
|
2
|
+
the timeout/proxy-patched copy Shop and Selfcare each vendored separately)
|
|
3
|
+
so the fleet ships exactly one blessed copy.
|
|
4
|
+
|
|
5
|
+
OTPs use ``verify_lookup`` with the configured template (``token``; an extra
|
|
6
|
+
``url=`` kwarg becomes ``token2``, Shop's pattern); plain SMS uses
|
|
7
|
+
``sms_send``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from ..base import SendResult, SmsEngine
|
|
13
|
+
from ..exceptions import SMSPermanentError, SMSTransientError
|
|
14
|
+
from ..kavenegar_sdk import APIException, HTTPException, KavenegarAPI
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KavenegarEngine(SmsEngine):
|
|
20
|
+
name = "kavenegar"
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: dict | None = None):
|
|
23
|
+
super().__init__(config)
|
|
24
|
+
# HOST supports the kavenegar.nilva.ai mirror (InternetAccess pattern)
|
|
25
|
+
# when api.kavenegar.com is unreachable from the cluster; mirrors
|
|
26
|
+
# usually need VERIFY_SSL off.
|
|
27
|
+
self._api = KavenegarAPI(
|
|
28
|
+
self.require("API_KEY"),
|
|
29
|
+
timeout=self.timeout,
|
|
30
|
+
proxies=self.proxies,
|
|
31
|
+
host=self.config.get("HOST"),
|
|
32
|
+
verify=self.config.get("VERIFY_SSL", True),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def _call(self, method: str, params: dict) -> list:
|
|
36
|
+
try:
|
|
37
|
+
return getattr(self._api, method)(params)
|
|
38
|
+
except APIException as exc:
|
|
39
|
+
raise SMSPermanentError(f"kavenegar api error: {exc}") from exc
|
|
40
|
+
except HTTPException as exc:
|
|
41
|
+
raise SMSTransientError(f"kavenegar connection failure: {exc}") from exc
|
|
42
|
+
|
|
43
|
+
def _check_entry(self, entries: list) -> SendResult:
|
|
44
|
+
entry = entries[0] if entries else None
|
|
45
|
+
statuses = tuple(self.config.get("SUCCESS_STATUSES") or (5,))
|
|
46
|
+
if not entry or entry.get("status") not in statuses:
|
|
47
|
+
raise SMSPermanentError(f"kavenegar rejected send with status {(entry or {}).get('status')!r}")
|
|
48
|
+
return SendResult(engine=self.name, raw=entry, message_ids=[str(entry.get("messageid", ""))])
|
|
49
|
+
|
|
50
|
+
def send_sms(self, message: str, recipient: str) -> SendResult:
|
|
51
|
+
params = {"receptor": recipient, "message": message}
|
|
52
|
+
sender = self.config.get("SENDER")
|
|
53
|
+
if sender:
|
|
54
|
+
params["sender"] = sender
|
|
55
|
+
return self._check_entry(self._call("sms_send", params))
|
|
56
|
+
|
|
57
|
+
def send_otp(self, code: str, recipient: str, url: str | None = None, **kwargs) -> SendResult:
|
|
58
|
+
params = {"receptor": recipient, "template": self.require("OTP_TEMPLATE"), "token": str(code)}
|
|
59
|
+
if url:
|
|
60
|
+
params["token2"] = url
|
|
61
|
+
return self._check_entry(self._call("verify_lookup", params))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Magfa engine (HTTP v2 send API, basic auth ``username/domain:password``).
|
|
2
|
+
|
|
3
|
+
Consolidates the ``MagfaSMSService`` copies in Cheshmeh, Etka, Selfcare and
|
|
4
|
+
Ghadir. Magfa has no OTP-template endpoint, so OTPs are plain sends using the
|
|
5
|
+
``NILVA_SMS["OTP_MESSAGE"]`` template.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from ..base import SendResult, SmsEngine
|
|
13
|
+
from ..exceptions import SMSPermanentError, SMSTransientError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
SUCCESS_STATUS = 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MagfaEngine(SmsEngine):
|
|
21
|
+
name = "magfa"
|
|
22
|
+
|
|
23
|
+
def send_sms(self, message: str, recipient: str) -> SendResult:
|
|
24
|
+
sender = self.require("SENDER")
|
|
25
|
+
payload = {"senders": [sender], "messages": [message], "recipients": [recipient]}
|
|
26
|
+
url = f"{str(self.require('SERVER_URL')).rstrip('/')}/http/sms/v2/send"
|
|
27
|
+
auth = (f"{self.require('USERNAME')}/{self.require('DOMAIN')}", self.require("PASSWORD"))
|
|
28
|
+
headers = {"accept": "application/json", "cache-control": "no-cache"}
|
|
29
|
+
try:
|
|
30
|
+
response = requests.post(
|
|
31
|
+
url, headers=headers, auth=auth, json=payload, proxies=self.proxies, timeout=self.timeout
|
|
32
|
+
)
|
|
33
|
+
except requests.Timeout as exc:
|
|
34
|
+
raise SMSTransientError("magfa request timed out") from exc
|
|
35
|
+
except requests.RequestException as exc:
|
|
36
|
+
raise SMSTransientError(f"magfa request failed: {exc}") from exc
|
|
37
|
+
if response.status_code >= 500:
|
|
38
|
+
raise SMSTransientError(f"magfa returned {response.status_code}: {response.text}")
|
|
39
|
+
if response.status_code >= 400:
|
|
40
|
+
raise SMSPermanentError(f"magfa rejected request with {response.status_code}: {response.text}")
|
|
41
|
+
try:
|
|
42
|
+
raw = response.json()
|
|
43
|
+
except ValueError as exc:
|
|
44
|
+
raise SMSTransientError(f"magfa returned a non-JSON response: {response.text}") from exc
|
|
45
|
+
if raw.get("status") != SUCCESS_STATUS:
|
|
46
|
+
raise SMSPermanentError(f"magfa rejected send with status {raw.get('status')!r}")
|
|
47
|
+
logger.info("magfa sms sent", extra={"engine": self.name})
|
|
48
|
+
return SendResult(engine=self.name, raw=raw)
|
|
49
|
+
|
|
50
|
+
def send_otp(self, code: str, recipient: str, **kwargs) -> SendResult:
|
|
51
|
+
return self.send_sms(self.otp_message(code), recipient)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Test engine: records every send in a class-level outbox.
|
|
2
|
+
|
|
3
|
+
Configure ``{"FAIL": True}`` (or ``{"FAIL": "transient"}``) to make it raise,
|
|
4
|
+
for exercising fallback chains and retry paths.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ..base import SendResult, SmsEngine
|
|
8
|
+
from ..exceptions import SMSPermanentError, SMSTransientError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MemoryEngine(SmsEngine):
|
|
12
|
+
name = "memory"
|
|
13
|
+
|
|
14
|
+
#: [(kind, recipient, payload), ...] — shared across instances, like
|
|
15
|
+
#: django.core.mail.outbox. Clear it between tests.
|
|
16
|
+
outbox: list[tuple[str, str, str]] = []
|
|
17
|
+
|
|
18
|
+
def _maybe_fail(self):
|
|
19
|
+
fail = self.config.get("FAIL")
|
|
20
|
+
if not fail:
|
|
21
|
+
return
|
|
22
|
+
if fail == "transient":
|
|
23
|
+
raise SMSTransientError("memory engine configured to fail (transient)")
|
|
24
|
+
raise SMSPermanentError("memory engine configured to fail")
|
|
25
|
+
|
|
26
|
+
def send_sms(self, message: str, recipient: str) -> SendResult:
|
|
27
|
+
self._maybe_fail()
|
|
28
|
+
self.outbox.append(("sms", recipient, message))
|
|
29
|
+
return SendResult(engine=self.name)
|
|
30
|
+
|
|
31
|
+
def send_otp(self, code: str, recipient: str, **kwargs) -> SendResult:
|
|
32
|
+
self._maybe_fail()
|
|
33
|
+
self.outbox.append(("otp", recipient, code))
|
|
34
|
+
return SendResult(engine=self.name)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def clear(cls):
|
|
38
|
+
cls.outbox.clear()
|