statezero 0.1.0b22__tar.gz → 0.1.0b24__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.
Potentially problematic release.
This version of statezero might be problematic. Click here for more details.
- {statezero-0.1.0b22 → statezero-0.1.0b24}/PKG-INFO +1 -1
- {statezero-0.1.0b22 → statezero-0.1.0b24}/pyproject.toml +1 -1
- statezero-0.1.0b24/statezero/adaptors/django/middleware.py +15 -0
- statezero-0.1.0b24/statezero/adaptors/django/migrations/0001_initial.py +63 -0
- statezero-0.1.0b24/statezero/adaptors/django/namespace_models.py +61 -0
- statezero-0.1.0b24/statezero/adaptors/django/namespace_views.py +360 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/serializers.py +12 -2
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/urls.py +5 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/ast_parser.py +119 -13
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/config.py +3 -0
- statezero-0.1.0b24/statezero/core/context_storage.py +55 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/event_bus.py +83 -35
- statezero-0.1.0b24/statezero/core/namespace_models.py +66 -0
- statezero-0.1.0b24/statezero/core/namespace_utils.py +275 -0
- statezero-0.1.0b24/statezero/core/process_request.py +220 -0
- statezero-0.1.0b24/statezero/core/query_cache.py +160 -0
- statezero-0.1.0b24/statezero/core/telemetry.py +209 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero.egg-info/PKG-INFO +1 -1
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero.egg-info/SOURCES.txt +6 -1
- statezero-0.1.0b22/statezero/adaptors/django/middleware.py +0 -10
- statezero-0.1.0b22/statezero/adaptors/django/migrations/0001_initial.py +0 -33
- statezero-0.1.0b22/statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +0 -16
- statezero-0.1.0b22/statezero/core/context_storage.py +0 -4
- statezero-0.1.0b22/statezero/core/process_request.py +0 -183
- {statezero-0.1.0b22 → statezero-0.1.0b24}/README.md +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/license.md +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/requirements.txt +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/setup.cfg +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/__init__.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/__init__.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/__init__.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/actions.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/apps.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/config.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/context_manager.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/event_emitters.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/exception_handler.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/__init__.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/f_handler.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/helpers.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/migrations/__init__.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/orm.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/permissions.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/query_optimizer.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/schemas.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/search_providers/__init__.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/search_providers/basic_search.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/search_providers/postgres_search.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/views.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/__init__.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/actions.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/ast_validator.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/classes.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/event_emitters.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/exceptions.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/hook_checks.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/interfaces.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/types.py +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero.egg-info/dependency_links.txt +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero.egg-info/requires.txt +0 -0
- {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: statezero
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.0b24
|
|
4
4
|
Summary: Connect your Python backend to a modern JavaScript SPA frontend with 90% less complexity.
|
|
5
5
|
Author-email: Robert <robert.herring@statezero.dev>
|
|
6
6
|
Project-URL: homepage, https://www.statezero.dev
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "statezero"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.0b24"
|
|
8
8
|
description = "Connect your Python backend to a modern JavaScript SPA frontend with 90% less complexity."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from django.utils.deprecation import MiddlewareMixin
|
|
2
|
+
|
|
3
|
+
from statezero.core.context_storage import current_operation_id, current_canonical_id
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OperationIDMiddleware(MiddlewareMixin):
|
|
7
|
+
def process_request(self, request):
|
|
8
|
+
# The header in Django is available via request.META (HTTP headers are prefixed with HTTP_)
|
|
9
|
+
op_id = request.META.get("HTTP_X_OPERATION_ID")
|
|
10
|
+
current_operation_id.set(op_id)
|
|
11
|
+
|
|
12
|
+
# Also read canonical_id from headers if provided by client
|
|
13
|
+
canonical_id = request.META.get("HTTP_X_CANONICAL_ID")
|
|
14
|
+
if canonical_id:
|
|
15
|
+
current_canonical_id.set(canonical_id)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Generated manually for namespace subscription models
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
import django.db.models.deletion
|
|
6
|
+
import django.utils.timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
|
|
11
|
+
initial = True
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
operations = [
|
|
18
|
+
migrations.CreateModel(
|
|
19
|
+
name='SocketConnection',
|
|
20
|
+
fields=[
|
|
21
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
22
|
+
('socket_id', models.CharField(db_index=True, max_length=255, unique=True)),
|
|
23
|
+
('connected_at', models.DateTimeField(default=django.utils.timezone.now)),
|
|
24
|
+
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
25
|
+
],
|
|
26
|
+
options={
|
|
27
|
+
'db_table': 'statezero_socket_connection',
|
|
28
|
+
},
|
|
29
|
+
),
|
|
30
|
+
migrations.CreateModel(
|
|
31
|
+
name='NamespaceSubscription',
|
|
32
|
+
fields=[
|
|
33
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
34
|
+
('model_name', models.CharField(db_index=True, max_length=255)),
|
|
35
|
+
('namespace', models.JSONField()),
|
|
36
|
+
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
|
37
|
+
('connection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='statezero.socketconnection')),
|
|
38
|
+
],
|
|
39
|
+
options={
|
|
40
|
+
'db_table': 'statezero_namespace_subscription',
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
migrations.AddIndex(
|
|
44
|
+
model_name='socketconnection',
|
|
45
|
+
index=models.Index(fields=['socket_id'], name='statezero_s_socket__a1b2c3_idx'),
|
|
46
|
+
),
|
|
47
|
+
migrations.AddIndex(
|
|
48
|
+
model_name='socketconnection',
|
|
49
|
+
index=models.Index(fields=['user'], name='statezero_s_user_id_d4e5f6_idx'),
|
|
50
|
+
),
|
|
51
|
+
migrations.AddIndex(
|
|
52
|
+
model_name='namespacesubscription',
|
|
53
|
+
index=models.Index(fields=['model_name'], name='statezero_n_model_n_g7h8i9_idx'),
|
|
54
|
+
),
|
|
55
|
+
migrations.AddIndex(
|
|
56
|
+
model_name='namespacesubscription',
|
|
57
|
+
index=models.Index(fields=['connection'], name='statezero_n_connect_j1k2l3_idx'),
|
|
58
|
+
),
|
|
59
|
+
migrations.AlterUniqueTogether(
|
|
60
|
+
name='namespacesubscription',
|
|
61
|
+
unique_together={('connection', 'model_name', 'namespace')},
|
|
62
|
+
),
|
|
63
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django implementation of namespace subscription models.
|
|
3
|
+
"""
|
|
4
|
+
from django.db import models
|
|
5
|
+
from django.contrib.auth import get_user_model
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
User = get_user_model()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SocketConnection(models.Model):
|
|
13
|
+
"""
|
|
14
|
+
Tracks active WebSocket connections.
|
|
15
|
+
|
|
16
|
+
Automatically cleaned up on disconnect.
|
|
17
|
+
"""
|
|
18
|
+
socket_id = models.CharField(max_length=255, unique=True, db_index=True)
|
|
19
|
+
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
|
|
20
|
+
connected_at = models.DateTimeField(default=timezone.now)
|
|
21
|
+
|
|
22
|
+
class Meta:
|
|
23
|
+
app_label = 'statezero'
|
|
24
|
+
db_table = 'statezero_socket_connection'
|
|
25
|
+
indexes = [
|
|
26
|
+
models.Index(fields=['socket_id']),
|
|
27
|
+
models.Index(fields=['user']),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
def __str__(self):
|
|
31
|
+
user_str = f"user:{self.user.id}" if self.user else "anonymous"
|
|
32
|
+
return f"SocketConnection({self.socket_id}, {user_str})"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NamespaceSubscription(models.Model):
|
|
36
|
+
"""
|
|
37
|
+
Tracks which namespaces each socket connection is subscribed to.
|
|
38
|
+
|
|
39
|
+
When deleted on disconnect, all subscriptions are automatically removed.
|
|
40
|
+
"""
|
|
41
|
+
connection = models.ForeignKey(
|
|
42
|
+
SocketConnection,
|
|
43
|
+
on_delete=models.CASCADE,
|
|
44
|
+
related_name='subscriptions'
|
|
45
|
+
)
|
|
46
|
+
model_name = models.CharField(max_length=255, db_index=True)
|
|
47
|
+
namespace = models.JSONField() # e.g., {'room_id': 5}
|
|
48
|
+
created_at = models.DateTimeField(default=timezone.now)
|
|
49
|
+
|
|
50
|
+
class Meta:
|
|
51
|
+
app_label = 'statezero'
|
|
52
|
+
db_table = 'statezero_namespace_subscription'
|
|
53
|
+
indexes = [
|
|
54
|
+
models.Index(fields=['model_name']),
|
|
55
|
+
models.Index(fields=['connection']),
|
|
56
|
+
]
|
|
57
|
+
# Prevent duplicate subscriptions
|
|
58
|
+
unique_together = [['connection', 'model_name', 'namespace']]
|
|
59
|
+
|
|
60
|
+
def __str__(self):
|
|
61
|
+
return f"Subscription({self.connection.socket_id}, {self.model_name}, {self.namespace})"
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Views for namespace subscription management.
|
|
3
|
+
"""
|
|
4
|
+
import logging
|
|
5
|
+
from rest_framework import status
|
|
6
|
+
from rest_framework.response import Response
|
|
7
|
+
from rest_framework.views import APIView
|
|
8
|
+
|
|
9
|
+
from statezero.adaptors.django.namespace_models import (
|
|
10
|
+
SocketConnection,
|
|
11
|
+
NamespaceSubscription,
|
|
12
|
+
)
|
|
13
|
+
from statezero.adaptors.django.exception_handler import explicit_exception_handler
|
|
14
|
+
from statezero.core.namespace_utils import extract_namespace_from_filter
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NamespaceSubscribeView(APIView):
|
|
20
|
+
"""
|
|
21
|
+
Allow clients to subscribe to namespaces for selective event notifications.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def post(self, request):
|
|
25
|
+
"""
|
|
26
|
+
Subscribe to a namespace.
|
|
27
|
+
|
|
28
|
+
Expected payload:
|
|
29
|
+
{
|
|
30
|
+
"socket_id": "socket-123",
|
|
31
|
+
"model_name": "django_app.Message",
|
|
32
|
+
"ast": {
|
|
33
|
+
"query": {
|
|
34
|
+
"type": "read",
|
|
35
|
+
"filter": {
|
|
36
|
+
"type": "filter",
|
|
37
|
+
"conditions": {"room_id": 5, "created_at__gte": "2024-01-01"}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Or simplified without AST wrapper:
|
|
44
|
+
{
|
|
45
|
+
"socket_id": "socket-123",
|
|
46
|
+
"model_name": "django_app.Message",
|
|
47
|
+
"filter": {"room_id": 5, "created_at__gte": "2024-01-01"}
|
|
48
|
+
}
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
socket_id = request.data.get('socket_id')
|
|
52
|
+
model_name = request.data.get('model_name')
|
|
53
|
+
ast = request.data.get('ast')
|
|
54
|
+
query_filter = request.data.get('filter')
|
|
55
|
+
|
|
56
|
+
if not socket_id:
|
|
57
|
+
return Response(
|
|
58
|
+
{"error": "socket_id is required"},
|
|
59
|
+
status=status.HTTP_400_BAD_REQUEST
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if not model_name:
|
|
63
|
+
return Response(
|
|
64
|
+
{"error": "model_name is required"},
|
|
65
|
+
status=status.HTTP_400_BAD_REQUEST
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Extract filter from AST if provided
|
|
69
|
+
if ast and not query_filter:
|
|
70
|
+
query = ast.get('query', {})
|
|
71
|
+
filter_node = query.get('filter', {})
|
|
72
|
+
if filter_node and filter_node.get('type') == 'filter':
|
|
73
|
+
query_filter = filter_node.get('conditions', {})
|
|
74
|
+
|
|
75
|
+
# Extract namespace from filter
|
|
76
|
+
if not query_filter:
|
|
77
|
+
return Response(
|
|
78
|
+
{"error": "ast or filter is required"},
|
|
79
|
+
status=status.HTTP_400_BAD_REQUEST
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
namespace = extract_namespace_from_filter(query_filter)
|
|
83
|
+
|
|
84
|
+
# Import namespace hash utility
|
|
85
|
+
from statezero.core.namespace_utils import get_namespace_hash
|
|
86
|
+
namespace_hash = get_namespace_hash(namespace)
|
|
87
|
+
|
|
88
|
+
# Get or create socket connection
|
|
89
|
+
connection, created = SocketConnection.objects.get_or_create(
|
|
90
|
+
socket_id=socket_id,
|
|
91
|
+
defaults={'user': request.user if request.user.is_authenticated else None}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if created:
|
|
95
|
+
logger.info(f"Created new socket connection: {socket_id}")
|
|
96
|
+
|
|
97
|
+
# Create or update subscription
|
|
98
|
+
subscription, created = NamespaceSubscription.objects.get_or_create(
|
|
99
|
+
connection=connection,
|
|
100
|
+
model_name=model_name,
|
|
101
|
+
namespace=namespace
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if created:
|
|
105
|
+
logger.info(
|
|
106
|
+
f"Created subscription for {socket_id} to {model_name} namespace {namespace}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return Response({
|
|
110
|
+
"success": True,
|
|
111
|
+
"socket_id": socket_id,
|
|
112
|
+
"model_name": model_name,
|
|
113
|
+
"namespace": namespace,
|
|
114
|
+
"namespace_hash": namespace_hash,
|
|
115
|
+
"channel": f"{model_name}:{namespace_hash}",
|
|
116
|
+
"created": created
|
|
117
|
+
}, status=status.HTTP_200_OK)
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
return explicit_exception_handler(e)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class NamespaceUnsubscribeView(APIView):
|
|
124
|
+
"""
|
|
125
|
+
Allow clients to unsubscribe from namespaces.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def post(self, request):
|
|
129
|
+
"""
|
|
130
|
+
Unsubscribe from a namespace.
|
|
131
|
+
|
|
132
|
+
Expected payload:
|
|
133
|
+
{
|
|
134
|
+
"socket_id": "socket-123",
|
|
135
|
+
"model_name": "django_app.Message",
|
|
136
|
+
"ast": {...} # or "filter": {...}
|
|
137
|
+
}
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
socket_id = request.data.get('socket_id')
|
|
141
|
+
model_name = request.data.get('model_name')
|
|
142
|
+
ast = request.data.get('ast')
|
|
143
|
+
query_filter = request.data.get('filter')
|
|
144
|
+
|
|
145
|
+
if not socket_id or not model_name:
|
|
146
|
+
return Response(
|
|
147
|
+
{"error": "socket_id and model_name are required"},
|
|
148
|
+
status=status.HTTP_400_BAD_REQUEST
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Extract filter from AST if provided
|
|
152
|
+
if ast and not query_filter:
|
|
153
|
+
query = ast.get('query', {})
|
|
154
|
+
filter_node = query.get('filter', {})
|
|
155
|
+
if filter_node and filter_node.get('type') == 'filter':
|
|
156
|
+
query_filter = filter_node.get('conditions', {})
|
|
157
|
+
|
|
158
|
+
# Extract namespace from filter
|
|
159
|
+
if not query_filter:
|
|
160
|
+
return Response(
|
|
161
|
+
{"error": "ast or filter is required"},
|
|
162
|
+
status=status.HTTP_400_BAD_REQUEST
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
namespace = extract_namespace_from_filter(query_filter)
|
|
166
|
+
|
|
167
|
+
# Find and delete the subscription
|
|
168
|
+
deleted_count, _ = NamespaceSubscription.objects.filter(
|
|
169
|
+
connection__socket_id=socket_id,
|
|
170
|
+
model_name=model_name,
|
|
171
|
+
namespace=namespace
|
|
172
|
+
).delete()
|
|
173
|
+
|
|
174
|
+
if deleted_count > 0:
|
|
175
|
+
logger.info(
|
|
176
|
+
f"Unsubscribed {socket_id} from {model_name} namespace {namespace}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return Response({
|
|
180
|
+
"success": True,
|
|
181
|
+
"deleted": deleted_count > 0
|
|
182
|
+
}, status=status.HTTP_200_OK)
|
|
183
|
+
|
|
184
|
+
except Exception as e:
|
|
185
|
+
return explicit_exception_handler(e)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class PusherWebhookView(APIView):
|
|
189
|
+
"""
|
|
190
|
+
Handle Pusher webhook events for connection lifecycle.
|
|
191
|
+
|
|
192
|
+
This is called by Pusher when events occur (client disconnect, etc).
|
|
193
|
+
Configure in Pusher dashboard: Settings > Webhooks
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
# Pusher webhooks don't need authentication (they use webhook signature)
|
|
197
|
+
permission_classes = []
|
|
198
|
+
|
|
199
|
+
def post(self, request):
|
|
200
|
+
"""
|
|
201
|
+
Handle Pusher webhook events.
|
|
202
|
+
|
|
203
|
+
Pusher sends events like:
|
|
204
|
+
{
|
|
205
|
+
"time_ms": 1234567890,
|
|
206
|
+
"events": [
|
|
207
|
+
{
|
|
208
|
+
"name": "channel_vacated",
|
|
209
|
+
"channel": "presence-room-5",
|
|
210
|
+
"socket_id": "socket-123.456"
|
|
211
|
+
}
|
|
212
|
+
]
|
|
213
|
+
}
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
# Validate webhook signature (optional but recommended)
|
|
217
|
+
if not self._validate_webhook(request):
|
|
218
|
+
logger.warning("Invalid Pusher webhook signature")
|
|
219
|
+
return Response(
|
|
220
|
+
{"error": "Invalid signature"},
|
|
221
|
+
status=status.HTTP_401_UNAUTHORIZED
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
events = request.data.get('events', [])
|
|
225
|
+
|
|
226
|
+
for event in events:
|
|
227
|
+
event_name = event.get('name')
|
|
228
|
+
|
|
229
|
+
# Handle different event types
|
|
230
|
+
if event_name == 'channel_vacated':
|
|
231
|
+
# A channel has been vacated (last member left)
|
|
232
|
+
self._handle_channel_vacated(event)
|
|
233
|
+
|
|
234
|
+
elif event_name == 'member_removed':
|
|
235
|
+
# A member left a presence channel
|
|
236
|
+
self._handle_member_removed(event)
|
|
237
|
+
|
|
238
|
+
elif event_name == 'member_added':
|
|
239
|
+
# A member joined a presence channel
|
|
240
|
+
self._handle_member_added(event)
|
|
241
|
+
|
|
242
|
+
return Response({"success": True}, status=status.HTTP_200_OK)
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.exception(f"Error handling Pusher webhook: {e}")
|
|
246
|
+
return Response(
|
|
247
|
+
{"error": str(e)},
|
|
248
|
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _validate_webhook(self, request):
|
|
252
|
+
"""
|
|
253
|
+
Validate Pusher webhook signature.
|
|
254
|
+
|
|
255
|
+
Pusher signs webhooks with your app secret.
|
|
256
|
+
See: https://pusher.com/docs/channels/server_api/webhooks/#authentication
|
|
257
|
+
"""
|
|
258
|
+
from django.conf import settings
|
|
259
|
+
import hmac
|
|
260
|
+
import hashlib
|
|
261
|
+
|
|
262
|
+
# Get Pusher secret from STATEZERO_PUSHER settings
|
|
263
|
+
pusher_config = getattr(settings, 'STATEZERO_PUSHER', {})
|
|
264
|
+
webhook_secret = pusher_config.get('SECRET')
|
|
265
|
+
|
|
266
|
+
if not webhook_secret:
|
|
267
|
+
# If no webhook secret configured, skip validation (dev mode)
|
|
268
|
+
logger.warning("No STATEZERO_PUSHER['SECRET'] configured, skipping webhook validation")
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
# Get signature from header
|
|
272
|
+
signature = request.headers.get('X-Pusher-Signature')
|
|
273
|
+
if not signature:
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
# Compute expected signature
|
|
277
|
+
body = request.body.decode('utf-8')
|
|
278
|
+
expected = hmac.new(
|
|
279
|
+
webhook_secret.encode('utf-8'),
|
|
280
|
+
body.encode('utf-8'),
|
|
281
|
+
hashlib.sha256
|
|
282
|
+
).hexdigest()
|
|
283
|
+
|
|
284
|
+
return hmac.compare_digest(signature, expected)
|
|
285
|
+
|
|
286
|
+
def _handle_channel_vacated(self, event):
|
|
287
|
+
"""Handle channel_vacated event."""
|
|
288
|
+
channel = event.get('channel')
|
|
289
|
+
logger.info(f"Channel vacated: {channel}")
|
|
290
|
+
# Channel is empty, but we track per-socket, not per-channel
|
|
291
|
+
|
|
292
|
+
def _handle_member_removed(self, event):
|
|
293
|
+
"""
|
|
294
|
+
Handle member_removed event.
|
|
295
|
+
|
|
296
|
+
This is the key event for cleanup - when a user disconnects.
|
|
297
|
+
"""
|
|
298
|
+
user_id = event.get('user_id') # From presence channel
|
|
299
|
+
socket_id = event.get('socket_id') # May not be present in all events
|
|
300
|
+
|
|
301
|
+
if socket_id:
|
|
302
|
+
# Clean up by socket_id (most reliable)
|
|
303
|
+
deleted_count, _ = SocketConnection.objects.filter(
|
|
304
|
+
socket_id=socket_id
|
|
305
|
+
).delete()
|
|
306
|
+
|
|
307
|
+
if deleted_count > 0:
|
|
308
|
+
logger.info(
|
|
309
|
+
f"Cleaned up socket {socket_id} on member_removed event "
|
|
310
|
+
f"({deleted_count} connections deleted)"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def _handle_member_added(self, event):
|
|
314
|
+
"""Handle member_added event."""
|
|
315
|
+
user_id = event.get('user_id')
|
|
316
|
+
socket_id = event.get('socket_id')
|
|
317
|
+
logger.debug(f"Member added: user={user_id}, socket={socket_id}")
|
|
318
|
+
# We handle connection creation in NamespaceSubscribeView
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class SocketDisconnectView(APIView):
|
|
322
|
+
"""
|
|
323
|
+
Manual disconnect endpoint (for testing or explicit cleanup).
|
|
324
|
+
|
|
325
|
+
In production, use PusherWebhookView which is called automatically.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
def post(self, request):
|
|
329
|
+
"""
|
|
330
|
+
Manually clean up all subscriptions for a socket.
|
|
331
|
+
|
|
332
|
+
Expected payload:
|
|
333
|
+
{
|
|
334
|
+
"socket_id": "socket-123"
|
|
335
|
+
}
|
|
336
|
+
"""
|
|
337
|
+
try:
|
|
338
|
+
socket_id = request.data.get('socket_id')
|
|
339
|
+
|
|
340
|
+
if not socket_id:
|
|
341
|
+
return Response(
|
|
342
|
+
{"error": "socket_id is required"},
|
|
343
|
+
status=status.HTTP_400_BAD_REQUEST
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Delete the connection (cascades to subscriptions)
|
|
347
|
+
deleted_count, _ = SocketConnection.objects.filter(
|
|
348
|
+
socket_id=socket_id
|
|
349
|
+
).delete()
|
|
350
|
+
|
|
351
|
+
if deleted_count > 0:
|
|
352
|
+
logger.info(f"Manually cleaned up socket connection: {socket_id}")
|
|
353
|
+
|
|
354
|
+
return Response({
|
|
355
|
+
"success": True,
|
|
356
|
+
"deleted": deleted_count > 0
|
|
357
|
+
}, status=status.HTTP_200_OK)
|
|
358
|
+
|
|
359
|
+
except Exception as e:
|
|
360
|
+
return explicit_exception_handler(e)
|
|
@@ -468,9 +468,19 @@ class DRFDynamicSerializer(AbstractDataSerializer):
|
|
|
468
468
|
result["included"][model_type] = pk_indexed_data
|
|
469
469
|
|
|
470
470
|
except Exception as e:
|
|
471
|
-
logger.error(
|
|
472
|
-
|
|
471
|
+
logger.error(
|
|
472
|
+
f"Error serializing {model_type}: {e}",
|
|
473
|
+
exc_info=True, # This logs the full traceback
|
|
474
|
+
extra={
|
|
475
|
+
"model_type": model_type,
|
|
476
|
+
"instance_count": len(instances),
|
|
477
|
+
"instance_pks": [getattr(inst, inst._meta.pk.name, None) for inst in instances[:5]] # First 5 PKs for debugging
|
|
478
|
+
}
|
|
479
|
+
)
|
|
480
|
+
# Include an empty dict for this model type to maintain the expected structure
|
|
473
481
|
result["included"][model_type] = {}
|
|
482
|
+
# Re-raise to propagate the error
|
|
483
|
+
raise
|
|
474
484
|
|
|
475
485
|
return result
|
|
476
486
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from django.urls import path
|
|
2
2
|
|
|
3
3
|
from .views import EventsAuthView, ModelListView, ModelView, SchemaView, FileUploadView, FastUploadView, ActionSchemaView, ActionView, ValidateView, FieldPermissionsView
|
|
4
|
+
from .namespace_views import NamespaceSubscribeView, NamespaceUnsubscribeView, SocketDisconnectView, PusherWebhookView
|
|
4
5
|
|
|
5
6
|
app_name = "statezero"
|
|
6
7
|
|
|
@@ -11,6 +12,10 @@ urlpatterns = [
|
|
|
11
12
|
path("files/fast-upload/", FastUploadView.as_view(), name="fast_file_upload"),
|
|
12
13
|
path("actions/<str:action_name>/", ActionView.as_view(), name="action"),
|
|
13
14
|
path("actions-schema/", ActionSchemaView.as_view(), name="actions_schema"),
|
|
15
|
+
path("webhooks/pusher/", PusherWebhookView.as_view(), name="pusher_webhook"),
|
|
16
|
+
path("namespaces/subscribe/", NamespaceSubscribeView.as_view(), name="namespace_subscribe"),
|
|
17
|
+
path("namespaces/unsubscribe/", NamespaceUnsubscribeView.as_view(), name="namespace_unsubscribe"),
|
|
18
|
+
path("namespaces/disconnect/", SocketDisconnectView.as_view(), name="socket_disconnect"),
|
|
14
19
|
path("<str:model_name>/validate/", ValidateView.as_view(), name="validate"),
|
|
15
20
|
path("<str:model_name>/field-permissions/", FieldPermissionsView.as_view(), name="field_permissions"),
|
|
16
21
|
path("<str:model_name>/get-schema/", SchemaView.as_view(), name="schema_view"),
|