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