ownsms 0.3.2__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.
- ownsms-0.3.2/LICENSE +21 -0
- ownsms-0.3.2/PKG-INFO +140 -0
- ownsms-0.3.2/README.md +93 -0
- ownsms-0.3.2/pyproject.toml +98 -0
- ownsms-0.3.2/setup.cfg +4 -0
- ownsms-0.3.2/src/ownsms/__init__.py +1 -0
- ownsms-0.3.2/src/ownsms/admin.py +33 -0
- ownsms-0.3.2/src/ownsms/apps.py +7 -0
- ownsms-0.3.2/src/ownsms/auth.py +64 -0
- ownsms-0.3.2/src/ownsms/conf.py +14 -0
- ownsms-0.3.2/src/ownsms/errors.py +13 -0
- ownsms-0.3.2/src/ownsms/management/__init__.py +0 -0
- ownsms-0.3.2/src/ownsms/management/commands/__init__.py +0 -0
- ownsms-0.3.2/src/ownsms/management/commands/ownsms_housekeeping.py +12 -0
- ownsms-0.3.2/src/ownsms/management/commands/ownsms_webhooks.py +10 -0
- ownsms-0.3.2/src/ownsms/migrations/0001_initial.py +89 -0
- ownsms-0.3.2/src/ownsms/migrations/0002_sim_daily_quota_sim_jitter_max_sim_jitter_min_and_more.py +53 -0
- ownsms-0.3.2/src/ownsms/migrations/0003_pairingcode.py +25 -0
- ownsms-0.3.2/src/ownsms/migrations/0004_alter_account_email.py +18 -0
- ownsms-0.3.2/src/ownsms/migrations/0005_message_scheduled_at_campaign_message_campaign.py +42 -0
- ownsms-0.3.2/src/ownsms/migrations/0006_webhook_webhookdelivery.py +42 -0
- ownsms-0.3.2/src/ownsms/migrations/0007_apikey_is_test_message_callback_url_message_is_test.py +28 -0
- ownsms-0.3.2/src/ownsms/migrations/0008_apikey_ip_allowlist_auditlog.py +31 -0
- ownsms-0.3.2/src/ownsms/migrations/__init__.py +0 -0
- ownsms-0.3.2/src/ownsms/models.py +144 -0
- ownsms-0.3.2/src/ownsms/openapi.yaml +600 -0
- ownsms-0.3.2/src/ownsms/phone.py +13 -0
- ownsms-0.3.2/src/ownsms/segments.py +19 -0
- ownsms-0.3.2/src/ownsms/services/__init__.py +0 -0
- ownsms-0.3.2/src/ownsms/services/audit.py +8 -0
- ownsms-0.3.2/src/ownsms/services/campaigns.py +112 -0
- ownsms-0.3.2/src/ownsms/services/dispatch.py +62 -0
- ownsms-0.3.2/src/ownsms/services/messaging.py +79 -0
- ownsms-0.3.2/src/ownsms/services/registration.py +66 -0
- ownsms-0.3.2/src/ownsms/services/templating.py +12 -0
- ownsms-0.3.2/src/ownsms/services/webhooks.py +97 -0
- ownsms-0.3.2/src/ownsms/tokens.py +16 -0
- ownsms-0.3.2/src/ownsms/urls.py +40 -0
- ownsms-0.3.2/src/ownsms/views/__init__.py +0 -0
- ownsms-0.3.2/src/ownsms/views/account.py +31 -0
- ownsms-0.3.2/src/ownsms/views/campaigns.py +104 -0
- ownsms-0.3.2/src/ownsms/views/device.py +139 -0
- ownsms-0.3.2/src/ownsms/views/devices.py +63 -0
- ownsms-0.3.2/src/ownsms/views/docs.py +38 -0
- ownsms-0.3.2/src/ownsms/views/keys.py +74 -0
- ownsms-0.3.2/src/ownsms/views/messages.py +125 -0
- ownsms-0.3.2/src/ownsms/views/registration.py +63 -0
- ownsms-0.3.2/src/ownsms/views/webhooks.py +66 -0
- ownsms-0.3.2/src/ownsms.egg-info/PKG-INFO +140 -0
- ownsms-0.3.2/src/ownsms.egg-info/SOURCES.txt +72 -0
- ownsms-0.3.2/src/ownsms.egg-info/dependency_links.txt +1 -0
- ownsms-0.3.2/src/ownsms.egg-info/requires.txt +18 -0
- ownsms-0.3.2/src/ownsms.egg-info/top_level.txt +1 -0
- ownsms-0.3.2/tests/test_auth.py +55 -0
- ownsms-0.3.2/tests/test_campaigns_api.py +97 -0
- ownsms-0.3.2/tests/test_device_api.py +64 -0
- ownsms-0.3.2/tests/test_dispatch_service.py +72 -0
- ownsms-0.3.2/tests/test_docs.py +19 -0
- ownsms-0.3.2/tests/test_housekeeping.py +23 -0
- ownsms-0.3.2/tests/test_management_api.py +61 -0
- ownsms-0.3.2/tests/test_messages_api.py +50 -0
- ownsms-0.3.2/tests/test_messaging_service.py +67 -0
- ownsms-0.3.2/tests/test_models.py +19 -0
- ownsms-0.3.2/tests/test_phone.py +16 -0
- ownsms-0.3.2/tests/test_properties.py +42 -0
- ownsms-0.3.2/tests/test_public_status.py +58 -0
- ownsms-0.3.2/tests/test_readapi.py +66 -0
- ownsms-0.3.2/tests/test_registration_api.py +42 -0
- ownsms-0.3.2/tests/test_security.py +59 -0
- ownsms-0.3.2/tests/test_segments.py +11 -0
- ownsms-0.3.2/tests/test_sendapi_extra.py +62 -0
- ownsms-0.3.2/tests/test_smoke.py +5 -0
- ownsms-0.3.2/tests/test_tokens.py +14 -0
- ownsms-0.3.2/tests/test_webhooks_api.py +69 -0
ownsms-0.3.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ownsms
|
|
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.
|
ownsms-0.3.2/PKG-INFO
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ownsms
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Send SMS through your own phone's SIM via API — Django app.
|
|
5
|
+
Author-email: ownsms <dev@ownsms.uz>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://ownsms.omadli.uz
|
|
8
|
+
Project-URL: Documentation, https://ownsms.omadli.uz
|
|
9
|
+
Project-URL: Repository, https://github.com/ownsms/django-ownsms
|
|
10
|
+
Project-URL: Issues, https://github.com/ownsms/django-ownsms/issues
|
|
11
|
+
Keywords: sms,django,gateway,otp,api,uzbekistan
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 4.2
|
|
15
|
+
Classifier: Framework :: Django :: 5.0
|
|
16
|
+
Classifier: Framework :: Django :: 5.1
|
|
17
|
+
Classifier: Framework :: Django :: 5.2
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Communications :: Telephony
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: Django<6.0,>=4.2
|
|
30
|
+
Requires-Dist: phonenumbers>=8.13
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-django>=4.8; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
35
|
+
Requires-Dist: tox>=4; extra == "dev"
|
|
36
|
+
Requires-Dist: coverage; extra == "dev"
|
|
37
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
38
|
+
Requires-Dist: hypothesis; extra == "dev"
|
|
39
|
+
Requires-Dist: mypy; extra == "dev"
|
|
40
|
+
Requires-Dist: django-stubs[compatible-mypy]; extra == "dev"
|
|
41
|
+
Requires-Dist: bandit; extra == "dev"
|
|
42
|
+
Requires-Dist: pip-audit; extra == "dev"
|
|
43
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
44
|
+
Requires-Dist: build; extra == "dev"
|
|
45
|
+
Requires-Dist: twine; extra == "dev"
|
|
46
|
+
Dynamic: license-file
|
|
47
|
+
|
|
48
|
+
# ownsms
|
|
49
|
+
|
|
50
|
+
[](https://pypi.org/project/ownsms/)
|
|
51
|
+
[](https://pypi.org/project/ownsms/)
|
|
52
|
+
[](https://www.djangoproject.com/)
|
|
53
|
+
[](https://github.com/ownsms/django-ownsms/actions/workflows/ci.yml)
|
|
54
|
+
[](https://github.com/ownsms/django-ownsms/blob/main/LICENSE)
|
|
55
|
+
|
|
56
|
+
Send SMS through your own phone's SIM card via a simple REST API. `ownsms` is a Django app that
|
|
57
|
+
pairs with an Android device; the device long-polls for outbound messages and dispatches them over
|
|
58
|
+
its own SIM — like Eskiz or Play Mobile, but from your number, at your tariff, with no third-party
|
|
59
|
+
SMS gateway.
|
|
60
|
+
|
|
61
|
+
- **Documentation:** https://ownsms.omadli.uz
|
|
62
|
+
- **Live API demo:** https://sms.omadli.uz/api/v1/docs
|
|
63
|
+
- **Android sender app:** https://github.com/ownsms/ownsms-android
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install ownsms
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
Add the app to `INSTALLED_APPS` and include its URLs:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# settings.py
|
|
77
|
+
INSTALLED_APPS = [
|
|
78
|
+
...
|
|
79
|
+
"ownsms",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
# urls.py
|
|
83
|
+
from django.urls import include, path
|
|
84
|
+
|
|
85
|
+
urlpatterns = [
|
|
86
|
+
path("", include("ownsms.urls")),
|
|
87
|
+
]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Migrate, then send an SMS with a Bearer API key:
|
|
91
|
+
|
|
92
|
+
```console
|
|
93
|
+
$ python manage.py migrate
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```http
|
|
97
|
+
POST /api/v1/messages
|
|
98
|
+
Authorization: Bearer osk_<your-api-key>
|
|
99
|
+
Content-Type: application/json
|
|
100
|
+
|
|
101
|
+
{"to": "+998901234567", "text": "Hello from ownsms!"}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The paired Android device polls the server, picks up the message, and sends it over the SIM. See
|
|
105
|
+
the [quickstart](QUICKSTART.md) for the full register → key → send → status flow.
|
|
106
|
+
|
|
107
|
+
## Features
|
|
108
|
+
|
|
109
|
+
- **Single send** — immediate or `queued`, with per-message `from` (SIM), `ttl`, `idempotency_key`,
|
|
110
|
+
`callback_url`, and `send_at` scheduling.
|
|
111
|
+
- **Campaigns** — one template with `{var}` placeholders + a recipients list, fail-fast validation,
|
|
112
|
+
progress, and pause / resume / cancel.
|
|
113
|
+
- **Delivery lifecycle** — `queued → sending → sent → delivered | failed`, `expired`, `canceled`;
|
|
114
|
+
at-most-once (a job left uncertain by a crash is failed, never resent).
|
|
115
|
+
- **Webhooks** — HMAC-signed, retried delivery on status transitions (off by default).
|
|
116
|
+
- **Per-SIM pacing** — rate limits, jitter, working hours, and daily quota, synced to the device.
|
|
117
|
+
- **Security** — API-key scopes (`send` / `read`), revocation, IP allowlist, and an audit log.
|
|
118
|
+
- **Sandbox** — `is_test` API keys simulate delivery without touching a device.
|
|
119
|
+
- **Django admin** — every model registered for management.
|
|
120
|
+
|
|
121
|
+
Full endpoint reference: Swagger UI at `/api/v1/docs`, schema at `/api/v1/openapi.yaml`.
|
|
122
|
+
|
|
123
|
+
## Compatibility
|
|
124
|
+
|
|
125
|
+
Tested on **Python 3.11 – 3.14** and **Django 4.2 – 5.2** (Django 4.2 and 5.0 require Python ≤ 3.12).
|
|
126
|
+
Requires Python 3.11+.
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
pip install -e ".[dev]"
|
|
132
|
+
pytest # tests
|
|
133
|
+
ruff check src tests # lint
|
|
134
|
+
ruff format src tests # format
|
|
135
|
+
tox # full Python × Django matrix
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
[MIT](LICENSE)
|
ownsms-0.3.2/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# ownsms
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/ownsms/)
|
|
4
|
+
[](https://pypi.org/project/ownsms/)
|
|
5
|
+
[](https://www.djangoproject.com/)
|
|
6
|
+
[](https://github.com/ownsms/django-ownsms/actions/workflows/ci.yml)
|
|
7
|
+
[](https://github.com/ownsms/django-ownsms/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
Send SMS through your own phone's SIM card via a simple REST API. `ownsms` is a Django app that
|
|
10
|
+
pairs with an Android device; the device long-polls for outbound messages and dispatches them over
|
|
11
|
+
its own SIM — like Eskiz or Play Mobile, but from your number, at your tariff, with no third-party
|
|
12
|
+
SMS gateway.
|
|
13
|
+
|
|
14
|
+
- **Documentation:** https://ownsms.omadli.uz
|
|
15
|
+
- **Live API demo:** https://sms.omadli.uz/api/v1/docs
|
|
16
|
+
- **Android sender app:** https://github.com/ownsms/ownsms-android
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install ownsms
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
Add the app to `INSTALLED_APPS` and include its URLs:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# settings.py
|
|
30
|
+
INSTALLED_APPS = [
|
|
31
|
+
...
|
|
32
|
+
"ownsms",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# urls.py
|
|
36
|
+
from django.urls import include, path
|
|
37
|
+
|
|
38
|
+
urlpatterns = [
|
|
39
|
+
path("", include("ownsms.urls")),
|
|
40
|
+
]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Migrate, then send an SMS with a Bearer API key:
|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
$ python manage.py migrate
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```http
|
|
50
|
+
POST /api/v1/messages
|
|
51
|
+
Authorization: Bearer osk_<your-api-key>
|
|
52
|
+
Content-Type: application/json
|
|
53
|
+
|
|
54
|
+
{"to": "+998901234567", "text": "Hello from ownsms!"}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The paired Android device polls the server, picks up the message, and sends it over the SIM. See
|
|
58
|
+
the [quickstart](QUICKSTART.md) for the full register → key → send → status flow.
|
|
59
|
+
|
|
60
|
+
## Features
|
|
61
|
+
|
|
62
|
+
- **Single send** — immediate or `queued`, with per-message `from` (SIM), `ttl`, `idempotency_key`,
|
|
63
|
+
`callback_url`, and `send_at` scheduling.
|
|
64
|
+
- **Campaigns** — one template with `{var}` placeholders + a recipients list, fail-fast validation,
|
|
65
|
+
progress, and pause / resume / cancel.
|
|
66
|
+
- **Delivery lifecycle** — `queued → sending → sent → delivered | failed`, `expired`, `canceled`;
|
|
67
|
+
at-most-once (a job left uncertain by a crash is failed, never resent).
|
|
68
|
+
- **Webhooks** — HMAC-signed, retried delivery on status transitions (off by default).
|
|
69
|
+
- **Per-SIM pacing** — rate limits, jitter, working hours, and daily quota, synced to the device.
|
|
70
|
+
- **Security** — API-key scopes (`send` / `read`), revocation, IP allowlist, and an audit log.
|
|
71
|
+
- **Sandbox** — `is_test` API keys simulate delivery without touching a device.
|
|
72
|
+
- **Django admin** — every model registered for management.
|
|
73
|
+
|
|
74
|
+
Full endpoint reference: Swagger UI at `/api/v1/docs`, schema at `/api/v1/openapi.yaml`.
|
|
75
|
+
|
|
76
|
+
## Compatibility
|
|
77
|
+
|
|
78
|
+
Tested on **Python 3.11 – 3.14** and **Django 4.2 – 5.2** (Django 4.2 and 5.0 require Python ≤ 3.12).
|
|
79
|
+
Requires Python 3.11+.
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install -e ".[dev]"
|
|
85
|
+
pytest # tests
|
|
86
|
+
ruff check src tests # lint
|
|
87
|
+
ruff format src tests # format
|
|
88
|
+
tox # full Python × Django matrix
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ownsms"
|
|
7
|
+
version = "0.3.2"
|
|
8
|
+
description = "Send SMS through your own phone's SIM via API — Django app."
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = ["Django>=4.2,<6.0", "phonenumbers>=8.13"]
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
authors = [{name = "ownsms", email = "dev@ownsms.uz"}]
|
|
15
|
+
keywords = ["sms", "django", "gateway", "otp", "api", "uzbekistan"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Framework :: Django",
|
|
19
|
+
"Framework :: Django :: 4.2",
|
|
20
|
+
"Framework :: Django :: 5.0",
|
|
21
|
+
"Framework :: Django :: 5.1",
|
|
22
|
+
"Framework :: Django :: 5.2",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Programming Language :: Python :: 3.14",
|
|
30
|
+
"Topic :: Communications :: Telephony",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://ownsms.omadli.uz"
|
|
35
|
+
Documentation = "https://ownsms.omadli.uz"
|
|
36
|
+
Repository = "https://github.com/ownsms/django-ownsms"
|
|
37
|
+
Issues = "https://github.com/ownsms/django-ownsms/issues"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=8",
|
|
42
|
+
"pytest-django>=4.8",
|
|
43
|
+
"ruff>=0.6",
|
|
44
|
+
"tox>=4",
|
|
45
|
+
"coverage",
|
|
46
|
+
"pytest-cov",
|
|
47
|
+
"hypothesis",
|
|
48
|
+
"mypy",
|
|
49
|
+
"django-stubs[compatible-mypy]",
|
|
50
|
+
"bandit",
|
|
51
|
+
"pip-audit",
|
|
52
|
+
"pre-commit",
|
|
53
|
+
"build",
|
|
54
|
+
"twine",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[tool.setuptools.packages.find]
|
|
58
|
+
where = ["src"]
|
|
59
|
+
|
|
60
|
+
[tool.setuptools.package-data]
|
|
61
|
+
ownsms = ["openapi.yaml"]
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
DJANGO_SETTINGS_MODULE = "testproject.settings"
|
|
65
|
+
pythonpath = ["src", "."]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
line-length = 120
|
|
70
|
+
target-version = "py311"
|
|
71
|
+
extend-exclude = ["**/migrations/*"]
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint]
|
|
74
|
+
select = ["E", "F", "I"]
|
|
75
|
+
# tests favour compact style (long lines, one-line setup, throwaway locals)
|
|
76
|
+
per-file-ignores = { "tests/*" = ["E501", "E701", "E702", "F841"] }
|
|
77
|
+
|
|
78
|
+
[tool.coverage.run]
|
|
79
|
+
source = ["ownsms"]
|
|
80
|
+
omit = ["*/migrations/*"]
|
|
81
|
+
|
|
82
|
+
[tool.coverage.report]
|
|
83
|
+
show_missing = true
|
|
84
|
+
fail_under = 80
|
|
85
|
+
|
|
86
|
+
[tool.mypy]
|
|
87
|
+
python_version = "3.11"
|
|
88
|
+
ignore_missing_imports = true
|
|
89
|
+
plugins = ["mypy_django_plugin.main"]
|
|
90
|
+
exclude = ["migrations", "tests", "testproject"]
|
|
91
|
+
|
|
92
|
+
[tool.django-stubs]
|
|
93
|
+
django_settings_module = "testproject.settings"
|
|
94
|
+
|
|
95
|
+
[tool.bandit]
|
|
96
|
+
# B310: urlopen of a user-configured webhook URL is intentional (we own the URL).
|
|
97
|
+
# B110: bare except/pass in audit log and webhook retry are intentional fire-and-forget guards.
|
|
98
|
+
skips = ["B310", "B110"]
|
ownsms-0.3.2/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.2"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
|
|
3
|
+
from . import models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@admin.register(models.Account)
|
|
7
|
+
class AccountAdmin(admin.ModelAdmin):
|
|
8
|
+
list_display = ("id", "email", "status", "created_at")
|
|
9
|
+
search_fields = ("email",)
|
|
10
|
+
list_filter = ("status",)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@admin.register(models.Device)
|
|
14
|
+
class DeviceAdmin(admin.ModelAdmin):
|
|
15
|
+
list_display = ("id", "account", "name", "status", "last_seen_at")
|
|
16
|
+
list_filter = ("status",)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@admin.register(models.Message)
|
|
20
|
+
class MessageAdmin(admin.ModelAdmin):
|
|
21
|
+
list_display = ("id", "account", "to", "status", "is_test", "created_at")
|
|
22
|
+
list_filter = ("status", "is_test")
|
|
23
|
+
search_fields = ("to",)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@admin.register(models.Campaign)
|
|
27
|
+
class CampaignAdmin(admin.ModelAdmin):
|
|
28
|
+
list_display = ("id", "account", "status", "total", "created_at")
|
|
29
|
+
list_filter = ("status",)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
for m in (models.Sim, models.ApiKey, models.PairingCode, models.Webhook, models.WebhookDelivery, models.AuditLog):
|
|
33
|
+
admin.site.register(m)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
|
|
3
|
+
from django.utils import timezone
|
|
4
|
+
|
|
5
|
+
from .errors import ApiError
|
|
6
|
+
from .models import ApiKey, Device
|
|
7
|
+
from .tokens import hash_token
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def bearer_from_request(request) -> str:
|
|
11
|
+
header = request.META.get("HTTP_AUTHORIZATION", "")
|
|
12
|
+
if not header.startswith("Bearer "):
|
|
13
|
+
raise ApiError("unauthorized", "Missing Bearer token", 401)
|
|
14
|
+
return header[len("Bearer ") :].strip()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _client_ip(request):
|
|
18
|
+
xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
|
19
|
+
if xff:
|
|
20
|
+
return xff.split(",")[0].strip()
|
|
21
|
+
return request.META.get("REMOTE_ADDR", "")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ip_allowed(ip, allow):
|
|
25
|
+
try:
|
|
26
|
+
addr = ipaddress.ip_address(ip)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return False
|
|
29
|
+
for entry in allow:
|
|
30
|
+
try:
|
|
31
|
+
if addr in ipaddress.ip_network(entry, strict=False):
|
|
32
|
+
return True
|
|
33
|
+
except ValueError:
|
|
34
|
+
if entry == ip:
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_api_key(request, scope=None) -> ApiKey:
|
|
40
|
+
token = bearer_from_request(request)
|
|
41
|
+
try:
|
|
42
|
+
key = ApiKey.objects.select_related("account", "device").get(key_hash=hash_token(token))
|
|
43
|
+
except ApiKey.DoesNotExist:
|
|
44
|
+
raise ApiError("unauthorized", "Invalid API key", 401)
|
|
45
|
+
if key.revoked:
|
|
46
|
+
raise ApiError("unauthorized", "API key revoked", 401)
|
|
47
|
+
if scope and scope not in key.scopes:
|
|
48
|
+
raise ApiError("forbidden", f"Key lacks scope '{scope}'", 403)
|
|
49
|
+
if key.ip_allowlist:
|
|
50
|
+
if not _ip_allowed(_client_ip(request), key.ip_allowlist):
|
|
51
|
+
raise ApiError("ip_not_allowed", "Request IP is not in the allowlist", 403)
|
|
52
|
+
ApiKey.objects.filter(pk=key.pk).update(last_used_at=timezone.now())
|
|
53
|
+
return key
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_device(request) -> Device:
|
|
57
|
+
token = bearer_from_request(request)
|
|
58
|
+
try:
|
|
59
|
+
dev = Device.objects.select_related("account").get(device_token=hash_token(token))
|
|
60
|
+
except Device.DoesNotExist:
|
|
61
|
+
raise ApiError("unauthorized", "Invalid device token", 401)
|
|
62
|
+
if dev.status != "active":
|
|
63
|
+
raise ApiError("device_inactive", "Device is inactive", 403)
|
|
64
|
+
return dev
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
|
|
3
|
+
DEFAULTS = {
|
|
4
|
+
"DEVICE_ONLINE_SECONDS": 60,
|
|
5
|
+
"POLL_TIMEOUT_SECONDS": 30,
|
|
6
|
+
"POLL_INTERVAL_SECONDS": 1,
|
|
7
|
+
"POLL_BATCH_SIZE": 100,
|
|
8
|
+
"LEASE_SECONDS": 60,
|
|
9
|
+
"DEFAULT_TTL_SECONDS": 24 * 3600,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get(name):
|
|
14
|
+
return getattr(settings, "OWNSMS", {}).get(name, DEFAULTS[name])
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django.http import JsonResponse
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ApiError(Exception):
|
|
5
|
+
def __init__(self, code, message, status):
|
|
6
|
+
super().__init__(message)
|
|
7
|
+
self.code = code
|
|
8
|
+
self.message = message
|
|
9
|
+
self.status = status
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def error_response(err: "ApiError") -> JsonResponse:
|
|
13
|
+
return JsonResponse({"error": {"code": err.code, "message": err.message}}, status=err.status)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand
|
|
2
|
+
from django.utils import timezone
|
|
3
|
+
|
|
4
|
+
from ownsms.services.dispatch import expire_and_reclaim
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Command(BaseCommand):
|
|
8
|
+
help = "Expire TTL-passed queued messages and reclaim leases (run periodically)."
|
|
9
|
+
|
|
10
|
+
def handle(self, *args, **opts):
|
|
11
|
+
res = expire_and_reclaim(timezone.now())
|
|
12
|
+
self.stdout.write(f"expired={res['expired']} reclaimed={res['reclaimed']}")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand
|
|
2
|
+
|
|
3
|
+
from ownsms.services import webhooks
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Command(BaseCommand):
|
|
7
|
+
help = "Deliver pending webhooks (run periodically)."
|
|
8
|
+
|
|
9
|
+
def handle(self, *args, **opts):
|
|
10
|
+
self.stdout.write(f"delivered={webhooks.send_pending()}")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Generated by Django 5.2.15 on 2026-06-30 11:07
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
initial = True
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.CreateModel(
|
|
16
|
+
name='Account',
|
|
17
|
+
fields=[
|
|
18
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
19
|
+
('name', models.CharField(blank=True, max_length=200)),
|
|
20
|
+
('email', models.EmailField(max_length=254)),
|
|
21
|
+
('status', models.CharField(default='active', max_length=20)),
|
|
22
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
23
|
+
],
|
|
24
|
+
),
|
|
25
|
+
migrations.CreateModel(
|
|
26
|
+
name='Device',
|
|
27
|
+
fields=[
|
|
28
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
29
|
+
('name', models.CharField(max_length=200)),
|
|
30
|
+
('device_token', models.CharField(max_length=64, unique=True)),
|
|
31
|
+
('app_version', models.CharField(blank=True, max_length=40)),
|
|
32
|
+
('status', models.CharField(default='active', max_length=20)),
|
|
33
|
+
('last_seen_at', models.DateTimeField(blank=True, null=True)),
|
|
34
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
35
|
+
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devices', to='ownsms.account')),
|
|
36
|
+
],
|
|
37
|
+
),
|
|
38
|
+
migrations.CreateModel(
|
|
39
|
+
name='ApiKey',
|
|
40
|
+
fields=[
|
|
41
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
42
|
+
('key_hash', models.CharField(max_length=64, unique=True)),
|
|
43
|
+
('prefix', models.CharField(max_length=16)),
|
|
44
|
+
('name', models.CharField(blank=True, max_length=200)),
|
|
45
|
+
('scopes', models.JSONField(default=list)),
|
|
46
|
+
('revoked', models.BooleanField(default=False)),
|
|
47
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
48
|
+
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
|
49
|
+
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to='ownsms.account')),
|
|
50
|
+
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to='ownsms.device')),
|
|
51
|
+
],
|
|
52
|
+
),
|
|
53
|
+
migrations.CreateModel(
|
|
54
|
+
name='Sim',
|
|
55
|
+
fields=[
|
|
56
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
57
|
+
('subscription_id', models.IntegerField()),
|
|
58
|
+
('number', models.CharField(max_length=20)),
|
|
59
|
+
('operator', models.CharField(blank=True, max_length=40)),
|
|
60
|
+
('is_default', models.BooleanField(default=False)),
|
|
61
|
+
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sims', to='ownsms.device')),
|
|
62
|
+
],
|
|
63
|
+
),
|
|
64
|
+
migrations.CreateModel(
|
|
65
|
+
name='Message',
|
|
66
|
+
fields=[
|
|
67
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
68
|
+
('to', models.CharField(max_length=20)),
|
|
69
|
+
('text', models.TextField()),
|
|
70
|
+
('status', models.CharField(default='queued', max_length=20)),
|
|
71
|
+
('queued', models.BooleanField(default=False)),
|
|
72
|
+
('ttl', models.IntegerField(blank=True, null=True)),
|
|
73
|
+
('segments', models.IntegerField(default=1)),
|
|
74
|
+
('idempotency_key', models.CharField(blank=True, default='', max_length=120)),
|
|
75
|
+
('error_code', models.CharField(blank=True, default='', max_length=60)),
|
|
76
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
77
|
+
('dispatched_at', models.DateTimeField(blank=True, null=True)),
|
|
78
|
+
('sent_at', models.DateTimeField(blank=True, null=True)),
|
|
79
|
+
('delivered_at', models.DateTimeField(blank=True, null=True)),
|
|
80
|
+
('lease_expires_at', models.DateTimeField(blank=True, null=True)),
|
|
81
|
+
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='ownsms.account')),
|
|
82
|
+
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='ownsms.device')),
|
|
83
|
+
('sim', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='messages', to='ownsms.sim')),
|
|
84
|
+
],
|
|
85
|
+
options={
|
|
86
|
+
'constraints': [models.UniqueConstraint(condition=models.Q(('idempotency_key__gt', '')), fields=('account', 'idempotency_key'), name='uniq_account_idempotency')],
|
|
87
|
+
},
|
|
88
|
+
),
|
|
89
|
+
]
|
ownsms-0.3.2/src/ownsms/migrations/0002_sim_daily_quota_sim_jitter_max_sim_jitter_min_and_more.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Generated by Django 5.2.15 on 2026-06-30 14:14
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('ownsms', '0001_initial'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='sim',
|
|
15
|
+
name='daily_quota',
|
|
16
|
+
field=models.IntegerField(blank=True, null=True),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name='sim',
|
|
20
|
+
name='jitter_max',
|
|
21
|
+
field=models.IntegerField(default=5),
|
|
22
|
+
),
|
|
23
|
+
migrations.AddField(
|
|
24
|
+
model_name='sim',
|
|
25
|
+
name='jitter_min',
|
|
26
|
+
field=models.IntegerField(default=2),
|
|
27
|
+
),
|
|
28
|
+
migrations.AddField(
|
|
29
|
+
model_name='sim',
|
|
30
|
+
name='rate_per_day',
|
|
31
|
+
field=models.IntegerField(default=500),
|
|
32
|
+
),
|
|
33
|
+
migrations.AddField(
|
|
34
|
+
model_name='sim',
|
|
35
|
+
name='rate_per_hour',
|
|
36
|
+
field=models.IntegerField(default=200),
|
|
37
|
+
),
|
|
38
|
+
migrations.AddField(
|
|
39
|
+
model_name='sim',
|
|
40
|
+
name='rate_per_min',
|
|
41
|
+
field=models.IntegerField(default=15),
|
|
42
|
+
),
|
|
43
|
+
migrations.AddField(
|
|
44
|
+
model_name='sim',
|
|
45
|
+
name='work_hours_end',
|
|
46
|
+
field=models.TimeField(blank=True, null=True),
|
|
47
|
+
),
|
|
48
|
+
migrations.AddField(
|
|
49
|
+
model_name='sim',
|
|
50
|
+
name='work_hours_start',
|
|
51
|
+
field=models.TimeField(blank=True, null=True),
|
|
52
|
+
),
|
|
53
|
+
]
|