django-cfg 1.4.62__py3-none-any.whl → 1.4.64__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 (181) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/accounts/services/otp_service.py +3 -14
  3. django_cfg/apps/centrifugo/__init__.py +57 -0
  4. django_cfg/apps/centrifugo/admin/__init__.py +13 -0
  5. django_cfg/apps/centrifugo/admin/centrifugo_log.py +249 -0
  6. django_cfg/apps/centrifugo/admin/config.py +82 -0
  7. django_cfg/apps/centrifugo/apps.py +31 -0
  8. django_cfg/apps/centrifugo/codegen/IMPLEMENTATION_SUMMARY.md +475 -0
  9. django_cfg/apps/centrifugo/codegen/README.md +242 -0
  10. django_cfg/apps/centrifugo/codegen/USAGE.md +616 -0
  11. django_cfg/apps/centrifugo/codegen/__init__.py +19 -0
  12. django_cfg/apps/centrifugo/codegen/discovery.py +246 -0
  13. django_cfg/apps/centrifugo/codegen/generators/go_thin/__init__.py +5 -0
  14. django_cfg/apps/centrifugo/codegen/generators/go_thin/generator.py +174 -0
  15. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/README.md.j2 +182 -0
  16. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/client.go.j2 +64 -0
  17. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/go.mod.j2 +10 -0
  18. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/rpc_client.go.j2 +300 -0
  19. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/rpc_client.go.j2.old +267 -0
  20. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/types.go.j2 +16 -0
  21. django_cfg/apps/centrifugo/codegen/generators/python_thin/__init__.py +7 -0
  22. django_cfg/apps/centrifugo/codegen/generators/python_thin/generator.py +241 -0
  23. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/README.md.j2 +128 -0
  24. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/__init__.py.j2 +22 -0
  25. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/client.py.j2 +73 -0
  26. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/models.py.j2 +19 -0
  27. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/requirements.txt.j2 +8 -0
  28. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/rpc_client.py.j2 +193 -0
  29. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/__init__.py +5 -0
  30. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/generator.py +124 -0
  31. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/README.md.j2 +38 -0
  32. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/client.ts.j2 +25 -0
  33. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/index.ts.j2 +12 -0
  34. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/package.json.j2 +13 -0
  35. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +137 -0
  36. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/tsconfig.json.j2 +14 -0
  37. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/types.ts.j2 +9 -0
  38. django_cfg/apps/centrifugo/codegen/utils/__init__.py +37 -0
  39. django_cfg/apps/centrifugo/codegen/utils/naming.py +155 -0
  40. django_cfg/apps/centrifugo/codegen/utils/type_converter.py +349 -0
  41. django_cfg/apps/centrifugo/decorators.py +137 -0
  42. django_cfg/apps/centrifugo/management/__init__.py +1 -0
  43. django_cfg/apps/centrifugo/management/commands/__init__.py +1 -0
  44. django_cfg/apps/centrifugo/management/commands/generate_centrifugo_clients.py +254 -0
  45. django_cfg/apps/centrifugo/managers/__init__.py +12 -0
  46. django_cfg/apps/centrifugo/managers/centrifugo_log.py +264 -0
  47. django_cfg/apps/centrifugo/migrations/0001_initial.py +164 -0
  48. django_cfg/apps/centrifugo/migrations/__init__.py +3 -0
  49. django_cfg/apps/centrifugo/models/__init__.py +11 -0
  50. django_cfg/apps/centrifugo/models/centrifugo_log.py +210 -0
  51. django_cfg/apps/centrifugo/registry.py +106 -0
  52. django_cfg/apps/centrifugo/router.py +125 -0
  53. django_cfg/apps/centrifugo/serializers/__init__.py +40 -0
  54. django_cfg/apps/centrifugo/serializers/admin_api.py +264 -0
  55. django_cfg/apps/centrifugo/serializers/channels.py +26 -0
  56. django_cfg/apps/centrifugo/serializers/health.py +17 -0
  57. django_cfg/apps/centrifugo/serializers/publishes.py +16 -0
  58. django_cfg/apps/centrifugo/serializers/stats.py +21 -0
  59. django_cfg/apps/centrifugo/services/__init__.py +12 -0
  60. django_cfg/apps/centrifugo/services/client/__init__.py +29 -0
  61. django_cfg/apps/centrifugo/services/client/client.py +582 -0
  62. django_cfg/apps/centrifugo/services/client/config.py +236 -0
  63. django_cfg/apps/centrifugo/services/client/exceptions.py +212 -0
  64. django_cfg/apps/centrifugo/services/config_helper.py +63 -0
  65. django_cfg/apps/centrifugo/services/dashboard_notifier.py +157 -0
  66. django_cfg/apps/centrifugo/services/logging.py +677 -0
  67. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/css/dashboard.css +260 -0
  68. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/live_channels.mjs +313 -0
  69. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/live_testing.mjs +803 -0
  70. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/main.mjs +333 -0
  71. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/overview.mjs +432 -0
  72. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/testing.mjs +33 -0
  73. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/websocket.mjs +210 -0
  74. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/channels_content.html +46 -0
  75. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/live_channels_content.html +123 -0
  76. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/overview_content.html +45 -0
  77. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/publishes_content.html +84 -0
  78. django_cfg/apps/{ipc/templates/django_cfg_ipc → centrifugo/templates/django_cfg_centrifugo}/components/stat_cards.html +23 -20
  79. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/system_status.html +91 -0
  80. django_cfg/apps/{ipc/templates/django_cfg_ipc → centrifugo/templates/django_cfg_centrifugo}/components/tab_navigation.html +15 -15
  81. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/testing_tools.html +415 -0
  82. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/layout/base.html +61 -0
  83. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/pages/dashboard.html +58 -0
  84. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/tags/connection_script.html +48 -0
  85. django_cfg/apps/centrifugo/templatetags/__init__.py +1 -0
  86. django_cfg/apps/centrifugo/templatetags/centrifugo_tags.py +81 -0
  87. django_cfg/apps/centrifugo/urls.py +31 -0
  88. django_cfg/apps/{ipc → centrifugo}/urls_admin.py +4 -4
  89. django_cfg/apps/centrifugo/views/__init__.py +15 -0
  90. django_cfg/apps/centrifugo/views/admin_api.py +380 -0
  91. django_cfg/apps/centrifugo/views/dashboard.py +15 -0
  92. django_cfg/apps/centrifugo/views/monitoring.py +286 -0
  93. django_cfg/apps/centrifugo/views/testing_api.py +422 -0
  94. django_cfg/apps/support/utils/support_email_service.py +5 -18
  95. django_cfg/apps/tasks/templates/tasks/layout/base.html +0 -2
  96. django_cfg/apps/urls.py +5 -5
  97. django_cfg/core/base/config_model.py +4 -44
  98. django_cfg/core/builders/apps_builder.py +2 -2
  99. django_cfg/core/generation/integration_generators/third_party.py +8 -8
  100. django_cfg/core/utils/__init__.py +5 -0
  101. django_cfg/core/utils/url_helpers.py +73 -0
  102. django_cfg/modules/base.py +7 -7
  103. django_cfg/modules/django_client/core/__init__.py +2 -1
  104. django_cfg/modules/django_client/core/config/config.py +8 -0
  105. django_cfg/modules/django_client/core/generator/__init__.py +42 -2
  106. django_cfg/modules/django_client/core/generator/go/__init__.py +14 -0
  107. django_cfg/modules/django_client/core/generator/go/client_generator.py +124 -0
  108. django_cfg/modules/django_client/core/generator/go/files_generator.py +133 -0
  109. django_cfg/modules/django_client/core/generator/go/generator.py +203 -0
  110. django_cfg/modules/django_client/core/generator/go/models_generator.py +304 -0
  111. django_cfg/modules/django_client/core/generator/go/naming.py +193 -0
  112. django_cfg/modules/django_client/core/generator/go/operations_generator.py +134 -0
  113. django_cfg/modules/django_client/core/generator/go/templates/Makefile.j2 +38 -0
  114. django_cfg/modules/django_client/core/generator/go/templates/README.md.j2 +55 -0
  115. django_cfg/modules/django_client/core/generator/go/templates/client.go.j2 +122 -0
  116. django_cfg/modules/django_client/core/generator/go/templates/enums.go.j2 +49 -0
  117. django_cfg/modules/django_client/core/generator/go/templates/errors.go.j2 +182 -0
  118. django_cfg/modules/django_client/core/generator/go/templates/go.mod.j2 +6 -0
  119. django_cfg/modules/django_client/core/generator/go/templates/main_client.go.j2 +60 -0
  120. django_cfg/modules/django_client/core/generator/go/templates/middleware.go.j2 +388 -0
  121. django_cfg/modules/django_client/core/generator/go/templates/models.go.j2 +28 -0
  122. django_cfg/modules/django_client/core/generator/go/templates/operations_client.go.j2 +142 -0
  123. django_cfg/modules/django_client/core/generator/go/templates/validation.go.j2 +217 -0
  124. django_cfg/modules/django_client/core/generator/go/type_mapper.py +380 -0
  125. django_cfg/modules/django_client/management/commands/generate_client.py +53 -3
  126. django_cfg/modules/django_client/system/generate_mjs_clients.py +3 -1
  127. django_cfg/modules/django_client/system/schema_parser.py +5 -1
  128. django_cfg/modules/django_tailwind/templates/django_tailwind/base.html +1 -0
  129. django_cfg/modules/django_twilio/sendgrid_service.py +7 -4
  130. django_cfg/modules/django_unfold/dashboard.py +25 -19
  131. django_cfg/pyproject.toml +1 -1
  132. django_cfg/registry/core.py +2 -0
  133. django_cfg/registry/modules.py +2 -2
  134. django_cfg/static/js/api/centrifugo/client.mjs +164 -0
  135. django_cfg/static/js/api/centrifugo/index.mjs +13 -0
  136. django_cfg/static/js/api/index.mjs +5 -5
  137. django_cfg/static/js/api/types.mjs +89 -26
  138. {django_cfg-1.4.62.dist-info → django_cfg-1.4.64.dist-info}/METADATA +1 -1
  139. {django_cfg-1.4.62.dist-info → django_cfg-1.4.64.dist-info}/RECORD +142 -70
  140. django_cfg/apps/ipc/README.md +0 -346
  141. django_cfg/apps/ipc/RPC_LOGGING.md +0 -321
  142. django_cfg/apps/ipc/TESTING.md +0 -539
  143. django_cfg/apps/ipc/__init__.py +0 -60
  144. django_cfg/apps/ipc/admin.py +0 -232
  145. django_cfg/apps/ipc/apps.py +0 -98
  146. django_cfg/apps/ipc/migrations/0001_initial.py +0 -137
  147. django_cfg/apps/ipc/migrations/0002_rpclog_is_event.py +0 -23
  148. django_cfg/apps/ipc/migrations/__init__.py +0 -0
  149. django_cfg/apps/ipc/models.py +0 -229
  150. django_cfg/apps/ipc/serializers/__init__.py +0 -29
  151. django_cfg/apps/ipc/serializers/serializers.py +0 -343
  152. django_cfg/apps/ipc/services/__init__.py +0 -7
  153. django_cfg/apps/ipc/services/client/__init__.py +0 -23
  154. django_cfg/apps/ipc/services/client/client.py +0 -621
  155. django_cfg/apps/ipc/services/client/config.py +0 -214
  156. django_cfg/apps/ipc/services/client/exceptions.py +0 -201
  157. django_cfg/apps/ipc/services/logging.py +0 -239
  158. django_cfg/apps/ipc/services/monitor.py +0 -466
  159. django_cfg/apps/ipc/services/rpc_log_consumer.py +0 -330
  160. django_cfg/apps/ipc/static/django_cfg_ipc/js/dashboard/main.mjs +0 -269
  161. django_cfg/apps/ipc/static/django_cfg_ipc/js/dashboard/overview.mjs +0 -259
  162. django_cfg/apps/ipc/static/django_cfg_ipc/js/dashboard/testing.mjs +0 -375
  163. django_cfg/apps/ipc/static/django_cfg_ipc/js/dashboard.mjs.old +0 -441
  164. django_cfg/apps/ipc/templates/django_cfg_ipc/components/methods_content.html +0 -22
  165. django_cfg/apps/ipc/templates/django_cfg_ipc/components/notifications_content.html +0 -9
  166. django_cfg/apps/ipc/templates/django_cfg_ipc/components/overview_content.html +0 -9
  167. django_cfg/apps/ipc/templates/django_cfg_ipc/components/requests_content.html +0 -23
  168. django_cfg/apps/ipc/templates/django_cfg_ipc/components/system_status.html +0 -47
  169. django_cfg/apps/ipc/templates/django_cfg_ipc/components/testing_tools.html +0 -184
  170. django_cfg/apps/ipc/templates/django_cfg_ipc/layout/base.html +0 -71
  171. django_cfg/apps/ipc/templates/django_cfg_ipc/pages/dashboard.html +0 -56
  172. django_cfg/apps/ipc/urls.py +0 -23
  173. django_cfg/apps/ipc/views/__init__.py +0 -13
  174. django_cfg/apps/ipc/views/dashboard.py +0 -15
  175. django_cfg/apps/ipc/views/monitoring.py +0 -251
  176. django_cfg/apps/ipc/views/testing.py +0 -285
  177. django_cfg/static/js/api/ipc/client.mjs +0 -114
  178. django_cfg/static/js/api/ipc/index.mjs +0 -13
  179. {django_cfg-1.4.62.dist-info → django_cfg-1.4.64.dist-info}/WHEEL +0 -0
  180. {django_cfg-1.4.62.dist-info → django_cfg-1.4.64.dist-info}/entry_points.txt +0 -0
  181. {django_cfg-1.4.62.dist-info → django_cfg-1.4.64.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,582 @@
1
+ """
2
+ Django-CFG Centrifugo Client.
3
+
4
+ Async client enabling Django applications to publish messages
5
+ to Centrifugo via Python Wrapper with ACK tracking.
6
+
7
+ Mirrors DjangoCfgRPCClient interface for easy migration from legacy WebSocket solution.
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ import time
13
+ from typing import Any, Optional, Type, TypeVar
14
+ from uuid import uuid4
15
+
16
+ import httpx
17
+ from django_cfg.modules.django_logging import get_logger
18
+ from pydantic import BaseModel
19
+
20
+ from .exceptions import (
21
+ CentrifugoConfigurationError,
22
+ CentrifugoConnectionError,
23
+ CentrifugoPublishError,
24
+ CentrifugoTimeoutError,
25
+ CentrifugoValidationError,
26
+ )
27
+
28
+ logger = get_logger("centrifugo.client")
29
+
30
+ TData = TypeVar("TData", bound=BaseModel)
31
+
32
+
33
+ class PublishResponse(BaseModel):
34
+ """Response from publish operation."""
35
+
36
+ message_id: str
37
+ published: bool
38
+ delivered: bool = False
39
+ acks_received: int = 0
40
+ timeout: bool = False
41
+
42
+
43
+ class CentrifugoClient:
44
+ """
45
+ Async Centrifugo client for Django to communicate with Centrifugo server.
46
+
47
+ Features:
48
+ - Async/await API for Django 5.2+ async views
49
+ - Publishes messages via Python Wrapper HTTP API
50
+ - Supports ACK tracking for delivery confirmation
51
+ - Type-safe API with Pydantic models
52
+ - Connection pooling for performance
53
+ - Automatic logging with CentrifugoLogger
54
+ - Mirrors DjangoCfgRPCClient interface for migration
55
+
56
+ Example:
57
+ >>> from django_cfg.apps.centrifugo import get_centrifugo_client
58
+ >>>
59
+ >>> client = get_centrifugo_client()
60
+ >>>
61
+ >>> # Simple publish (fire-and-forget)
62
+ >>> result = await client.publish(
63
+ ... channel="broadcast",
64
+ ... data={"message": "Hello everyone"}
65
+ ... )
66
+ >>>
67
+ >>> # Publish with ACK tracking
68
+ >>> result = await client.publish_with_ack(
69
+ ... channel="user#123",
70
+ ... data={"title": "Notification", "message": "Test"},
71
+ ... ack_timeout=10
72
+ ... )
73
+ >>> if result.delivered:
74
+ ... print(f"Delivered to {result.acks_received} clients")
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ wrapper_url: Optional[str] = None,
80
+ wrapper_api_key: Optional[str] = None,
81
+ default_timeout: int = 30,
82
+ ack_timeout: int = 10,
83
+ http_timeout: int = 35,
84
+ max_retries: int = 3,
85
+ retry_delay: float = 1.0,
86
+ verify_ssl: bool = False,
87
+ ):
88
+ """
89
+ Initialize Centrifugo client.
90
+
91
+ Args:
92
+ wrapper_url: Python Wrapper HTTP API URL
93
+ wrapper_api_key: Optional API key for wrapper authentication
94
+ default_timeout: Default publish timeout (seconds)
95
+ ack_timeout: Default ACK timeout (seconds)
96
+ http_timeout: HTTP request timeout (seconds)
97
+ max_retries: Maximum retry attempts
98
+ retry_delay: Delay between retries (seconds)
99
+ verify_ssl: Whether to verify SSL certificates (default: False, allows self-signed certs)
100
+ """
101
+ self.wrapper_url = wrapper_url or self._get_wrapper_url_from_settings()
102
+ self.wrapper_api_key = wrapper_api_key
103
+ self.default_timeout = default_timeout
104
+ self.ack_timeout = ack_timeout
105
+ self.http_timeout = http_timeout
106
+ self.max_retries = max_retries
107
+ self.retry_delay = retry_delay
108
+ self.verify_ssl = verify_ssl
109
+
110
+ # Create HTTP client with connection pooling
111
+ headers = {"Content-Type": "application/json"}
112
+ if self.wrapper_api_key:
113
+ headers["X-API-Key"] = self.wrapper_api_key
114
+
115
+ self._http_client = httpx.AsyncClient(
116
+ base_url=self.wrapper_url,
117
+ headers=headers,
118
+ timeout=httpx.Timeout(self.http_timeout),
119
+ limits=httpx.Limits(max_keepalive_connections=20, max_connections=50),
120
+ verify=self.verify_ssl,
121
+ )
122
+
123
+ logger.info(f"Centrifugo client initialized: {self.wrapper_url}")
124
+
125
+ def _get_wrapper_url_from_settings(self) -> str:
126
+ """
127
+ Get Wrapper URL from django-cfg config.
128
+
129
+ Returns:
130
+ Wrapper URL string
131
+
132
+ Raises:
133
+ CentrifugoConfigurationError: If settings not configured
134
+ """
135
+ from ..config_helper import get_centrifugo_config
136
+
137
+ config = get_centrifugo_config()
138
+
139
+ if config and config.wrapper_url:
140
+ return config.wrapper_url
141
+
142
+ raise CentrifugoConfigurationError(
143
+ "Centrifugo config not found in django-cfg. "
144
+ "Configure DjangoCfgCentrifugoConfig in DjangoConfig.",
145
+ config_key="centrifugo.wrapper_url",
146
+ )
147
+
148
+ async def publish(
149
+ self,
150
+ channel: str,
151
+ data: BaseModel | dict,
152
+ user: Optional[Any] = None,
153
+ caller_ip: Optional[str] = None,
154
+ user_agent: Optional[str] = None,
155
+ ) -> PublishResponse:
156
+ """
157
+ Publish message to Centrifugo channel (fire-and-forget).
158
+
159
+ Does not wait for client acknowledgment.
160
+
161
+ Args:
162
+ channel: Centrifugo channel (e.g., "user#123", "broadcast")
163
+ data: Pydantic model or dict with message data
164
+ user: Django User instance for logging (optional)
165
+ caller_ip: IP address for logging (optional)
166
+ user_agent: User agent for logging (optional)
167
+
168
+ Returns:
169
+ PublishResponse with published=True if sent successfully
170
+
171
+ Raises:
172
+ CentrifugoPublishError: If publish fails
173
+ CentrifugoValidationError: If data validation fails
174
+
175
+ Example:
176
+ >>> result = await client.publish(
177
+ ... channel="broadcast",
178
+ ... data={"message": "Hello everyone"}
179
+ ... )
180
+ >>> print(result.published) # True
181
+ """
182
+ return await self._publish_internal(
183
+ channel=channel,
184
+ data=data,
185
+ wait_for_ack=False,
186
+ ack_timeout=0,
187
+ user=user,
188
+ caller_ip=caller_ip,
189
+ user_agent=user_agent,
190
+ )
191
+
192
+ async def publish_with_ack(
193
+ self,
194
+ channel: str,
195
+ data: BaseModel | dict,
196
+ ack_timeout: Optional[int] = None,
197
+ user: Optional[Any] = None,
198
+ caller_ip: Optional[str] = None,
199
+ user_agent: Optional[str] = None,
200
+ ) -> PublishResponse:
201
+ """
202
+ Publish message with ACK tracking (delivery confirmation).
203
+
204
+ Waits for client(s) to acknowledge receipt.
205
+
206
+ Args:
207
+ channel: Centrifugo channel (e.g., "user#123")
208
+ data: Pydantic model or dict with message data
209
+ ack_timeout: ACK timeout override (seconds)
210
+ user: Django User instance for logging (optional)
211
+ caller_ip: IP address for logging (optional)
212
+ user_agent: User agent for logging (optional)
213
+
214
+ Returns:
215
+ PublishResponse with delivered=True and acks_received count
216
+
217
+ Raises:
218
+ CentrifugoTimeoutError: If ACK timeout exceeded
219
+ CentrifugoPublishError: If publish fails
220
+ CentrifugoValidationError: If data validation fails
221
+
222
+ Example:
223
+ >>> result = await client.publish_with_ack(
224
+ ... channel="user#123",
225
+ ... data={"title": "Alert", "message": "Important"},
226
+ ... ack_timeout=10
227
+ ... )
228
+ >>> if result.delivered:
229
+ ... print(f"Delivered to {result.acks_received} clients")
230
+ """
231
+ timeout = ack_timeout or self.ack_timeout
232
+
233
+ return await self._publish_internal(
234
+ channel=channel,
235
+ data=data,
236
+ wait_for_ack=True,
237
+ ack_timeout=timeout,
238
+ user=user,
239
+ caller_ip=caller_ip,
240
+ user_agent=user_agent,
241
+ )
242
+
243
+ async def _publish_internal(
244
+ self,
245
+ channel: str,
246
+ data: BaseModel | dict,
247
+ wait_for_ack: bool,
248
+ ack_timeout: int,
249
+ user: Optional[Any] = None,
250
+ caller_ip: Optional[str] = None,
251
+ user_agent: Optional[str] = None,
252
+ ) -> PublishResponse:
253
+ """
254
+ Internal publish implementation with retry logic.
255
+
256
+ Args:
257
+ channel: Centrifugo channel
258
+ data: Message data
259
+ wait_for_ack: Whether to wait for ACK
260
+ ack_timeout: ACK timeout in seconds
261
+ user: Django User for logging
262
+ caller_ip: Caller IP for logging
263
+ user_agent: User agent for logging
264
+
265
+ Returns:
266
+ PublishResponse
267
+
268
+ Raises:
269
+ CentrifugoPublishError: If all retries fail
270
+ CentrifugoTimeoutError: If ACK timeout
271
+ """
272
+ message_id = str(uuid4())
273
+ start_time = time.time()
274
+
275
+ # Serialize data
276
+ if isinstance(data, BaseModel):
277
+ try:
278
+ data_dict = data.model_dump()
279
+ except Exception as e:
280
+ raise CentrifugoValidationError(
281
+ f"Failed to serialize Pydantic model: {e}",
282
+ validation_errors=[str(e)],
283
+ )
284
+ elif isinstance(data, dict):
285
+ data_dict = data
286
+ else:
287
+ raise CentrifugoValidationError(
288
+ f"data must be BaseModel or dict, got {type(data).__name__}"
289
+ )
290
+
291
+ # Create log entry (async)
292
+ log_entry = None
293
+ try:
294
+ from ..logging import CentrifugoLogger
295
+
296
+ log_entry = await CentrifugoLogger.create_log_async(
297
+ message_id=message_id,
298
+ channel=channel,
299
+ data=data_dict,
300
+ wait_for_ack=wait_for_ack,
301
+ ack_timeout=ack_timeout if wait_for_ack else None,
302
+ user=user,
303
+ caller_ip=caller_ip,
304
+ user_agent=user_agent,
305
+ )
306
+ except Exception as e:
307
+ logger.warning(f"Failed to create log entry: {e}", exc_info=True)
308
+
309
+ # Prepare request payload
310
+ payload = {
311
+ "channel": channel,
312
+ "data": data_dict,
313
+ "wait_for_ack": wait_for_ack,
314
+ "ack_timeout": ack_timeout if wait_for_ack else 0,
315
+ }
316
+
317
+ # Retry logic
318
+ last_error = None
319
+ for attempt in range(self.max_retries):
320
+ try:
321
+ response = await self._http_client.post("/api/publish", json=payload)
322
+
323
+ if response.status_code == 200:
324
+ result_data = response.json()
325
+ duration_ms = int((time.time() - start_time) * 1000)
326
+
327
+ # Update log entry (async)
328
+ if log_entry:
329
+ try:
330
+ from ..logging import CentrifugoLogger
331
+
332
+ if result_data.get("delivered", False):
333
+ await CentrifugoLogger.mark_success_async(
334
+ log_entry,
335
+ acks_received=result_data.get("acks_received", 0),
336
+ duration_ms=duration_ms,
337
+ )
338
+ elif wait_for_ack:
339
+ await CentrifugoLogger.mark_timeout_async(
340
+ log_entry,
341
+ acks_received=result_data.get("acks_received", 0),
342
+ duration_ms=duration_ms,
343
+ )
344
+ else:
345
+ await CentrifugoLogger.mark_success_async(
346
+ log_entry, acks_received=0, duration_ms=duration_ms
347
+ )
348
+ except Exception as e:
349
+ logger.warning(f"Failed to update log entry: {e}", exc_info=True)
350
+
351
+ return PublishResponse(
352
+ message_id=result_data.get("message_id", message_id),
353
+ published=result_data.get("published", True),
354
+ delivered=result_data.get("delivered", False),
355
+ acks_received=result_data.get("acks_received", 0),
356
+ timeout=not result_data.get("delivered", False) and wait_for_ack,
357
+ )
358
+
359
+ else:
360
+ last_error = CentrifugoPublishError(
361
+ f"Wrapper returned HTTP {response.status_code}",
362
+ channel=channel,
363
+ status_code=response.status_code,
364
+ response_data=response.json() if response.text else None,
365
+ )
366
+
367
+ except httpx.TimeoutException as e:
368
+ last_error = CentrifugoTimeoutError(
369
+ f"HTTP timeout to wrapper: {e}",
370
+ channel=channel,
371
+ timeout_seconds=self.http_timeout,
372
+ )
373
+
374
+ except httpx.ConnectError as e:
375
+ last_error = CentrifugoConnectionError(
376
+ f"Failed to connect to wrapper: {e}",
377
+ wrapper_url=self.wrapper_url,
378
+ )
379
+
380
+ except Exception as e:
381
+ last_error = CentrifugoPublishError(
382
+ f"Unexpected error: {e}",
383
+ channel=channel,
384
+ )
385
+
386
+ # Retry delay
387
+ if attempt < self.max_retries - 1:
388
+ await asyncio.sleep(self.retry_delay)
389
+
390
+ # All retries failed
391
+ duration_ms = int((time.time() - start_time) * 1000)
392
+
393
+ if log_entry:
394
+ try:
395
+ from ..logging import CentrifugoLogger
396
+
397
+ error_code = type(last_error).__name__ if last_error else "unknown"
398
+ error_message = str(last_error) if last_error else "Unknown error"
399
+ CentrifugoLogger.mark_failed(
400
+ log_entry,
401
+ error_code=error_code,
402
+ error_message=error_message,
403
+ duration_ms=duration_ms,
404
+ )
405
+ except Exception as e:
406
+ logger.warning(f"Failed to update log entry: {e}")
407
+
408
+ raise last_error if last_error else CentrifugoPublishError(
409
+ "All retries failed", channel=channel
410
+ )
411
+
412
+ async def fire_and_forget(
413
+ self,
414
+ channel: str,
415
+ data: BaseModel | dict,
416
+ ) -> str:
417
+ """
418
+ Send message without waiting for response (alias for publish).
419
+
420
+ Args:
421
+ channel: Centrifugo channel
422
+ data: Message data
423
+
424
+ Returns:
425
+ Message ID
426
+
427
+ Example:
428
+ >>> message_id = await client.fire_and_forget(
429
+ ... channel="logs",
430
+ ... data={"event": "user_login", "user_id": "123"}
431
+ ... )
432
+ """
433
+ result = await self.publish(channel=channel, data=data)
434
+ return result.message_id
435
+
436
+ async def health_check(self, timeout: int = 5) -> bool:
437
+ """
438
+ Check if wrapper is healthy.
439
+
440
+ Args:
441
+ timeout: Health check timeout (seconds)
442
+
443
+ Returns:
444
+ True if healthy, False otherwise
445
+
446
+ Example:
447
+ >>> if await client.health_check():
448
+ ... print("Wrapper healthy")
449
+ """
450
+ try:
451
+ response = await self._http_client.get(
452
+ "/health",
453
+ timeout=httpx.Timeout(timeout),
454
+ )
455
+
456
+ if response.status_code == 200:
457
+ health_data = response.json()
458
+ return health_data.get("status") == "healthy"
459
+
460
+ return False
461
+
462
+ except Exception as e:
463
+ logger.error(f"Health check failed: {e}")
464
+ return False
465
+
466
+ def get_connection_info(self) -> dict:
467
+ """
468
+ Get connection information.
469
+
470
+ Returns:
471
+ Dictionary with connection details
472
+
473
+ Example:
474
+ >>> info = client.get_connection_info()
475
+ >>> print(info["wrapper_url"])
476
+ """
477
+ return {
478
+ "wrapper_url": self.wrapper_url,
479
+ "has_api_key": self.wrapper_api_key is not None,
480
+ "default_timeout": self.default_timeout,
481
+ "ack_timeout": self.ack_timeout,
482
+ "http_timeout": self.http_timeout,
483
+ "max_retries": self.max_retries,
484
+ "retry_delay": self.retry_delay,
485
+ }
486
+
487
+ async def close(self):
488
+ """
489
+ Close HTTP client connections.
490
+
491
+ Call this when shutting down application to clean up resources.
492
+
493
+ Example:
494
+ >>> await client.close()
495
+ """
496
+ await self._http_client.aclose()
497
+ logger.info("Centrifugo client closed")
498
+
499
+ async def __aenter__(self):
500
+ """Async context manager entry."""
501
+ return self
502
+
503
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
504
+ """Async context manager exit."""
505
+ await self.close()
506
+
507
+
508
+ # ==================== Singleton Pattern ====================
509
+
510
+ _centrifugo_client: Optional[CentrifugoClient] = None
511
+ _centrifugo_client_lock = None
512
+
513
+
514
+ def get_centrifugo_client(force_new: bool = False) -> CentrifugoClient:
515
+ """
516
+ Get global Centrifugo client instance (singleton).
517
+
518
+ Creates client from Django settings on first call.
519
+ Subsequent calls return the same instance (thread-safe).
520
+
521
+ Args:
522
+ force_new: Force create new instance (for testing)
523
+
524
+ Returns:
525
+ CentrifugoClient instance
526
+
527
+ Example:
528
+ >>> from django_cfg.apps.centrifugo import get_centrifugo_client
529
+ >>> client = get_centrifugo_client()
530
+ >>> result = await client.publish_with_ack(...)
531
+ """
532
+ global _centrifugo_client, _centrifugo_client_lock
533
+
534
+ if force_new:
535
+ return _create_client_from_settings()
536
+
537
+ if _centrifugo_client is None:
538
+ # Thread-safe singleton creation
539
+ import threading
540
+
541
+ if _centrifugo_client_lock is None:
542
+ _centrifugo_client_lock = threading.Lock()
543
+
544
+ with _centrifugo_client_lock:
545
+ if _centrifugo_client is None:
546
+ _centrifugo_client = _create_client_from_settings()
547
+
548
+ return _centrifugo_client
549
+
550
+
551
+ def _create_client_from_settings() -> CentrifugoClient:
552
+ """
553
+ Create Centrifugo client from django-cfg config.
554
+
555
+ Returns:
556
+ CentrifugoClient instance
557
+
558
+ Raises:
559
+ CentrifugoConfigurationError: If settings not configured
560
+ """
561
+ from ..config_helper import get_centrifugo_config_or_default
562
+
563
+ cfg = get_centrifugo_config_or_default()
564
+ logger.debug(f"Creating Centrifugo client from config: {cfg.wrapper_url}")
565
+
566
+ return CentrifugoClient(
567
+ wrapper_url=cfg.wrapper_url,
568
+ wrapper_api_key=cfg.wrapper_api_key,
569
+ default_timeout=cfg.default_timeout,
570
+ ack_timeout=cfg.ack_timeout,
571
+ http_timeout=cfg.http_timeout,
572
+ max_retries=cfg.max_retries,
573
+ retry_delay=cfg.retry_delay,
574
+ verify_ssl=cfg.verify_ssl,
575
+ )
576
+
577
+
578
+ __all__ = [
579
+ "CentrifugoClient",
580
+ "get_centrifugo_client",
581
+ "PublishResponse",
582
+ ]