django-cfg 1.5.20__py3-none-any.whl → 1.5.31__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 (98) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
  3. django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
  4. django_cfg/apps/integrations/centrifugo/services/logging.py +90 -14
  5. django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
  6. django_cfg/apps/integrations/centrifugo/views/testing_api.py +47 -43
  7. django_cfg/apps/integrations/centrifugo/views/wrapper.py +41 -29
  8. django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
  9. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +1 -1
  10. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +22 -36
  11. django_cfg/apps/integrations/grpc/managers/grpc_request_log.py +84 -0
  12. django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
  13. django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
  14. django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
  15. django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
  16. django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
  17. django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/demo.py +1 -1
  18. django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/test_publish.py +4 -4
  19. django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
  20. django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
  21. django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
  22. django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
  23. django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
  24. django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
  25. django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
  26. django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
  27. django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
  28. django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
  29. django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
  30. django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
  31. django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
  32. django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +62 -55
  33. django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +216 -5
  34. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
  35. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
  36. django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
  37. django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
  38. django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
  39. django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
  40. django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
  41. django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
  42. django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
  43. django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
  44. django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
  45. django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
  46. django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
  47. django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
  48. django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
  49. django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
  50. django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
  51. django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
  52. django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
  53. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +55 -8
  54. django_cfg/apps/integrations/grpc/views/charts.py +1 -1
  55. django_cfg/apps/integrations/grpc/views/config.py +1 -1
  56. django_cfg/core/base/config_model.py +11 -0
  57. django_cfg/core/builders/middleware_builder.py +5 -0
  58. django_cfg/management/commands/pool_status.py +153 -0
  59. django_cfg/middleware/pool_cleanup.py +261 -0
  60. django_cfg/models/api/grpc/config.py +2 -2
  61. django_cfg/models/infrastructure/database/config.py +16 -0
  62. django_cfg/models/infrastructure/database/converters.py +2 -0
  63. django_cfg/modules/django_admin/utils/html/composition.py +57 -13
  64. django_cfg/modules/django_admin/utils/html_builder.py +1 -0
  65. django_cfg/modules/django_client/core/generator/typescript/files_generator.py +12 -0
  66. django_cfg/modules/django_client/core/generator/typescript/generator.py +8 -0
  67. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +22 -0
  68. django_cfg/modules/django_client/core/generator/typescript/templates/main_index.ts.jinja +4 -0
  69. django_cfg/modules/django_client/core/generator/typescript/templates/utils/validation-events.ts.jinja +133 -0
  70. django_cfg/modules/django_client/core/groups/manager.py +25 -18
  71. django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
  72. django_cfg/modules/django_client/urls.py +38 -5
  73. django_cfg/modules/django_logging/django_logger.py +58 -19
  74. django_cfg/modules/django_twilio/email_otp.py +3 -1
  75. django_cfg/modules/django_twilio/sms.py +3 -1
  76. django_cfg/modules/django_twilio/unified.py +6 -2
  77. django_cfg/modules/django_twilio/whatsapp.py +3 -1
  78. django_cfg/pyproject.toml +3 -3
  79. django_cfg/static/frontend/admin.zip +0 -0
  80. django_cfg/templates/admin/index.html +17 -57
  81. django_cfg/utils/pool_monitor.py +320 -0
  82. django_cfg/utils/smart_defaults.py +233 -7
  83. {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/METADATA +75 -5
  84. {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/RECORD +97 -68
  85. django_cfg/apps/integrations/grpc/centrifugo/bridge.py +0 -277
  86. /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/__init__.py +0 -0
  87. /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/config.py +0 -0
  88. /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/transformers.py +0 -0
  89. /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
  90. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +0 -0
  91. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/centrifugo.py +0 -0
  92. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
  93. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
  94. /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
  95. /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
  96. {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/WHEEL +0 -0
  97. {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/entry_points.txt +0 -0
  98. {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/licenses/LICENSE +0 -0
django_cfg/__init__.py CHANGED
@@ -32,7 +32,7 @@ Example:
32
32
  default_app_config = "django_cfg.apps.DjangoCfgConfig"
33
33
 
34
34
  # Version information
35
- __version__ = "1.5.20"
35
+ __version__ = "1.5.31"
36
36
  __license__ = "MIT"
37
37
 
38
38
  # Setup warnings debug early (checks env var only at this point)
@@ -23,6 +23,7 @@ Example:
23
23
  """
24
24
 
25
25
  from .services.client.client import CentrifugoClient, get_centrifugo_client, PublishResponse
26
+ from .services.client.direct_client import DirectCentrifugoClient
26
27
  from .services.client.config import DjangoCfgCentrifugoConfig
27
28
  from .services.client.exceptions import (
28
29
  CentrifugoBaseException,
@@ -42,6 +43,7 @@ __all__ = [
42
43
  "DjangoCfgCentrifugoConfig",
43
44
  # Client
44
45
  "CentrifugoClient",
46
+ "DirectCentrifugoClient",
45
47
  "get_centrifugo_client",
46
48
  "PublishResponse",
47
49
  # Logging
@@ -396,7 +396,7 @@ class CentrifugoClient:
396
396
 
397
397
  error_code = type(last_error).__name__ if last_error else "unknown"
398
398
  error_message = str(last_error) if last_error else "Unknown error"
399
- CentrifugoLogger.mark_failed(
399
+ await CentrifugoLogger.mark_failed_async(
400
400
  log_entry,
401
401
  error_code=error_code,
402
402
  error_message=error_message,
@@ -8,6 +8,7 @@ Mirrors RPCLogger patterns from legacy WebSocket solution for easy migration.
8
8
  import time
9
9
  from typing import Any, Optional
10
10
 
11
+ from django.utils import timezone
11
12
  from django_cfg.modules.django_logging import get_logger
12
13
 
13
14
  logger = get_logger("centrifugo")
@@ -87,11 +88,11 @@ class CentrifugoLogger:
87
88
 
88
89
  logger.info(f"✅ Creating CentrifugoLog entry for {message_id} (async)")
89
90
  try:
90
- from asgiref.sync import sync_to_async
91
91
  from ..models import CentrifugoLog
92
92
 
93
- # Wrap ORM call in sync_to_async
94
- log_entry = await sync_to_async(CentrifugoLog.objects.create)(
93
+ # Use Django 5.2+ async ORM instead of sync_to_async
94
+ # This prevents connection leaks from sync_to_async threads
95
+ log_entry = await CentrifugoLog.objects.acreate(
95
96
  message_id=message_id,
96
97
  channel=channel,
97
98
  data=data,
@@ -234,14 +235,17 @@ class CentrifugoLogger:
234
235
  return
235
236
 
236
237
  try:
237
- from asgiref.sync import sync_to_async
238
238
  from ..models import CentrifugoLog
239
239
 
240
- await sync_to_async(CentrifugoLog.objects.mark_success)(
241
- log_instance=log_entry,
242
- acks_received=acks_received,
243
- duration_ms=duration_ms,
244
- )
240
+ # ✅ Use Django 5.2+ async ORM instead of sync_to_async
241
+ log_entry.status = CentrifugoLog.StatusChoices.SUCCESS
242
+ log_entry.acks_received = acks_received
243
+ log_entry.completed_at = timezone.now()
244
+
245
+ if duration_ms is not None:
246
+ log_entry.duration_ms = duration_ms
247
+
248
+ await log_entry.asave(update_fields=["status", "acks_received", "completed_at", "duration_ms"])
245
249
 
246
250
  logger.info(
247
251
  f"Centrifugo publish successful: {log_entry.message_id}",
@@ -400,6 +404,64 @@ class CentrifugoLogger:
400
404
  extra={"message_id": getattr(log_entry, "message_id", "unknown")},
401
405
  )
402
406
 
407
+ @staticmethod
408
+ async def mark_failed_async(
409
+ log_entry: Any,
410
+ error_code: str,
411
+ error_message: str,
412
+ duration_ms: int | None = None,
413
+ ) -> None:
414
+ """
415
+ Mark publish operation as failed (async version).
416
+
417
+ Args:
418
+ log_entry: CentrifugoLog instance
419
+ error_code: Error code
420
+ error_message: Error message
421
+ duration_ms: Duration in milliseconds
422
+ """
423
+ if log_entry is None:
424
+ return
425
+
426
+ try:
427
+ from ..models import CentrifugoLog
428
+
429
+ # ✅ Use Django 5.2+ async ORM instead of sync_to_async
430
+ log_entry.status = CentrifugoLog.StatusChoices.FAILED
431
+ log_entry.error_code = error_code
432
+ log_entry.error_message = error_message
433
+ log_entry.completed_at = timezone.now()
434
+
435
+ if duration_ms is not None:
436
+ log_entry.duration_ms = duration_ms
437
+
438
+ await log_entry.asave(
439
+ update_fields=[
440
+ "status",
441
+ "error_code",
442
+ "error_message",
443
+ "completed_at",
444
+ "duration_ms",
445
+ ]
446
+ )
447
+
448
+ logger.error(
449
+ f"Centrifugo publish failed: {log_entry.message_id}",
450
+ extra={
451
+ "message_id": log_entry.message_id,
452
+ "channel": log_entry.channel,
453
+ "error_code": error_code,
454
+ "error_message": error_message,
455
+ "duration_ms": duration_ms,
456
+ },
457
+ )
458
+
459
+ except Exception as e:
460
+ logger.error(
461
+ f"Failed to mark Centrifugo log as failed: {e}",
462
+ extra={"message_id": getattr(log_entry, "message_id", "unknown")},
463
+ )
464
+
403
465
  @staticmethod
404
466
  async def mark_timeout_async(
405
467
  log_entry: Any,
@@ -418,13 +480,27 @@ class CentrifugoLogger:
418
480
  return
419
481
 
420
482
  try:
421
- from asgiref.sync import sync_to_async
422
483
  from ..models import CentrifugoLog
423
484
 
424
- await sync_to_async(CentrifugoLog.objects.mark_timeout)(
425
- log_instance=log_entry,
426
- acks_received=acks_received,
427
- duration_ms=duration_ms,
485
+ # ✅ Use Django 5.2+ async ORM instead of sync_to_async
486
+ log_entry.status = CentrifugoLog.StatusChoices.TIMEOUT
487
+ log_entry.acks_received = acks_received
488
+ log_entry.error_code = "timeout"
489
+ log_entry.error_message = f"Timeout after {log_entry.ack_timeout}s"
490
+ log_entry.completed_at = timezone.now()
491
+
492
+ if duration_ms is not None:
493
+ log_entry.duration_ms = duration_ms
494
+
495
+ await log_entry.asave(
496
+ update_fields=[
497
+ "status",
498
+ "acks_received",
499
+ "error_code",
500
+ "error_message",
501
+ "completed_at",
502
+ "duration_ms",
503
+ ]
428
504
  )
429
505
 
430
506
  logger.warning(
@@ -5,7 +5,6 @@ Proxies requests to Centrifugo server API with authentication and type safety.
5
5
  Provides Django endpoints that map to Centrifugo HTTP API methods.
6
6
  """
7
7
 
8
- import asyncio
9
8
  import httpx
10
9
  from django.http import JsonResponse
11
10
  from django_cfg.modules.django_logging import get_logger
@@ -113,10 +112,10 @@ class CentrifugoAdminAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
113
112
  },
114
113
  )
115
114
  @action(detail=False, methods=["post"], url_path="info")
116
- def info(self, request):
117
- """Get Centrifugo server information."""
115
+ async def info(self, request):
116
+ """Get Centrifugo server information (ASYNC)."""
118
117
  try:
119
- result = asyncio.run(self._call_centrifugo_api("info", params={}))
118
+ result = await self._call_centrifugo_api("info", params={})
120
119
 
121
120
  # Check for Centrifugo API error
122
121
  if "error" in result and result["error"]:
@@ -147,13 +146,13 @@ class CentrifugoAdminAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
147
146
  },
148
147
  )
149
148
  @action(detail=False, methods=["post"], url_path="channels")
150
- def channels(self, request):
151
- """List active channels."""
149
+ async def channels(self, request):
150
+ """List active channels (ASYNC)."""
152
151
  try:
153
152
  req_data = CentrifugoChannelsRequest(**request.data)
154
- result = asyncio.run(self._call_centrifugo_api(
153
+ result = await self._call_centrifugo_api(
155
154
  "channels", params=req_data.model_dump(exclude_none=True)
156
- ))
155
+ )
157
156
 
158
157
  # Check for Centrifugo API error
159
158
  if "error" in result and result["error"]:
@@ -183,13 +182,13 @@ class CentrifugoAdminAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
183
182
  },
