django-cfg 1.5.8__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/api/commands/serializers.py +152 -0
- django_cfg/apps/api/commands/views.py +32 -0
- django_cfg/apps/business/accounts/management/commands/otp_test.py +5 -2
- django_cfg/apps/business/accounts/serializers/profile.py +42 -0
- django_cfg/apps/business/agents/management/commands/create_agent.py +5 -194
- django_cfg/apps/business/agents/management/commands/load_agent_templates.py +205 -0
- django_cfg/apps/business/agents/management/commands/orchestrator_status.py +4 -2
- django_cfg/apps/business/knowbase/management/commands/knowbase_stats.py +4 -2
- django_cfg/apps/business/knowbase/management/commands/setup_knowbase.py +4 -2
- django_cfg/apps/business/newsletter/management/commands/test_newsletter.py +5 -2
- django_cfg/apps/business/payments/management/commands/check_payment_status.py +4 -2
- django_cfg/apps/business/payments/management/commands/create_payment.py +4 -2
- django_cfg/apps/business/payments/management/commands/sync_currencies.py +4 -2
- 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 +6 -6
- django_cfg/apps/integrations/centrifugo/serializers/__init__.py +2 -1
- django_cfg/apps/integrations/centrifugo/serializers/publishes.py +22 -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/monitoring.py +25 -40
- 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/admin/__init__.py +7 -1
- django_cfg/apps/integrations/grpc/admin/config.py +113 -9
- django_cfg/apps/integrations/grpc/admin/grpc_api_key.py +129 -0
- django_cfg/apps/integrations/grpc/admin/grpc_request_log.py +72 -63
- django_cfg/apps/integrations/grpc/admin/grpc_server_status.py +236 -0
- django_cfg/apps/integrations/grpc/auth/__init__.py +11 -3
- django_cfg/apps/integrations/grpc/auth/api_key_auth.py +320 -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/interceptors/logging.py +17 -20
- django_cfg/apps/integrations/grpc/interceptors/metrics.py +15 -14
- django_cfg/apps/integrations/grpc/interceptors/request_logger.py +79 -59
- django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
- django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +185 -0
- django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +474 -95
- django_cfg/apps/integrations/grpc/management/commands/test_grpc_integration.py +75 -0
- 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/managers/__init__.py +2 -0
- django_cfg/apps/integrations/grpc/managers/grpc_api_key.py +192 -0
- django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +19 -11
- django_cfg/apps/integrations/grpc/migrations/0005_grpcapikey.py +143 -0
- django_cfg/apps/integrations/grpc/migrations/0006_grpcrequestlog_api_key_and_more.py +34 -0
- django_cfg/apps/integrations/grpc/models/__init__.py +2 -0
- django_cfg/apps/integrations/grpc/models/grpc_api_key.py +198 -0
- django_cfg/apps/integrations/grpc/models/grpc_request_log.py +11 -0
- django_cfg/apps/integrations/grpc/models/grpc_server_status.py +39 -4
- django_cfg/apps/integrations/grpc/serializers/__init__.py +22 -6
- django_cfg/apps/integrations/grpc/serializers/api_keys.py +63 -0
- django_cfg/apps/integrations/grpc/serializers/charts.py +118 -120
- django_cfg/apps/integrations/grpc/serializers/config.py +65 -51
- django_cfg/apps/integrations/grpc/serializers/health.py +7 -7
- django_cfg/apps/integrations/grpc/serializers/proto_files.py +74 -0
- django_cfg/apps/integrations/grpc/serializers/requests.py +13 -7
- django_cfg/apps/integrations/grpc/serializers/service_registry.py +181 -112
- django_cfg/apps/integrations/grpc/serializers/services.py +14 -32
- django_cfg/apps/integrations/grpc/serializers/stats.py +50 -12
- django_cfg/apps/integrations/grpc/serializers/testing.py +66 -58
- django_cfg/apps/integrations/grpc/services/__init__.py +2 -0
- django_cfg/apps/integrations/grpc/services/discovery.py +7 -1
- django_cfg/apps/integrations/grpc/services/monitoring_service.py +149 -43
- django_cfg/apps/integrations/grpc/services/proto_files_manager.py +268 -0
- django_cfg/apps/integrations/grpc/services/service_registry.py +48 -46
- django_cfg/apps/integrations/grpc/services/testing_service.py +10 -15
- django_cfg/apps/integrations/grpc/urls.py +8 -0
- django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
- django_cfg/apps/integrations/grpc/utils/__init__.py +4 -13
- django_cfg/apps/integrations/grpc/utils/integration_test.py +334 -0
- django_cfg/apps/integrations/grpc/utils/proto_gen.py +48 -8
- django_cfg/apps/integrations/grpc/utils/streaming_logger.py +378 -0
- django_cfg/apps/integrations/grpc/views/__init__.py +4 -0
- django_cfg/apps/integrations/grpc/views/api_keys.py +255 -0
- django_cfg/apps/integrations/grpc/views/charts.py +21 -14
- django_cfg/apps/integrations/grpc/views/config.py +8 -6
- django_cfg/apps/integrations/grpc/views/monitoring.py +51 -79
- django_cfg/apps/integrations/grpc/views/proto_files.py +214 -0
- django_cfg/apps/integrations/grpc/views/services.py +30 -21
- django_cfg/apps/integrations/grpc/views/testing.py +45 -43
- django_cfg/apps/integrations/rq/views/jobs.py +19 -9
- django_cfg/apps/integrations/rq/views/schedule.py +7 -3
- django_cfg/apps/system/dashboard/serializers/commands.py +25 -1
- django_cfg/apps/system/dashboard/serializers/config.py +95 -9
- django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
- django_cfg/apps/system/dashboard/services/commands_service.py +12 -1
- django_cfg/apps/system/frontend/views.py +87 -6
- django_cfg/apps/system/maintenance/management/commands/maintenance.py +5 -2
- django_cfg/apps/system/maintenance/management/commands/process_scheduled_maintenance.py +4 -2
- django_cfg/apps/system/maintenance/management/commands/sync_cloudflare.py +5 -2
- django_cfg/config.py +33 -0
- django_cfg/core/builders/security_builder.py +1 -0
- django_cfg/core/generation/integration_generators/api.py +2 -0
- django_cfg/core/generation/integration_generators/grpc_generator.py +30 -32
- django_cfg/management/commands/check_endpoints.py +2 -2
- django_cfg/management/commands/check_settings.py +3 -10
- django_cfg/management/commands/clear_constance.py +3 -10
- django_cfg/management/commands/create_token.py +4 -11
- django_cfg/management/commands/list_urls.py +4 -10
- django_cfg/management/commands/migrate_all.py +18 -12
- django_cfg/management/commands/migrator.py +4 -11
- django_cfg/management/commands/script.py +4 -10
- django_cfg/management/commands/show_config.py +8 -16
- django_cfg/management/commands/show_urls.py +5 -11
- django_cfg/management/commands/superuser.py +4 -11
- django_cfg/management/commands/tree.py +5 -10
- django_cfg/management/utils/README.md +402 -0
- django_cfg/management/utils/__init__.py +29 -0
- django_cfg/management/utils/mixins.py +176 -0
- django_cfg/middleware/pagination.py +53 -54
- django_cfg/models/api/grpc/__init__.py +15 -21
- django_cfg/models/api/grpc/config.py +155 -73
- django_cfg/models/ngrok/config.py +7 -6
- django_cfg/modules/django_client/core/generator/python/files_generator.py +5 -13
- django_cfg/modules/django_client/core/generator/python/templates/api_wrapper.py.jinja +16 -4
- django_cfg/modules/django_client/core/generator/python/templates/main_init.py.jinja +2 -3
- django_cfg/modules/django_client/core/generator/typescript/files_generator.py +6 -5
- 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/generator/typescript/templates/main_index.ts.jinja +12 -8
- django_cfg/modules/django_client/core/ir/schema.py +15 -1
- django_cfg/modules/django_client/core/parser/base.py +126 -30
- django_cfg/modules/django_client/management/commands/generate_client.py +5 -2
- django_cfg/modules/django_client/management/commands/validate_openapi.py +5 -2
- django_cfg/modules/django_email/management/commands/test_email.py +4 -10
- django_cfg/modules/django_ngrok/management/commands/runserver_ngrok.py +16 -13
- django_cfg/modules/django_telegram/management/commands/test_telegram.py +4 -11
- django_cfg/modules/django_twilio/management/commands/test_twilio.py +4 -11
- django_cfg/modules/django_unfold/navigation.py +6 -18
- django_cfg/pyproject.toml +1 -1
- django_cfg/registry/modules.py +1 -4
- django_cfg/requirements.txt +52 -0
- django_cfg/static/frontend/admin.zip +0 -0
- {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/METADATA +1 -1
- {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/RECORD +158 -121
- django_cfg/apps/integrations/grpc/auth/jwt_auth.py +0 -295
- {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API Key Authentication Interceptor for gRPC.
|
|
3
|
+
|
|
4
|
+
Handles API key verification and Django user authentication for gRPC requests.
|
|
5
|
+
Simple, secure, and manageable through Django admin.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import contextvars
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Callable, Optional
|
|
12
|
+
|
|
13
|
+
import grpc
|
|
14
|
+
import grpc.aio
|
|
15
|
+
from django.conf import settings
|
|
16
|
+
from django.contrib.auth import get_user_model
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
User = get_user_model()
|
|
21
|
+
|
|
22
|
+
# Context variables for passing user/api_key between async interceptors
|
|
23
|
+
_grpc_user_var: contextvars.ContextVar = contextvars.ContextVar('grpc_user', default=None)
|
|
24
|
+
_grpc_api_key_var: contextvars.ContextVar = contextvars.ContextVar('grpc_api_key', default=None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ApiKeyAuthInterceptor(grpc.aio.ServerInterceptor):
|
|
28
|
+
"""
|
|
29
|
+
gRPC interceptor for API key authentication.
|
|
30
|
+
|
|
31
|
+
Features:
|
|
32
|
+
- Validates API keys from database (GrpcApiKey model)
|
|
33
|
+
- Accepts Django SECRET_KEY for development/internal use
|
|
34
|
+
- Loads Django user from API key
|
|
35
|
+
- Sets user on request context
|
|
36
|
+
- Supports public methods whitelist
|
|
37
|
+
- Tracks API key usage
|
|
38
|
+
- Handles authentication errors gracefully
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
```python
|
|
42
|
+
# In Django settings (auto-configured by django-cfg)
|
|
43
|
+
GRPC_FRAMEWORK = {
|
|
44
|
+
"SERVER_INTERCEPTORS": [
|
|
45
|
+
"django_cfg.apps.integrations.grpc.auth.ApiKeyAuthInterceptor",
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
API Key Format:
|
|
51
|
+
x-api-key: <api_key_string>
|
|
52
|
+
|
|
53
|
+
Or for SECRET_KEY:
|
|
54
|
+
x-api-key: <Django SECRET_KEY>
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self):
|
|
58
|
+
"""Initialize API key authentication interceptor."""
|
|
59
|
+
self.grpc_auth_config = getattr(settings, "GRPC_AUTH", {})
|
|
60
|
+
self.enabled = self.grpc_auth_config.get("enabled", True)
|
|
61
|
+
self.require_auth = self.grpc_auth_config.get("require_auth", False)
|
|
62
|
+
|
|
63
|
+
# API Key settings
|
|
64
|
+
self.api_key_header = self.grpc_auth_config.get("api_key_header", "x-api-key")
|
|
65
|
+
self.accept_django_secret_key = self.grpc_auth_config.get(
|
|
66
|
+
"accept_django_secret_key", True
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Public methods (don't require auth)
|
|
70
|
+
self.public_methods = self.grpc_auth_config.get("public_methods", [
|
|
71
|
+
"/grpc.health.v1.Health/Check",
|
|
72
|
+
"/grpc.health.v1.Health/Watch",
|
|
73
|
+
"/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo",
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
async def intercept_service(
|
|
77
|
+
self,
|
|
78
|
+
continuation: Callable,
|
|
79
|
+
handler_call_details: grpc.HandlerCallDetails
|
|
80
|
+
) -> grpc.RpcMethodHandler:
|
|
81
|
+
"""
|
|
82
|
+
Intercept gRPC service call for authentication (async).
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
continuation: Function to invoke the next interceptor or handler
|
|
86
|
+
handler_call_details: Details about the RPC call
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
RPC method handler (possibly wrapped with auth)
|
|
90
|
+
"""
|
|
91
|
+
# Skip if auth is disabled
|
|
92
|
+
if not self.enabled:
|
|
93
|
+
return await continuation(handler_call_details)
|
|
94
|
+
|
|
95
|
+
# Check if method is public
|
|
96
|
+
method_name = handler_call_details.method
|
|
97
|
+
if method_name in self.public_methods:
|
|
98
|
+
logger.debug(f"Public method accessed: {method_name}")
|
|
99
|
+
return await continuation(handler_call_details)
|
|
100
|
+
|
|
101
|
+
# Extract API key from metadata
|
|
102
|
+
api_key = self._extract_api_key(handler_call_details.invocation_metadata)
|
|
103
|
+
|
|
104
|
+
# If no API key provided
|
|
105
|
+
if not api_key:
|
|
106
|
+
if self.require_auth:
|
|
107
|
+
logger.warning(f"Missing API key for {method_name}")
|
|
108
|
+
return self._abort_unauthenticated("API key is required")
|
|
109
|
+
else:
|
|
110
|
+
# Allow anonymous access (no user/api_key in context)
|
|
111
|
+
logger.debug(f"No API key provided for {method_name}, allowing anonymous access")
|
|
112
|
+
return await continuation(handler_call_details)
|
|
113
|
+
|
|
114
|
+
# Verify API key and get user + api_key instance (async)
|
|
115
|
+
user, api_key_instance = await self._verify_api_key(api_key)
|
|
116
|
+
|
|
117
|
+
# If API key is valid, ALWAYS set user and api_key in context (even if require_auth=False)
|
|
118
|
+
if user:
|
|
119
|
+
logger.debug(f"Authenticated user {user.id} ({user.username}) for {method_name}")
|
|
120
|
+
return await self._continue_with_user(continuation, handler_call_details, user, api_key_instance)
|
|
121
|
+
|
|
122
|
+
# API key provided but invalid
|
|
123
|
+
if self.require_auth:
|
|
124
|
+
logger.warning(f"Invalid API key for {method_name}")
|
|
125
|
+
return self._abort_unauthenticated("Invalid or expired API key")
|
|
126
|
+
else:
|
|
127
|
+
# Allow anonymous access even with invalid key
|
|
128
|
+
logger.debug(f"Invalid API key for {method_name}, allowing anonymous access")
|
|
129
|
+
return await continuation(handler_call_details)
|
|
130
|
+
|
|
131
|
+
def _extract_api_key(self, metadata: tuple) -> Optional[str]:
|
|
132
|
+
"""
|
|
133
|
+
Extract API key from gRPC metadata.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
metadata: gRPC invocation metadata
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
API key string or None
|
|
140
|
+
"""
|
|
141
|
+
if not metadata:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
# Convert metadata to dict (case-insensitive lookup)
|
|
145
|
+
metadata_dict = dict(metadata)
|
|
146
|
+
|
|
147
|
+
# Get API key header (case-insensitive)
|
|
148
|
+
for key, value in metadata_dict.items():
|
|
149
|
+
if key.lower() == self.api_key_header.lower():
|
|
150
|
+
return value
|
|
151
|
+
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
async def _verify_api_key(self, api_key: str) -> tuple[Optional[User], Optional["GrpcApiKey"]]:
|
|
155
|
+
"""
|
|
156
|
+
Verify API key and return user and api_key instance (async).
|
|
157
|
+
|
|
158
|
+
Checks:
|
|
159
|
+
1. Django SECRET_KEY (if enabled)
|
|
160
|
+
2. GrpcApiKey model in database
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
api_key: API key string
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Tuple of (Django User instance or None, GrpcApiKey instance or None)
|
|
167
|
+
"""
|
|
168
|
+
# Check if it's Django SECRET_KEY
|
|
169
|
+
if self.accept_django_secret_key and api_key == settings.SECRET_KEY:
|
|
170
|
+
logger.debug("API key matches Django SECRET_KEY")
|
|
171
|
+
# For SECRET_KEY, return first superuser or None (no api_key instance)
|
|
172
|
+
try:
|
|
173
|
+
# Wrap Django ORM in asyncio.to_thread()
|
|
174
|
+
superuser = await asyncio.to_thread(
|
|
175
|
+
lambda: User.objects.filter(is_superuser=True, is_active=True).first()
|
|
176
|
+
)
|
|
177
|
+
if superuser:
|
|
178
|
+
return superuser, None
|
|
179
|
+
else:
|
|
180
|
+
logger.warning("No active superuser found for SECRET_KEY authentication")
|
|
181
|
+
return None, None
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.error(f"Error loading superuser for SECRET_KEY: {e}")
|
|
184
|
+
return None, None
|
|
185
|
+
|
|
186
|
+
# Check API key in database
|
|
187
|
+
try:
|
|
188
|
+
from django_cfg.apps.integrations.grpc.models import GrpcApiKey
|
|
189
|
+
|
|
190
|
+
# Wrap Django ORM in asyncio.to_thread()
|
|
191
|
+
api_key_obj = await asyncio.to_thread(
|
|
192
|
+
lambda: GrpcApiKey.objects.filter(key=api_key, is_active=True).first()
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if api_key_obj and api_key_obj.is_valid:
|
|
196
|
+
# Update usage tracking (also wrapped in to_thread)
|
|
197
|
+
await asyncio.to_thread(api_key_obj.mark_used)
|
|
198
|
+
logger.debug(f"Valid API key for user {api_key_obj.user.id} ({api_key_obj.user.username})")
|
|
199
|
+
return api_key_obj.user, api_key_obj
|
|
200
|
+
else:
|
|
201
|
+
logger.debug("API key not found or invalid in database")
|
|
202
|
+
return None, None
|
|
203
|
+
|
|
204
|
+
except Exception as e:
|
|
205
|
+
logger.error(f"Error validating API key: {e}")
|
|
206
|
+
return None, None
|
|
207
|
+
|
|
208
|
+
async def _continue_with_user(
|
|
209
|
+
self,
|
|
210
|
+
continuation: Callable,
|
|
211
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
212
|
+
user: User,
|
|
213
|
+
api_key_instance: Optional["GrpcApiKey"] = None,
|
|
214
|
+
) -> grpc.RpcMethodHandler:
|
|
215
|
+
"""
|
|
216
|
+
Continue RPC with authenticated user and api_key in context (async).
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
continuation: Function to invoke next interceptor or handler
|
|
220
|
+
handler_call_details: Details about the RPC call
|
|
221
|
+
user: Authenticated Django user
|
|
222
|
+
api_key_instance: GrpcApiKey instance used for authentication (if applicable)
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
RPC method handler with user and api_key context
|
|
226
|
+
"""
|
|
227
|
+
# Get the handler (await because continuation is async)
|
|
228
|
+
handler = await continuation(handler_call_details)
|
|
229
|
+
|
|
230
|
+
if handler is None:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
# Wrap the handler to inject user and api_key into contextvars (not context directly)
|
|
234
|
+
# All wrappers must be async for grpc.aio
|
|
235
|
+
async def wrapped_unary_unary(request, context):
|
|
236
|
+
# Set context variables for async context
|
|
237
|
+
_grpc_user_var.set(user)
|
|
238
|
+
_grpc_api_key_var.set(api_key_instance)
|
|
239
|
+
logger.info(f"[Auth] Set contextvar api_key = {api_key_instance} (user={user})")
|
|
240
|
+
return await handler.unary_unary(request, context)
|
|
241
|
+
|
|
242
|
+
async def wrapped_unary_stream(request, context):
|
|
243
|
+
# Set context variables for async context
|
|
244
|
+
_grpc_user_var.set(user)
|
|
245
|
+
_grpc_api_key_var.set(api_key_instance)
|
|
246
|
+
logger.info(f"[Auth] Set contextvar api_key = {api_key_instance} (user={user})")
|
|
247
|
+
async for response in handler.unary_stream(request, context):
|
|
248
|
+
yield response
|
|
249
|
+
|
|
250
|
+
async def wrapped_stream_unary(request_iterator, context):
|
|
251
|
+
# Set context variables for async context
|
|
252
|
+
_grpc_user_var.set(user)
|
|
253
|
+
_grpc_api_key_var.set(api_key_instance)
|
|
254
|
+
return await handler.stream_unary(request_iterator, context)
|
|
255
|
+
|
|
256
|
+
async def wrapped_stream_stream(request_iterator, context):
|
|
257
|
+
# Set context variables for async context
|
|
258
|
+
_grpc_user_var.set(user)
|
|
259
|
+
_grpc_api_key_var.set(api_key_instance)
|
|
260
|
+
async for response in handler.stream_stream(request_iterator, context):
|
|
261
|
+
yield response
|
|
262
|
+
|
|
263
|
+
# Return wrapped handler based on type
|
|
264
|
+
return grpc.unary_unary_rpc_method_handler(
|
|
265
|
+
wrapped_unary_unary,
|
|
266
|
+
request_deserializer=handler.request_deserializer,
|
|
267
|
+
response_serializer=handler.response_serializer,
|
|
268
|
+
) if handler.unary_unary else (
|
|
269
|
+
grpc.unary_stream_rpc_method_handler(
|
|
270
|
+
wrapped_unary_stream,
|
|
271
|
+
request_deserializer=handler.request_deserializer,
|
|
272
|
+
response_serializer=handler.response_serializer,
|
|
273
|
+
) if handler.unary_stream else (
|
|
274
|
+
grpc.stream_unary_rpc_method_handler(
|
|
275
|
+
wrapped_stream_unary,
|
|
276
|
+
request_deserializer=handler.request_deserializer,
|
|
277
|
+
response_serializer=handler.response_serializer,
|
|
278
|
+
) if handler.stream_unary else (
|
|
279
|
+
grpc.stream_stream_rpc_method_handler(
|
|
280
|
+
wrapped_stream_stream,
|
|
281
|
+
request_deserializer=handler.request_deserializer,
|
|
282
|
+
response_serializer=handler.response_serializer,
|
|
283
|
+
) if handler.stream_stream else None
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def _abort_unauthenticated(self, message: str) -> grpc.RpcMethodHandler:
|
|
289
|
+
"""
|
|
290
|
+
Return handler that aborts with UNAUTHENTICATED status.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
message: Error message
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
RPC method handler that aborts
|
|
297
|
+
"""
|
|
298
|
+
def abort(*args, **kwargs):
|
|
299
|
+
context = args[1] if len(args) > 1 else None
|
|
300
|
+
if context:
|
|
301
|
+
context.abort(grpc.StatusCode.UNAUTHENTICATED, message)
|
|
302
|
+
|
|
303
|
+
return grpc.unary_unary_rpc_method_handler(
|
|
304
|
+
abort,
|
|
305
|
+
request_deserializer=lambda x: x,
|
|
306
|
+
response_serializer=lambda x: x,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
__all__ = ["ApiKeyAuthInterceptor", "get_current_grpc_user", "get_current_grpc_api_key"]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def get_current_grpc_user():
|
|
314
|
+
"""Get current gRPC user from context variables (async-safe)."""
|
|
315
|
+
return _grpc_user_var.get()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_current_grpc_api_key():
|
|
319
|
+
"""Get current gRPC API key from context variables (async-safe)."""
|
|
320
|
+
return _grpc_api_key_var.get()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gRPC → Centrifugo Integration.
|
|
3
|
+
|
|
4
|
+
Mixin and configuration for bridging gRPC streaming events to Centrifugo WebSocket.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .bridge import CentrifugoBridgeMixin
|
|
8
|
+
from .config import ChannelConfig, CentrifugoChannels
|
|
9
|
+
from .demo import (
|
|
10
|
+
DemoChannels,
|
|
11
|
+
DemoBridgeService,
|
|
12
|
+
test_complete_integration,
|
|
13
|
+
test_demo_service,
|
|
14
|
+
send_demo_event,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
# Core components
|
|
19
|
+
"CentrifugoBridgeMixin",
|
|
20
|
+
"ChannelConfig",
|
|
21
|
+
"CentrifugoChannels",
|
|
22
|
+
|
|
23
|
+
# Demo/testing
|
|
24
|
+
"DemoChannels",
|
|
25
|
+
"DemoBridgeService",
|
|
26
|
+
"test_complete_integration",
|
|
27
|
+
"test_demo_service",
|
|
28
|
+
"send_demo_event",
|
|
29
|
+
]
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centrifugo Bridge Mixin for gRPC Services.
|
|
3
|
+
|
|
4
|
+
Universal mixin that enables automatic publishing of gRPC stream events
|
|
5
|
+
to Centrifugo WebSocket channels using Pydantic configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, timezone as tz
|
|
11
|
+
from typing import Dict, Optional, Any, TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from .config import CentrifugoChannels, ChannelConfig
|
|
14
|
+
from .transformers import transform_protobuf_to_dict
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from django_cfg.apps.integrations.centrifugo import CentrifugoClient
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CentrifugoBridgeMixin:
|
|
23
|
+
"""
|
|
24
|
+
Universal mixin for publishing gRPC stream events to Centrifugo.
|
|
25
|
+
|
|
26
|
+
Uses Pydantic models for type-safe, validated configuration.
|
|
27
|
+
|
|
28
|
+
Features:
|
|
29
|
+
- Type-safe Pydantic configuration
|
|
30
|
+
- Automatic event publishing to WebSocket channels
|
|
31
|
+
- Built-in protobuf → JSON transformation
|
|
32
|
+
- Graceful degradation if Centrifugo unavailable
|
|
33
|
+
- Custom transform functions support
|
|
34
|
+
- Template-based channel naming
|
|
35
|
+
- Per-channel rate limiting
|
|
36
|
+
- Critical event bypassing
|
|
37
|
+
|
|
38
|
+
Usage:
|
|
39
|
+
```python
|
|
40
|
+
from django_cfg.apps.integrations.grpc.mixins import (
|
|
41
|
+
CentrifugoBridgeMixin,
|
|
42
|
+
CentrifugoChannels,
|
|
43
|
+
ChannelConfig,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
class BotChannels(CentrifugoChannels):
|
|
47
|
+
heartbeat: ChannelConfig = ChannelConfig(
|
|
48
|
+
template='bot#{bot_id}#heartbeat',
|
|
49
|
+
rate_limit=0.1
|
|
50
|
+
)
|
|
51
|
+
status: ChannelConfig = ChannelConfig(
|
|
52
|
+
template='bot#{bot_id}#status',
|
|
53
|
+
critical=True
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
class BotStreamingService(
|
|
57
|
+
bot_streaming_service_pb2_grpc.BotStreamingServiceServicer,
|
|
58
|
+
CentrifugoBridgeMixin
|
|
59
|
+
):
|
|
60
|
+
centrifugo_channels = BotChannels()
|
|
61
|
+
|
|
62
|
+
async def ConnectBot(self, request_iterator, context):
|
|
63
|
+
async for message in request_iterator:
|
|
64
|
+
# Your business logic
|
|
65
|
+
await self._handle_message(bot_id, message)
|
|
66
|
+
|
|
67
|
+
# Auto-publish to Centrifugo (1 line!)
|
|
68
|
+
await self._notify_centrifugo(message, bot_id=bot_id)
|
|
69
|
+
```
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
# Class-level Pydantic config (optional, can be set in __init__)
|
|
73
|
+
centrifugo_channels: Optional[CentrifugoChannels] = None
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
"""Initialize Centrifugo bridge from Pydantic configuration."""
|
|
77
|
+
super().__init__()
|
|
78
|
+
|
|
79
|
+
# Instance attributes
|
|
80
|
+
self._centrifugo_enabled: bool = False
|
|
81
|
+
self._centrifugo_graceful: bool = True
|
|
82
|
+
self._centrifugo_client: Optional['CentrifugoClient'] = None
|
|
83
|
+
self._centrifugo_mappings: Dict[str, Dict[str, Any]] = {}
|
|
84
|
+
self._centrifugo_last_publish: Dict[str, float] = {}
|
|
85
|
+
|
|
86
|
+
# Auto-setup if config exists
|
|
87
|
+
if self.centrifugo_channels:
|
|
88
|
+
self._setup_from_pydantic_config(self.centrifugo_channels)
|
|
89
|
+
|
|
90
|
+
def _setup_from_pydantic_config(self, config: CentrifugoChannels):
|
|
91
|
+
"""
|
|
92
|
+
Setup Centrifugo bridge from Pydantic configuration.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
config: CentrifugoChannels instance with channel mappings
|
|
96
|
+
"""
|
|
97
|
+
self._centrifugo_enabled = config.enabled
|
|
98
|
+
self._centrifugo_graceful = config.graceful_degradation
|
|
99
|
+
|
|
100
|
+
# Extract channel mappings
|
|
101
|
+
for field_name, channel_config in config.get_channel_mappings().items():
|
|
102
|
+
if channel_config.enabled:
|
|
103
|
+
self._centrifugo_mappings[field_name] = {
|
|
104
|
+
'template': channel_config.template,
|
|
105
|
+
'rate_limit': channel_config.rate_limit or config.default_rate_limit,
|
|
106
|
+
'critical': channel_config.critical,
|
|
107
|
+
'transform': channel_config.transform,
|
|
108
|
+
'metadata': channel_config.metadata,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Initialize client if enabled
|
|
112
|
+
if self._centrifugo_enabled and self._centrifugo_mappings:
|
|
113
|
+
self._initialize_centrifugo_client()
|
|
114
|
+
|
|
115
|
+
def _initialize_centrifugo_client(self):
|
|
116
|
+
"""Lazy initialize Centrifugo client."""
|
|
117
|
+
try:
|
|
118
|
+
from django_cfg.apps.integrations.centrifugo import get_centrifugo_client
|
|
119
|
+
self._centrifugo_client = get_centrifugo_client()
|
|
120
|
+
logger.info(
|
|
121
|
+
f"✅ Centrifugo bridge enabled with {len(self._centrifugo_mappings)} channels"
|
|
122
|
+
)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.warning(f"⚠️ Centrifugo client not available: {e}")
|
|
125
|
+
if not self._centrifugo_graceful:
|
|
126
|
+
raise
|
|
127
|
+
self._centrifugo_enabled = False
|
|
128
|
+
|
|
129
|
+
async def _notify_centrifugo(
|
|
130
|
+
self,
|
|
131
|
+
message: Any, # Protobuf message
|
|
132
|
+
**context: Any # Template variables for channel rendering
|
|
133
|
+
) -> bool:
|
|
134
|
+
"""
|
|
135
|
+
Publish protobuf message to Centrifugo based on configured mappings.
|
|
136
|
+
|
|
137
|
+
Automatically detects which field is set in the message and publishes
|
|
138
|
+
to the corresponding channel.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
message: Protobuf message (e.g., BotMessage with heartbeat/status/etc.)
|
|
142
|
+
**context: Template variables for channel name rendering
|
|
143
|
+
Example: bot_id='123', user_id='456'
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
True if published successfully, False otherwise
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
```python
|
|
150
|
+
# message = BotMessage with heartbeat field set
|
|
151
|
+
await self._notify_centrifugo(message, bot_id='bot-123')
|
|
152
|
+
# → Publishes to channel: bot#bot-123#heartbeat
|
|
153
|
+
```
|
|
154
|
+
"""
|
|
155
|
+
if not self._centrifugo_enabled or not self._centrifugo_client:
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
# Check each mapped field
|
|
159
|
+
for field_name, mapping in self._centrifugo_mappings.items():
|
|
160
|
+
if message.HasField(field_name):
|
|
161
|
+
return await self._publish_field(
|
|
162
|
+
field_name,
|
|
163
|
+
message,
|
|
164
|
+
mapping,
|
|
165
|
+
context
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
async def _publish_field(
|
|
171
|
+
self,
|
|
172
|
+
field_name: str,
|
|
173
|
+
message: Any,
|
|
174
|
+
mapping: Dict[str, Any],
|
|
175
|
+
context: dict
|
|
176
|
+
) -> bool:
|
|
177
|
+
"""
|
|
178
|
+
Publish specific message field to Centrifugo.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
field_name: Name of the protobuf field
|
|
182
|
+
message: Full protobuf message
|
|
183
|
+
mapping: Channel mapping configuration
|
|
184
|
+
context: Template variables
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
True if published successfully
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
# Render channel from template
|
|
191
|
+
channel = mapping['template'].format(**context)
|
|
192
|
+
|
|
193
|
+
# Rate limiting check (unless critical)
|
|
194
|
+
if not mapping['critical'] and mapping['rate_limit']:
|
|
195
|
+
now = time.time()
|
|
196
|
+
last = self._centrifugo_last_publish.get(channel, 0)
|
|
197
|
+
if now - last < mapping['rate_limit']:
|
|
198
|
+
logger.debug(f"⏱️ Rate limit: skipping {field_name} for {channel}")
|
|
199
|
+
return False
|
|
200
|
+
self._centrifugo_last_publish[channel] = now
|
|
201
|
+
|
|
202
|
+
# Get field value
|
|
203
|
+
field_value = getattr(message, field_name)
|
|
204
|
+
|
|
205
|
+
# Transform to dict
|
|
206
|
+
data = self._transform_field(field_name, field_value, mapping, context)
|
|
207
|
+
|
|
208
|
+
# Publish to Centrifugo
|
|
209
|
+
await self._centrifugo_client.publish(
|
|
210
|
+
channel=channel,
|
|
211
|
+
data=data
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
logger.debug(f"✅ Published {field_name} to {channel}")
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
except KeyError as e:
|
|
218
|
+
logger.error(
|
|
219
|
+
f"❌ Missing template variable in channel: {e}. "
|
|
220
|
+
f"Template: {mapping['template']}, Context: {context}"
|
|
221
|
+
)
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.error(
|
|
226
|
+
f"❌ Failed to publish {field_name} to Centrifugo: {e}",
|
|
227
|
+
exc_info=True
|
|
228
|
+
)
|
|
229
|
+
if not self._centrifugo_graceful:
|
|
230
|
+
raise
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
def _transform_field(
|
|
234
|
+
self,
|
|
235
|
+
field_name: str,
|
|
236
|
+
field_value: Any,
|
|
237
|
+
mapping: Dict[str, Any],
|
|
238
|
+
context: dict
|
|
239
|
+
) -> dict:
|
|
240
|
+
"""
|
|
241
|
+
Transform protobuf field to JSON-serializable dict.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
field_name: Field name
|
|
245
|
+
field_value: Protobuf message field value
|
|
246
|
+
mapping: Channel mapping with optional transform function
|
|
247
|
+
context: Template context variables
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
JSON-serializable dictionary
|
|
251
|
+
"""
|
|
252
|
+
# Use custom transform if provided
|
|
253
|
+
if mapping['transform']:
|
|
254
|
+
data = mapping['transform'](field_name, field_value)
|
|
255
|
+
else:
|
|
256
|
+
# Default protobuf → dict transform
|
|
257
|
+
data = transform_protobuf_to_dict(field_value)
|
|
258
|
+
|
|
259
|
+
# Add metadata
|
|
260
|
+
data['type'] = field_name
|
|
261
|
+
data['timestamp'] = datetime.now(tz.utc).isoformat()
|
|
262
|
+
|
|
263
|
+
# Merge channel metadata
|
|
264
|
+
if mapping['metadata']:
|
|
265
|
+
for key, value in mapping['metadata'].items():
|
|
266
|
+
if key not in data:
|
|
267
|
+
data[key] = value
|
|
268
|
+
|
|
269
|
+
# Add context variables (bot_id, user_id, etc.)
|
|
270
|
+
for key, value in context.items():
|
|
271
|
+
if key not in data:
|
|
272
|
+
data[key] = value
|
|
273
|
+
|
|
274
|
+
return data
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
__all__ = ["CentrifugoBridgeMixin"]
|