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.

Files changed (64) hide show
  1. {statezero-0.1.0b22 → statezero-0.1.0b24}/PKG-INFO +1 -1
  2. {statezero-0.1.0b22 → statezero-0.1.0b24}/pyproject.toml +1 -1
  3. statezero-0.1.0b24/statezero/adaptors/django/middleware.py +15 -0
  4. statezero-0.1.0b24/statezero/adaptors/django/migrations/0001_initial.py +63 -0
  5. statezero-0.1.0b24/statezero/adaptors/django/namespace_models.py +61 -0
  6. statezero-0.1.0b24/statezero/adaptors/django/namespace_views.py +360 -0
  7. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/serializers.py +12 -2
  8. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/urls.py +5 -0
  9. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/ast_parser.py +119 -13
  10. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/config.py +3 -0
  11. statezero-0.1.0b24/statezero/core/context_storage.py +55 -0
  12. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/event_bus.py +83 -35
  13. statezero-0.1.0b24/statezero/core/namespace_models.py +66 -0
  14. statezero-0.1.0b24/statezero/core/namespace_utils.py +275 -0
  15. statezero-0.1.0b24/statezero/core/process_request.py +220 -0
  16. statezero-0.1.0b24/statezero/core/query_cache.py +160 -0
  17. statezero-0.1.0b24/statezero/core/telemetry.py +209 -0
  18. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero.egg-info/PKG-INFO +1 -1
  19. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero.egg-info/SOURCES.txt +6 -1
  20. statezero-0.1.0b22/statezero/adaptors/django/middleware.py +0 -10
  21. statezero-0.1.0b22/statezero/adaptors/django/migrations/0001_initial.py +0 -33
  22. statezero-0.1.0b22/statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +0 -16
  23. statezero-0.1.0b22/statezero/core/context_storage.py +0 -4
  24. statezero-0.1.0b22/statezero/core/process_request.py +0 -183
  25. {statezero-0.1.0b22 → statezero-0.1.0b24}/README.md +0 -0
  26. {statezero-0.1.0b22 → statezero-0.1.0b24}/license.md +0 -0
  27. {statezero-0.1.0b22 → statezero-0.1.0b24}/requirements.txt +0 -0
  28. {statezero-0.1.0b22 → statezero-0.1.0b24}/setup.cfg +0 -0
  29. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/__init__.py +0 -0
  30. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/__init__.py +0 -0
  31. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/__init__.py +0 -0
  32. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/actions.py +0 -0
  33. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/apps.py +0 -0
  34. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/config.py +0 -0
  35. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/context_manager.py +0 -0
  36. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/event_emitters.py +0 -0
  37. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/exception_handler.py +0 -0
  38. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/__init__.py +0 -0
  39. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
  40. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +0 -0
  41. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +0 -0
  42. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/f_handler.py +0 -0
  43. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/helpers.py +0 -0
  44. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/migrations/__init__.py +0 -0
  45. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/orm.py +0 -0
  46. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/permissions.py +0 -0
  47. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/query_optimizer.py +0 -0
  48. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/schemas.py +0 -0
  49. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/search_providers/__init__.py +0 -0
  50. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/search_providers/basic_search.py +0 -0
  51. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/search_providers/postgres_search.py +0 -0
  52. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/adaptors/django/views.py +0 -0
  53. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/__init__.py +0 -0
  54. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/actions.py +0 -0
  55. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/ast_validator.py +0 -0
  56. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/classes.py +0 -0
  57. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/event_emitters.py +0 -0
  58. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/exceptions.py +0 -0
  59. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/hook_checks.py +0 -0
  60. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/interfaces.py +0 -0
  61. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero/core/types.py +0 -0
  62. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero.egg-info/dependency_links.txt +0 -0
  63. {statezero-0.1.0b22 → statezero-0.1.0b24}/statezero.egg-info/requires.txt +0 -0
  64. {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.0b22
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.0b22"
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(f"Error serializing {model_type}: {e}")
472
- # Include an empty list for this model type to maintain the expected structure
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"),