django-cfg 1.5.14__py3-none-any.whl → 1.5.29__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 (118) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/business/accounts/serializers/profile.py +42 -0
  3. django_cfg/apps/business/support/serializers.py +3 -2
  4. django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
  5. django_cfg/apps/integrations/centrifugo/apps.py +2 -1
  6. django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
  7. django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
  8. django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
  9. django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
  10. django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
  11. django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
  12. django_cfg/apps/integrations/centrifugo/services/logging.py +47 -0
  13. django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
  14. django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
  15. django_cfg/apps/integrations/centrifugo/urls.py +8 -0
  16. django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
  17. django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
  18. django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -116
  19. django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
  20. django_cfg/apps/integrations/centrifugo/views/wrapper.py +259 -0
  21. django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
  22. django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
  23. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +56 -1
  24. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +315 -26
  25. django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
  26. django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
  27. django_cfg/apps/integrations/grpc/managers/grpc_request_log.py +84 -0
  28. django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
  29. django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
  30. django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
  31. django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
  32. django_cfg/apps/integrations/grpc/services/centrifugo/__init__.py +29 -0
  33. django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
  34. django_cfg/apps/integrations/grpc/services/centrifugo/config.py +167 -0
  35. django_cfg/apps/integrations/grpc/services/centrifugo/demo.py +626 -0
  36. django_cfg/apps/integrations/grpc/services/centrifugo/test_publish.py +229 -0
  37. django_cfg/apps/integrations/grpc/services/centrifugo/transformers.py +89 -0
  38. django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
  39. django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
  40. django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
  41. django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
  42. django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
  43. django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
  44. django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
  45. django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
  46. django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
  47. django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
  48. django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
  49. django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
  50. django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
  51. django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +67 -54
  52. django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +215 -5
  53. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +3 -1
  54. django_cfg/apps/integrations/grpc/services/interceptors/centrifugo.py +541 -0
  55. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
  56. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
  57. django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
  58. django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
  59. django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
  60. django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
  61. django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
  62. django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
  63. django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
  64. django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
  65. django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
  66. django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
  67. django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
  68. django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
  69. django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
  70. django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
  71. django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
  72. django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
  73. django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
  74. django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
  75. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +261 -13
  76. django_cfg/apps/integrations/grpc/views/charts.py +1 -1
  77. django_cfg/apps/integrations/grpc/views/config.py +1 -1
  78. django_cfg/apps/system/dashboard/serializers/config.py +95 -9
  79. django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
  80. django_cfg/apps/system/frontend/views.py +87 -6
  81. django_cfg/core/base/config_model.py +11 -0
  82. django_cfg/core/builders/middleware_builder.py +5 -0
  83. django_cfg/core/builders/security_builder.py +1 -0
  84. django_cfg/core/generation/integration_generators/api.py +2 -0
  85. django_cfg/management/commands/pool_status.py +153 -0
  86. django_cfg/middleware/pool_cleanup.py +261 -0
  87. django_cfg/models/api/grpc/config.py +2 -2
  88. django_cfg/models/infrastructure/database/config.py +16 -0
  89. django_cfg/models/infrastructure/database/converters.py +2 -0
  90. django_cfg/modules/django_admin/utils/html/composition.py +57 -13
  91. django_cfg/modules/django_admin/utils/html_builder.py +1 -0
  92. django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
  93. django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
  94. django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
  95. django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
  96. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
  97. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
  98. django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
  99. django_cfg/modules/django_client/core/groups/manager.py +25 -18
  100. django_cfg/modules/django_client/core/ir/schema.py +15 -1
  101. django_cfg/modules/django_client/core/parser/base.py +12 -0
  102. django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
  103. django_cfg/modules/django_logging/django_logger.py +58 -19
  104. django_cfg/pyproject.toml +3 -3
  105. django_cfg/static/frontend/admin.zip +0 -0
  106. django_cfg/templates/admin/index.html +0 -39
  107. django_cfg/utils/pool_monitor.py +320 -0
  108. django_cfg/utils/smart_defaults.py +233 -7
  109. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
  110. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/RECORD +118 -74
  111. /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
  112. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
  113. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
  114. /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
  115. /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
  116. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/WHEEL +0 -0
  117. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
  118. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,122 @@
