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.

Files changed (118) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/business/accounts/serializers/profile.py +42 -0
  3. django_cfg/apps/business/support/serializers.py +3 -2
  4. django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
  5. django_cfg/apps/integrations/centrifugo/apps.py +2 -1
  6. django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
  7. django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
  8. django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
  9. django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
  10. django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
  11. django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
  12. django_cfg/apps/integrations/centrifugo/services/logging.py +47 -0
  13. django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
  14. django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
  15. django_cfg/apps/integrations/centrifugo/urls.py +8 -0
  16. django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
  17. django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
  18. django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -116
  19. django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
  20. django_cfg/apps/integrations/centrifugo/views/wrapper.py +259 -0
  21. django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
  22. django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
  23. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +56 -1
  24. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +315 -26
  25. django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
  26. django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
  27. django_cfg/apps/integrations/grpc/managers/grpc_request_log.py +84 -0
  28. django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
  29. django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
  30. django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
  31. django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
  32. django_cfg/apps/integrations/grpc/services/centrifugo/__init__.py +29 -0
  33. django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
  34. django_cfg/apps/integrations/grpc/services/centrifugo/config.py +167 -0
  35. django_cfg/apps/integrations/grpc/services/centrifugo/demo.py +626 -0
  36. django_cfg/apps/integrations/grpc/services/centrifugo/test_publish.py +229 -0
  37. django_cfg/apps/integrations/grpc/services/centrifugo/transformers.py +89 -0
  38. django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
  39. django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
  40. django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
  41. django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
  42. django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
  43. django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
  44. django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
  45. django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
  46. django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
  47. django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
  48. django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
  49. django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
  50. django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
  51. django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +67 -54
  52. django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +215 -5
  53. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +3 -1
  54. django_cfg/apps/integrations/grpc/services/interceptors/centrifugo.py +541 -0
  55. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
  56. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
  57. django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
  58. django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
  59. django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
  60. django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
  61. django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
  62. django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
  63. django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
  64. django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
  65. django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
  66. django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
  67. django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
  68. django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
  69. django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
  70. django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
  71. django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
  72. django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
  73. django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
  74. django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
  75. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +261 -13
  76. django_cfg/apps/integrations/grpc/views/charts.py +1 -1
  77. django_cfg/apps/integrations/grpc/views/config.py +1 -1
  78. django_cfg/apps/system/dashboard/serializers/config.py +95 -9
  79. django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
  80. django_cfg/apps/system/frontend/views.py +87 -6
  81. django_cfg/core/base/config_model.py +11 -0
  82. django_cfg/core/builders/middleware_builder.py +5 -0
  83. django_cfg/core/builders/security_builder.py +1 -0
  84. django_cfg/core/generation/integration_generators/api.py +2 -0
  85. django_cfg/management/commands/pool_status.py +153 -0
  86. django_cfg/middleware/pool_cleanup.py +261 -0
  87. django_cfg/models/api/grpc/config.py +2 -2
  88. django_cfg/models/infrastructure/database/config.py +16 -0
  89. django_cfg/models/infrastructure/database/converters.py +2 -0
  90. django_cfg/modules/django_admin/utils/html/composition.py +57 -13
  91. django_cfg/modules/django_admin/utils/html_builder.py +1 -0
  92. django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
  93. django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
  94. django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
  95. django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
  96. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
  97. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
  98. django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
  99. django_cfg/modules/django_client/core/groups/manager.py +25 -18
  100. django_cfg/modules/django_client/core/ir/schema.py +15 -1
  101. django_cfg/modules/django_client/core/parser/base.py +12 -0
  102. django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
  103. django_cfg/modules/django_logging/django_logger.py +58 -19
  104. django_cfg/pyproject.toml +3 -3
  105. django_cfg/static/frontend/admin.zip +0 -0
  106. django_cfg/templates/admin/index.html +0 -39
  107. django_cfg/utils/pool_monitor.py +320 -0
  108. django_cfg/utils/smart_defaults.py +233 -7
  109. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
  110. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/RECORD +118 -74
  111. /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
  112. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
  113. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
  114. /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
  115. /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
  116. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/WHEEL +0 -0
  117. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
  118. {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
+ ]