statezero 0.1.0b12__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.0b12 → statezero-0.1.0b24}/PKG-INFO +1 -1
  2. {statezero-0.1.0b12 → statezero-0.1.0b24}/pyproject.toml +1 -1
  3. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/actions.py +8 -0
  4. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +8 -0
  5. statezero-0.1.0b24/statezero/adaptors/django/middleware.py +15 -0
  6. statezero-0.1.0b24/statezero/adaptors/django/migrations/0001_initial.py +63 -0
  7. statezero-0.1.0b24/statezero/adaptors/django/namespace_models.py +61 -0
  8. statezero-0.1.0b24/statezero/adaptors/django/namespace_views.py +360 -0
  9. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/orm.py +29 -5
  10. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/permissions.py +6 -0
  11. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/query_optimizer.py +40 -2
  12. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/schemas.py +15 -2
  13. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/serializers.py +96 -38
  14. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/urls.py +7 -1
  15. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/views.py +101 -0
  16. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/ast_parser.py +162 -13
  17. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/classes.py +1 -0
  18. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/config.py +3 -37
  19. statezero-0.1.0b24/statezero/core/context_storage.py +55 -0
  20. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/event_bus.py +83 -35
  21. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/interfaces.py +29 -4
  22. statezero-0.1.0b24/statezero/core/namespace_models.py +66 -0
  23. statezero-0.1.0b24/statezero/core/namespace_utils.py +275 -0
  24. statezero-0.1.0b24/statezero/core/process_request.py +220 -0
  25. statezero-0.1.0b24/statezero/core/query_cache.py +160 -0
  26. statezero-0.1.0b24/statezero/core/telemetry.py +209 -0
  27. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/types.py +1 -0
  28. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero.egg-info/PKG-INFO +1 -1
  29. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero.egg-info/SOURCES.txt +6 -1
  30. statezero-0.1.0b12/statezero/adaptors/django/middleware.py +0 -10
  31. statezero-0.1.0b12/statezero/adaptors/django/migrations/0001_initial.py +0 -33
  32. statezero-0.1.0b12/statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +0 -16
  33. statezero-0.1.0b12/statezero/core/context_storage.py +0 -4
  34. statezero-0.1.0b12/statezero/core/process_request.py +0 -184
  35. {statezero-0.1.0b12 → statezero-0.1.0b24}/README.md +0 -0
  36. {statezero-0.1.0b12 → statezero-0.1.0b24}/license.md +0 -0
  37. {statezero-0.1.0b12 → statezero-0.1.0b24}/requirements.txt +0 -0
  38. {statezero-0.1.0b12 → statezero-0.1.0b24}/setup.cfg +0 -0
  39. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/__init__.py +0 -0
  40. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/__init__.py +0 -0
  41. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/__init__.py +0 -0
  42. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/apps.py +0 -0
  43. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/config.py +0 -0
  44. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/context_manager.py +0 -0
  45. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/event_emitters.py +0 -0
  46. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/exception_handler.py +0 -0
  47. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/__init__.py +0 -0
  48. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
  49. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +0 -0
  50. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/f_handler.py +0 -0
  51. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/helpers.py +0 -0
  52. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/migrations/__init__.py +0 -0
  53. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/search_providers/__init__.py +0 -0
  54. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/search_providers/basic_search.py +0 -0
  55. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/search_providers/postgres_search.py +0 -0
  56. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/__init__.py +0 -0
  57. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/actions.py +0 -0
  58. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/ast_validator.py +0 -0
  59. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/event_emitters.py +0 -0
  60. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/exceptions.py +0 -0
  61. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/hook_checks.py +0 -0
  62. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero.egg-info/dependency_links.txt +0 -0
  63. {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero.egg-info/requires.txt +0 -0
  64. {statezero-0.1.0b12 → 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.0b12
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.0b12"
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" }
@@ -125,6 +125,14 @@ class DjangoActionSchemaGenerator:
125
125
  return "string"
126
126
  return "integer"
127
127
 
128
+ # Handle nested serializers (many=True creates a ListSerializer)
129
+ if isinstance(field, serializers.ListSerializer):
130
+ return "array"
131
+
132
+ # Handle nested serializers (single nested serializer)
133
+ if isinstance(field, serializers.Serializer):
134
+ return "object"
135
+
128
136
  type_mapping = {
129
137
  fields.BooleanField: "boolean",
130
138
  fields.CharField: "string",
@@ -15,6 +15,14 @@ class MoneyFieldSerializer(serializers.Field):
15
15
  self.decimal_places = kwargs.pop("decimal_places", 2)
16
16
  super().__init__(**kwargs)
17
17
 
18
+ @classmethod
19
+ def get_prefetch_db_fields(cls, field_name: str):
20
+ """
21
+ Return all database fields required for this field to serialize.
22
+ MoneyField creates two database columns: field_name and field_name_currency.
23
+ """
24
+ return [field_name, f"{field_name}_currency"]
25
+
18
26
  def to_representation(self, value):
19
27
  djmoney_field = MoneyField(
20
28
  max_digits=self.max_digits, decimal_places=self.decimal_places
@@ -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)
@@ -258,6 +258,26 @@ class DjangoORMAdapter(AbstractORMProvider):
258
258
  fields_map=fields_map,
259
259
  )
260
260
 
261
+ def bulk_create(
262
+ self,
263
+ model: Type[models.Model],
264
+ data_list: List[Dict[str, Any]],
265
+ serializer,
266
+ req,
267
+ fields_map,
268
+ ) -> List[models.Model]:
269
+ """Create multiple model instances using Django's bulk_create."""
270
+ # Create instances without saving to DB yet
271
+ instances = [model(**data) for data in data_list]
272
+
273
+ # Use Django's bulk_create for efficiency
274
+ created_instances = model.objects.bulk_create(instances)
275
+
276
+ # Emit bulk create event for cache invalidation and frontend notification
277
+ config.event_bus.emit_bulk_event(ActionType.BULK_CREATE, created_instances)
278
+
279
+ return created_instances
280
+
261
281
  def update_instance(
262
282
  self,
263
283
  model: Type[models.Model],
@@ -750,19 +770,15 @@ class DjangoORMAdapter(AbstractORMProvider):
750
770
  req: RequestType,
751
771
  model: Type,
752
772
  initial_ast: Dict[str, Any],
753
- custom_querysets: Dict[str, Type[AbstractCustomQueryset]],
754
773
  registered_permissions: List[Type[AbstractPermission]],
755
774
  ) -> Any:
756
775
  """Assemble and return the base QuerySet for the given model."""
757
- custom_name = initial_ast.get("custom_queryset")
758
- if custom_name and custom_name in custom_querysets:
759
- custom_queryset_class = custom_querysets[custom_name]
760
- return custom_queryset_class().get_queryset(req)
761
776
  return model.objects.all()
762
777
 
763
778
  def get_fields(self, model: models.Model) -> Set[str]:
764
779
  """
765
780
  Return a set of the model fields.
781
+ Includes both database fields and additional_fields (computed fields).
766
782
  """
767
783
  model_config = registry.get_config(model)
768
784
  if model_config.fields and "__all__" != model_config.fields:
@@ -775,6 +791,14 @@ class DjangoORMAdapter(AbstractORMProvider):
775
791
  resolved_fields = resolved_fields.union(additional_fields)
776
792
  return resolved_fields
777
793
 
794
+ def get_db_fields(self, model: models.Model) -> Set[str]:
795
+ """
796
+ Return only actual database fields for the model.
797
+ Excludes read-only additional_fields (computed fields).
798
+ Used for deserialization - hooks can write to any DB field.
799
+ """
800
+ return set(field.name for field in model._meta.get_fields())
801
+
778
802
  def build_model_graph(
779
803
  self, model: Type[models.Model], model_graph: nx.DiGraph = None
780
804
  ) -> nx.DiGraph:
@@ -25,6 +25,7 @@ class AllowAllPermission(AbstractPermission):
25
25
  ActionType.DELETE,
26
26
  ActionType.READ,
27
27
  ActionType.UPDATE,
28
+ ActionType.BULK_CREATE,
28
29
  }
29
30
 
30
31
  def allowed_object_actions(self, request, obj, model: Type[ORMModel]) -> Set[ActionType]: # type: ignore
@@ -33,6 +34,7 @@ class AllowAllPermission(AbstractPermission):
33
34
  ActionType.DELETE,
34
35
  ActionType.READ,
35
36
  ActionType.UPDATE,
37
+ ActionType.BULK_CREATE,
36
38
  }