184
183
  )
185
184
  @action(detail=False, methods=["post"], url_path="presence")
186
- def presence(self, request):
187
- """Get channel presence (active subscribers)."""
185
+ async def presence(self, request):
186
+ """Get channel presence (active subscribers) (ASYNC)."""
188
187
  try:
189
188
  req_data = CentrifugoPresenceRequest(**request.data)
190
- result = asyncio.run(self._call_centrifugo_api(
189
+ result = await self._call_centrifugo_api(
191
190
  "presence", params=req_data.model_dump()
192
- ))
191
+ )
193
192
 
194
193
  # Check for Centrifugo API error (e.g., code 108 "not available")
195
194
  if "error" in result and result["error"]:
@@ -219,13 +218,13 @@ class CentrifugoAdminAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
219
218
  },
220
219
  )
221
220
  @action(detail=False, methods=["post"], url_path="presence-stats")
222
- def presence_stats(self, request):
223
- """Get channel presence statistics."""
221
+ async def presence_stats(self, request):
222
+ """Get channel presence statistics (ASYNC)."""
224
223
  try:
225
224
  req_data = CentrifugoPresenceStatsRequest(**request.data)
226
- result = asyncio.run(self._call_centrifugo_api(
225
+ result = await self._call_centrifugo_api(
227
226
  "presence_stats", params=req_data.model_dump()
228
- ))
227
+ )
229
228
 