1
+ """
2
+ Centrifugo Token Generator Service.
3
+
4
+ Provides utilities for generating Centrifugo JWT tokens with user permissions.
5
+ """
6
+
7
+ import time
8
+ import jwt
9
+ from datetime import datetime, timezone
10
+ from typing import List, Dict, Any, Optional
11
+
12
+ from .config_helper import get_centrifugo_config
13
+
14
+
15
+ def get_user_channels(user) -> List[str]:
16
+ """
17
+ Get list of Centrifugo channels user is allowed to subscribe to.
18
+
19
+ Args:
20
+ user: Django user instance
21
+
22
+ Returns:
23
+ List of channel names user can access
24
+
25
+ Channel naming convention:
26
+ - user#{user_id} - Personal channel for RPC responses
27
+ - notifications#user#{user_id} - Personal notifications
28
+ - centrifugo#dashboard - Admin dashboard events
29
+ - admin#notifications - Admin notifications
30
+ - grpc#* - All gRPC bot events (admin only)
31
+ - broadcast - Global broadcast channel
32
+ """
33
+ channels = []
34
+
35
+ # Personal channel for RPC responses
36
+ channels.append(f"user#{user.id}")
37
+
38
+ # Notifications channel
39
+ channels.append(f"notifications#user#{user.id}")
40
+
41
+ # Admin channels
42
+ if user.is_staff or user.is_superuser:
43
+ channels.append("centrifugo#dashboard")
44
+ channels.append("admin#notifications")
45
+ # Allow admins to see all gRPC bot events
46
+ channels.append("grpc#*")
47
+
48
+ # Broadcast channel for all users
49
+ channels.append("broadcast")
50
+
51
+ return channels
52
+
53
+
54
+ def generate_centrifugo_token(
55
+ user,
56
+ exp_seconds: int = 3600,
57
+ additional_channels: Optional[List[str]] = None
58
+ ) -> Dict[str, Any]:
59
+ """
60
+ Generate Centrifugo JWT token with user's allowed channels.
61
+
62
+ Args:
63
+ user: Django user instance
64
+ exp_seconds: Token expiration time in seconds (default: 1 hour)
65
+ additional_channels: Optional additional channels to include
66
+
67
+ Returns:
68
+ Dictionary with:
69
+ - token: JWT token string
70
+ - centrifugo_url: Centrifugo WebSocket URL
71
+ - expires_at: Token expiration datetime
72
+ - channels: List of allowed channels
73
+
74
+ Raises:
75
+ ValueError: If Centrifugo is not configured or disabled
76
+ """
77
+ config = get_centrifugo_config()
78
+ if not config or not config.enabled:
79
+ raise ValueError("Centrifugo not configured or disabled")
80
+
81
+ # Get user's allowed channels
82
+ channels = get_user_channels(user)
83
+
84
+ # Add additional channels if provided
85
+ if additional_channels:
86
+ channels.extend(additional_channels)
87
+ # Remove duplicates while preserving order
88
+ channels = list(dict.fromkeys(channels))
89
+
90
+ # Generate JWT token
91
+ now = int(time.time())
92
+ exp = now + exp_seconds
93
+
94
+ payload = {
95
+ "sub": str(user.id), # User ID
96
+ "exp": exp, # Expiration time
97
+ "iat": now, # Issued at
98
+ "channels": channels, # Allowed channels
99
+ }
100
+
101
+ # Sign token with HMAC secret
102
+ token = jwt.encode(
103
+ payload,
104
+ config.centrifugo_token_hmac_secret,
105
+ algorithm="HS256"
106
+ )
107
+
108
+ # Use timezone-aware datetime for proper ISO 8601 format
109
+ expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
110
+
111
+ return {
112
+ "token": token,
113
+ "centrifugo_url": config.centrifugo_url,
114
+ "expires_at": expires_at,
115
+ "channels": channels,
116
+ }
117
+
118
+
119
+ __all__ = [
120
+ "get_user_channels",
121
+ "generate_centrifugo_token",
122
+ ]
@@ -10,6 +10,8 @@ from rest_framework import routers
10
10
  from .views.admin_api import CentrifugoAdminAPIViewSet
11
11
  from .views.monitoring import CentrifugoMonitorViewSet
12
12
  from .views.testing_api import CentrifugoTestingAPIViewSet
13
+ from .views.token_api import CentrifugoTokenViewSet
14
+ from .views.wrapper import PublishWrapperView
13
15
 
14
16
  app_name = 'django_cfg_centrifugo'
15
17
 
@@ -25,7 +27,13 @@ router.register(r'server', CentrifugoAdminAPIViewSet, basename='server')
25
27
  # Testing API endpoints (live testing from dashboard)
