django-cfg 1.5.14__py3-none-any.whl → 1.5.20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-cfg might be problematic. Click here for more details.

Files changed (53) 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/apps.py +2 -1
  5. django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
  6. django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
  7. django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
  8. django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
  9. django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
  10. django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
  11. django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
  12. django_cfg/apps/integrations/centrifugo/urls.py +8 -0
  13. django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
  14. django_cfg/apps/integrations/centrifugo/views/testing_api.py +0 -79
  15. django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
  16. django_cfg/apps/integrations/centrifugo/views/wrapper.py +257 -0
  17. django_cfg/apps/integrations/grpc/centrifugo/__init__.py +29 -0
  18. django_cfg/apps/integrations/grpc/centrifugo/bridge.py +277 -0
  19. django_cfg/apps/integrations/grpc/centrifugo/config.py +167 -0
  20. django_cfg/apps/integrations/grpc/centrifugo/demo.py +626 -0
  21. django_cfg/apps/integrations/grpc/centrifugo/test_publish.py +229 -0
  22. django_cfg/apps/integrations/grpc/centrifugo/transformers.py +89 -0
  23. django_cfg/apps/integrations/grpc/interceptors/__init__.py +3 -1
  24. django_cfg/apps/integrations/grpc/interceptors/centrifugo.py +541 -0
  25. django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
  26. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +55 -0
  27. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +311 -7
  28. django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
  29. django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
  30. django_cfg/apps/integrations/grpc/services/discovery.py +7 -1
  31. django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
  32. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +206 -5
  33. django_cfg/apps/system/dashboard/serializers/config.py +95 -9
  34. django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
  35. django_cfg/apps/system/frontend/views.py +87 -6
  36. django_cfg/core/builders/security_builder.py +1 -0
  37. django_cfg/core/generation/integration_generators/api.py +2 -0
  38. django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
  39. django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
  40. django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
  41. django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
  42. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
  43. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
  44. django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
  45. django_cfg/modules/django_client/core/ir/schema.py +15 -1
  46. django_cfg/modules/django_client/core/parser/base.py +12 -0
  47. django_cfg/pyproject.toml +1 -1
  48. django_cfg/static/frontend/admin.zip +0 -0
  49. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/METADATA +1 -1
  50. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/RECORD +53 -37
  51. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/WHEEL +0 -0
  52. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/entry_points.txt +0 -0
  53. {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/licenses/LICENSE +0 -0
django_cfg/__init__.py CHANGED
@@ -32,7 +32,7 @@ Example:
32
32
  default_app_config = "django_cfg.apps.DjangoCfgConfig"
33
33
 
34
34
  # Version information
35
- __version__ = "1.5.14"
35
+ __version__ = "1.5.20"
36
36
  __license__ = "MIT"
37
37
 
38
38
  # Setup warnings debug early (checks env var only at this point)
@@ -4,6 +4,24 @@ from rest_framework import serializers
4
4
  from ..models import CustomUser, RegistrationSource, UserRegistrationSource
5
5
 
6
6
 
7
+ class CentrifugoTokenSerializer(serializers.Serializer):
8
+ """Nested serializer for Centrifugo WebSocket connection token."""
9
+
10
+ token = serializers.CharField(
11
+ help_text="JWT token for Centrifugo WebSocket connection"
12
+ )
13
+ centrifugo_url = serializers.URLField(
14
+ help_text="Centrifugo WebSocket URL"
15
+ )
16
+ expires_at = serializers.DateTimeField(
17
+ help_text="Token expiration time (ISO 8601)"
18
+ )
19
+ channels = serializers.ListField(
20
+ child=serializers.CharField(),
21
+ help_text="List of allowed channels for this user"
22
+ )
23
+
24
+
7
25
  class UserSerializer(serializers.ModelSerializer):
8
26
  """Serializer for user details."""
9
27
 
@@ -11,6 +29,7 @@ class UserSerializer(serializers.ModelSerializer):
11
29
  initials = serializers.ReadOnlyField()
12
30
  display_username = serializers.ReadOnlyField()
13
31
  avatar = serializers.SerializerMethodField()
32
+ centrifugo = serializers.SerializerMethodField()
14
33
 
15
34
  class Meta:
16
35
  model = CustomUser
@@ -31,6 +50,7 @@ class UserSerializer(serializers.ModelSerializer):
31
50
  "date_joined",
32
51
  "last_login",
33
52
  "unanswered_messages_count",
53
+ "centrifugo",
34
54
  ]
35
55
  read_only_fields = [
36
56
  "id",
@@ -52,6 +72,28 @@ class UserSerializer(serializers.ModelSerializer):
52
72
  return obj.avatar.url
53
73
  return None
54
74
 
75
+ @extend_schema_field(CentrifugoTokenSerializer(allow_null=True))
76
+ def get_centrifugo(self, obj):
77
+ """
78
+ Generate Centrifugo WebSocket connection token if enabled.
79
+
80
+ Returns None if Centrifugo is disabled in config.
81
+ """
82
+ try:
83
+ # Import here to avoid circular imports
84
+ from django_cfg.apps.integrations.centrifugo.services import generate_centrifugo_token
85
+
86
+ # Generate token with user's channels
87
+ token_data = generate_centrifugo_token(obj)
88
+ return token_data
89
+
90
+ except ValueError:
91
+ # Centrifugo not configured or disabled
92
+ return None
93
+ except Exception:
94
+ # If token generation fails, return None (don't break profile response)
95
+ return None
96
+
55
97
 
56
98
 
57
99
  class UserProfileUpdateSerializer(serializers.ModelSerializer):
@@ -8,13 +8,14 @@ from .models import Message, Ticket
8
8
  User = get_user_model()
9
9
 
10
10
  class SenderSerializer(serializers.ModelSerializer):
11
- avatar = serializers.SerializerMethodField()
11
+ avatar = serializers.SerializerMethodField(allow_null=True)
12
12
  initials = serializers.ReadOnlyField()
13
13
 
14
14
  class Meta:
15
15
  model = User
16
16
  fields = ['id', 'display_username', 'email', 'avatar', 'initials', 'is_staff', 'is_superuser']
17
- read_only_fields = fields
17
+ # Don't include avatar in read_only_fields to make it optional in OpenAPI schema
18
+ read_only_fields = ['id', 'display_username', 'email', 'initials', 'is_staff', 'is_superuser']
18
19
 
19
20
  def get_avatar(self, obj) -> Optional[str]:
20
21
  if obj.avatar:
@@ -32,6 +32,7 @@ class CentrifugoConfig(AppConfig):
32
32
  Initialize app when Django starts.
33
33
 
34
34
  Validates that all required Centrifugo dependencies are installed.
35
+ Registers signal handlers for JWT token customization.
35
36
  """
36
37
  from django_cfg.modules.django_logging import get_logger
37
38
 
@@ -40,7 +41,7 @@ class CentrifugoConfig(AppConfig):
40
41
  # Check dependencies if needed (only when using Centrifugo features)
41
42
  self._check_dependencies_if_needed()
42
43
 
43
- logger.info("Centrifugo app initialized")
44
+ logger.info("Centrifugo app initialized (middleware will inject JWT tokens)")
44
45
 
45
46
  def _check_dependencies_if_needed(self):
46
47
  """
@@ -10,6 +10,7 @@ import { Centrifuge } from 'centrifuge';
10
10
  export class CentrifugoRPCClient {
11
11
  private centrifuge: Centrifuge;
12
12
  private subscription: any;
13
+ private channelSubscriptions: Map<string, any> = new Map();
13
14
  private pendingRequests: Map<string, { resolve: Function; reject: Function }> = new Map();
14
15
  private readonly replyChannel: string;
15
16
  private readonly timeout: number;
@@ -27,12 +28,7 @@ export class CentrifugoRPCClient {
27
28
  token,
28
29
  });
29
30
 
30
- this.centrifuge.on('connected', (ctx) => {
31
- console.log('✅ Connected to Centrifugo');
32
- });
33
-
34
31
  this.centrifuge.on('disconnected', (ctx) => {
35
- console.warn('Disconnected:', ctx);
36
32
  // Reject all pending requests
37
33
  this.pendingRequests.forEach(({ reject }) => {
38
34
  reject(new Error('Disconnected from Centrifugo'));
@@ -43,6 +39,27 @@ export class CentrifugoRPCClient {
43
39
 
44
40
  async connect(): Promise<void> {
45
41
  return new Promise((resolve, reject) => {
42
+ let resolved = false;
43
+
44
+ // Listen to Centrifuge connection events
45
+ const onConnected = () => {
46
+ if (!resolved) {
47
+ resolved = true;
48
+ resolve();
49
+ }
50
+ };
51
+
52
+ const onError = (ctx: any) => {
53
+ if (!resolved) {
54
+ resolved = true;
55
+ reject(new Error(ctx.message || 'Connection error'));
56
+ }
57
+ };
58
+
59
+ this.centrifuge.on('connected', onConnected);
60
+ this.centrifuge.on('error', onError);
61
+
62
+ // Start connection
46
63
  this.centrifuge.connect();
47
64
 
48
65
  // Subscribe to reply channel
@@ -53,11 +70,17 @@ export class CentrifugoRPCClient {
53
70
  });
54
71
 
55
72
  this.subscription.on('subscribed', () => {
56
- resolve();
73
+ // Subscription successful (optional, we already resolved on 'connected')
57
74
  });
58
75
 
59
76
  this.subscription.on('error', (ctx: any) => {
60
- reject(new Error(ctx.error?.message || 'Subscription error'));
77
+ // Error code 105 = "already subscribed" (server-side subscription from JWT)
78
+ // This is not an error - the channel is already active via server-side subscription
79
+ if (ctx.error?.code === 105) {
80
+ // This is fine, server-side subscription exists
81
+ } else {
82
+ console.error(`Subscription error for ${this.replyChannel}:`, ctx.error);
83
+ }
61
84
  });
62
85
 
63
86
  this.subscription.subscribe();
@@ -65,9 +88,14 @@ export class CentrifugoRPCClient {
65
88
  }
66
89
 
67
90
  async disconnect(): Promise<void> {
91
+ // Unsubscribe from all event channels
92
+ this.unsubscribeAll();
93
+
94
+ // Unsubscribe from RPC reply channel
68
95
  if (this.subscription) {
69
96
  this.subscription.unsubscribe();
70
97
  }
98
+
71
99
  this.centrifuge.disconnect();
72
100
  }
73
101
 
@@ -103,21 +131,17 @@ export class CentrifugoRPCClient {
103
131
  // Publish request
104
132
  await this.centrifuge.publish('rpc.requests', message);
105
133
 
106
- console.log(`📤 RPC call: ${method} (${correlationId})`);
107
-
108
134
  return promise;
109
135
  }
110
136
 
111
137
  private handleResponse(data: any): void {
112
138
  const correlationId = data.correlation_id;
113
139
  if (!correlationId) {
114
- console.warn('Received response without correlation_id');
115
140
  return;
116
141
  }
117
142
 
118
143
  const pending = this.pendingRequests.get(correlationId);
119
144
  if (!pending) {
120
- console.warn(`Received response for unknown correlation_id: ${correlationId}`);
121
145
  return;
122
146
  }
123
147
 
@@ -126,7 +150,6 @@ export class CentrifugoRPCClient {
126
150
  if (data.error) {
127
151
  pending.reject(new Error(data.error.message || 'RPC error'));
128
152
  } else {
129
- console.log(`📥 RPC response: ${correlationId}`);
130
153
  pending.resolve(data.result);
131
154
  }
132
155
  }
@@ -134,4 +157,120 @@ export class CentrifugoRPCClient {
134
157
  private generateCorrelationId(): string {
135
158
  return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
136
159
  }
160
+
161
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
162
+ // Channel Subscription API (for gRPC events)
163
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
164
+
165
+ /**
166
+ * Subscribe to a Centrifugo channel for real-time events.
167
+ *
168
+ * @param channel - Channel name (e.g., 'bot#bot-123#heartbeat')
169
+ * @param callback - Callback for received messages
170
+ * @returns Unsubscribe function
171
+ *
172
+ * @example
173
+ * const unsubscribe = client.subscribe('bot#bot-123#heartbeat', (data) => {
174
+ * console.log('Heartbeat:', data);
175
+ * });
176
+ *
177
+ * // Later: unsubscribe when done
178
+ * unsubscribe();
179
+ */
180
+ subscribe(channel: string, callback: (data: any) => void): () => void {
181
+ // Check if already subscribed
182
+ if (this.channelSubscriptions.has(channel)) {
183
+ return () => {}; // Return no-op unsubscribe
184
+ }
185
+
186
+ // Create new subscription
187
+ const sub = this.centrifuge.newSubscription(channel);
188
+
189
+ // Handle publications
190
+ sub.on('publication', (ctx: any) => {
191
+ callback(ctx.data);
192
+ });
193
+
194
+ // Handle subscription lifecycle
195
+ sub.on('subscribed', () => {
196
+ // Subscription successful
197
+ });
198
+
199
+ sub.on('error', (ctx: any) => {
200
+ console.error(`Subscription error for ${channel}:`, ctx.error);
201
+ });
202
+
203
+ // Start subscription
204
+ sub.subscribe();
205
+
206
+ // Store subscription
207
+ this.channelSubscriptions.set(channel, sub);
208
+
209
+ // Return unsubscribe function
210
+ return () => this.unsubscribe(channel);
211
+ }
212
+
213
+ /**
214
+ * Unsubscribe from a channel.
215
+ *
216
+ * @param channel - Channel name
217
+ */
218
+ unsubscribe(channel: string): void {
219
+ const sub = this.channelSubscriptions.get(channel);
220
+ if (!sub) {
221
+ return;
222
+ }
223
+
224
+ sub.unsubscribe();
225
+ this.channelSubscriptions.delete(channel);
226
+ }
227
+
228
+ /**
229
+ * Unsubscribe from all channels.
230
+ */
231
+ unsubscribeAll(): void {
232
+ if (this.channelSubscriptions.size === 0) {
233
+ return;
234
+ }
235
+
236
+ this.channelSubscriptions.forEach((sub, channel) => {
237
+ sub.unsubscribe();
238
+ });
239
+ this.channelSubscriptions.clear();
240
+ }
241
+
242
+ /**
243
+ * Get list of active client-side subscriptions.
244
+ */
245
+ getActiveSubscriptions(): string[] {
246
+ return Array.from(this.channelSubscriptions.keys());
247
+ }
248
+
249
+ /**
250
+ * Get list of server-side subscriptions (from JWT token).
251
+ *
252
+ * These are channels automatically subscribed by Centrifugo server
253
+ * based on the 'channels' claim in the JWT token.
254
+ */
255
+ getServerSideSubscriptions(): string[] {
256
+ try {
257
+ // Access Centrifuge.js internal state for server-side subs
258
+ // @ts-ignore - accessing internal property
259
+ const serverSubs = this.centrifuge._serverSubs || {};
260
+ return Object.keys(serverSubs);
261
+ } catch (error) {
262
+ return [];
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Get all active subscriptions (both client-side and server-side).
268
+ */
269
+ getAllSubscriptions(): string[] {
270
+ const clientSubs = this.getActiveSubscriptions();
271
+ const serverSubs = this.getServerSideSubscriptions();
272
+
273
+ // Combine and deduplicate
274
+ return Array.from(new Set([...clientSubs, ...serverSubs]));
275
+ }
137
276
  }
@@ -6,6 +6,7 @@ Usage:
6
6
  python manage.py generate_centrifugo_clients -o ./clients --python --verbose
7
7
  """
8
8
 
9
+ import logging
9
10
  from pathlib import Path
10
11
  from typing import List
11
12
 
@@ -131,11 +132,10 @@ class Command(AdminCommand):
131
132
  if not methods:
132
133
  self.stdout.write(
133
134
  colorize(
134
- "No RPC methods found. Did you register handlers with @websocket_rpc?",
135
+ "⚠️ No RPC methods found. Will generate base RPC client without API methods.",
135
136
  fg="yellow",
136
137
  )
137
138
  )
138
- return
139
139
 
140
140
  # Create output directory
141
141
  output_dir.mkdir(parents=True, exist_ok=True)
@@ -5,8 +5,14 @@ Business logic layer for Centrifugo integration.
5
5
  """
6
6
 
7
7
  from .config_helper import get_centrifugo_config, get_centrifugo_config_or_default
8
+ from .publisher import CentrifugoPublisher, get_centrifugo_publisher
9
+ from .token_generator import get_user_channels, generate_centrifugo_token
8
10
 
9
11
  __all__ = [
10
12
  "get_centrifugo_config",
11
13
  "get_centrifugo_config_or_default",
14
+ "CentrifugoPublisher",
15
+ "get_centrifugo_publisher",
16
+ "get_user_channels",
17
+ "generate_centrifugo_token",
12
18
  ]
@@ -1,11 +1,14 @@
1
1
  """
2
2
  Centrifugo Client.
3
3
 
4
- Django client for publishing messages to Centrifugo via Python Wrapper.
4
+ Two client implementations:
5
+ - CentrifugoClient: Via wrapper (for external API, with auth & logging)
6
+ - DirectCentrifugoClient: Direct to Centrifugo (for internal use, lightweight)
5
7
  """
6
8
 
7
9
  from .client import CentrifugoClient, PublishResponse, get_centrifugo_client
8
10
  from .config import DjangoCfgCentrifugoConfig
11
+ from .direct_client import DirectCentrifugoClient, get_direct_centrifugo_client
9
12
  from .exceptions import (
10
13
  CentrifugoBaseException,
11
14
  CentrifugoConfigurationError,
@@ -19,6 +22,8 @@ __all__ = [
19
22
  "DjangoCfgCentrifugoConfig",
20
23
  "CentrifugoClient",
21
24
  "get_centrifugo_client",
25
+ "DirectCentrifugoClient",
26
+ "get_direct_centrifugo_client",
22
27
  "PublishResponse",
23
28
  "CentrifugoBaseException",
24
29
  "CentrifugoTimeoutError",
@@ -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
+ ]