230
229
  # Check for Centrifugo API error (e.g., code 108 "not available")
231
230
  if "error" in result and result["error"]:
@@ -255,13 +254,13 @@ class CentrifugoAdminAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
255
254
  },
256
255
  )
257
256
  @action(detail=False, methods=["post"], url_path="history")
258
- def history(self, request):
259
- """Get channel message history."""
257
+ async def history(self, request):
258
+ """Get channel message history (ASYNC)."""
260
259
  try:
261
260
  req_data = CentrifugoHistoryRequest(**request.data)
262
- result = asyncio.run(self._call_centrifugo_api(
261
+ result = await self._call_centrifugo_api(
263
262
  "history", params=req_data.model_dump(exclude_none=True)
264
- ))
263
+ )
265
264
 
266
265
  # Check for Centrifugo API error (e.g., code 108 "not available")
267
266
  if "error" in result and result["error"]:
@@ -359,19 +358,17 @@ class CentrifugoAdminAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
359
358
  status=status.HTTP_500_INTERNAL_SERVER_ERROR
360
359
  )
361
360
 
362
- def __del__(self):
363
- """Cleanup HTTP client on deletion."""
361
+ async def cleanup(self):
362
+ """
363
+ Explicit async cleanup method for HTTP client.
364
+
365
+ Note: Django handles ViewSet lifecycle automatically.
366
+ This method is provided for explicit cleanup if needed,
367
+ but httpx.AsyncClient will be garbage collected normally.
368
+ """
364
369
  if self._http_client:
