django-cfg 1.5.14__py3-none-any.whl → 1.5.20__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of django-cfg might be problematic. Click here for more details.
- django_cfg/__init__.py +1 -1
- django_cfg/apps/business/accounts/serializers/profile.py +42 -0
- django_cfg/apps/business/support/serializers.py +3 -2
- django_cfg/apps/integrations/centrifugo/apps.py +2 -1
- django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
- django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
- django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
- django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
- django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
- django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
- django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
- django_cfg/apps/integrations/centrifugo/urls.py +8 -0
- django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
- django_cfg/apps/integrations/centrifugo/views/testing_api.py +0 -79
- django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
- django_cfg/apps/integrations/centrifugo/views/wrapper.py +257 -0
- django_cfg/apps/integrations/grpc/centrifugo/__init__.py +29 -0
- django_cfg/apps/integrations/grpc/centrifugo/bridge.py +277 -0
- django_cfg/apps/integrations/grpc/centrifugo/config.py +167 -0
- django_cfg/apps/integrations/grpc/centrifugo/demo.py +626 -0
- django_cfg/apps/integrations/grpc/centrifugo/test_publish.py +229 -0
- django_cfg/apps/integrations/grpc/centrifugo/transformers.py +89 -0
- django_cfg/apps/integrations/grpc/interceptors/__init__.py +3 -1
- django_cfg/apps/integrations/grpc/interceptors/centrifugo.py +541 -0
- django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
- django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +55 -0
- django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +311 -7
- django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
- django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
- django_cfg/apps/integrations/grpc/services/discovery.py +7 -1
- django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
- django_cfg/apps/integrations/grpc/utils/streaming_logger.py +206 -5
- django_cfg/apps/system/dashboard/serializers/config.py +95 -9
- django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
- django_cfg/apps/system/frontend/views.py +87 -6
- django_cfg/core/builders/security_builder.py +1 -0
- django_cfg/core/generation/integration_generators/api.py +2 -0
- django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
- django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
- django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
- django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
- django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
- django_cfg/modules/django_client/core/ir/schema.py +15 -1
- django_cfg/modules/django_client/core/parser/base.py +12 -0
- django_cfg/pyproject.toml +1 -1
- django_cfg/static/frontend/admin.zip +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/METADATA +1 -1
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/RECORD +53 -37
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centrifugo Token API
|
|
3
|
+
|
|
4
|
+
Provides endpoint for generating Centrifugo JWT tokens with user permissions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from drf_spectacular.utils import extend_schema
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
from rest_framework import status, viewsets
|
|
12
|
+
from rest_framework.decorators import action
|
|
13
|
+
from rest_framework.response import Response
|
|
14
|
+
from rest_framework.permissions import IsAuthenticated
|
|
15
|
+
|
|
16
|
+
from ..services import generate_centrifugo_token
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ========================================================================
|
|
22
|
+
# Response Models
|
|
23
|
+
# ========================================================================
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConnectionTokenResponse(BaseModel):
|
|
27
|
+
"""Response model for Centrifugo connection token."""
|
|
28
|
+
|
|
29
|
+
token: str = Field(..., description="JWT token for Centrifugo connection")
|
|
30
|
+
centrifugo_url: str = Field(..., description="Centrifugo WebSocket URL")
|
|
31
|
+
expires_at: str = Field(..., description="Token expiration time (ISO 8601)")
|
|
32
|
+
channels: list[str] = Field(..., description="List of allowed channels")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CentrifugoTokenViewSet(viewsets.ViewSet):
|
|
36
|
+
"""
|
|
37
|
+
Centrifugo Token API ViewSet.
|
|
38
|
+
|
|
39
|
+
Provides endpoint for authenticated users to get Centrifugo JWT token
|
|
40
|
+
with their allowed channels based on permissions.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
permission_classes = [IsAuthenticated]
|
|
44
|
+
|
|
45
|
+
@extend_schema(
|
|
46
|
+
tags=["Centrifugo Auth"],
|
|
47
|
+
summary="Get Centrifugo connection token",
|
|
48
|
+
description=(
|
|
49
|
+
"Generate JWT token for WebSocket connection to Centrifugo. "
|
|
50
|
+
"Token includes user's allowed channels based on their permissions. "
|
|
51
|
+
"Requires authentication."
|
|
52
|
+
),
|
|
53
|
+
responses={
|
|
54
|
+
200: ConnectionTokenResponse,
|
|
55
|
+
401: {"description": "Unauthorized - authentication required"},
|
|
56
|
+
500: {"description": "Server error"},
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
@action(detail=False, methods=["get"], url_path="token")
|
|
60
|
+
def get_token(self, request):
|
|
61
|
+
"""
|
|
62
|
+
Get Centrifugo connection token for authenticated user.
|
|
63
|
+
|
|
64
|
+
Returns JWT token with user's allowed channels that can be used
|
|
65
|
+
to connect to Centrifugo WebSocket.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
user = request.user
|
|
69
|
+
|
|
70
|
+
# Generate token with user's channels
|
|
71
|
+
token_data = generate_centrifugo_token(user)
|
|
72
|
+
|
|
73
|
+
logger.debug(
|
|
74
|
+
f"Generated Centrifugo token for user {user.email} (ID: {user.id}) "
|
|
75
|
+
f"with {len(token_data['channels'])} channels: {token_data['channels']}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Format expires_at as ISO 8601 string
|
|
79
|
+
return Response({
|
|
80
|
+
"token": token_data["token"],
|
|
81
|
+
"centrifugo_url": token_data["centrifugo_url"],
|
|
82
|
+
"expires_at": token_data["expires_at"].isoformat() + "Z",
|
|
83
|
+
"channels": token_data["channels"],
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
except ValueError as e:
|
|
87
|
+
# Centrifugo not configured or disabled
|
|
88
|
+
logger.warning(f"Centrifugo token generation failed: {e}")
|
|
89
|
+
return Response(
|
|
90
|
+
{"error": str(e)},
|
|
91
|
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
92
|
+
)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.error(f"Failed to generate Centrifugo token: {e}", exc_info=True)
|
|
95
|
+
return Response(
|
|
96
|
+
{"error": "Failed to generate token"},
|
|
97
|
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["CentrifugoTokenViewSet"]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centrifugo Wrapper API.
|
|
3
|
+
|
|
4
|
+
Provides /api/publish endpoint that acts as a proxy to Centrifugo
|
|
5
|
+
with ACK tracking and database logging.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from django.http import JsonResponse
|
|
15
|
+
from django.utils.decorators import method_decorator
|
|
16
|
+
from django.views import View
|
|
17
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
18
|
+
from django_cfg.modules.django_logging import get_logger
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
from ..services import get_centrifugo_config
|
|
22
|
+
from ..services.logging import CentrifugoLogger
|
|
23
|
+
|
|
24
|
+
logger = get_logger("centrifugo.wrapper")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ========================================================================
|
|
28
|
+
# Request/Response Models
|
|
29
|
+
# ========================================================================
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PublishRequest(BaseModel):
|
|
33
|
+
"""Request model for publish endpoint."""
|
|
34
|
+
|
|
35
|
+
channel: str = Field(..., description="Target channel name")
|
|
36
|
+
data: Dict[str, Any] = Field(..., description="Message data")
|
|
37
|
+
wait_for_ack: bool = Field(default=False, description="Wait for client ACK")
|
|
38
|
+
ack_timeout: int = Field(default=10, description="ACK timeout in seconds")
|
|
39
|
+
message_id: str | None = Field(default=None, description="Optional message ID")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PublishResponse(BaseModel):
|
|
43
|
+
"""Response model for publish endpoint."""
|
|
44
|
+
|
|
45
|
+
published: bool = Field(..., description="Whether message was published")
|
|
46
|
+
message_id: str = Field(..., description="Unique message ID")
|
|
47
|
+
channel: str = Field(..., description="Target channel")
|
|
48
|
+
delivered: bool = Field(default=False, description="Whether message was delivered")
|
|
49
|
+
acks_received: int = Field(default=0, description="Number of ACKs received")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ========================================================================
|
|
53
|
+
# Wrapper View
|
|
54
|
+
# ========================================================================
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@method_decorator(csrf_exempt, name='dispatch')
|
|
58
|
+
class PublishWrapperView(View):
|
|
59
|
+
"""
|
|
60
|
+
Centrifugo publish wrapper endpoint.
|
|
61
|
+
|
|
62
|
+
Provides /api/publish endpoint that:
|
|
63
|
+
- Accepts publish requests from CentrifugoClient
|
|
64
|
+
- Logs to database (CentrifugoLog)
|
|
65
|
+
- Proxies to Centrifugo HTTP API
|
|
66
|
+
- Returns publish result with ACK tracking
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, *args, **kwargs):
|
|
70
|
+
super().__init__(*args, **kwargs)
|
|
71
|
+
self._http_client = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def http_client(self) -> httpx.AsyncClient:
|
|
75
|
+
"""Get or create HTTP client for Centrifugo API calls."""
|
|
76
|
+
if self._http_client is None:
|
|
77
|
+
config = get_centrifugo_config()
|
|
78
|
+
if not config:
|
|
79
|
+
raise ValueError("Centrifugo not configured")
|
|
80
|
+
|
|
81
|
+
headers = {"Content-Type": "application/json"}
|
|
82
|
+
|
|
83
|
+
# Add Centrifugo API key for server-to-server auth
|
|
84
|
+
if config.centrifugo_api_key:
|
|
85
|
+
headers["Authorization"] = f"apikey {config.centrifugo_api_key}"
|
|
86
|
+
|
|
87
|
+
# Use Centrifugo API URL (not wrapper URL)
|
|
88
|
+
base_url = config.centrifugo_api_url.rstrip("/api").rstrip("/")
|
|
89
|
+
|
|
90
|
+
self._http_client = httpx.AsyncClient(
|
|
91
|
+
base_url=base_url,
|
|
92
|
+
headers=headers,
|
|
93
|
+
timeout=httpx.Timeout(config.http_timeout),
|
|
94
|
+
verify=config.verify_ssl,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return self._http_client
|
|
98
|
+
|
|
99
|
+
async def _publish_to_centrifugo(
|
|
100
|
+
self, channel: str, data: Dict[str, Any], wait_for_ack: bool, ack_timeout: int, message_id: str
|
|
101
|
+
) -> Dict[str, Any]:
|
|
102
|
+
"""
|
|
103
|
+
Publish message to Centrifugo API.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
channel: Target channel
|
|
107
|
+
data: Message data
|
|
108
|
+
wait_for_ack: Whether to wait for ACK
|
|
109
|
+
ack_timeout: ACK timeout in seconds
|
|
110
|
+
message_id: Message ID
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Publish result dict
|
|
114
|
+
"""
|
|
115
|
+
start_time = time.time()
|
|
116
|
+
|
|
117
|
+
# Create log entry
|
|
118
|
+
log_entry = await CentrifugoLogger.create_log_async(
|
|
119
|
+
message_id=message_id,
|
|
120
|
+
channel=channel,
|
|
121
|
+
data=data,
|
|
122
|
+
wait_for_ack=wait_for_ack,
|
|
123
|
+
ack_timeout=ack_timeout if wait_for_ack else None,
|
|
124
|
+
is_notification=True,
|
|
125
|
+
user=None, # Can extract from request if needed
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
# Centrifugo API format: POST /api with method in body
|
|
130
|
+
payload = {
|
|
131
|
+
"method": "publish",
|
|
132
|
+
"params": {
|
|
133
|
+
"channel": channel,
|
|
134
|
+
"data": data,
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
response = await self.http_client.post("/api", json=payload)
|
|
139
|
+
response.raise_for_status()
|
|
140
|
+
result = response.json()
|
|
141
|
+
|
|
142
|
+
# Check for Centrifugo error
|
|
143
|
+
if "error" in result and result["error"]:
|
|
144
|
+
raise Exception(f"Centrifugo error: {result['error']}")
|
|
145
|
+
|
|
146
|
+
# Calculate duration
|
|
147
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
148
|
+
|
|
149
|
+
# Mark as success
|
|
150
|
+
if log_entry:
|
|
151
|
+
await CentrifugoLogger.mark_success_async(
|
|
152
|
+
log_entry,
|
|
153
|
+
acks_received=0, # ACK tracking would be implemented separately
|
|
154
|
+
duration_ms=duration_ms,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Return wrapper-compatible response
|
|
158
|
+
return {
|
|
159
|
+
"published": True,
|
|
160
|
+
"message_id": message_id,
|
|
161
|
+
"channel": channel,
|
|
162
|
+
"acks_received": 0,
|
|
163
|
+
"delivered": True, # Centrifugo confirms publish, not delivery
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
except Exception as e:
|
|
167
|
+
# Calculate duration
|
|
168
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
169
|
+
|
|
170
|
+
# Mark as failed
|
|
171
|
+
if log_entry:
|
|
172
|
+
from asgiref.sync import sync_to_async
|
|
173
|
+
from ..models import CentrifugoLog
|
|
174
|
+
|
|
175
|
+
await sync_to_async(CentrifugoLog.objects.mark_failed)(
|
|
176
|
+
log_instance=log_entry,
|
|
177
|
+
error_code=type(e).__name__,
|
|
178
|
+
error_message=str(e),
|
|
179
|
+
duration_ms=duration_ms,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
def post(self, request):
|
|
185
|
+
"""
|
|
186
|
+
Handle POST /api/publish request.
|
|
187
|
+
|
|
188
|
+
Request body:
|
|
189
|
+
{
|
|
190
|
+
"channel": "test#demo",
|
|
191
|
+
"data": {"key": "value"},
|
|
192
|
+
"wait_for_ack": false,
|
|
193
|
+
"ack_timeout": 10,
|
|
194
|
+
"message_id": "optional-uuid"
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
Response:
|
|
198
|
+
{
|
|
199
|
+
"published": true,
|
|
200
|
+
"message_id": "uuid",
|
|
201
|
+
"channel": "test#demo",
|
|
202
|
+
"delivered": true,
|
|
203
|
+
"acks_received": 0
|
|
204
|
+
}
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
import json
|
|
208
|
+
|
|
209
|
+
# Parse request body
|
|
210
|
+
body = json.loads(request.body)
|
|
211
|
+
req_data = PublishRequest(**body)
|
|
212
|
+
|
|
213
|
+
# Generate message ID if not provided
|
|
214
|
+
message_id = req_data.message_id or str(uuid.uuid4())
|
|
215
|
+
|
|
216
|
+
# Publish to Centrifugo
|
|
217
|
+
result = asyncio.run(
|
|
218
|
+
self._publish_to_centrifugo(
|
|
219
|
+
channel=req_data.channel,
|
|
220
|
+
data=req_data.data,
|
|
221
|
+
wait_for_ack=req_data.wait_for_ack,
|
|
222
|
+
ack_timeout=req_data.ack_timeout,
|
|
223
|
+
message_id=message_id,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
response = PublishResponse(**result)
|
|
228
|
+
return JsonResponse(response.model_dump(), status=200)
|
|
229
|
+
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.error(f"Failed to publish via wrapper: {e}", exc_info=True)
|
|
232
|
+
return JsonResponse(
|
|
233
|
+
{
|
|
234
|
+
"published": False,
|
|
235
|
+
"message_id": "",
|
|
236
|
+
"channel": body.get("channel", "") if "body" in locals() else "",
|
|
237
|
+
"delivered": False,
|
|
238
|
+
"acks_received": 0,
|
|
239
|
+
"error": str(e),
|
|
240
|
+
},
|
|
241
|
+
status=500,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def __del__(self):
|
|
245
|
+
"""Cleanup HTTP client on deletion."""
|
|
246
|
+
if self._http_client:
|
|
247
|
+
try:
|
|
248
|
+
loop = asyncio.get_event_loop()
|
|
249
|
+
if loop.is_running():
|
|
250
|
+
loop.create_task(self._http_client.aclose())
|
|
251
|
+
else:
|
|
252
|
+
loop.run_until_complete(self._http_client.aclose())
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
__all__ = ["PublishWrapperView"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gRPC → Centrifugo Integration.
|
|
3
|
+
|
|
4
|
+
Mixin and configuration for bridging gRPC streaming events to Centrifugo WebSocket.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .bridge import CentrifugoBridgeMixin
|
|
8
|
+
from .config import ChannelConfig, CentrifugoChannels
|
|
9
|
+
from .demo import (
|
|
10
|
+
DemoChannels,
|
|
11
|
+
DemoBridgeService,
|
|
12
|
+
test_complete_integration,
|
|
13
|
+
test_demo_service,
|
|
14
|
+
send_demo_event,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
# Core components
|
|
19
|
+
"CentrifugoBridgeMixin",
|
|
20
|
+
"ChannelConfig",
|
|
21
|
+
"CentrifugoChannels",
|
|
22
|
+
|
|
23
|
+
# Demo/testing
|
|
24
|
+
"DemoChannels",
|
|
25
|
+
"DemoBridgeService",
|
|
26
|
+
"test_complete_integration",
|
|
27
|
+
"test_demo_service",
|
|
28
|
+
"send_demo_event",
|
|
29
|
+
]
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centrifugo Bridge Mixin for gRPC Services.
|
|
3
|
+
|
|
4
|
+
Universal mixin that enables automatic publishing of gRPC stream events
|
|
5
|
+
to Centrifugo WebSocket channels using Pydantic configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, timezone as tz
|
|
11
|
+
from typing import Dict, Optional, Any, TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from .config import CentrifugoChannels, ChannelConfig
|
|
14
|
+
from .transformers import transform_protobuf_to_dict
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from django_cfg.apps.integrations.centrifugo import CentrifugoClient
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CentrifugoBridgeMixin:
|
|
23
|
+
"""
|
|
24
|
+
Universal mixin for publishing gRPC stream events to Centrifugo.
|
|
25
|
+
|
|
26
|
+
Uses Pydantic models for type-safe, validated configuration.
|
|
27
|
+
|
|
28
|
+
Features:
|
|
29
|
+
- Type-safe Pydantic configuration
|
|
30
|
+
- Automatic event publishing to WebSocket channels
|
|
31
|
+
- Built-in protobuf → JSON transformation
|
|
32
|
+
- Graceful degradation if Centrifugo unavailable
|
|
33
|
+
- Custom transform functions support
|
|
34
|
+
- Template-based channel naming
|
|
35
|
+
- Per-channel rate limiting
|
|
36
|
+
- Critical event bypassing
|
|
37
|
+
|
|
38
|
+
Usage:
|
|
39
|
+
```python
|
|
40
|
+
from django_cfg.apps.integrations.grpc.mixins import (
|
|
41
|
+
CentrifugoBridgeMixin,
|
|
42
|
+
CentrifugoChannels,
|
|
43
|
+
ChannelConfig,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
class BotChannels(CentrifugoChannels):
|
|
47
|
+
heartbeat: ChannelConfig = ChannelConfig(
|
|
48
|
+
template='bot#{bot_id}#heartbeat',
|
|
49
|
+
rate_limit=0.1
|
|
50
|
+
)
|
|
51
|
+
status: ChannelConfig = ChannelConfig(
|
|
52
|
+
template='bot#{bot_id}#status',
|
|
53
|
+
critical=True
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
class BotStreamingService(
|
|
57
|
+
bot_streaming_service_pb2_grpc.BotStreamingServiceServicer,
|
|
58
|
+
CentrifugoBridgeMixin
|
|
59
|
+
):
|
|
60
|
+
centrifugo_channels = BotChannels()
|
|
61
|
+
|
|
62
|
+
async def ConnectBot(self, request_iterator, context):
|
|
63
|
+
async for message in request_iterator:
|
|
64
|
+
# Your business logic
|
|
65
|
+
await self._handle_message(bot_id, message)
|
|
66
|
+
|
|
67
|
+
# Auto-publish to Centrifugo (1 line!)
|
|
68
|
+
await self._notify_centrifugo(message, bot_id=bot_id)
|
|
69
|
+
```
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
# Class-level Pydantic config (optional, can be set in __init__)
|
|
73
|
+
centrifugo_channels: Optional[CentrifugoChannels] = None
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
"""Initialize Centrifugo bridge from Pydantic configuration."""
|
|
77
|
+
super().__init__()
|
|
78
|
+
|
|
79
|
+
# Instance attributes
|
|
80
|
+
self._centrifugo_enabled: bool = False
|
|
81
|
+
self._centrifugo_graceful: bool = True
|
|
82
|
+
self._centrifugo_client: Optional['CentrifugoClient'] = None
|
|
83
|
+
self._centrifugo_mappings: Dict[str, Dict[str, Any]] = {}
|
|
84
|
+
self._centrifugo_last_publish: Dict[str, float] = {}
|
|
85
|
+
|
|
86
|
+
# Auto-setup if config exists
|
|
87
|
+
if self.centrifugo_channels:
|
|
88
|
+
self._setup_from_pydantic_config(self.centrifugo_channels)
|
|
89
|
+
|
|
90
|
+
def _setup_from_pydantic_config(self, config: CentrifugoChannels):
|
|
91
|
+
"""
|
|
92
|
+
Setup Centrifugo bridge from Pydantic configuration.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
config: CentrifugoChannels instance with channel mappings
|
|
96
|
+
"""
|
|
97
|
+
self._centrifugo_enabled = config.enabled
|
|
98
|
+
self._centrifugo_graceful = config.graceful_degradation
|
|
99
|
+
|
|
100
|
+
# Extract channel mappings
|
|
101
|
+
for field_name, channel_config in config.get_channel_mappings().items():
|
|
102
|
+
if channel_config.enabled:
|
|
103
|
+
self._centrifugo_mappings[field_name] = {
|
|
104
|
+
'template': channel_config.template,
|
|
105
|
+
'rate_limit': channel_config.rate_limit or config.default_rate_limit,
|
|
106
|
+
'critical': channel_config.critical,
|
|
107
|
+
'transform': channel_config.transform,
|
|
108
|
+
'metadata': channel_config.metadata,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Initialize client if enabled
|
|
112
|
+
if self._centrifugo_enabled and self._centrifugo_mappings:
|
|
113
|
+
self._initialize_centrifugo_client()
|
|
114
|
+
|
|
115
|
+
def _initialize_centrifugo_client(self):
|
|
116
|
+
"""Lazy initialize Centrifugo client."""
|
|
117
|
+
try:
|
|
118
|
+
from django_cfg.apps.integrations.centrifugo import get_centrifugo_client
|
|
119
|
+
self._centrifugo_client = get_centrifugo_client()
|
|
120
|
+
logger.info(
|
|
121
|
+
f"✅ Centrifugo bridge enabled with {len(self._centrifugo_mappings)} channels"
|
|
122
|
+
)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.warning(f"⚠️ Centrifugo client not available: {e}")
|
|
125
|
+
if not self._centrifugo_graceful:
|
|
126
|
+
raise
|
|
127
|
+
self._centrifugo_enabled = False
|
|
128
|
+
|
|
129
|
+
async def _notify_centrifugo(
|
|
130
|
+
self,
|
|
131
|
+
message: Any, # Protobuf message
|
|
132
|
+
**context: Any # Template variables for channel rendering
|
|
133
|
+
) -> bool:
|
|
134
|
+
"""
|
|
135
|
+
Publish protobuf message to Centrifugo based on configured mappings.
|
|
136
|
+
|
|
137
|
+
Automatically detects which field is set in the message and publishes
|
|
138
|
+
to the corresponding channel.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
message: Protobuf message (e.g., BotMessage with heartbeat/status/etc.)
|
|
142
|
+
**context: Template variables for channel name rendering
|
|
143
|
+
Example: bot_id='123', user_id='456'
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
True if published successfully, False otherwise
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
```python
|
|
150
|
+
# message = BotMessage with heartbeat field set
|
|
151
|
+
await self._notify_centrifugo(message, bot_id='bot-123')
|
|
152
|
+
# → Publishes to channel: bot#bot-123#heartbeat
|
|
153
|
+
```
|
|
154
|
+
"""
|
|
155
|
+
if not self._centrifugo_enabled or not self._centrifugo_client:
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
# Check each mapped field
|
|
159
|
+
for field_name, mapping in self._centrifugo_mappings.items():
|
|
160
|
+
if message.HasField(field_name):
|
|
161
|
+
return await self._publish_field(
|
|
162
|
+
field_name,
|
|
163
|
+
message,
|
|
164
|
+
mapping,
|
|
165
|
+
context
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
async def _publish_field(
|
|
171
|
+
self,
|
|
172
|
+
field_name: str,
|
|
173
|
+
message: Any,
|
|
174
|
+
mapping: Dict[str, Any],
|
|
175
|
+
context: dict
|
|
176
|
+
) -> bool:
|
|
177
|
+
"""
|
|
178
|
+
Publish specific message field to Centrifugo.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
field_name: Name of the protobuf field
|
|
182
|
+
message: Full protobuf message
|
|
183
|
+
mapping: Channel mapping configuration
|
|
184
|
+
context: Template variables
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
True if published successfully
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
# Render channel from template
|
|
191
|
+
channel = mapping['template'].format(**context)
|
|
192
|
+
|
|
193
|
+
# Rate limiting check (unless critical)
|
|
194
|
+
if not mapping['critical'] and mapping['rate_limit']:
|
|
195
|
+
now = time.time()
|
|
196
|
+
last = self._centrifugo_last_publish.get(channel, 0)
|
|
197
|
+
if now - last < mapping['rate_limit']:
|
|
198
|
+
logger.debug(f"⏱️ Rate limit: skipping {field_name} for {channel}")
|
|
199
|
+
return False
|
|
200
|
+
self._centrifugo_last_publish[channel] = now
|
|
201
|
+
|
|
202
|
+
# Get field value
|
|
203
|
+
field_value = getattr(message, field_name)
|
|
204
|
+
|
|
205
|
+
# Transform to dict
|
|
206
|
+
data = self._transform_field(field_name, field_value, mapping, context)
|
|
207
|
+
|
|
208
|
+
# Publish to Centrifugo
|
|
209
|
+
await self._centrifugo_client.publish(
|
|
210
|
+
channel=channel,
|
|
211
|
+
data=data
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
logger.debug(f"✅ Published {field_name} to {channel}")
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
except KeyError as e:
|
|
218
|
+
logger.error(
|
|
219
|
+
f"❌ Missing template variable in channel: {e}. "
|
|
220
|
+
f"Template: {mapping['template']}, Context: {context}"
|
|
221
|
+
)
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.error(
|
|
226
|
+
f"❌ Failed to publish {field_name} to Centrifugo: {e}",
|
|
227
|
+
exc_info=True
|
|
228
|
+
)
|
|
229
|
+
if not self._centrifugo_graceful:
|
|
230
|
+
raise
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
def _transform_field(
|
|
234
|
+
self,
|
|
235
|
+
field_name: str,
|
|
236
|
+
field_value: Any,
|
|
237
|
+
mapping: Dict[str, Any],
|
|
238
|
+
context: dict
|
|
239
|
+
) -> dict:
|
|
240
|
+
"""
|
|
241
|
+
Transform protobuf field to JSON-serializable dict.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
field_name: Field name
|
|
245
|
+
field_value: Protobuf message field value
|
|
246
|
+
mapping: Channel mapping with optional transform function
|
|
247
|
+
context: Template context variables
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
JSON-serializable dictionary
|
|
251
|
+
"""
|
|
252
|
+
# Use custom transform if provided
|
|
253
|
+
if mapping['transform']:
|
|
254
|
+
data = mapping['transform'](field_name, field_value)
|
|
255
|
+
else:
|
|
256
|
+
# Default protobuf → dict transform
|
|
257
|
+
data = transform_protobuf_to_dict(field_value)
|
|
258
|
+
|
|
259
|
+
# Add metadata
|
|
260
|
+
data['type'] = field_name
|
|
261
|
+
data['timestamp'] = datetime.now(tz.utc).isoformat()
|
|
262
|
+
|
|
263
|
+
# Merge channel metadata
|
|
264
|
+
if mapping['metadata']:
|
|
265
|
+
for key, value in mapping['metadata'].items():
|
|
266
|
+
if key not in data:
|
|
267
|
+
data[key] = value
|
|
268
|
+
|
|
269
|
+
# Add context variables (bot_id, user_id, etc.)
|
|
270
|
+
for key, value in context.items():
|
|
271
|
+
if key not in data:
|
|
272
|
+
data[key] = value
|
|
273
|
+
|
|
274
|
+
return data
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
__all__ = ["CentrifugoBridgeMixin"]
|