37
39
 
38
40
  def _get_user_fields(self) -> Set[str]:
@@ -74,6 +76,7 @@ class IsAuthenticatedPermission(AbstractPermission):
74
76
  ActionType.DELETE,
75
77
  ActionType.READ,
76
78
  ActionType.UPDATE,
79
+ ActionType.BULK_CREATE,
77
80
  }
78
81
 
79
82
  def allowed_object_actions(
@@ -86,6 +89,7 @@ class IsAuthenticatedPermission(AbstractPermission):
86
89
  ActionType.DELETE,
87
90
  ActionType.READ,
88
91
  ActionType.UPDATE,
92
+ ActionType.BULK_CREATE,
89
93
  }
90
94
 
91
95
  def _get_user_fields(self) -> Set[str]:
@@ -134,6 +138,7 @@ class IsStaffPermission(AbstractPermission):
134
138
  ActionType.DELETE,
135
139
  ActionType.READ,
136
140
  ActionType.UPDATE,
141
+ ActionType.BULK_CREATE,
137
142
  }
138
143
 
139
144
  def allowed_object_actions(
@@ -146,6 +151,7 @@ class IsStaffPermission(AbstractPermission):
146
151
  ActionType.DELETE,
147
152
  ActionType.READ,
148
153
  ActionType.UPDATE,
154
+ ActionType.BULK_CREATE,
149
155
  }
150
156
 
151
157
  def _get_user_fields(self) -> Set[str]: