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.
- django_cfg/__init__.py +1 -1
- django_cfg/apps/business/accounts/serializers/profile.py +42 -0
- django_cfg/apps/business/support/serializers.py +3 -2
- django_cfg/apps/integrations/centrifugo/apps.py +2 -1
- django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
- django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
- django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
- django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
- django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
- django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
- django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
- django_cfg/apps/integrations/centrifugo/urls.py +8 -0
- django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
- django_cfg/apps/integrations/centrifugo/views/testing_api.py +0 -79
- django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
- django_cfg/apps/integrations/centrifugo/views/wrapper.py +257 -0
- django_cfg/apps/integrations/grpc/centrifugo/__init__.py +29 -0
- django_cfg/apps/integrations/grpc/centrifugo/bridge.py +277 -0
- django_cfg/apps/integrations/grpc/centrifugo/config.py +167 -0
- django_cfg/apps/integrations/grpc/centrifugo/demo.py +626 -0
- django_cfg/apps/integrations/grpc/centrifugo/test_publish.py +229 -0
- django_cfg/apps/integrations/grpc/centrifugo/transformers.py +89 -0
- django_cfg/apps/integrations/grpc/interceptors/__init__.py +3 -1
- django_cfg/apps/integrations/grpc/interceptors/centrifugo.py +541 -0
- django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
- django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +55 -0
- django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +311 -7
- django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
- django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
- django_cfg/apps/integrations/grpc/services/discovery.py +7 -1
- django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
- django_cfg/apps/integrations/grpc/utils/streaming_logger.py +206 -5
- django_cfg/apps/system/dashboard/serializers/config.py +95 -9
- django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
- django_cfg/apps/system/frontend/views.py +87 -6
- django_cfg/core/builders/security_builder.py +1 -0
- django_cfg/core/generation/integration_generators/api.py +2 -0
- django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
- django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
- django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
- django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
- django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
- django_cfg/modules/django_client/core/ir/schema.py +15 -1
- django_cfg/modules/django_client/core/parser/base.py +12 -0
- django_cfg/pyproject.toml +1 -1
- django_cfg/static/frontend/admin.zip +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/METADATA +1 -1
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/RECORD +53 -37
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.20.dist-info}/licenses/LICENSE +0 -0
django_cfg/__init__.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
73
|
+
// Subscription successful (optional, we already resolved on 'connected')
|
|
57
74
|
});
|
|
58
75
|
|
|
59
76
|
this.subscription.on('error', (ctx: any) => {
|
|
60
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
]
|