365
- import asyncio
366
-
367
- try:
368
- loop = asyncio.get_event_loop()
369
- if loop.is_running():
370
- loop.create_task(self._http_client.aclose())
371
- else:
372
- loop.run_until_complete(self._http_client.aclose())
373
- except Exception:
374
- pass # Ignore cleanup errors
370
+ await self._http_client.aclose()
371
+ self._http_client = None
375
372
 
376
373
 
377
374
  __all__ = ["CentrifugoAdminAPIViewSet"]
@@ -5,7 +5,6 @@ Provides endpoints for live testing of Centrifugo integration from dashboard.
5
5
  Includes connection tokens, publish proxying, and ACK management.
6
6
  """
7
7
 
8
- import asyncio
9
8
  import time
10
9
  from datetime import datetime, timedelta
11
10
  from typing import Any, Dict
@@ -13,6 +12,7 @@ from typing import Any, Dict
13
12
  import httpx
14
13
  import jwt
15
14
  from django.conf import settings
15
+ from django.utils import timezone
16
16
  from django_cfg.modules.django_logging import get_logger
17
17
  from drf_spectacular.utils import extend_schema
18
18
  from pydantic import BaseModel, Field
@@ -127,23 +127,21 @@ class CentrifugoTestingAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
127
127
  },
128
128
  )
129
129
  @action(detail=False, methods=["post"], url_path="publish-test")
130
- def publish_test(self, request):
130
+ async def publish_test(self, request):
131
131
  """