26
28
  router.register(r'testing', CentrifugoTestingAPIViewSet, basename='testing')
27
29
 
30
+ # Token API endpoints (JWT token generation for client connections)
31
+ router.register(r'auth', CentrifugoTokenViewSet, basename='auth')
32
+
28
33
  urlpatterns = [
34
+ # Wrapper API endpoint (for CentrifugoClient)
35
+ path('api/publish', PublishWrapperView.as_view(), name='wrapper_publish'),
36
+
29
37
  # Include router URLs
30
38
  path('', include(router.urls)),
31
39
  ]
@@ -5,9 +5,11 @@ Views for Centrifugo module.
5
5
  from .admin_api import CentrifugoAdminAPIViewSet
6
6
  from .monitoring import CentrifugoMonitorViewSet
7
7
  from .testing_api import CentrifugoTestingAPIViewSet
8
+ from .wrapper import PublishWrapperView
8
9
 
9
10
  __all__ = [
10
11
  'CentrifugoMonitorViewSet',
11
12
  'CentrifugoAdminAPIViewSet',
12
13
  'CentrifugoTestingAPIViewSet',
14
+ 'PublishWrapperView',
13
15
  ]
@@ -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
@@ -32,23 +31,6 @@ logger = get_logger("centrifugo.testing_api")
32
31
  # ========================================================================
33
32
 
34
33
 
35
- class ConnectionTokenRequest(BaseModel):
36
- """Request model for connection token generation."""
37
-
38
- user_id: str = Field(..., description="User ID for the connection")
39
- channels: list[str] = Field(
40
- default_factory=list, description="List of channels to authorize"
41
- )
42
-
43
-
44
- class ConnectionTokenResponse(BaseModel):
45
- """Response model for connection token."""
46
-
47
- token: str = Field(..., description="JWT token for WebSocket connection")
48
- centrifugo_url: str = Field(..., description="Centrifugo WebSocket URL")
49
- expires_at: str = Field(..., description="Token expiration time (ISO 8601)")
50
-
51
-
52
34
  class PublishTestRequest(BaseModel):
53
35
  """Request model for test message publishing."""
54
36
 
@@ -132,68 +114,6 @@ class CentrifugoTestingAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
132
114
 
133
115
  return self._http_client
134
116
 
135
- @extend_schema(
136
- tags=["Centrifugo Testing"],
137
- summary="Generate connection token",
138
- description="Generate JWT token for WebSocket connection to Centrifugo.",
139
- request=ConnectionTokenRequest,
140
- responses={
141
- 200: ConnectionTokenResponse,
142
- 400: {"description": "Invalid request"},
143
- 500: {"description": "Server error"},
144
- },
145
- )
146
- @action(detail=False, methods=["post"], url_path="connection-token")
147
- def connection_token(self, request):
148
- """
149
- Generate JWT token for WebSocket connection.
150
-
151
- Returns token that can be used to connect to Centrifugo from browser.
152
- """
153
- try:
154
- config = get_centrifugo_config()
155
- if not config:
156
- return Response(
157
- {"error": "Centrifugo not configured"},
158
- status=status.HTTP_500_INTERNAL_SERVER_ERROR,
159
- )
160
-
161
- # Parse request
162
- req_data = ConnectionTokenRequest(**request.data)
163
-
164
- # Generate JWT token
165
- now = int(time.time())
166
- exp = now + 3600 # 1 hour
167
-
168
- payload = {
169
- "sub": req_data.user_id,
170
- "exp": exp,
171
- "iat": now,
172
- }
173
-
174
- # Add channels if provided
175
- if req_data.channels:
176
- payload["channels"] = req_data.channels
177
-
178
- # Use HMAC secret from config or Django SECRET_KEY
179
- secret = config.centrifugo_token_hmac_secret or settings.SECRET_KEY
180
-
181
- token = jwt.encode(payload, secret, algorithm="HS256")
182
-
183
- response = ConnectionTokenResponse(
184
- token=token,
185
- centrifugo_url=config.centrifugo_url,
186
- expires_at=datetime.utcfromtimestamp(exp).isoformat() + "Z",
187
- )
188
-
189
- return Response(response.model_dump())
190
-
191
- except Exception as e:
192
- logger.error(f"Failed to generate connection token: {e}", exc_info=True)
193
- return Response(
194
- {"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
195
- )
196
-
197
117
  @extend_schema(
198
118
  tags=["Centrifugo Testing"],
199
119
  summary="Publish test message",
@@ -206,23 +126,21 @@ class CentrifugoTestingAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
206
126
  },
207
127
  )
