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.
Files changed (74) hide show
  1. ownsms-0.3.2/LICENSE +21 -0
  2. ownsms-0.3.2/PKG-INFO +140 -0
  3. ownsms-0.3.2/README.md +93 -0
  4. ownsms-0.3.2/pyproject.toml +98 -0
  5. ownsms-0.3.2/setup.cfg +4 -0
  6. ownsms-0.3.2/src/ownsms/__init__.py +1 -0
  7. ownsms-0.3.2/src/ownsms/admin.py +33 -0
  8. ownsms-0.3.2/src/ownsms/apps.py +7 -0
  9. ownsms-0.3.2/src/ownsms/auth.py +64 -0
  10. ownsms-0.3.2/src/ownsms/conf.py +14 -0
  11. ownsms-0.3.2/src/ownsms/errors.py +13 -0
  12. ownsms-0.3.2/src/ownsms/management/__init__.py +0 -0
  13. ownsms-0.3.2/src/ownsms/management/commands/__init__.py +0 -0
  14. ownsms-0.3.2/src/ownsms/management/commands/ownsms_housekeeping.py +12 -0
  15. ownsms-0.3.2/src/ownsms/management/commands/ownsms_webhooks.py +10 -0
  16. ownsms-0.3.2/src/ownsms/migrations/0001_initial.py +89 -0
  17. ownsms-0.3.2/src/ownsms/migrations/0002_sim_daily_quota_sim_jitter_max_sim_jitter_min_and_more.py +53 -0
  18. ownsms-0.3.2/src/ownsms/migrations/0003_pairingcode.py +25 -0
  19. ownsms-0.3.2/src/ownsms/migrations/0004_alter_account_email.py +18 -0
  20. ownsms-0.3.2/src/ownsms/migrations/0005_message_scheduled_at_campaign_message_campaign.py +42 -0
  21. ownsms-0.3.2/src/ownsms/migrations/0006_webhook_webhookdelivery.py +42 -0
  22. ownsms-0.3.2/src/ownsms/migrations/0007_apikey_is_test_message_callback_url_message_is_test.py +28 -0
  23. ownsms-0.3.2/src/ownsms/migrations/0008_apikey_ip_allowlist_auditlog.py +31 -0
  24. ownsms-0.3.2/src/ownsms/migrations/__init__.py +0 -0
  25. ownsms-0.3.2/src/ownsms/models.py +144 -0
  26. ownsms-0.3.2/src/ownsms/openapi.yaml +600 -0
  27. ownsms-0.3.2/src/ownsms/phone.py +13 -0
  28. ownsms-0.3.2/src/ownsms/segments.py +19 -0
  29. ownsms-0.3.2/src/ownsms/services/__init__.py +0 -0
  30. ownsms-0.3.2/src/ownsms/services/audit.py +8 -0
  31. ownsms-0.3.2/src/ownsms/services/campaigns.py +112 -0
  32. ownsms-0.3.2/src/ownsms/services/dispatch.py +62 -0
  33. ownsms-0.3.2/src/ownsms/services/messaging.py +79 -0
  34. ownsms-0.3.2/src/ownsms/services/registration.py +66 -0
  35. ownsms-0.3.2/src/ownsms/services/templating.py +12 -0
  36. ownsms-0.3.2/src/ownsms/services/webhooks.py +97 -0
  37. ownsms-0.3.2/src/ownsms/tokens.py +16 -0
  38. ownsms-0.3.2/src/ownsms/urls.py +40 -0
  39. ownsms-0.3.2/src/ownsms/views/__init__.py +0 -0
  40. ownsms-0.3.2/src/ownsms/views/account.py +31 -0
  41. ownsms-0.3.2/src/ownsms/views/campaigns.py +104 -0
  42. ownsms-0.3.2/src/ownsms/views/device.py +139 -0
  43. ownsms-0.3.2/src/ownsms/views/devices.py +63 -0
  44. ownsms-0.3.2/src/ownsms/views/docs.py +38 -0
  45. ownsms-0.3.2/src/ownsms/views/keys.py +74 -0
  46. ownsms-0.3.2/src/ownsms/views/messages.py +125 -0
  47. ownsms-0.3.2/src/ownsms/views/registration.py +63 -0
  48. ownsms-0.3.2/src/ownsms/views/webhooks.py +66 -0
  49. ownsms-0.3.2/src/ownsms.egg-info/PKG-INFO +140 -0
  50. ownsms-0.3.2/src/ownsms.egg-info/SOURCES.txt +72 -0
  51. ownsms-0.3.2/src/ownsms.egg-info/dependency_links.txt +1 -0
  52. ownsms-0.3.2/src/ownsms.egg-info/requires.txt +18 -0
  53. ownsms-0.3.2/src/ownsms.egg-info/top_level.txt +1 -0
  54. ownsms-0.3.2/tests/test_auth.py +55 -0
  55. ownsms-0.3.2/tests/test_campaigns_api.py +97 -0
  56. ownsms-0.3.2/tests/test_device_api.py +64 -0
  57. ownsms-0.3.2/tests/test_dispatch_service.py +72 -0
  58. ownsms-0.3.2/tests/test_docs.py +19 -0
  59. ownsms-0.3.2/tests/test_housekeeping.py +23 -0
  60. ownsms-0.3.2/tests/test_management_api.py +61 -0
  61. ownsms-0.3.2/tests/test_messages_api.py +50 -0
  62. ownsms-0.3.2/tests/test_messaging_service.py +67 -0
  63. ownsms-0.3.2/tests/test_models.py +19 -0
  64. ownsms-0.3.2/tests/test_phone.py +16 -0
  65. ownsms-0.3.2/tests/test_properties.py +42 -0
  66. ownsms-0.3.2/tests/test_public_status.py +58 -0
  67. ownsms-0.3.2/tests/test_readapi.py +66 -0
  68. ownsms-0.3.2/tests/test_registration_api.py +42 -0
  69. ownsms-0.3.2/tests/test_security.py +59 -0
  70. ownsms-0.3.2/tests/test_segments.py +11 -0
  71. ownsms-0.3.2/tests/test_sendapi_extra.py +62 -0
  72. ownsms-0.3.2/tests/test_smoke.py +5 -0
  73. ownsms-0.3.2/tests/test_tokens.py +14 -0
  74. 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
+ [![PyPI](https://img.shields.io/pypi/v/ownsms.svg)](https://pypi.org/project/ownsms/)
51
+ [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue.svg)](https://pypi.org/project/ownsms/)
52
+ [![Django](https://img.shields.io/badge/django-4.2%20%7C%205.0%20%7C%205.1%20%7C%205.2-0C4B33.svg)](https://www.djangoproject.com/)
53
+ [![CI](https://github.com/ownsms/django-ownsms/actions/workflows/ci.yml/badge.svg)](https://github.com/ownsms/django-ownsms/actions/workflows/ci.yml)
54
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/ownsms.svg)](https://pypi.org/project/ownsms/)
4
+ [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue.svg)](https://pypi.org/project/ownsms/)
5
+ [![Django](https://img.shields.io/badge/django-4.2%20%7C%205.0%20%7C%205.1%20%7C%205.2-0C4B33.svg)](https://www.djangoproject.com/)
6
+ [![CI](https://github.com/ownsms/django-ownsms/actions/workflows/ci.yml/badge.svg)](https://github.com/ownsms/django-ownsms/actions/workflows/ci.yml)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class OwnsmsConfig(AppConfig):
5
+ name = "ownsms"
6
+ default_auto_field = "django.db.models.BigAutoField"
7
+ verbose_name = "ownsms"
@@ -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
@@ -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
+ ]
@@ -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
+ ]