132
- Publish test message via wrapper.
132
+ Publish test message via wrapper (ASYNC).
133
133
 
134
134
  Proxies request to Centrifugo wrapper with ACK tracking support.
135
135
  """
136
136
  try:
137
137
  req_data = PublishTestRequest(**request.data)
138
138
 
139
- # Call wrapper API
140
- result = asyncio.run(
141
- self._publish_to_wrapper(
142
- channel=req_data.channel,
143
- data=req_data.data,
144
- wait_for_ack=req_data.wait_for_ack,
145
- ack_timeout=req_data.ack_timeout,
146
- )
139
+ # Call wrapper API (ASYNC - no asyncio.run()!)
140
+ result = await self._publish_to_wrapper(
141
+ channel=req_data.channel,
142
+ data=req_data.data,
143
+ wait_for_ack=req_data.wait_for_ack,
144
+ ack_timeout=req_data.ack_timeout,
147
145
  )
148
146
 
149
147
  response = PublishTestResponse(
@@ -180,20 +178,18 @@ class CentrifugoTestingAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
180
178
  },
181
179
  )
182
180
  @action(detail=False, methods=["post"], url_path="send-ack")
183
- def send_ack(self, request):
181
+ async def send_ack(self, request):
184
182
  """
185
- Send manual ACK for message.
183
+ Send manual ACK for message (ASYNC).
186
184
 
187
185
  Proxies ACK to wrapper for testing ACK flow.
188
186
  """
189
187
  try:
190
188
  req_data = ManualAckRequest(**request.data)
191
189
 
192
- # Send ACK to wrapper
193
- result = asyncio.run(
194
- self._send_ack_to_wrapper(
195
- message_id=req_data.message_id, client_id=req_data.client_id
196
- )
190
+ # Send ACK to wrapper (ASYNC - no asyncio.run()!)
191
+ result = await self._send_ack_to_wrapper(
192
+ message_id=req_data.message_id, client_id=req_data.client_id
197
193
  )
198
194
 
199
195
  response = ManualAckResponse(
@@ -289,14 +285,23 @@ class CentrifugoTestingAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
289
285
 
290
286
  # Mark as failed
291
287
  if log_entry:
292
- from asgiref.sync import sync_to_async
293
288
  from ..models import CentrifugoLog
294
289
 
295
- await sync_to_async(CentrifugoLog.objects.mark_failed)(
296
- log_instance=log_entry,
297
- error_code=type(e).__name__,
298
- error_message=str(e),
299
- duration_ms=duration_ms,
290
+ # ✅ Use Django 5.2+ async ORM instead of sync_to_async
291
+ log_entry.status = CentrifugoLog.StatusChoices.FAILED
292
+ log_entry.error_code = type(e).__name__
293
+ log_entry.error_message = str(e)
294
+ log_entry.completed_at = timezone.now()
295
+ log_entry.duration_ms = duration_ms
296
+
297
+ await log_entry.asave(
298
+ update_fields=[
299
+ "status",
300
+ "error_code",
301
+ "error_message",
302
+ "completed_at",
303
+ "duration_ms",
304
+ ]
300
305
  )
301
306
 
302
307
  raise
@@ -334,9 +339,9 @@ class CentrifugoTestingAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
334
339
  },
335
340
  )
336
341
  @action(detail=False, methods=["post"], url_path="publish-with-logging")
337
- def publish_with_logging(self, request):
342
+ async def publish_with_logging(self, request):
338
343
  """
339
- Publish message using CentrifugoClient with database logging.
344
+ Publish message using CentrifugoClient with database logging (ASYNC).
340
345
 
341
346
  This endpoint uses the production CentrifugoClient which logs all
342
347
  publishes to the database (CentrifugoLog model).
@@ -347,25 +352,24 @@ class CentrifugoTestingAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
347
352
  # Use CentrifugoClient for publishing
348
353
  client = CentrifugoClient()
349
354
 
