ownsms 0.3.2__py3-none-any.whl
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/__init__.py +1 -0
- ownsms/admin.py +33 -0
- ownsms/apps.py +7 -0
- ownsms/auth.py +64 -0
- ownsms/conf.py +14 -0
- ownsms/errors.py +13 -0
- ownsms/management/__init__.py +0 -0
- ownsms/management/commands/__init__.py +0 -0
- ownsms/management/commands/ownsms_housekeeping.py +12 -0
- ownsms/management/commands/ownsms_webhooks.py +10 -0
- ownsms/migrations/0001_initial.py +89 -0
- ownsms/migrations/0002_sim_daily_quota_sim_jitter_max_sim_jitter_min_and_more.py +53 -0
- ownsms/migrations/0003_pairingcode.py +25 -0
- ownsms/migrations/0004_alter_account_email.py +18 -0
- ownsms/migrations/0005_message_scheduled_at_campaign_message_campaign.py +42 -0
- ownsms/migrations/0006_webhook_webhookdelivery.py +42 -0
- ownsms/migrations/0007_apikey_is_test_message_callback_url_message_is_test.py +28 -0
- ownsms/migrations/0008_apikey_ip_allowlist_auditlog.py +31 -0
- ownsms/migrations/__init__.py +0 -0
- ownsms/models.py +144 -0
- ownsms/openapi.yaml +600 -0
- ownsms/phone.py +13 -0
- ownsms/segments.py +19 -0
- ownsms/services/__init__.py +0 -0
- ownsms/services/audit.py +8 -0
- ownsms/services/campaigns.py +112 -0
- ownsms/services/dispatch.py +62 -0
- ownsms/services/messaging.py +79 -0
- ownsms/services/registration.py +66 -0
- ownsms/services/templating.py +12 -0
- ownsms/services/webhooks.py +97 -0
- ownsms/tokens.py +16 -0
- ownsms/urls.py +40 -0
- ownsms/views/__init__.py +0 -0
- ownsms/views/account.py +31 -0
- ownsms/views/campaigns.py +104 -0
- ownsms/views/device.py +139 -0
- ownsms/views/devices.py +63 -0
- ownsms/views/docs.py +38 -0
- ownsms/views/keys.py +74 -0
- ownsms/views/messages.py +125 -0
- ownsms/views/registration.py +63 -0
- ownsms/views/webhooks.py +66 -0
- ownsms-0.3.2.dist-info/METADATA +140 -0
- ownsms-0.3.2.dist-info/RECORD +48 -0
- ownsms-0.3.2.dist-info/WHEEL +5 -0
- ownsms-0.3.2.dist-info/licenses/LICENSE +21 -0
- ownsms-0.3.2.dist-info/top_level.txt +1 -0
ownsms/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.2"
|
ownsms/admin.py
ADDED
|
@@ -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)
|
ownsms/apps.py
ADDED
ownsms/auth.py
ADDED
|
@@ -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
|
ownsms/conf.py
ADDED
|
@@ -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])
|
ownsms/errors.py
ADDED
|
@@ -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
|
+
]
|
|
@@ -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
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Generated by Django 5.2.15 on 2026-06-30 14:20
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('ownsms', '0002_sim_daily_quota_sim_jitter_max_sim_jitter_min_and_more'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.CreateModel(
|
|
15
|
+
name='PairingCode',
|
|
16
|
+
fields=[
|
|
17
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
18
|
+
('code', models.CharField(max_length=32, unique=True)),
|
|
19
|
+
('expires_at', models.DateTimeField()),
|
|
20
|
+
('used', models.BooleanField(default=False)),
|
|
21
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
22
|
+
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pairing_codes', to='ownsms.account')),
|
|
23
|
+
],
|
|
24
|
+
),
|
|
25
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 5.2.15 on 2026-06-30 14:26
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('ownsms', '0003_pairingcode'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AlterField(
|
|
14
|
+
model_name='account',
|
|
15
|
+
name='email',
|
|
16
|
+
field=models.EmailField(blank=True, max_length=254),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Generated by Django 5.2.15 on 2026-06-30 18:03
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('ownsms', '0004_alter_account_email'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AddField(
|
|
15
|
+
model_name='message',
|
|
16
|
+
name='scheduled_at',
|
|
17
|
+
field=models.DateTimeField(blank=True, null=True),
|
|
18
|
+
),
|
|
19
|
+
migrations.CreateModel(
|
|
20
|
+
name='Campaign',
|
|
21
|
+
fields=[
|
|
22
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
23
|
+
('text', models.TextField()),
|
|
24
|
+
('from_number', models.CharField(blank=True, max_length=20)),
|
|
25
|
+
('rotate_sims', models.BooleanField(default=False)),
|
|
26
|
+
('send_at', models.DateTimeField(blank=True, null=True)),
|
|
27
|
+
('queued', models.BooleanField(default=True)),
|
|
28
|
+
('callback_url', models.CharField(blank=True, max_length=500)),
|
|
29
|
+
('status', models.CharField(default='running', max_length=20)),
|
|
30
|
+
('total', models.IntegerField(default=0)),
|
|
31
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
32
|
+
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='campaigns', to='ownsms.account')),
|
|
33
|
+
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='campaigns', to='ownsms.device')),
|
|
34
|
+
('sim', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='campaigns', to='ownsms.sim')),
|
|
35
|
+
],
|
|
36
|
+
),
|
|
37
|
+
migrations.AddField(
|
|
38
|
+
model_name='message',
|
|
39
|
+
name='campaign',
|
|
40
|
+
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='ownsms.campaign'),
|
|
41
|
+
),
|
|
42
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Generated by Django 5.2.15 on 2026-06-30 18:08
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('ownsms', '0005_message_scheduled_at_campaign_message_campaign'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.CreateModel(
|
|
15
|
+
name='Webhook',
|
|
16
|
+
fields=[
|
|
17
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
18
|
+
('url', models.CharField(blank=True, max_length=500)),
|
|
19
|
+
('secret', models.CharField(max_length=64)),
|
|
20
|
+
('events', models.JSONField(default=list)),
|
|
21
|
+
('enabled', models.BooleanField(default=False)),
|
|
22
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
23
|
+
('account', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='webhook', to='ownsms.account')),
|
|
24
|
+
],
|
|
25
|
+
),
|
|
26
|
+
migrations.CreateModel(
|
|
27
|
+
name='WebhookDelivery',
|
|
28
|
+
fields=[
|
|
29
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
30
|
+
('event_id', models.CharField(max_length=64, unique=True)),
|
|
31
|
+
('event', models.CharField(max_length=40)),
|
|
32
|
+
('url', models.CharField(max_length=500)),
|
|
33
|
+
('payload', models.JSONField()),
|
|
34
|
+
('attempts', models.IntegerField(default=0)),
|
|
35
|
+
('status', models.CharField(default='pending', max_length=20)),
|
|
36
|
+
('next_retry_at', models.DateTimeField(blank=True, null=True)),
|
|
37
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
38
|
+
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhook_deliveries', to='ownsms.account')),
|
|
39
|
+
('message', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='ownsms.message')),
|
|
40
|
+
],
|
|
41
|
+
),
|
|
42
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by Django 5.2.15 on 2026-06-30 18:13
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('ownsms', '0006_webhook_webhookdelivery'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name='apikey',
|
|
15
|
+
name='is_test',
|
|
16
|
+
field=models.BooleanField(default=False),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name='message',
|
|
20
|
+
name='callback_url',
|
|
21
|
+
field=models.CharField(blank=True, default='', max_length=500),
|
|
22
|
+
),
|
|
23
|
+
migrations.AddField(
|
|
24
|
+
model_name='message',
|
|
25
|
+
name='is_test',
|
|
26
|
+
field=models.BooleanField(default=False),
|
|
27
|
+
),
|
|
28
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Generated by Django 5.2.15 on 2026-06-30 18:16
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('ownsms', '0007_apikey_is_test_message_callback_url_message_is_test'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AddField(
|
|
15
|
+
model_name='apikey',
|
|
16
|
+
name='ip_allowlist',
|
|
17
|
+
field=models.JSONField(default=list),
|
|
18
|
+
),
|
|
19
|
+
migrations.CreateModel(
|
|
20
|
+
name='AuditLog',
|
|
21
|
+
fields=[
|
|
22
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
23
|
+
('actor', models.CharField(max_length=40)),
|
|
24
|
+
('action', models.CharField(max_length=60)),
|
|
25
|
+
('target', models.CharField(blank=True, max_length=120)),
|
|
26
|
+
('ip', models.CharField(blank=True, max_length=64)),
|
|
27
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
28
|
+
('account', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='audit_logs', to='ownsms.account')),
|
|
29
|
+
],
|
|
30
|
+
),
|
|
31
|
+
]
|
|
File without changes
|
ownsms/models.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
from django.db import models
|
|
4
|
+
|
|
5
|
+
from . import conf
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Account(models.Model):
|
|
9
|
+
name = models.CharField(max_length=200, blank=True)
|
|
10
|
+
email = models.EmailField(blank=True) # optional (no verification in v1)
|
|
11
|
+
status = models.CharField(max_length=20, default="active") # active|suspended
|
|
12
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Device(models.Model):
|
|
16
|
+
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="devices")
|
|
17
|
+
name = models.CharField(max_length=200)
|
|
18
|
+
device_token = models.CharField(max_length=64, unique=True) # sha256 hex
|
|
19
|
+
app_version = models.CharField(max_length=40, blank=True)
|
|
20
|
+
status = models.CharField(max_length=20, default="active") # active|inactive
|
|
21
|
+
last_seen_at = models.DateTimeField(null=True, blank=True)
|
|
22
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
23
|
+
|
|
24
|
+
def is_online(self, now):
|
|
25
|
+
if not self.last_seen_at:
|
|
26
|
+
return False
|
|
27
|
+
return (now - self.last_seen_at) < timedelta(seconds=conf.get("DEVICE_ONLINE_SECONDS"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Sim(models.Model):
|
|
31
|
+
device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="sims")
|
|
32
|
+
subscription_id = models.IntegerField()
|
|
33
|
+
number = models.CharField(max_length=20)
|
|
34
|
+
operator = models.CharField(max_length=40, blank=True)
|
|
35
|
+
is_default = models.BooleanField(default=False)
|
|
36
|
+
rate_per_min = models.IntegerField(default=15)
|
|
37
|
+
rate_per_hour = models.IntegerField(default=200)
|
|
38
|
+
rate_per_day = models.IntegerField(default=500)
|
|
39
|
+
jitter_min = models.IntegerField(default=2) # seconds
|
|
40
|
+
jitter_max = models.IntegerField(default=5)
|
|
41
|
+
work_hours_start = models.TimeField(null=True, blank=True)
|
|
42
|
+
work_hours_end = models.TimeField(null=True, blank=True)
|
|
43
|
+
daily_quota = models.IntegerField(null=True, blank=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ApiKey(models.Model):
|
|
47
|
+
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="api_keys")
|
|
48
|
+
device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="api_keys")
|
|
49
|
+
key_hash = models.CharField(max_length=64, unique=True)
|
|
50
|
+
prefix = models.CharField(max_length=16)
|
|
51
|
+
name = models.CharField(max_length=200, blank=True)
|
|
52
|
+
scopes = models.JSONField(default=list) # ["send","read"]
|
|
53
|
+
ip_allowlist = models.JSONField(default=list) # [] = unrestricted; ["10.0.0.0/8", "1.2.3.4"]
|
|
54
|
+
is_test = models.BooleanField(default=False)
|
|
55
|
+
revoked = models.BooleanField(default=False)
|
|
56
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
57
|
+
last_used_at = models.DateTimeField(null=True, blank=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Campaign(models.Model):
|
|
61
|
+
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="campaigns")
|
|
62
|
+
device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="campaigns")
|
|
63
|
+
sim = models.ForeignKey(Sim, on_delete=models.SET_NULL, null=True, related_name="campaigns")
|
|
64
|
+
text = models.TextField()
|
|
65
|
+
from_number = models.CharField(max_length=20, blank=True)
|
|
66
|
+
rotate_sims = models.BooleanField(default=False)
|
|
67
|
+
send_at = models.DateTimeField(null=True, blank=True)
|
|
68
|
+
queued = models.BooleanField(default=True)
|
|
69
|
+
callback_url = models.CharField(max_length=500, blank=True)
|
|
70
|
+
status = models.CharField(max_length=20, default="running") # scheduled|running|paused|completed|canceled
|
|
71
|
+
total = models.IntegerField(default=0)
|
|
72
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Message(models.Model):
|
|
76
|
+
STATUS = ["queued", "dispatched", "sent", "delivered", "failed", "expired", "canceled"]
|
|
77
|
+
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="messages")
|
|
78
|
+
device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="messages")
|
|
79
|
+
sim = models.ForeignKey(Sim, on_delete=models.SET_NULL, null=True, related_name="messages")
|
|
80
|
+
to = models.CharField(max_length=20)
|
|
81
|
+
text = models.TextField()
|
|
82
|
+
status = models.CharField(max_length=20, default="queued")
|
|
83
|
+
queued = models.BooleanField(default=False) # True = send when online
|
|
84
|
+
ttl = models.IntegerField(null=True, blank=True)
|
|
85
|
+
segments = models.IntegerField(default=1)
|
|
86
|
+
idempotency_key = models.CharField(max_length=120, blank=True, default="")
|
|
87
|
+
error_code = models.CharField(max_length=60, blank=True, default="")
|
|
88
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
89
|
+
dispatched_at = models.DateTimeField(null=True, blank=True)
|
|
90
|
+
sent_at = models.DateTimeField(null=True, blank=True)
|
|
91
|
+
delivered_at = models.DateTimeField(null=True, blank=True)
|
|
92
|
+
lease_expires_at = models.DateTimeField(null=True, blank=True)
|
|
93
|
+
campaign = models.ForeignKey("Campaign", on_delete=models.CASCADE, null=True, related_name="messages")
|
|
94
|
+
scheduled_at = models.DateTimeField(null=True, blank=True)
|
|
95
|
+
callback_url = models.CharField(max_length=500, blank=True, default="")
|
|
96
|
+
is_test = models.BooleanField(default=False)
|
|
97
|
+
|
|
98
|
+
class Meta:
|
|
99
|
+
constraints = [
|
|
100
|
+
models.UniqueConstraint(
|
|
101
|
+
fields=["account", "idempotency_key"],
|
|
102
|
+
condition=models.Q(idempotency_key__gt=""),
|
|
103
|
+
name="uniq_account_idempotency",
|
|
104
|
+
)
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class PairingCode(models.Model):
|
|
109
|
+
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="pairing_codes")
|
|
110
|
+
code = models.CharField(max_length=32, unique=True)
|
|
111
|
+
expires_at = models.DateTimeField()
|
|
112
|
+
used = models.BooleanField(default=False)
|
|
113
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Webhook(models.Model):
|
|
117
|
+
account = models.OneToOneField(Account, on_delete=models.CASCADE, related_name="webhook")
|
|
118
|
+
url = models.CharField(max_length=500, blank=True)
|
|
119
|
+
secret = models.CharField(max_length=64)
|
|
120
|
+
events = models.JSONField(default=list) # ["message.sent","message.delivered","message.failed","message.expired"]
|
|
121
|
+
enabled = models.BooleanField(default=False)
|
|
122
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class WebhookDelivery(models.Model):
|
|
126
|
+
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="webhook_deliveries")
|
|
127
|
+
event_id = models.CharField(max_length=64, unique=True)
|
|
128
|
+
event = models.CharField(max_length=40)
|
|
129
|
+
message = models.ForeignKey(Message, on_delete=models.CASCADE, null=True)
|
|
130
|
+
url = models.CharField(max_length=500)
|
|
131
|
+
payload = models.JSONField()
|
|
132
|
+
attempts = models.IntegerField(default=0)
|
|
133
|
+
status = models.CharField(max_length=20, default="pending") # pending|delivered|failed
|
|
134
|
+
next_retry_at = models.DateTimeField(null=True, blank=True)
|
|
135
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class AuditLog(models.Model):
|
|
139
|
+
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="audit_logs", null=True)
|
|
140
|
+
actor = models.CharField(max_length=40) # system|device|key|user|admin
|
|
141
|
+
action = models.CharField(max_length=60)
|
|
142
|
+
target = models.CharField(max_length=120, blank=True)
|
|
143
|
+
ip = models.CharField(max_length=64, blank=True)
|
|
144
|
+
created_at = models.DateTimeField(auto_now_add=True)
|