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.
- {statezero-0.1.0b12 → statezero-0.1.0b24}/PKG-INFO +1 -1
- {statezero-0.1.0b12 → statezero-0.1.0b24}/pyproject.toml +1 -1
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/actions.py +8 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +8 -0
- 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.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/orm.py +29 -5
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/permissions.py +6 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/query_optimizer.py +40 -2
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/schemas.py +15 -2
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/serializers.py +96 -38
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/urls.py +7 -1
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/views.py +101 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/ast_parser.py +162 -13
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/classes.py +1 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/config.py +3 -37
- statezero-0.1.0b24/statezero/core/context_storage.py +55 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/event_bus.py +83 -35
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/interfaces.py +29 -4
- 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.0b12 → statezero-0.1.0b24}/statezero/core/types.py +1 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero.egg-info/PKG-INFO +1 -1
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero.egg-info/SOURCES.txt +6 -1
- statezero-0.1.0b12/statezero/adaptors/django/middleware.py +0 -10
- statezero-0.1.0b12/statezero/adaptors/django/migrations/0001_initial.py +0 -33
- statezero-0.1.0b12/statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +0 -16
- statezero-0.1.0b12/statezero/core/context_storage.py +0 -4
- statezero-0.1.0b12/statezero/core/process_request.py +0 -184
- {statezero-0.1.0b12 → statezero-0.1.0b24}/README.md +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/license.md +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/requirements.txt +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/setup.cfg +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/__init__.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/__init__.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/__init__.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/apps.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/config.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/context_manager.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/event_emitters.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/exception_handler.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/__init__.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/f_handler.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/helpers.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/migrations/__init__.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/search_providers/__init__.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/search_providers/basic_search.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/adaptors/django/search_providers/postgres_search.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/__init__.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/actions.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/ast_validator.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/event_emitters.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/exceptions.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero/core/hook_checks.py +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero.egg-info/dependency_links.txt +0 -0
- {statezero-0.1.0b12 → statezero-0.1.0b24}/statezero.egg-info/requires.txt +0 -0
- {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.
|
|
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" }
|
|
@@ -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]:
|