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.
Files changed (48) hide show
  1. ownsms/__init__.py +1 -0
  2. ownsms/admin.py +33 -0
  3. ownsms/apps.py +7 -0
  4. ownsms/auth.py +64 -0
  5. ownsms/conf.py +14 -0
  6. ownsms/errors.py +13 -0
  7. ownsms/management/__init__.py +0 -0
  8. ownsms/management/commands/__init__.py +0 -0
  9. ownsms/management/commands/ownsms_housekeeping.py +12 -0
  10. ownsms/management/commands/ownsms_webhooks.py +10 -0
  11. ownsms/migrations/0001_initial.py +89 -0
  12. ownsms/migrations/0002_sim_daily_quota_sim_jitter_max_sim_jitter_min_and_more.py +53 -0
  13. ownsms/migrations/0003_pairingcode.py +25 -0
  14. ownsms/migrations/0004_alter_account_email.py +18 -0
  15. ownsms/migrations/0005_message_scheduled_at_campaign_message_campaign.py +42 -0
  16. ownsms/migrations/0006_webhook_webhookdelivery.py +42 -0
  17. ownsms/migrations/0007_apikey_is_test_message_callback_url_message_is_test.py +28 -0
  18. ownsms/migrations/0008_apikey_ip_allowlist_auditlog.py +31 -0
  19. ownsms/migrations/__init__.py +0 -0
  20. ownsms/models.py +144 -0
  21. ownsms/openapi.yaml +600 -0
  22. ownsms/phone.py +13 -0
  23. ownsms/segments.py +19 -0
  24. ownsms/services/__init__.py +0 -0
  25. ownsms/services/audit.py +8 -0
  26. ownsms/services/campaigns.py +112 -0
  27. ownsms/services/dispatch.py +62 -0
  28. ownsms/services/messaging.py +79 -0
  29. ownsms/services/registration.py +66 -0
  30. ownsms/services/templating.py +12 -0
  31. ownsms/services/webhooks.py +97 -0
  32. ownsms/tokens.py +16 -0
  33. ownsms/urls.py +40 -0
  34. ownsms/views/__init__.py +0 -0
  35. ownsms/views/account.py +31 -0
  36. ownsms/views/campaigns.py +104 -0
  37. ownsms/views/device.py +139 -0
  38. ownsms/views/devices.py +63 -0
  39. ownsms/views/docs.py +38 -0
  40. ownsms/views/keys.py +74 -0
  41. ownsms/views/messages.py +125 -0
  42. ownsms/views/registration.py +63 -0
  43. ownsms/views/webhooks.py +66 -0
  44. ownsms-0.3.2.dist-info/METADATA +140 -0
  45. ownsms-0.3.2.dist-info/RECORD +48 -0
  46. ownsms-0.3.2.dist-info/WHEEL +5 -0
  47. ownsms-0.3.2.dist-info/licenses/LICENSE +21 -0
  48. 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
@@ -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"
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)