350
- # Publish message
351
- result = asyncio.run(
352
- client.publish_with_ack(
355
+ # Publish message (ASYNC - no asyncio.run()!)
356
+ if req_data.wait_for_ack:
357
+ result = await client.publish_with_ack(
353
358
  channel=req_data.channel,
354
359
  data=req_data.data,
355
- ack_timeout=req_data.ack_timeout if req_data.wait_for_ack else None,
360
+ ack_timeout=req_data.ack_timeout,
356
361
  user=request.user if request.user.is_authenticated else None,
357
362
  caller_ip=request.META.get("REMOTE_ADDR"),
358
363
  user_agent=request.META.get("HTTP_USER_AGENT"),
359
364
  )
360
- if req_data.wait_for_ack
361
- else client.publish(
365
+ else:
366
+ result = await client.publish(
362
367
  channel=req_data.channel,
363
368
  data=req_data.data,
364
369
  user=request.user if request.user.is_authenticated else None,
365
370
  caller_ip=request.META.get("REMOTE_ADDR"),
366
371
  user_agent=request.META.get("HTTP_USER_AGENT"),
367
372
  )
368
- )
369
373
 
370
374
  # Convert PublishResponse to dict
371
375
  response_data = {
@@ -386,17 +390,17 @@ class CentrifugoTestingAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
386
390
  status=status.HTTP_500_INTERNAL_SERVER_ERROR,
387
391
  )
388
392
 
389
- def __del__(self):
390
- """Cleanup HTTP client on deletion."""
393
+ async def cleanup(self):
394
+ """
395
+ Explicit async cleanup method for HTTP client.
396
+
397
+ Note: Django handles ViewSet lifecycle automatically.
398
+ This method is provided for explicit cleanup if needed,
399
+ but httpx.AsyncClient will be garbage collected normally.
400
+ """
391
401
  if self._http_client:
392
- try:
393
- loop = asyncio.get_event_loop()
394
- if loop.is_running():
395
- loop.create_task(self._http_client.aclose())
396
- else:
397
- loop.run_until_complete(self._http_client.aclose())
398
- except Exception:
399
- pass
402
+ await self._http_client.aclose()
403
+ self._http_client = None
400
404
 
401
405
 
402
406
  __all__ = ["CentrifugoTestingAPIViewSet"]
@@ -5,13 +5,14 @@ Provides /api/publish endpoint that acts as a proxy to Centrifugo
5
5
  with ACK tracking and database logging.
6
6
  """
7
7
 
8
- import asyncio
9
8
  import time
10
9
  import uuid
11
10
  from typing import Any, Dict
12
11
 
13
12
  import httpx
13
+ from django.db import transaction
14
14
  from django.http import JsonResponse
15
+ from django.utils import timezone
15
16
  from django.utils.decorators import method_decorator
16
17
  from django.views import View
17
18
  from django.views.decorators.csrf import csrf_exempt
@@ -55,15 +56,19 @@ class PublishResponse(BaseModel):
55
56
 
56
57
 
57
58
  @method_decorator(csrf_exempt, name='dispatch')
59
+ @method_decorator(transaction.non_atomic_requests, name='dispatch')
58
60
  class PublishWrapperView(View):
59
61
  """
60
- Centrifugo publish wrapper endpoint.
62
+ Centrifugo publish wrapper endpoint (ASYNC).
61
63
 
62
64
  Provides /api/publish endpoint that:
63
65
  - Accepts publish requests from CentrifugoClient
64
66
  - Logs to database (CentrifugoLog)
65
67
  - Proxies to Centrifugo HTTP API
66
68
  - Returns publish result with ACK tracking
69
+
70
+ NOTE: This is an async view for proper async/await handling.
71
+ Using asyncio.run() in sync views causes event loop conflicts.
67
72
  """
68
73
 
69
74
  def __init__(self, *args, **kwargs):
@@ -169,21 +174,30 @@ class PublishWrapperView(View):
169
174
 
170
175
  # Mark as failed
171
176
  if log_entry:
172
- from asgiref.sync import sync_to_async
173
177
  from ..models import CentrifugoLog
174
178
 
175
- await sync_to_async(CentrifugoLog.objects.mark_failed)(
176
- log_instance=log_entry,
177
- error_code=type(e).__name__,
178
- error_message=str(e),
179
- duration_ms=duration_ms,
179
+ # ✅ Use Django 5.2+ async ORM instead of sync_to_async
180
+ log_entry.status = CentrifugoLog.StatusChoices.FAILED
181
+ log_entry.error_code = type(e).__name__
182
+ log_entry.error_message = str(e)
183
+ log_entry.completed_at = timezone.now()
184
+ log_entry.duration_ms = duration_ms
185
+
186
+ await log_entry.asave(
187
+ update_fields=[
188
+ "status",
189
+ "error_code",
190
+ "error_message",
191
+ "completed_at",
192
+ "duration_ms",
193
+ ]
180
194
  )
181
195
 
182
196
  raise
183
197
 
184
- def post(self, request):
198
+ async def post(self, request):
185
199
  """
186
- Handle POST /api/publish request.
200
+ Handle POST /api/publish request (ASYNC).
187
201
 
188
202
  Request body:
189
203
  {
@@ -213,15 +227,13 @@ class PublishWrapperView(View):
213
227
  # Generate message ID if not provided
214
228
  message_id = req_data.message_id or str(uuid.uuid4())
215
229
 
216
- # Publish to Centrifugo
217
- result = asyncio.run(
218
- self._publish_to_centrifugo(
219
- channel=req_data.channel,
220
- data=req_data.data,
221
- wait_for_ack=req_data.wait_for_ack,
222
- ack_timeout=req_data.ack_timeout,
223
- message_id=message_id,
224
- )
230
+ # Publish to Centrifugo (ASYNC - no asyncio.run()!)
231
+ result = await self._publish_to_centrifugo(
232
+ channel=req_data.channel,
233
+ data=req_data.data,
234
+ wait_for_ack=req_data.wait_for_ack,
235
+ ack_timeout=req_data.ack_timeout,
236
+ message_id=message_id,
225
237
  )
226
238
 
227
239
  response = PublishResponse(**result)
@@ -241,17 +253,17 @@ class PublishWrapperView(View):
241
253
  status=500,
242
254
  )
243
255
 
244
- def __del__(self):
245
- """Cleanup HTTP client on deletion."""
256
+ async def cleanup(self):
257
+ """
258
+ Explicit async cleanup method for HTTP client.
259
+
260
+ Note: Django handles View lifecycle automatically.
261
+ This method is provided for explicit cleanup if needed,
262
+ but httpx.AsyncClient will be garbage collected normally.
263
+ """
246
264
  if self._http_client:
247
- try:
248
- loop = asyncio.get_event_loop()
249
- if loop.is_running():
250
- loop.create_task(self._http_client.aclose())
251
- else:
252
- loop.run_until_complete(self._http_client.aclose())
253
- except Exception:
254
- pass
265
+ await self._http_client.aclose()
266
+ self._http_client = None
255
267
 
256
268
 
257
269
  __all__ = ["PublishWrapperView"]
@@ -170,10 +170,11 @@ class ApiKeyAuthInterceptor(grpc.aio.ServerInterceptor):
170
170
  logger.debug("API key matches Django SECRET_KEY")
171
171
  # For SECRET_KEY, return first superuser or None (no api_key instance)
172
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
- )
173
+ # Django 5.2: Native async ORM
174
+ superuser = await User.objects.filter(
175
+ is_superuser=True, is_active=True
176
+ ).afirst()
177
+
177
178
  if superuser:
178
179
  return superuser, None
179
180
  else:
@@ -187,14 +188,14 @@ class ApiKeyAuthInterceptor(grpc.aio.ServerInterceptor):
187
188
  try:
188
189
  from django_cfg.apps.integrations.grpc.models import GrpcApiKey
189
190
 
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
- )
191
+ # Django 5.2: Native async ORM
192
+ api_key_obj = await GrpcApiKey.objects.filter(
193
+ key=api_key, is_active=True
194
+ ).afirst()
194
195
 
195
196
  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)
197
+ # Update usage tracking (async method call)
198
+ await api_key_obj.amark_used()
198
199
  logger.debug(f"Valid API key for user {api_key_obj.user.id} ({api_key_obj.user.username})")
199
200
  return api_key_obj.user, api_key_obj
200
201
  else:
@@ -50,7 +50,7 @@ class Command(AdminCommand):
50
50
 
51
51
  def handle(self, *args, **options):
52
52
  from django_cfg.apps.integrations.grpc.utils.proto_gen import generate_proto_for_app
53
- from django_cfg.apps.integrations.grpc.services.config_helper import get_grpc_config
53
+ from django_cfg.apps.integrations.grpc.services.management.config_helper import get_grpc_config
54
54
 
55
55
  # Get gRPC config
56
56
  grpc_config = get_grpc_config()