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.

Files changed (159) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/api/commands/serializers.py +152 -0
  3. django_cfg/apps/api/commands/views.py +32 -0
  4. django_cfg/apps/business/accounts/management/commands/otp_test.py +5 -2
  5. django_cfg/apps/business/accounts/serializers/profile.py +42 -0
  6. django_cfg/apps/business/agents/management/commands/create_agent.py +5 -194
  7. django_cfg/apps/business/agents/management/commands/load_agent_templates.py +205 -0
  8. django_cfg/apps/business/agents/management/commands/orchestrator_status.py +4 -2
  9. django_cfg/apps/business/knowbase/management/commands/knowbase_stats.py +4 -2
  10. django_cfg/apps/business/knowbase/management/commands/setup_knowbase.py +4 -2
  11. django_cfg/apps/business/newsletter/management/commands/test_newsletter.py +5 -2
  12. django_cfg/apps/business/payments/management/commands/check_payment_status.py +4 -2
  13. django_cfg/apps/business/payments/management/commands/create_payment.py +4 -2
  14. django_cfg/apps/business/payments/management/commands/sync_currencies.py +4 -2
  15. django_cfg/apps/business/support/serializers.py +3 -2
  16. django_cfg/apps/integrations/centrifugo/apps.py +2 -1
  17. django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
  18. django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +6 -6
  19. django_cfg/apps/integrations/centrifugo/serializers/__init__.py +2 -1
  20. django_cfg/apps/integrations/centrifugo/serializers/publishes.py +22 -2
  21. django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
  22. django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
  23. django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
  24. django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
  25. django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
  26. django_cfg/apps/integrations/centrifugo/urls.py +8 -0
  27. django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
  28. django_cfg/apps/integrations/centrifugo/views/monitoring.py +25 -40
  29. django_cfg/apps/integrations/centrifugo/views/testing_api.py +0 -79
  30. django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
  31. django_cfg/apps/integrations/centrifugo/views/wrapper.py +257 -0
  32. django_cfg/apps/integrations/grpc/admin/__init__.py +7 -1
  33. django_cfg/apps/integrations/grpc/admin/config.py +113 -9
  34. django_cfg/apps/integrations/grpc/admin/grpc_api_key.py +129 -0
  35. django_cfg/apps/integrations/grpc/admin/grpc_request_log.py +72 -63
  36. django_cfg/apps/integrations/grpc/admin/grpc_server_status.py +236 -0
  37. django_cfg/apps/integrations/grpc/auth/__init__.py +11 -3
  38. django_cfg/apps/integrations/grpc/auth/api_key_auth.py +320 -0
  39. django_cfg/apps/integrations/grpc/centrifugo/__init__.py +29 -0
  40. django_cfg/apps/integrations/grpc/centrifugo/bridge.py +277 -0
  41. django_cfg/apps/integrations/grpc/centrifugo/config.py +167 -0
  42. django_cfg/apps/integrations/grpc/centrifugo/demo.py +626 -0
  43. django_cfg/apps/integrations/grpc/centrifugo/test_publish.py +229 -0
  44. django_cfg/apps/integrations/grpc/centrifugo/transformers.py +89 -0
  45. django_cfg/apps/integrations/grpc/interceptors/__init__.py +3 -1
  46. django_cfg/apps/integrations/grpc/interceptors/centrifugo.py +541 -0
  47. django_cfg/apps/integrations/grpc/interceptors/logging.py +17 -20
  48. django_cfg/apps/integrations/grpc/interceptors/metrics.py +15 -14
  49. django_cfg/apps/integrations/grpc/interceptors/request_logger.py +79 -59
  50. django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
  51. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +185 -0
  52. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +474 -95
  53. django_cfg/apps/integrations/grpc/management/commands/test_grpc_integration.py +75 -0
  54. django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
  55. django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
  56. django_cfg/apps/integrations/grpc/managers/__init__.py +2 -0
  57. django_cfg/apps/integrations/grpc/managers/grpc_api_key.py +192 -0
  58. django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +19 -11
  59. django_cfg/apps/integrations/grpc/migrations/0005_grpcapikey.py +143 -0
  60. django_cfg/apps/integrations/grpc/migrations/0006_grpcrequestlog_api_key_and_more.py +34 -0
  61. django_cfg/apps/integrations/grpc/models/__init__.py +2 -0
  62. django_cfg/apps/integrations/grpc/models/grpc_api_key.py +198 -0
  63. django_cfg/apps/integrations/grpc/models/grpc_request_log.py +11 -0
  64. django_cfg/apps/integrations/grpc/models/grpc_server_status.py +39 -4
  65. django_cfg/apps/integrations/grpc/serializers/__init__.py +22 -6
  66. django_cfg/apps/integrations/grpc/serializers/api_keys.py +63 -0
  67. django_cfg/apps/integrations/grpc/serializers/charts.py +118 -120
  68. django_cfg/apps/integrations/grpc/serializers/config.py +65 -51
  69. django_cfg/apps/integrations/grpc/serializers/health.py +7 -7
  70. django_cfg/apps/integrations/grpc/serializers/proto_files.py +74 -0
  71. django_cfg/apps/integrations/grpc/serializers/requests.py +13 -7
  72. django_cfg/apps/integrations/grpc/serializers/service_registry.py +181 -112
  73. django_cfg/apps/integrations/grpc/serializers/services.py +14 -32
  74. django_cfg/apps/integrations/grpc/serializers/stats.py +50 -12
  75. django_cfg/apps/integrations/grpc/serializers/testing.py +66 -58
  76. django_cfg/apps/integrations/grpc/services/__init__.py +2 -0
  77. django_cfg/apps/integrations/grpc/services/discovery.py +7 -1
  78. django_cfg/apps/integrations/grpc/services/monitoring_service.py +149 -43
  79. django_cfg/apps/integrations/grpc/services/proto_files_manager.py +268 -0
  80. django_cfg/apps/integrations/grpc/services/service_registry.py +48 -46
  81. django_cfg/apps/integrations/grpc/services/testing_service.py +10 -15
  82. django_cfg/apps/integrations/grpc/urls.py +8 -0
  83. django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
  84. django_cfg/apps/integrations/grpc/utils/__init__.py +4 -13
  85. django_cfg/apps/integrations/grpc/utils/integration_test.py +334 -0
  86. django_cfg/apps/integrations/grpc/utils/proto_gen.py +48 -8
  87. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +378 -0
  88. django_cfg/apps/integrations/grpc/views/__init__.py +4 -0
  89. django_cfg/apps/integrations/grpc/views/api_keys.py +255 -0
  90. django_cfg/apps/integrations/grpc/views/charts.py +21 -14
  91. django_cfg/apps/integrations/grpc/views/config.py +8 -6
  92. django_cfg/apps/integrations/grpc/views/monitoring.py +51 -79
  93. django_cfg/apps/integrations/grpc/views/proto_files.py +214 -0
  94. django_cfg/apps/integrations/grpc/views/services.py +30 -21
  95. django_cfg/apps/integrations/grpc/views/testing.py +45 -43
  96. django_cfg/apps/integrations/rq/views/jobs.py +19 -9
  97. django_cfg/apps/integrations/rq/views/schedule.py +7 -3
  98. django_cfg/apps/system/dashboard/serializers/commands.py +25 -1
  99. django_cfg/apps/system/dashboard/serializers/config.py +95 -9
  100. django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
  101. django_cfg/apps/system/dashboard/services/commands_service.py +12 -1
  102. django_cfg/apps/system/frontend/views.py +87 -6
  103. django_cfg/apps/system/maintenance/management/commands/maintenance.py +5 -2
  104. django_cfg/apps/system/maintenance/management/commands/process_scheduled_maintenance.py +4 -2
  105. django_cfg/apps/system/maintenance/management/commands/sync_cloudflare.py +5 -2
  106. django_cfg/config.py +33 -0
  107. django_cfg/core/builders/security_builder.py +1 -0
  108. django_cfg/core/generation/integration_generators/api.py +2 -0
  109. django_cfg/core/generation/integration_generators/grpc_generator.py +30 -32
  110. django_cfg/management/commands/check_endpoints.py +2 -2
  111. django_cfg/management/commands/check_settings.py +3 -10
  112. django_cfg/management/commands/clear_constance.py +3 -10
  113. django_cfg/management/commands/create_token.py +4 -11
  114. django_cfg/management/commands/list_urls.py +4 -10
  115. django_cfg/management/commands/migrate_all.py +18 -12
  116. django_cfg/management/commands/migrator.py +4 -11
  117. django_cfg/management/commands/script.py +4 -10
  118. django_cfg/management/commands/show_config.py +8 -16
  119. django_cfg/management/commands/show_urls.py +5 -11
  120. django_cfg/management/commands/superuser.py +4 -11
  121. django_cfg/management/commands/tree.py +5 -10
  122. django_cfg/management/utils/README.md +402 -0
  123. django_cfg/management/utils/__init__.py +29 -0
  124. django_cfg/management/utils/mixins.py +176 -0
  125. django_cfg/middleware/pagination.py +53 -54
  126. django_cfg/models/api/grpc/__init__.py +15 -21
  127. django_cfg/models/api/grpc/config.py +155 -73
  128. django_cfg/models/ngrok/config.py +7 -6
  129. django_cfg/modules/django_client/core/generator/python/files_generator.py +5 -13
  130. django_cfg/modules/django_client/core/generator/python/templates/api_wrapper.py.jinja +16 -4
  131. django_cfg/modules/django_client/core/generator/python/templates/main_init.py.jinja +2 -3
  132. django_cfg/modules/django_client/core/generator/typescript/files_generator.py +6 -5
  133. django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
  134. django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
  135. django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
  136. django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
  137. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
  138. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
  139. django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
  140. django_cfg/modules/django_client/core/generator/typescript/templates/main_index.ts.jinja +12 -8
  141. django_cfg/modules/django_client/core/ir/schema.py +15 -1
  142. django_cfg/modules/django_client/core/parser/base.py +126 -30
  143. django_cfg/modules/django_client/management/commands/generate_client.py +5 -2
  144. django_cfg/modules/django_client/management/commands/validate_openapi.py +5 -2
  145. django_cfg/modules/django_email/management/commands/test_email.py +4 -10
  146. django_cfg/modules/django_ngrok/management/commands/runserver_ngrok.py +16 -13
  147. django_cfg/modules/django_telegram/management/commands/test_telegram.py +4 -11
  148. django_cfg/modules/django_twilio/management/commands/test_twilio.py +4 -11
  149. django_cfg/modules/django_unfold/navigation.py +6 -18
  150. django_cfg/pyproject.toml +1 -1
  151. django_cfg/registry/modules.py +1 -4
  152. django_cfg/requirements.txt +52 -0
  153. django_cfg/static/frontend/admin.zip +0 -0
  154. {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/METADATA +1 -1
  155. {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/RECORD +158 -121
  156. django_cfg/apps/integrations/grpc/auth/jwt_auth.py +0 -295
  157. {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/WHEEL +0 -0
  158. {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/entry_points.txt +0 -0
  159. {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"]