208
128
  @action(detail=False, methods=["post"], url_path="publish-test")
209
- def publish_test(self, request):
129
+ async def publish_test(self, request):
210
130
  """
211
- Publish test message via wrapper.
131
+ Publish test message via wrapper (ASYNC).
212
132
 
213
133
  Proxies request to Centrifugo wrapper with ACK tracking support.
214
134
  """
215
135
  try:
216
136
  req_data = PublishTestRequest(**request.data)
217
137
 
218
- # Call wrapper API
219
- result = asyncio.run(
220
- self._publish_to_wrapper(
221
- channel=req_data.channel,
222
- data=req_data.data,
223
- wait_for_ack=req_data.wait_for_ack,
224
- ack_timeout=req_data.ack_timeout,
225
- )
138
+ # Call wrapper API (ASYNC - no asyncio.run()!)
139
+ result = await self._publish_to_wrapper(
140
+ channel=req_data.channel,
141
+ data=req_data.data,
142
+ wait_for_ack=req_data.wait_for_ack,
143
+ ack_timeout=req_data.ack_timeout,
226
144
  )
227
145
 
228
146
  response = PublishTestResponse(
@@ -259,20 +177,18 @@ class CentrifugoTestingAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
259
177
  },
260
178
  )
261
179
  @action(detail=False, methods=["post"], url_path="send-ack")
262
- def send_ack(self, request):
180
+ async def send_ack(self, request):
263
181
  """
264
- Send manual ACK for message.
182
+ Send manual ACK for message (ASYNC).
265
183
 
266
184
  Proxies ACK to wrapper for testing ACK flow.
267
185
  """
268
186
  try:
269
187
  req_data = ManualAckRequest(**request.data)
270
188
 
271
- # Send ACK to wrapper
272
- result = asyncio.run(
273
- self._send_ack_to_wrapper(
274
- message_id=req_data.message_id, client_id=req_data.client_id
275
- )
189
+ # Send ACK to wrapper (ASYNC - no asyncio.run()!)
190
+ result = await self._send_ack_to_wrapper(
191
+ message_id=req_data.message_id, client_id=req_data.client_id
276
192
  )
277
193
 
278
194
  response = ManualAckResponse(
@@ -413,9 +329,9 @@ class CentrifugoTestingAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
413
329
  },
414
330
  )
415
331
  @action(detail=False, methods=["post"], url_path="publish-with-logging")
