django-cfg 1.5.14__py3-none-any.whl → 1.5.29__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/__init__.py +2 -0
- 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/client.py +1 -1
- django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
- django_cfg/apps/integrations/centrifugo/services/logging.py +47 -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/admin_api.py +29 -32
- django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -116
- django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
- django_cfg/apps/integrations/centrifugo/views/wrapper.py +259 -0
- django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
- django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
- django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +56 -1
- django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +315 -26
- 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/managers/grpc_request_log.py +84 -0
- django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
- django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
- django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
- django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
- django_cfg/apps/integrations/grpc/services/centrifugo/__init__.py +29 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/config.py +167 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/demo.py +626 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/test_publish.py +229 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/transformers.py +89 -0
- django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
- django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
- django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
- django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
- django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
- django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
- django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
- django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +67 -54
- django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +215 -5
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +3 -1
- django_cfg/apps/integrations/grpc/services/interceptors/centrifugo.py +541 -0
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
- django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
- django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
- django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
- django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
- django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
- django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
- django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
- django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
- django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
- django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
- django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
- django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
- django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
- django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
- django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
- django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
- django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
- django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
- django_cfg/apps/integrations/grpc/utils/streaming_logger.py +261 -13
- django_cfg/apps/integrations/grpc/views/charts.py +1 -1
- django_cfg/apps/integrations/grpc/views/config.py +1 -1
- 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/base/config_model.py +11 -0
- django_cfg/core/builders/middleware_builder.py +5 -0
- django_cfg/core/builders/security_builder.py +1 -0
- django_cfg/core/generation/integration_generators/api.py +2 -0
- django_cfg/management/commands/pool_status.py +153 -0
- django_cfg/middleware/pool_cleanup.py +261 -0
- django_cfg/models/api/grpc/config.py +2 -2
- django_cfg/models/infrastructure/database/config.py +16 -0
- django_cfg/models/infrastructure/database/converters.py +2 -0
- django_cfg/modules/django_admin/utils/html/composition.py +57 -13
- django_cfg/modules/django_admin/utils/html_builder.py +1 -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/groups/manager.py +25 -18
- django_cfg/modules/django_client/core/ir/schema.py +15 -1
- django_cfg/modules/django_client/core/parser/base.py +12 -0
- django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
- django_cfg/modules/django_logging/django_logger.py +58 -19
- django_cfg/pyproject.toml +3 -3
- django_cfg/static/frontend/admin.zip +0 -0
- django_cfg/templates/admin/index.html +0 -39
- django_cfg/utils/pool_monitor.py +320 -0
- django_cfg/utils/smart_defaults.py +233 -7
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/RECORD +118 -74
- /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
- /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
- /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Direct Centrifugo Client.
|
|
3
|
+
|
|
4
|
+
Lightweight client for internal Django-to-Centrifugo communication.
|
|
5
|
+
Bypasses wrapper and connects directly to Centrifugo HTTP API.
|
|
6
|
+
|
|
7
|
+
Use this for:
|
|
8
|
+
- Internal gRPC events
|
|
9
|
+
- Demo/test events
|
|
10
|
+
- Background tasks
|
|
11
|
+
- Any server-side publishing
|
|
12
|
+
|
|
13
|
+
Use CentrifugoClient (with wrapper) for:
|
|
14
|
+
- External API calls (from Next.js frontend)
|
|
15
|
+
- When you need Django authorization
|
|
16
|
+
- When you need wrapper-level logging
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
from uuid import uuid4
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from django_cfg.modules.django_logging import get_logger
|
|
27
|
+
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
CentrifugoConfigurationError,
|
|
30
|
+
CentrifugoConnectionError,
|
|
31
|
+
CentrifugoPublishError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = get_logger("centrifugo.direct_client")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PublishResponse:
|
|
38
|
+
"""Response from direct publish operation."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message_id: str, published: bool):
|
|
41
|
+
self.message_id = message_id
|
|
42
|
+
self.published = published
|
|
43
|
+
self.delivered = published # For compatibility
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DirectCentrifugoClient:
|
|
47
|
+
"""
|
|
48
|
+
Direct Centrifugo HTTP API client.
|
|
49
|
+
|
|
50
|
+
Connects directly to Centrifugo without going through Django wrapper.
|
|
51
|
+
Uses Centrifugo JSON-RPC format: POST /api with {method, params}.
|
|
52
|
+
|
|
53
|
+
Features:
|
|
54
|
+
- No database logging (lightweight)
|
|
55
|
+
- No wrapper overhead
|
|
56
|
+
- Direct API key authentication
|
|
57
|
+
- Minimal latency for internal calls
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
>>> from django_cfg.apps.integrations.centrifugo.services.client import DirectCentrifugoClient
|
|
61
|
+
>>>
|
|
62
|
+
>>> client = DirectCentrifugoClient(
|
|
63
|
+
... api_url="http://localhost:7120/api",
|
|
64
|
+
... api_key="your-api-key"
|
|
65
|
+
... )
|
|
66
|
+
>>>
|
|
67
|
+
>>> result = await client.publish(
|
|
68
|
+
... channel="grpc#bot#123",
|
|
69
|
+
... data={"status": "running"}
|
|
70
|
+
... )
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
api_url: Optional[str] = None,
|
|
76
|
+
api_key: Optional[str] = None,
|
|
77
|
+
http_timeout: int = 10,
|
|
78
|
+
max_retries: int = 3,
|
|
79
|
+
retry_delay: float = 0.5,
|
|
80
|
+
verify_ssl: bool = False,
|
|
81
|
+
):
|
|
82
|
+
"""
|
|
83
|
+
Initialize direct Centrifugo client.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
api_url: Centrifugo HTTP API URL (e.g., "http://localhost:8000/api")
|
|
87
|
+
api_key: Centrifugo API key for authentication
|
|
88
|
+
http_timeout: HTTP request timeout (seconds)
|
|
89
|
+
max_retries: Maximum retry attempts
|
|
90
|
+
retry_delay: Delay between retries (seconds)
|
|
91
|
+
verify_ssl: Whether to verify SSL certificates
|
|
92
|
+
"""
|
|
93
|
+
self.api_url = api_url or self._get_api_url_from_settings()
|
|
94
|
+
self.api_key = api_key or self._get_api_key_from_settings()
|
|
95
|
+
self.http_timeout = http_timeout
|
|
96
|
+
self.max_retries = max_retries
|
|
97
|
+
self.retry_delay = retry_delay
|
|
98
|
+
self.verify_ssl = verify_ssl
|
|
99
|
+
|
|
100
|
+
# Create HTTP client
|
|
101
|
+
headers = {"Content-Type": "application/json"}
|
|
102
|
+
if self.api_key:
|
|
103
|
+
headers["Authorization"] = f"apikey {self.api_key}"
|
|
104
|
+
|
|
105
|
+
self._http_client = httpx.AsyncClient(
|
|
106
|
+
base_url=self.api_url.rstrip("/api"), # Remove /api from base
|
|
107
|
+
headers=headers,
|
|
108
|
+
timeout=httpx.Timeout(self.http_timeout),
|
|
109
|
+
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
|
110
|
+
verify=self.verify_ssl,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
logger.info(f"DirectCentrifugoClient initialized: {self.api_url}")
|
|
114
|
+
|
|
115
|
+
def _get_api_url_from_settings(self) -> str:
|
|
116
|
+
"""Get Centrifugo API URL from django-cfg config."""
|
|
117
|
+
from ..config_helper import get_centrifugo_config
|
|
118
|
+
|
|
119
|
+
config = get_centrifugo_config()
|
|
120
|
+
|
|
121
|
+
if config and config.centrifugo_api_url:
|
|
122
|
+
return config.centrifugo_api_url
|
|
123
|
+
|
|
124
|
+
raise CentrifugoConfigurationError(
|
|
125
|
+
"Centrifugo API URL not configured",
|
|
126
|
+
config_key="centrifugo.centrifugo_api_url",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _get_api_key_from_settings(self) -> str:
|
|
130
|
+
"""Get Centrifugo API key from django-cfg config."""
|
|
131
|
+
from ..config_helper import get_centrifugo_config
|
|
132
|
+
|
|
133
|
+
config = get_centrifugo_config()
|
|
134
|
+
|
|
135
|
+
if config and config.centrifugo_api_key:
|
|
136
|
+
return config.centrifugo_api_key
|
|
137
|
+
|
|
138
|
+
raise CentrifugoConfigurationError(
|
|
139
|
+
"Centrifugo API key not configured",
|
|
140
|
+
config_key="centrifugo.centrifugo_api_key",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
async def publish(
|
|
144
|
+
self,
|
|
145
|
+
channel: str,
|
|
146
|
+
data: Dict[str, Any],
|
|
147
|
+
) -> PublishResponse:
|
|
148
|
+
"""
|
|
149
|
+
Publish message to Centrifugo channel.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
channel: Centrifugo channel name
|
|
153
|
+
data: Message data dict
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
PublishResponse with result
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
CentrifugoPublishError: If publish fails
|
|
160
|
+
CentrifugoConnectionError: If connection fails
|
|
161
|
+
|
|
162
|
+
Example:
|
|
163
|
+
>>> result = await client.publish(
|
|
164
|
+
... channel="grpc#bot#123#status",
|
|
165
|
+
... data={"status": "running", "timestamp": "2025-11-05T09:00:00Z"}
|
|
166
|
+
... )
|
|
167
|
+
"""
|
|
168
|
+
message_id = str(uuid4())
|
|
169
|
+
start_time = time.time()
|
|
170
|
+
|
|
171
|
+
# Centrifugo JSON-RPC format
|
|
172
|
+
payload = {
|
|
173
|
+
"method": "publish",
|
|
174
|
+
"params": {
|
|
175
|
+
"channel": channel,
|
|
176
|
+
"data": data,
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
last_error = None
|
|
181
|
+
|
|
182
|
+
for attempt in range(self.max_retries):
|
|
183
|
+
try:
|
|
184
|
+
response = await self._http_client.post("/api", json=payload)
|
|
185
|
+
|
|
186
|
+
if response.status_code == 200:
|
|
187
|
+
result = response.json()
|
|
188
|
+
|
|
189
|
+
# Check for Centrifugo error
|
|
190
|
+
if "error" in result and result["error"]:
|
|
191
|
+
error_msg = result["error"].get("message", "Unknown error")
|
|
192
|
+
raise CentrifugoPublishError(
|
|
193
|
+
f"Centrifugo API error: {error_msg}",
|
|
194
|
+
channel=channel,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
198
|
+
logger.debug(
|
|
199
|
+
f"Published to {channel} (message_id={message_id}, {duration_ms}ms)"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return PublishResponse(message_id=message_id, published=True)
|
|
203
|
+
|
|
204
|
+
else:
|
|
205
|
+
raise CentrifugoPublishError(
|
|
206
|
+
f"HTTP {response.status_code}: {response.text}",
|
|
207
|
+
channel=channel,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
except httpx.ConnectError as e:
|
|
211
|
+
last_error = CentrifugoConnectionError(
|
|
212
|
+
f"Failed to connect to Centrifugo: {e}",
|
|
213
|
+
url=self.api_url,
|
|
214
|
+
)
|
|
215
|
+
logger.warning(
|
|
216
|
+
f"Connection attempt {attempt + 1}/{self.max_retries} failed: {e}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
except Exception as e:
|
|
220
|
+
last_error = CentrifugoPublishError(
|
|
221
|
+
f"Publish failed: {e}",
|
|
222
|
+
channel=channel,
|
|
223
|
+
)
|
|
224
|
+
logger.error(f"Publish attempt {attempt + 1}/{self.max_retries} failed: {e}")
|
|
225
|
+
|
|
226
|
+
# Retry delay
|
|
227
|
+
if attempt < self.max_retries - 1:
|
|
228
|
+
import asyncio
|
|
229
|
+
await asyncio.sleep(self.retry_delay)
|
|
230
|
+
|
|
231
|
+
# All retries failed
|
|
232
|
+
if last_error:
|
|
233
|
+
raise last_error
|
|
234
|
+
else:
|
|
235
|
+
raise CentrifugoPublishError(
|
|
236
|
+
f"Failed to publish after {self.max_retries} attempts",
|
|
237
|
+
channel=channel,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def close(self):
|
|
241
|
+
"""Close HTTP client connection."""
|
|
242
|
+
await self._http_client.aclose()
|
|
243
|
+
logger.debug("DirectCentrifugoClient closed")
|
|
244
|
+
|
|
245
|
+
async def __aenter__(self):
|
|
246
|
+
"""Async context manager entry."""
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
250
|
+
"""Async context manager exit."""
|
|
251
|
+
await self.close()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# Singleton instance
|
|
255
|
+
_direct_client_instance: Optional[DirectCentrifugoClient] = None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_direct_centrifugo_client() -> DirectCentrifugoClient:
|
|
259
|
+
"""
|
|
260
|
+
Get singleton DirectCentrifugoClient instance.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
DirectCentrifugoClient instance
|
|
264
|
+
|
|
265
|
+
Example:
|
|
266
|
+
>>> from django_cfg.apps.integrations.centrifugo.services.client import get_direct_centrifugo_client
|
|
267
|
+
>>> client = get_direct_centrifugo_client()
|
|
268
|
+
>>> await client.publish(channel="test", data={"foo": "bar"})
|
|
269
|
+
"""
|
|
270
|
+
global _direct_client_instance
|
|
271
|
+
|
|
272
|
+
if _direct_client_instance is None:
|
|
273
|
+
_direct_client_instance = DirectCentrifugoClient()
|
|
274
|
+
|
|
275
|
+
return _direct_client_instance
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
__all__ = [
|
|
279
|
+
"DirectCentrifugoClient",
|
|
280
|
+
"get_direct_centrifugo_client",
|
|
281
|
+
"PublishResponse",
|
|
282
|
+
]
|
|
@@ -400,6 +400,53 @@ class CentrifugoLogger:
|
|
|
400
400
|
extra={"message_id": getattr(log_entry, "message_id", "unknown")},
|
|
401
401
|
)
|
|
402
402
|
|
|
403
|
+
@staticmethod
|
|
404
|
+
async def mark_failed_async(
|
|
405
|
+
log_entry: Any,
|
|
406
|
+
error_code: str,
|
|
407
|
+
error_message: str,
|
|
408
|
+
duration_ms: int | None = None,
|
|
409
|
+
) -> None:
|
|
410
|
+
"""
|
|
411
|
+
Mark publish operation as failed (async version).
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
log_entry: CentrifugoLog instance
|
|
415
|
+
error_code: Error code
|
|
416
|
+
error_message: Error message
|
|
417
|
+
duration_ms: Duration in milliseconds
|
|
418
|
+
"""
|
|
419
|
+
if log_entry is None:
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
from asgiref.sync import sync_to_async
|
|
424
|
+
from ..models import CentrifugoLog
|
|
425
|
+
|
|
426
|
+
await sync_to_async(CentrifugoLog.objects.mark_failed)(
|
|
427
|
+
log_instance=log_entry,
|
|
428
|
+
error_code=error_code,
|
|
429
|
+
error_message=error_message,
|
|
430
|
+
duration_ms=duration_ms,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
logger.error(
|
|
434
|
+
f"Centrifugo publish failed: {log_entry.message_id}",
|
|
435
|
+
extra={
|
|
436
|
+
"message_id": log_entry.message_id,
|
|
437
|
+
"channel": log_entry.channel,
|
|
438
|
+
"error_code": error_code,
|
|
439
|
+
"error_message": error_message,
|
|
440
|
+
"duration_ms": duration_ms,
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
except Exception as e:
|
|
445
|
+
logger.error(
|
|
446
|
+
f"Failed to mark Centrifugo log as failed: {e}",
|
|
447
|
+
extra={"message_id": getattr(log_entry, "message_id", "unknown")},
|
|
448
|
+
)
|
|
449
|
+
|
|
403
450
|
@staticmethod
|
|
404
451
|
async def mark_timeout_async(
|
|
405
452
|
log_entry: Any,
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centrifugo Publishing Service.
|
|
3
|
+
|
|
4
|
+
Unified high-level API for publishing events to Centrifugo.
|
|
5
|
+
Abstracts away CentrifugoClient details and provides domain-specific methods.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
>>> from django_cfg.apps.integrations.centrifugo.services import CentrifugoPublisher
|
|
9
|
+
>>>
|
|
10
|
+
>>> publisher = CentrifugoPublisher()
|
|
11
|
+
>>>
|
|
12
|
+
>>> # Publish gRPC event
|
|
13
|
+
>>> await publisher.publish_grpc_event(
|
|
14
|
+
... channel="grpc#bot#123#status",
|
|
15
|
+
... method="/bot.BotService/Start",
|
|
16
|
+
... status="OK",
|
|
17
|
+
... duration_ms=150
|
|
18
|
+
... )
|
|
19
|
+
>>>
|
|
20
|
+
>>> # Publish demo event
|
|
21
|
+
>>> await publisher.publish_demo_event(
|
|
22
|
+
... channel="grpc#demo#test",
|
|
23
|
+
... metadata={"test": True}
|
|
24
|
+
... )
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from datetime import datetime, timezone as tz
|
|
30
|
+
from typing import Any, Dict, Optional
|
|
31
|
+
|
|
32
|
+
from django_cfg.modules.django_logging import get_logger
|
|
33
|
+
|
|
34
|
+
from ..services.client import (
|
|
35
|
+
CentrifugoClient,
|
|
36
|
+
DirectCentrifugoClient,
|
|
37
|
+
PublishResponse,
|
|
38
|
+
get_centrifugo_client,
|
|
39
|
+
get_direct_centrifugo_client,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
logger = get_logger("centrifugo.publisher")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CentrifugoPublisher:
|
|
46
|
+
"""
|
|
47
|
+
High-level publishing service for Centrifugo events.
|
|
48
|
+
|
|
49
|
+
Provides domain-specific methods that abstract away low-level client details.
|
|
50
|
+
All methods are async and handle errors gracefully.
|
|
51
|
+
|
|
52
|
+
Features:
|
|
53
|
+
- Unified API for all Centrifugo publishing
|
|
54
|
+
- Automatic timestamp injection
|
|
55
|
+
- Type-safe event metadata
|
|
56
|
+
- Error handling and logging
|
|
57
|
+
- Easy to mock for testing
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
client: Optional[CentrifugoClient | DirectCentrifugoClient] = None,
|
|
63
|
+
use_direct: bool = True,
|
|
64
|
+
):
|
|
65
|
+
"""
|
|
66
|
+
Initialize publisher.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
client: Optional client instance (CentrifugoClient or DirectCentrifugoClient)
|
|
70
|
+
use_direct: Use DirectCentrifugoClient (bypass wrapper, default=True)
|
|
71
|
+
"""
|
|
72
|
+
if client:
|
|
73
|
+
self._client = client
|
|
74
|
+
logger.debug("CentrifugoPublisher initialized with custom client")
|
|
75
|
+
elif use_direct:
|
|
76
|
+
# Use direct client (no wrapper, no DB logging)
|
|
77
|
+
self._client = get_direct_centrifugo_client()
|
|
78
|
+
logger.debug("CentrifugoPublisher initialized with DirectCentrifugoClient")
|
|
79
|
+
else:
|
|
80
|
+
# Use wrapper client (with auth & DB logging)
|
|
81
|
+
self._client = get_centrifugo_client()
|
|
82
|
+
logger.debug("CentrifugoPublisher initialized with CentrifugoClient (wrapper)")
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def client(self) -> CentrifugoClient | DirectCentrifugoClient:
|
|
86
|
+
"""Get underlying client instance."""
|
|
87
|
+
return self._client
|
|
88
|
+
|
|
89
|
+
async def publish_grpc_event(
|
|
90
|
+
self,
|
|
91
|
+
channel: str,
|
|
92
|
+
method: str,
|
|
93
|
+
status: str = "OK",
|
|
94
|
+
duration_ms: float = 0.0,
|
|
95
|
+
peer: Optional[str] = None,
|
|
96
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
97
|
+
**extra: Any,
|
|
98
|
+
) -> PublishResponse:
|
|
99
|
+
"""
|
|
100
|
+
Publish gRPC event (interceptor-style metadata).
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
channel: Centrifugo channel (e.g., "grpc#bot#123#status")
|
|
104
|
+
method: Full gRPC method name (e.g., "/bot.BotService/Start")
|
|
105
|
+
status: RPC status code (default: "OK")
|
|
106
|
+
duration_ms: RPC duration in milliseconds
|
|
107
|
+
peer: Client peer address
|
|
108
|
+
metadata: Additional metadata dict
|
|
109
|
+
**extra: Additional fields
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
PublishResponse with result
|
|
113
|
+
|
|
114
|
+
Example:
|
|
115
|
+
>>> await publisher.publish_grpc_event(
|
|
116
|
+
... channel="grpc#bot#123#status",
|
|
117
|
+
... method="/bot.BotService/Start",
|
|
118
|
+
... status="OK",
|
|
119
|
+
... duration_ms=150,
|
|
120
|
+
... peer="127.0.0.1:50051"
|
|
121
|
+
... )
|
|
122
|
+
"""
|
|
123
|
+
# Parse method name
|
|
124
|
+
service_name = None
|
|
125
|
+
method_name = None
|
|
126
|
+
if method.startswith("/") and "/" in method[1:]:
|
|
127
|
+
parts = method[1:].split("/")
|
|
128
|
+
service_name = parts[0]
|
|
129
|
+
method_name = parts[1]
|
|
130
|
+
|
|
131
|
+
# Build event data
|
|
132
|
+
event_data = {
|
|
133
|
+
"event_type": "grpc_event",
|
|
134
|
+
"method": method,
|
|
135
|
+
"status": status,
|
|
136
|
+
"timestamp": datetime.now(tz.utc).isoformat(),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if service_name:
|
|
140
|
+
event_data["service"] = service_name
|
|
141
|
+
if method_name:
|
|
142
|
+
event_data["method_name"] = method_name
|
|
143
|
+
if duration_ms:
|
|
144
|
+
event_data["duration_ms"] = duration_ms
|
|
145
|
+
if peer:
|
|
146
|
+
event_data["peer"] = peer
|
|
147
|
+
if metadata:
|
|
148
|
+
event_data.update(metadata)
|
|
149
|
+
if extra:
|
|
150
|
+
event_data.update(extra)
|
|
151
|
+
|
|
152
|
+
logger.debug(f"Publishing gRPC event: {channel} ({method})")
|
|
153
|
+
|
|
154
|
+
# DirectCentrifugoClient uses simpler API
|
|
155
|
+
if isinstance(self._client, DirectCentrifugoClient):
|
|
156
|
+
return await self._client.publish(channel=channel, data=event_data)
|
|
157
|
+
else:
|
|
158
|
+
return await self._client.publish(channel=channel, data=event_data)
|
|
159
|
+
|
|
160
|
+
async def publish_demo_event(
|
|
161
|
+
self,
|
|
162
|
+
channel: str,
|
|
163
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
164
|
+
**extra: Any,
|
|
165
|
+
) -> PublishResponse:
|
|
166
|
+
"""
|
|
167
|
+
Publish demo/test event.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
channel: Centrifugo channel
|
|
171
|
+
metadata: Event metadata
|
|
172
|
+
**extra: Additional fields
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
PublishResponse with result
|
|
176
|
+
|
|
177
|
+
Example:
|
|
178
|
+
>>> await publisher.publish_demo_event(
|
|
179
|
+
... channel="grpc#demo#test",
|
|
180
|
+
... metadata={"test": True, "source": "demo.py"}
|
|
181
|
+
... )
|
|
182
|
+
"""
|
|
183
|
+
event_data = {
|
|
184
|
+
"event_type": "demo_event",
|
|
185
|
+
"timestamp": datetime.now(tz.utc).isoformat(),
|
|
186
|
+
"test_mode": True,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if metadata:
|
|
190
|
+
event_data.update(metadata)
|
|
191
|
+
if extra:
|
|
192
|
+
event_data.update(extra)
|
|
193
|
+
|
|
194
|
+
logger.debug(f"Publishing demo event: {channel}")
|
|
195
|
+
|
|
196
|
+
if isinstance(self._client, DirectCentrifugoClient):
|
|
197
|
+
return await self._client.publish(channel=channel, data=event_data)
|
|
198
|
+
else:
|
|
199
|
+
return await self._client.publish(channel=channel, data=event_data)
|
|
200
|
+
|
|
201
|
+
async def publish_notification(
|
|
202
|
+
self,
|
|
203
|
+
channel: str,
|
|
204
|
+
title: str,
|
|
205
|
+
message: str,
|
|
206
|
+
level: str = "info",
|
|
207
|
+
user: Optional[Any] = None,
|
|
208
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
209
|
+
**extra: Any,
|
|
210
|
+
) -> PublishResponse:
|
|
211
|
+
"""
|
|
212
|
+
Publish user notification.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
channel: Centrifugo channel (e.g., "notifications#user#123")
|
|
216
|
+
title: Notification title
|
|
217
|
+
message: Notification message
|
|
218
|
+
level: Notification level (info, warning, error, success)
|
|
219
|
+
user: Django User instance
|
|
220
|
+
metadata: Additional metadata
|
|
221
|
+
**extra: Additional fields
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
PublishResponse with result
|
|
225
|
+
|
|
226
|
+
Example:
|
|
227
|
+
>>> await publisher.publish_notification(
|
|
228
|
+
... channel="notifications#user#123",
|
|
229
|
+
... title="Bot Started",
|
|
230
|
+
... message="Your bot has started successfully",
|
|
231
|
+
... level="success"
|
|
232
|
+
... )
|
|
233
|
+
"""
|
|
234
|
+
event_data = {
|
|
235
|
+
"event_type": "notification",
|
|
236
|
+
"title": title,
|
|
237
|
+
"message": message,
|
|
238
|
+
"level": level,
|
|
239
|
+
"timestamp": datetime.now(tz.utc).isoformat(),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if metadata:
|
|
243
|
+
event_data.update(metadata)
|
|
244
|
+
if extra:
|
|
245
|
+
event_data.update(extra)
|
|
246
|
+
|
|
247
|
+
logger.debug(f"Publishing notification: {channel} ({title})")
|
|
248
|
+
|
|
249
|
+
return await self._client.publish(channel=channel, data=event_data, user=user)
|
|
250
|
+
|
|
251
|
+
async def publish_status_change(
|
|
252
|
+
self,
|
|
253
|
+
channel: str,
|
|
254
|
+
old_status: str,
|
|
255
|
+
new_status: str,
|
|
256
|
+
reason: Optional[str] = None,
|
|
257
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
258
|
+
**extra: Any,
|
|
259
|
+
) -> PublishResponse:
|
|
260
|
+
"""
|
|
261
|
+
Publish status change event.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
channel: Centrifugo channel
|
|
265
|
+
old_status: Previous status
|
|
266
|
+
new_status: New status
|
|
267
|
+
reason: Reason for status change
|
|
268
|
+
metadata: Additional metadata
|
|
269
|
+
**extra: Additional fields
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
PublishResponse with result
|
|
273
|
+
|
|
274
|
+
Example:
|
|
275
|
+
>>> await publisher.publish_status_change(
|
|
276
|
+
... channel="bot#123#status",
|
|
277
|
+
... old_status="STOPPED",
|
|
278
|
+
... new_status="RUNNING",
|
|
279
|
+
... reason="User requested start"
|
|
280
|
+
... )
|
|
281
|
+
"""
|
|
282
|
+
event_data = {
|
|
283
|
+
"event_type": "status_change",
|
|
284
|
+
"old_status": old_status,
|
|
285
|
+
"new_status": new_status,
|
|
286
|
+
"timestamp": datetime.now(tz.utc).isoformat(),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if reason:
|
|
290
|
+
event_data["reason"] = reason
|
|
291
|
+
if metadata:
|
|
292
|
+
event_data.update(metadata)
|
|
293
|
+
if extra:
|
|
294
|
+
event_data.update(extra)
|
|
295
|
+
|
|
296
|
+
logger.debug(f"Publishing status change: {channel} ({old_status} → {new_status})")
|
|
297
|
+
|
|
298
|
+
return await self._client.publish(channel=channel, data=event_data)
|
|
299
|
+
|
|
300
|
+
async def publish_custom(
|
|
301
|
+
self,
|
|
302
|
+
channel: str,
|
|
303
|
+
event_type: str,
|
|
304
|
+
data: Dict[str, Any],
|
|
305
|
+
user: Optional[Any] = None,
|
|
306
|
+
) -> PublishResponse:
|
|
307
|
+
"""
|
|
308
|
+
Publish custom event with arbitrary data.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
channel: Centrifugo channel
|
|
312
|
+
event_type: Custom event type
|
|
313
|
+
data: Event data dict
|
|
314
|
+
user: Django User instance
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
PublishResponse with result
|
|
318
|
+
|
|
319
|
+
Example:
|
|
320
|
+
>>> await publisher.publish_custom(
|
|
321
|
+
... channel="custom#events",
|
|
322
|
+
... event_type="custom_event",
|
|
323
|
+
... data={"foo": "bar", "count": 42}
|
|
324
|
+
... )
|
|
325
|
+
"""
|
|
326
|
+
event_data = {
|
|
327
|
+
"event_type": event_type,
|
|
328
|
+
"timestamp": datetime.now(tz.utc).isoformat(),
|
|
329
|
+
**data,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
logger.debug(f"Publishing custom event: {channel} ({event_type})")
|
|
333
|
+
|
|
334
|
+
return await self._client.publish(channel=channel, data=event_data, user=user)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# Singleton instance
|
|
338
|
+
_publisher_instance: Optional[CentrifugoPublisher] = None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def get_centrifugo_publisher(client: Optional[CentrifugoClient] = None) -> CentrifugoPublisher:
|
|
342
|
+
"""
|
|
343
|
+
Get singleton CentrifugoPublisher instance.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
client: Optional CentrifugoClient (creates new publisher if provided)
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
CentrifugoPublisher instance
|
|
350
|
+
|
|
351
|
+
Example:
|
|
352
|
+
>>> from django_cfg.apps.integrations.centrifugo.services import get_centrifugo_publisher
|
|
353
|
+
>>> publisher = get_centrifugo_publisher()
|
|
354
|
+
>>> await publisher.publish_demo_event(channel="test", metadata={"foo": "bar"})
|
|
355
|
+
"""
|
|
356
|
+
global _publisher_instance
|
|
357
|
+
|
|
358
|
+
if client is not None:
|
|
359
|
+
# Create new instance with custom client
|
|
360
|
+
return CentrifugoPublisher(client=client)
|
|
361
|
+
|
|
362
|
+
if _publisher_instance is None:
|
|
363
|
+
_publisher_instance = CentrifugoPublisher()
|
|
364
|
+
|
|
365
|
+
return _publisher_instance
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
__all__ = [
|
|
369
|
+
"CentrifugoPublisher",
|
|
370
|
+
"get_centrifugo_publisher",
|
|
371
|
+
]
|