416
- def publish_with_logging(self, request):
332
+ async def publish_with_logging(self, request):
417
333
  """
418
- Publish message using CentrifugoClient with database logging.
334
+ Publish message using CentrifugoClient with database logging (ASYNC).
419
335
 
420
336
  This endpoint uses the production CentrifugoClient which logs all
421
337
  publishes to the database (CentrifugoLog model).
@@ -426,25 +342,24 @@ class CentrifugoTestingAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
426
342
  # Use CentrifugoClient for publishing
427
343
  client = CentrifugoClient()
428
344
 
429
- # Publish message
430
- result = asyncio.run(
431
- client.publish_with_ack(
345
+ # Publish message (ASYNC - no asyncio.run()!)
346
+ if req_data.wait_for_ack:
347
+ result = await client.publish_with_ack(
432
348
  channel=req_data.channel,
433
349
  data=req_data.data,
434
- ack_timeout=req_data.ack_timeout if req_data.wait_for_ack else None,
350
+ ack_timeout=req_data.ack_timeout,
435
351
  user=request.user if request.user.is_authenticated else None,
436
352
  caller_ip=request.META.get("REMOTE_ADDR"),
437
353
  user_agent=request.META.get("HTTP_USER_AGENT"),
438
354
  )
439
- if req_data.wait_for_ack
440
- else client.publish(
355
+ else:
356
+ result = await client.publish(
441
357
  channel=req_data.channel,
442
358
  data=req_data.data,
443
359
  user=request.user if request.user.is_authenticated else None,
444
360
  caller_ip=request.META.get("REMOTE_ADDR"),
445
361
  user_agent=request.META.get("HTTP_USER_AGENT"),
446
362
  )
447
- )
448
363
 
449
364
  # Convert PublishResponse to dict
450
365
  response_data = {
@@ -465,17 +380,17 @@ class CentrifugoTestingAPIViewSet(AdminAPIMixin, viewsets.ViewSet):
465
380
  status=status.HTTP_500_INTERNAL_SERVER_ERROR,
466
381
  )
467
382
 
468
- def __del__(self):
469
- """Cleanup HTTP client on deletion."""
383
+ async def cleanup(self):
384
+ """
385
+ Explicit async cleanup method for HTTP client.
386
+
387
+ Note: Django handles ViewSet lifecycle automatically.
388
+ This method is provided for explicit cleanup if needed,
389
+ but httpx.AsyncClient will be garbage collected normally.
390
+ """
470
391
  if self._http_client:
471
- try:
472
- loop = asyncio.get_event_loop()
473
- if loop.is_running():
474
- loop.create_task(self._http_client.aclose())
475
- else:
476
- loop.run_until_complete(self._http_client.aclose())
477
- except Exception:
478
- pass
392
+ await self._http_client.aclose()
393
+ self._http_client = None
479
394
 
480
395
 
481
396
  __all__ = ["CentrifugoTestingAPIViewSet"]
@@ -0,0 +1,101 @@
1
+ """
2
+ Centrifugo Token API
3
+
4
+ Provides endpoint for generating Centrifugo JWT tokens with user permissions.
5
+ """
6
+
7
+ import logging
8
+
9
+ from drf_spectacular.utils import extend_schema
10
+ from pydantic import BaseModel, Field
11
+ from rest_framework import status, viewsets
12
+ from rest_framework.decorators import action
13
+ from rest_framework.response import Response
14
+ from rest_framework.permissions import IsAuthenticated
15
+
16
+ from ..services import generate_centrifugo_token
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # ========================================================================
22
+ # Response Models
23
+ # ========================================================================
24
+
25
+
26
+ class ConnectionTokenResponse(BaseModel):
27
+ """Response model for Centrifugo connection token."""
28
+
29
+ token: str = Field(..., description="JWT token for Centrifugo connection")
30
+ centrifugo_url: str = Field(..., description="Centrifugo WebSocket URL")
31
+ expires_at: str = Field(..., description="Token expiration time (ISO 8601)")
32
+ channels: list[str] = Field(..., description="List of allowed channels")
33
+
34
+
35
+ class CentrifugoTokenViewSet(viewsets.ViewSet):
36
+ """
37
+ Centrifugo Token API ViewSet.
38
+
39
+ Provides endpoint for authenticated users to get Centrifugo JWT token
40
+ with their allowed channels based on permissions.
41
+ """
42
+
43
+ permission_classes = [IsAuthenticated]
44
+
45
+ @extend_schema(
46
+ tags=["Centrifugo Auth"],
47
+ summary="Get Centrifugo connection token",
48
+ description=(
49
+ "Generate JWT token for WebSocket connection to Centrifugo. "
50
+ "Token includes user's allowed channels based on their permissions. "
51
+ "Requires authentication."
52
+ ),
53
+ responses={
54
+ 200: ConnectionTokenResponse,
55
+ 401: {"description": "Unauthorized - authentication required"},
56
+ 500: {"description": "Server error"},
57
+ },
58
+ )
59
+ @action(detail=False, methods=["get"], url_path="token")
60
+ def get_token(self, request):
61
+ """
62
+ Get Centrifugo connection token for authenticated user.
63
+
64
+ Returns JWT token with user's allowed channels that can be used
65
+ to connect to Centrifugo WebSocket.
66
+ """
67
+ try:
68
+ user = request.user
69
+
70
+ # Generate token with user's channels
71
+ token_data = generate_centrifugo_token(user)
72
+
73
+ logger.debug(
74
+ f"Generated Centrifugo token for user {user.email} (ID: {user.id}) "
75
+ f"with {len(token_data['channels'])} channels: {token_data['channels']}"
76
+ )
77
+
78
+ # Format expires_at as ISO 8601 string
79
+ return Response({
80
+ "token": token_data["token"],
81
+ "centrifugo_url": token_data["centrifugo_url"],
82
+ "expires_at": token_data["expires_at"].isoformat() + "Z",
83
+ "channels": token_data["channels"],
84
+ })
85
+
86
+ except ValueError as e:
87
+ # Centrifugo not configured or disabled
88
+ logger.warning(f"Centrifugo token generation failed: {e}")
89
+ return Response(
90
+ {"error": str(e)},
91
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
92
+ )
93
+ except Exception as e:
94
+ logger.error(f"Failed to generate Centrifugo token: {e}", exc_info=True)
95
+ return Response(
96
+ {"error": "Failed to generate token"},
97
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
98
+ )
99
+
100
+
101
+ __all__ = ["CentrifugoTokenViewSet"]