django-cfg 1.4.62__py3-none-any.whl → 1.4.63__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 (181) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/accounts/services/otp_service.py +3 -14
  3. django_cfg/apps/centrifugo/__init__.py +57 -0
  4. django_cfg/apps/centrifugo/admin/__init__.py +13 -0
  5. django_cfg/apps/centrifugo/admin/centrifugo_log.py +249 -0
  6. django_cfg/apps/centrifugo/admin/config.py +82 -0
  7. django_cfg/apps/centrifugo/apps.py +31 -0
  8. django_cfg/apps/centrifugo/codegen/IMPLEMENTATION_SUMMARY.md +475 -0
  9. django_cfg/apps/centrifugo/codegen/README.md +242 -0
  10. django_cfg/apps/centrifugo/codegen/USAGE.md +616 -0
  11. django_cfg/apps/centrifugo/codegen/__init__.py +19 -0
  12. django_cfg/apps/centrifugo/codegen/discovery.py +246 -0
  13. django_cfg/apps/centrifugo/codegen/generators/go_thin/__init__.py +5 -0
  14. django_cfg/apps/centrifugo/codegen/generators/go_thin/generator.py +174 -0
  15. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/README.md.j2 +182 -0
  16. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/client.go.j2 +64 -0
  17. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/go.mod.j2 +10 -0
  18. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/rpc_client.go.j2 +300 -0
  19. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/rpc_client.go.j2.old +267 -0
  20. django_cfg/apps/centrifugo/codegen/generators/go_thin/templates/types.go.j2 +16 -0
  21. django_cfg/apps/centrifugo/codegen/generators/python_thin/__init__.py +7 -0
  22. django_cfg/apps/centrifugo/codegen/generators/python_thin/generator.py +241 -0
  23. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/README.md.j2 +128 -0
  24. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/__init__.py.j2 +22 -0
  25. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/client.py.j2 +73 -0
  26. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/models.py.j2 +19 -0
  27. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/requirements.txt.j2 +8 -0
  28. django_cfg/apps/centrifugo/codegen/generators/python_thin/templates/rpc_client.py.j2 +193 -0
  29. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/__init__.py +5 -0
  30. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/generator.py +124 -0
  31. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/README.md.j2 +38 -0
  32. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/client.ts.j2 +25 -0
  33. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/index.ts.j2 +12 -0
  34. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/package.json.j2 +13 -0
  35. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +137 -0
  36. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/tsconfig.json.j2 +14 -0
  37. django_cfg/apps/centrifugo/codegen/generators/typescript_thin/templates/types.ts.j2 +9 -0
  38. django_cfg/apps/centrifugo/codegen/utils/__init__.py +37 -0
  39. django_cfg/apps/centrifugo/codegen/utils/naming.py +155 -0
  40. django_cfg/apps/centrifugo/codegen/utils/type_converter.py +349 -0
  41. django_cfg/apps/centrifugo/decorators.py +137 -0
  42. django_cfg/apps/centrifugo/management/__init__.py +1 -0
  43. django_cfg/apps/centrifugo/management/commands/__init__.py +1 -0
  44. django_cfg/apps/centrifugo/management/commands/generate_centrifugo_clients.py +254 -0
  45. django_cfg/apps/centrifugo/managers/__init__.py +12 -0
  46. django_cfg/apps/centrifugo/managers/centrifugo_log.py +264 -0
  47. django_cfg/apps/centrifugo/migrations/0001_initial.py +164 -0
  48. django_cfg/apps/centrifugo/migrations/__init__.py +3 -0
  49. django_cfg/apps/centrifugo/models/__init__.py +11 -0
  50. django_cfg/apps/centrifugo/models/centrifugo_log.py +210 -0
  51. django_cfg/apps/centrifugo/registry.py +106 -0
  52. django_cfg/apps/centrifugo/router.py +125 -0
  53. django_cfg/apps/centrifugo/serializers/__init__.py +40 -0
  54. django_cfg/apps/centrifugo/serializers/admin_api.py +264 -0
  55. django_cfg/apps/centrifugo/serializers/channels.py +26 -0
  56. django_cfg/apps/centrifugo/serializers/health.py +17 -0
  57. django_cfg/apps/centrifugo/serializers/publishes.py +16 -0
  58. django_cfg/apps/centrifugo/serializers/stats.py +21 -0
  59. django_cfg/apps/centrifugo/services/__init__.py +12 -0
  60. django_cfg/apps/centrifugo/services/client/__init__.py +29 -0
  61. django_cfg/apps/centrifugo/services/client/client.py +577 -0
  62. django_cfg/apps/centrifugo/services/client/config.py +228 -0
  63. django_cfg/apps/centrifugo/services/client/exceptions.py +212 -0
  64. django_cfg/apps/centrifugo/services/config_helper.py +63 -0
  65. django_cfg/apps/centrifugo/services/dashboard_notifier.py +157 -0
  66. django_cfg/apps/centrifugo/services/logging.py +677 -0
  67. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/css/dashboard.css +260 -0
  68. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/live_channels.mjs +313 -0
  69. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/live_testing.mjs +803 -0
  70. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/main.mjs +333 -0
  71. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/overview.mjs +432 -0
  72. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/testing.mjs +33 -0
  73. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/websocket.mjs +210 -0
  74. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/channels_content.html +46 -0
  75. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/live_channels_content.html +123 -0
  76. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/overview_content.html +45 -0
  77. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/publishes_content.html +84 -0
  78. django_cfg/apps/{ipc/templates/django_cfg_ipc → centrifugo/templates/django_cfg_centrifugo}/components/stat_cards.html +23 -20
  79. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/system_status.html +91 -0
  80. django_cfg/apps/{ipc/templates/django_cfg_ipc → centrifugo/templates/django_cfg_centrifugo}/components/tab_navigation.html +15 -15
  81. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/testing_tools.html +415 -0
  82. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/layout/base.html +61 -0
  83. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/pages/dashboard.html +58 -0
  84. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/tags/connection_script.html +48 -0
  85. django_cfg/apps/centrifugo/templatetags/__init__.py +1 -0
  86. django_cfg/apps/centrifugo/templatetags/centrifugo_tags.py +81 -0
  87. django_cfg/apps/centrifugo/urls.py +31 -0
  88. django_cfg/apps/{ipc → centrifugo}/urls_admin.py +4 -4
  89. django_cfg/apps/centrifugo/views/__init__.py +15 -0
  90. django_cfg/apps/centrifugo/views/admin_api.py +374 -0
  91. django_cfg/apps/centrifugo/views/dashboard.py +15 -0
  92. django_cfg/apps/centrifugo/views/monitoring.py +286 -0
  93. django_cfg/apps/centrifugo/views/testing_api.py +422 -0
  94. django_cfg/apps/support/utils/support_email_service.py +5 -18
  95. django_cfg/apps/tasks/templates/tasks/layout/base.html +0 -2
  96. django_cfg/apps/urls.py +5 -5
  97. django_cfg/core/base/config_model.py +4 -44
  98. django_cfg/core/builders/apps_builder.py +2 -2
  99. django_cfg/core/generation/integration_generators/third_party.py +8 -8
  100. django_cfg/core/utils/__init__.py +5 -0
  101. django_cfg/core/utils/url_helpers.py +73 -0
  102. django_cfg/modules/base.py +7 -7
  103. django_cfg/modules/django_client/core/__init__.py +2 -1
  104. django_cfg/modules/django_client/core/config/config.py +8 -0
  105. django_cfg/modules/django_client/core/generator/__init__.py +42 -2
  106. django_cfg/modules/django_client/core/generator/go/__init__.py +14 -0
  107. django_cfg/modules/django_client/core/generator/go/client_generator.py +124 -0
  108. django_cfg/modules/django_client/core/generator/go/files_generator.py +133 -0
  109. django_cfg/modules/django_client/core/generator/go/generator.py +203 -0
  110. django_cfg/modules/django_client/core/generator/go/models_generator.py +304 -0
  111. django_cfg/modules/django_client/core/generator/go/naming.py +193 -0
  112. django_cfg/modules/django_client/core/generator/go/operations_generator.py +134 -0
  113. django_cfg/modules/django_client/core/generator/go/templates/Makefile.j2 +38 -0
  114. django_cfg/modules/django_client/core/generator/go/templates/README.md.j2 +55 -0
  115. django_cfg/modules/django_client/core/generator/go/templates/client.go.j2 +122 -0
  116. django_cfg/modules/django_client/core/generator/go/templates/enums.go.j2 +49 -0
  117. django_cfg/modules/django_client/core/generator/go/templates/errors.go.j2 +182 -0
  118. django_cfg/modules/django_client/core/generator/go/templates/go.mod.j2 +6 -0
  119. django_cfg/modules/django_client/core/generator/go/templates/main_client.go.j2 +60 -0
  120. django_cfg/modules/django_client/core/generator/go/templates/middleware.go.j2 +388 -0
  121. django_cfg/modules/django_client/core/generator/go/templates/models.go.j2 +28 -0
  122. django_cfg/modules/django_client/core/generator/go/templates/operations_client.go.j2 +142 -0
  123. django_cfg/modules/django_client/core/generator/go/templates/validation.go.j2 +217 -0
  124. django_cfg/modules/django_client/core/generator/go/type_mapper.py +380 -0
  125. django_cfg/modules/django_client/management/commands/generate_client.py +53 -3
  126. django_cfg/modules/django_client/system/generate_mjs_clients.py +3 -1
  127. django_cfg/modules/django_client/system/schema_parser.py +5 -1
  128. django_cfg/modules/django_tailwind/templates/django_tailwind/base.html +1 -0
  129. django_cfg/modules/django_twilio/sendgrid_service.py +7 -4
  130. django_cfg/modules/django_unfold/dashboard.py +25 -19
  131. django_cfg/pyproject.toml +1 -1
  132. django_cfg/registry/core.py +2 -0
  133. django_cfg/registry/modules.py +2 -2
  134. django_cfg/static/js/api/centrifugo/client.mjs +164 -0
  135. django_cfg/static/js/api/centrifugo/index.mjs +13 -0
  136. django_cfg/static/js/api/index.mjs +5 -5
  137. django_cfg/static/js/api/types.mjs +89 -26
  138. {django_cfg-1.4.62.dist-info → django_cfg-1.4.63.dist-info}/METADATA +1 -1
  139. {django_cfg-1.4.62.dist-info → django_cfg-1.4.63.dist-info}/RECORD +142 -70
  140. django_cfg/apps/ipc/README.md +0 -346
  141. django_cfg/apps/ipc/RPC_LOGGING.md +0 -321
  142. django_cfg/apps/ipc/TESTING.md +0 -539
  143. django_cfg/apps/ipc/__init__.py +0 -60
  144. django_cfg/apps/ipc/admin.py +0 -232
  145. django_cfg/apps/ipc/apps.py +0 -98
  146. django_cfg/apps/ipc/migrations/0001_initial.py +0 -137
  147. django_cfg/apps/ipc/migrations/0002_rpclog_is_event.py +0 -23
  148. django_cfg/apps/ipc/migrations/__init__.py +0 -0
  149. django_cfg/apps/ipc/models.py +0 -229
  150. django_cfg/apps/ipc/serializers/__init__.py +0 -29
  151. django_cfg/apps/ipc/serializers/serializers.py +0 -343
  152. django_cfg/apps/ipc/services/__init__.py +0 -7
  153. django_cfg/apps/ipc/services/client/__init__.py +0 -23
  154. django_cfg/apps/ipc/services/client/client.py +0 -621
  155. django_cfg/apps/ipc/services/client/config.py +0 -214
  156. django_cfg/apps/ipc/services/client/exceptions.py +0 -201
  157. django_cfg/apps/ipc/services/logging.py +0 -239
  158. django_cfg/apps/ipc/services/monitor.py +0 -466
  159. django_cfg/apps/ipc/services/rpc_log_consumer.py +0 -330
  160. django_cfg/apps/ipc/static/django_cfg_ipc/js/dashboard/main.mjs +0 -269
  161. django_cfg/apps/ipc/static/django_cfg_ipc/js/dashboard/overview.mjs +0 -259
  162. django_cfg/apps/ipc/static/django_cfg_ipc/js/dashboard/testing.mjs +0 -375
  163. django_cfg/apps/ipc/static/django_cfg_ipc/js/dashboard.mjs.old +0 -441
  164. django_cfg/apps/ipc/templates/django_cfg_ipc/components/methods_content.html +0 -22
  165. django_cfg/apps/ipc/templates/django_cfg_ipc/components/notifications_content.html +0 -9
  166. django_cfg/apps/ipc/templates/django_cfg_ipc/components/overview_content.html +0 -9
  167. django_cfg/apps/ipc/templates/django_cfg_ipc/components/requests_content.html +0 -23
  168. django_cfg/apps/ipc/templates/django_cfg_ipc/components/system_status.html +0 -47
  169. django_cfg/apps/ipc/templates/django_cfg_ipc/components/testing_tools.html +0 -184
  170. django_cfg/apps/ipc/templates/django_cfg_ipc/layout/base.html +0 -71
  171. django_cfg/apps/ipc/templates/django_cfg_ipc/pages/dashboard.html +0 -56
  172. django_cfg/apps/ipc/urls.py +0 -23
  173. django_cfg/apps/ipc/views/__init__.py +0 -13
  174. django_cfg/apps/ipc/views/dashboard.py +0 -15
  175. django_cfg/apps/ipc/views/monitoring.py +0 -251
  176. django_cfg/apps/ipc/views/testing.py +0 -285
  177. django_cfg/static/js/api/ipc/client.mjs +0 -114
  178. django_cfg/static/js/api/ipc/index.mjs +0 -13
  179. {django_cfg-1.4.62.dist-info → django_cfg-1.4.63.dist-info}/WHEEL +0 -0
  180. {django_cfg-1.4.62.dist-info → django_cfg-1.4.63.dist-info}/entry_points.txt +0 -0
  181. {django_cfg-1.4.62.dist-info → django_cfg-1.4.63.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,286 @@
1
+ """
2
+ Centrifugo Monitoring ViewSet.
3
+
4
+ Provides REST API endpoints for monitoring Centrifugo publish statistics.
5
+ """
6
+
7
+ from datetime import datetime
8
+
9
+ from django.db import models
10
+ from django.db.models import Avg, Count
11
+ from django_cfg.modules.django_logging import get_logger
12
+ from drf_spectacular.types import OpenApiTypes
13
+ from drf_spectacular.utils import OpenApiParameter, extend_schema
14
+ from rest_framework import status, viewsets
15
+ from rest_framework.authentication import SessionAuthentication
16
+ from rest_framework.decorators import action
17
+ from rest_framework.permissions import IsAdminUser
18
+ from rest_framework.response import Response
19
+
20
+ from ..models import CentrifugoLog
21
+ from ..serializers import (
22
+ ChannelListSerializer,
23
+ ChannelStatsSerializer,
24
+ HealthCheckSerializer,
25
+ OverviewStatsSerializer,
26
+ RecentPublishesSerializer,
27
+ )
28
+ from ..services import get_centrifugo_config
29
+
30
+ logger = get_logger("centrifugo.monitoring")
31
+
32
+
33
+ class CentrifugoMonitorViewSet(viewsets.ViewSet):
34
+ """
35
+ ViewSet for Centrifugo monitoring and statistics.
36
+
37
+ Provides comprehensive monitoring data for Centrifugo publishes including:
38
+ - Health checks
39
+ - Overview statistics
40
+ - Recent publishes
41
+ - Channel-level statistics
42
+ """
43
+
44
+ authentication_classes = [SessionAuthentication]
45
+ permission_classes = [IsAdminUser]
46
+
47
+ @extend_schema(
48
+ tags=["Centrifugo Monitoring"],
49
+ summary="Get Centrifugo health status",
50
+ description="Returns the current health status of the Centrifugo client.",
51
+ responses={
52
+ 200: HealthCheckSerializer,
53
+ 503: {"description": "Service unavailable"},
54
+ },
55
+ )
56
+ @action(detail=False, methods=["get"], url_path="health")
57
+ def health(self, request):
58
+ """Get health status of Centrifugo client."""
59
+ try:
60
+ config = get_centrifugo_config()
61
+
62
+ if not config:
63
+ return Response(
64
+ {"error": "Centrifugo not configured"},
65
+ status=status.HTTP_503_SERVICE_UNAVAILABLE,
66
+ )
67
+
68
+ health_data = {
69
+ "status": "healthy",
70
+ "wrapper_url": config.wrapper_url,
71
+ "has_api_key": config.centrifugo_api_key is not None,
72
+ "timestamp": datetime.now().isoformat(),
73
+ }
74
+
75
+ serializer = HealthCheckSerializer(**health_data)
76
+ return Response(serializer.model_dump())
77
+
78
+ except Exception as e:
79
+ logger.error(f"Health check error: {e}", exc_info=True)
80
+ return Response(
81
+ {"error": "Internal server error"},
82
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
83
+ )
84
+
85
+ @extend_schema(
86
+ tags=["Centrifugo Monitoring"],
87
+ summary="Get overview statistics",
88
+ description="Returns overview statistics for Centrifugo publishes.",
89
+ parameters=[
90
+ OpenApiParameter(
91
+ name="hours",
92
+ type=OpenApiTypes.INT,
93
+ location=OpenApiParameter.QUERY,
94
+ description="Statistics period in hours (default: 24)",
95
+ required=False,
96
+ ),
97
+ ],
98
+ responses={
99
+ 200: OverviewStatsSerializer,
100
+ 400: {"description": "Invalid parameters"},
101
+ },
102
+ )
103
+ @action(detail=False, methods=["get"], url_path="overview")
104
+ def overview(self, request):
105
+ """Get overview statistics for Centrifugo publishes."""
106
+ try:
107
+ hours = int(request.GET.get("hours", 24))
108
+ hours = min(max(hours, 1), 168) # 1 hour to 1 week
109
+
110
+ stats = CentrifugoLog.objects.get_statistics(hours=hours)
111
+ stats["period_hours"] = hours
112
+
113
+ serializer = OverviewStatsSerializer(**stats)
114
+ return Response(serializer.model_dump())
115
+
116
+ except ValueError as e:
117
+ logger.warning(f"Overview stats validation error: {e}")
118
+ return Response(
119
+ {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
120
+ )
121
+ except Exception as e:
122
+ logger.error(f"Overview stats error: {e}", exc_info=True)
123
+ return Response(
124
+ {"error": "Internal server error"},
125
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
126
+ )
127
+
128
+ @extend_schema(
129
+ tags=["Centrifugo Monitoring"],
130
+ summary="Get recent publishes",
131
+ description="Returns a list of recent Centrifugo publishes with their details.",
132
+ parameters=[
133
+ OpenApiParameter(
134
+ name="count",
135
+ type=OpenApiTypes.INT,
136
+ location=OpenApiParameter.QUERY,
137
+ description="Number of publishes to return (default: 50, max: 200)",
138
+ required=False,
139
+ ),
140
+ OpenApiParameter(
141
+ name="channel",
142
+ type=OpenApiTypes.STR,
143
+ location=OpenApiParameter.QUERY,
144
+ description="Filter by channel name",
145
+ required=False,
146
+ ),
147
+ ],
148
+ responses={
149
+ 200: RecentPublishesSerializer,
150
+ 400: {"description": "Invalid parameters"},
151
+ },
152
+ )
153
+ @action(detail=False, methods=["get"], url_path="publishes")
154
+ def publishes(self, request):
155
+ """Get recent Centrifugo publishes."""
156
+ try:
157
+ count = int(request.GET.get("count", 50))
158
+ count = min(count, 200) # Max 200
159
+
160
+ channel = request.GET.get("channel")
161
+
162
+ queryset = CentrifugoLog.objects.all()
163
+
164
+ if channel:
165
+ queryset = queryset.filter(channel=channel)
166
+
167
+ publishes_list = list(
168
+ queryset.order_by("-created_at")[:count].values(
169
+ "message_id",
170
+ "channel",
171
+ "status",
172
+ "wait_for_ack",
173
+ "acks_received",
174
+ "acks_expected",
175
+ "duration_ms",
176
+ "created_at",
177
+ "completed_at",
178
+ "error_code",
179
+ "error_message",
180
+ )
181
+ )
182
+
183
+ # Convert datetime to ISO format
184
+ for pub in publishes_list:
185
+ if pub["created_at"]:
186
+ pub["created_at"] = pub["created_at"].isoformat()
187
+ if pub["completed_at"]:
188
+ pub["completed_at"] = pub["completed_at"].isoformat()
189
+
190
+ total = queryset.count()
191
+
192
+ response_data = {
193
+ "publishes": publishes_list,
194
+ "count": len(publishes_list),
195
+ "total_available": total,
196
+ }
197
+
198
+ serializer = RecentPublishesSerializer(**response_data)
199
+ return Response(serializer.model_dump())
200
+
201
+ except ValueError as e:
202
+ logger.warning(f"Recent publishes validation error: {e}")
203
+ return Response(
204
+ {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
205
+ )
206
+ except Exception as e:
207
+ logger.error(f"Recent publishes error: {e}", exc_info=True)
208
+ return Response(
209
+ {"error": "Internal server error"},
210
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
211
+ )
212
+
213
+ @extend_schema(
214
+ tags=["Centrifugo Monitoring"],
215
+ summary="Get channel statistics",
216
+ description="Returns statistics grouped by channel.",
217
+ parameters=[
218
+ OpenApiParameter(
219
+ name="hours",
220
+ type=OpenApiTypes.INT,
221
+ location=OpenApiParameter.QUERY,
222
+ description="Statistics period in hours (default: 24)",
223
+ required=False,
224
+ ),
225
+ ],
226
+ responses={
227
+ 200: ChannelListSerializer,
228
+ 400: {"description": "Invalid parameters"},
229
+ },
230
+ )
231
+ @action(detail=False, methods=["get"], url_path="channels")
232
+ def channels(self, request):
233
+ """Get statistics per channel."""
234
+ try:
235
+ hours = int(request.GET.get("hours", 24))
236
+ hours = min(max(hours, 1), 168)
237
+
238
+ # Get channel statistics
239
+ channel_stats = (
240
+ CentrifugoLog.objects.recent(hours)
241
+ .values("channel")
242
+ .annotate(
243
+ total=Count("id"),
244
+ successful=Count("id", filter=models.Q(status="success")),
245
+ failed=Count("id", filter=models.Q(status="failed")),
246
+ avg_duration_ms=Avg("duration_ms"),
247
+ avg_acks=Avg("acks_received"),
248
+ )
249
+ .order_by("-total")
250
+ )
251
+
252
+ channels_list = []
253
+ for stats in channel_stats:
254
+ channels_list.append(
255
+ ChannelStatsSerializer(
256
+ channel=stats["channel"],
257
+ total=stats["total"],
258
+ successful=stats["successful"],
259
+ failed=stats["failed"],
260
+ avg_duration_ms=round(stats["avg_duration_ms"] or 0, 2),
261
+ avg_acks=round(stats["avg_acks"] or 0, 2),
262
+ )
263
+ )
264
+
265
+ response_data = {
266
+ "channels": [ch.model_dump() for ch in channels_list],
267
+ "total_channels": len(channels_list),
268
+ }
269
+
270
+ serializer = ChannelListSerializer(**response_data)
271
+ return Response(serializer.model_dump())
272
+
273
+ except ValueError as e:
274
+ logger.warning(f"Channel stats validation error: {e}")
275
+ return Response(
276
+ {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
277
+ )
278
+ except Exception as e:
279
+ logger.error(f"Channel stats error: {e}", exc_info=True)
280
+ return Response(
281
+ {"error": "Internal server error"},
282
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
283
+ )
284
+
285
+
286
+ __all__ = ["CentrifugoMonitorViewSet"]
@@ -0,0 +1,422 @@
1
+ """
2
+ Centrifugo Testing API.
3
+
4
+ Provides endpoints for live testing of Centrifugo integration from dashboard.
5
+ Includes connection tokens, publish proxying, and ACK management.
6
+ """
7
+
8
+ import asyncio
9
+ import time
10
+ from datetime import datetime, timedelta
11
+ from typing import Any, Dict
12
+
13
+ import httpx
14
+ import jwt
15
+ from django.conf import settings
16
+ from django_cfg.modules.django_logging import get_logger
17
+ from drf_spectacular.utils import extend_schema
18
+ from pydantic import BaseModel, Field
19
+ from rest_framework import status, viewsets
20
+ from rest_framework.authentication import SessionAuthentication
21
+ from rest_framework.decorators import action
22
+ from rest_framework.permissions import IsAdminUser
23
+ from rest_framework.response import Response
24
+
25
+ from ..services import get_centrifugo_config
26
+ from ..services.client import CentrifugoClient
27
+
28
+ logger = get_logger("centrifugo.testing_api")
29
+
30
+
31
+ # ========================================================================
32
+ # Request/Response Models
33
+ # ========================================================================
34
+
35
+
36
+ class ConnectionTokenRequest(BaseModel):
37
+ """Request model for connection token generation."""
38
+
39
+ user_id: str = Field(..., description="User ID for the connection")
40
+ channels: list[str] = Field(
41
+ default_factory=list, description="List of channels to authorize"
42
+ )
43
+
44
+
45
+ class ConnectionTokenResponse(BaseModel):
46
+ """Response model for connection token."""
47
+
48
+ token: str = Field(..., description="JWT token for WebSocket connection")
49
+ centrifugo_url: str = Field(..., description="Centrifugo WebSocket URL")
50
+ expires_at: str = Field(..., description="Token expiration time (ISO 8601)")
51
+
52
+
53
+ class PublishTestRequest(BaseModel):
54
+ """Request model for test message publishing."""
55
+
56
+ channel: str = Field(..., description="Target channel name")
57
+ data: Dict[str, Any] = Field(..., description="Message data (any JSON object)")
58
+ wait_for_ack: bool = Field(
59
+ default=False, description="Wait for client acknowledgment"
60
+ )
61
+ ack_timeout: int = Field(
62
+ default=10, ge=1, le=60, description="ACK timeout in seconds"
63
+ )
64
+
65
+
66
+ class PublishTestResponse(BaseModel):
67
+ """Response model for test message publishing."""
68
+
69
+ success: bool = Field(..., description="Whether publish succeeded")
70
+ message_id: str = Field(..., description="Unique message ID")
71
+ channel: str = Field(..., description="Target channel")
72
+ acks_received: int = Field(default=0, description="Number of ACKs received")
73
+ delivered: bool = Field(default=False, description="Whether message was delivered")
74
+ error: str | None = Field(default=None, description="Error message if failed")
75
+
76
+
77
+ class ManualAckRequest(BaseModel):
78
+ """Request model for manual ACK sending."""
79
+
80
+ message_id: str = Field(..., description="Message ID to acknowledge")
81
+ client_id: str = Field(..., description="Client ID sending the ACK")
82
+
83
+
84
+ class ManualAckResponse(BaseModel):
85
+ """Response model for manual ACK."""
86
+
87
+ success: bool = Field(..., description="Whether ACK was sent successfully")
88
+ message_id: str = Field(..., description="Message ID that was acknowledged")
89
+ error: str | None = Field(default=None, description="Error message if failed")
90
+
91
+
92
+ # ========================================================================
93
+ # Testing API ViewSet
94
+ # ========================================================================
95
+
96
+
97
+ class CentrifugoTestingAPIViewSet(viewsets.ViewSet):
98
+ """
99
+ Centrifugo Testing API ViewSet.
100
+
101
+ Provides endpoints for interactive testing of Centrifugo integration
102
+ from the dashboard. Includes connection token generation, test message
103
+ publishing, and manual ACK management.
104
+ """
105
+
106
+ authentication_classes = [SessionAuthentication]
107
+ permission_classes = [IsAdminUser]
108
+
109
+ def __init__(self, *args, **kwargs):
110
+ super().__init__(*args, **kwargs)
111
+ self._http_client = None
112
+
113
+ @property
114
+ def http_client(self) -> httpx.AsyncClient:
115
+ """Get or create HTTP client for wrapper API calls."""
116
+ if self._http_client is None:
117
+ config = get_centrifugo_config()
118
+ if not config:
119
+ raise ValueError("Centrifugo not configured")
120
+
121
+ headers = {"Content-Type": "application/json"}
122
+ if config.wrapper_api_key:
123
+ headers["X-API-Key"] = config.wrapper_api_key
124
+
125
+ # Use wrapper URL as base
126
+ base_url = config.wrapper_url.rstrip("/")
127
+
128
+ self._http_client = httpx.AsyncClient(
129
+ base_url=base_url, headers=headers, timeout=httpx.Timeout(30.0)
130
+ )
131
+
132
+ return self._http_client
133
+
134
+ @extend_schema(
135
+ tags=["Centrifugo Testing"],
136
+ summary="Generate connection token",
137
+ description="Generate JWT token for WebSocket connection to Centrifugo.",
138
+ request=ConnectionTokenRequest,
139
+ responses={
140
+ 200: ConnectionTokenResponse,
141
+ 400: {"description": "Invalid request"},
142
+ 500: {"description": "Server error"},
143
+ },
144
+ )
145
+ @action(detail=False, methods=["post"], url_path="connection-token")
146
+ def connection_token(self, request):
147
+ """
148
+ Generate JWT token for WebSocket connection.
149
+
150
+ Returns token that can be used to connect to Centrifugo from browser.
151
+ """
152
+ try:
153
+ config = get_centrifugo_config()
154
+ if not config:
155
+ return Response(
156
+ {"error": "Centrifugo not configured"},
157
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
158
+ )
159
+
160
+ # Parse request
161
+ req_data = ConnectionTokenRequest(**request.data)
162
+
163
+ # Generate JWT token
164
+ now = int(time.time())
165
+ exp = now + 3600 # 1 hour
166
+
167
+ payload = {
168
+ "sub": req_data.user_id,
169
+ "exp": exp,
170
+ "iat": now,
171
+ }
172
+
173
+ # Add channels if provided
174
+ if req_data.channels:
175
+ payload["channels"] = req_data.channels
176
+
177
+ # Use HMAC secret from config or Django SECRET_KEY
178
+ secret = config.centrifugo_token_hmac_secret or settings.SECRET_KEY
179
+
180
+ token = jwt.encode(payload, secret, algorithm="HS256")
181
+
182
+ response = ConnectionTokenResponse(
183
+ token=token,
184
+ centrifugo_url=config.centrifugo_url,
185
+ expires_at=datetime.utcfromtimestamp(exp).isoformat() + "Z",
186
+ )
187
+
188
+ return Response(response.model_dump())
189
+
190
+ except Exception as e:
191
+ logger.error(f"Failed to generate connection token: {e}", exc_info=True)
192
+ return Response(
193
+ {"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
194
+ )
195
+
196
+ @extend_schema(
197
+ tags=["Centrifugo Testing"],
198
+ summary="Publish test message",
199
+ description="Publish test message to Centrifugo via wrapper with optional ACK tracking.",
200
+ request=PublishTestRequest,
201
+ responses={
202
+ 200: PublishTestResponse,
203
+ 400: {"description": "Invalid request"},
204
+ 500: {"description": "Server error"},
205
+ },
206
+ )
207
+ @action(detail=False, methods=["post"], url_path="publish-test")
208
+ def publish_test(self, request):
209
+ """
210
+ Publish test message via wrapper.
211
+
212
+ Proxies request to Centrifugo wrapper with ACK tracking support.
213
+ """
214
+ try:
215
+ req_data = PublishTestRequest(**request.data)
216
+
217
+ # Call wrapper API
218
+ result = asyncio.run(
219
+ self._publish_to_wrapper(
220
+ channel=req_data.channel,
221
+ data=req_data.data,
222
+ wait_for_ack=req_data.wait_for_ack,
223
+ ack_timeout=req_data.ack_timeout,
224
+ )
225
+ )
226
+
227
+ response = PublishTestResponse(
228
+ success=result.get("published", False),
229
+ message_id=result.get("message_id", ""),
230
+ channel=result.get("channel", req_data.channel),
231
+ acks_received=result.get("acks_received", 0),
232
+ delivered=result.get("delivered", False),
233
+ )
234
+
235
+ return Response(response.model_dump())
236
+
237
+ except Exception as e:
238
+ logger.error(f"Failed to publish test message: {e}", exc_info=True)
239
+ return Response(
240
+ PublishTestResponse(
241
+ success=False,
242
+ message_id="",
243
+ channel=request.data.get("channel", ""),
244
+ error=str(e),
245
+ ).model_dump(),
246
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
247
+ )
248
+
249
+ @extend_schema(
250
+ tags=["Centrifugo Testing"],
251
+ summary="Send manual ACK",
252
+ description="Manually send ACK for a message to the wrapper. Pass message_id in request body.",
253
+ request=ManualAckRequest,
254
+ responses={
255
+ 200: ManualAckResponse,
256
+ 400: {"description": "Invalid request"},
257
+ 500: {"description": "Server error"},
258
+ },
259
+ )
260
+ @action(detail=False, methods=["post"], url_path="send-ack")
261
+ def send_ack(self, request):
262
+ """
263
+ Send manual ACK for message.
264
+
265
+ Proxies ACK to wrapper for testing ACK flow.
266
+ """
267
+ try:
268
+ req_data = ManualAckRequest(**request.data)
269
+
270
+ # Send ACK to wrapper
271
+ result = asyncio.run(
272
+ self._send_ack_to_wrapper(
273
+ message_id=req_data.message_id, client_id=req_data.client_id
274
+ )
275
+ )
276
+
277
+ response = ManualAckResponse(
278
+ success=result.get("status") == "ok",
279
+ message_id=req_data.message_id,
280
+ error=result.get("message") if result.get("status") != "ok" else None,
281
+ )
282
+
283
+ return Response(response.model_dump())
284
+
285
+ except Exception as e:
286
+ logger.error(f"Failed to send ACK: {e}", exc_info=True)
287
+ return Response(
288
+ ManualAckResponse(
289
+ success=False,
290
+ message_id=request.data.get("message_id", ""),
291
+ error=str(e)
292
+ ).model_dump(),
293
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
294
+ )
295
+
296
+ async def _publish_to_wrapper(
297
+ self, channel: str, data: Dict[str, Any], wait_for_ack: bool, ack_timeout: int
298
+ ) -> Dict[str, Any]:
299
+ """
300
+ Publish message to wrapper API.
301
+
302
+ Args:
303
+ channel: Target channel
304
+ data: Message data
305
+ wait_for_ack: Whether to wait for ACK
306
+ ack_timeout: ACK timeout in seconds
307
+
308
+ Returns:
309
+ Wrapper API response
310
+ """
311
+ payload = {
312
+ "channel": channel,
313
+ "data": data,
314
+ "wait_for_ack": wait_for_ack,
315
+ }
316
+
317
+ if wait_for_ack:
318
+ payload["ack_timeout"] = ack_timeout
319
+
320
+ response = await self.http_client.post("/api/publish", json=payload)
321
+ response.raise_for_status()
322
+ return response.json()
323
+
324
+ async def _send_ack_to_wrapper(
325
+ self, message_id: str, client_id: str
326
+ ) -> Dict[str, Any]:
327
+ """
328
+ Send ACK to wrapper API.
329
+
330
+ Args:
331
+ message_id: Message ID to acknowledge
332
+ client_id: Client ID sending the ACK
333
+
334
+ Returns:
335
+ Wrapper API response
336
+ """
337
+ payload = {"client_id": client_id}
338
+
339
+ response = await self.http_client.post(
340
+ f"/api/ack/{message_id}", json=payload
341
+ )
342
+ response.raise_for_status()
343
+ return response.json()
344
+
345
+ @extend_schema(
346
+ tags=["Centrifugo Testing"],
347
+ summary="Publish with database logging",
348
+ description="Publish message using CentrifugoClient with database logging. This will create CentrifugoLog records.",
349
+ request=PublishTestRequest,
350
+ responses={
351
+ 200: PublishTestResponse,
352
+ 400: {"description": "Invalid request"},
353
+ 500: {"description": "Server error"},
354
+ },
355
+ )
356
+ @action(detail=False, methods=["post"], url_path="publish-with-logging")
357
+ def publish_with_logging(self, request):
358
+ """
359
+ Publish message using CentrifugoClient with database logging.
360
+
361
+ This endpoint uses the production CentrifugoClient which logs all
362
+ publishes to the database (CentrifugoLog model).
363
+ """
364
+ try:
365
+ req_data = PublishTestRequest(**request.data)
366
+
367
+ # Use CentrifugoClient for publishing
368
+ client = CentrifugoClient()
369
+
370
+ # Publish message
371
+ result = asyncio.run(
372
+ client.publish_with_ack(
373
+ channel=req_data.channel,
374
+ data=req_data.data,
375
+ ack_timeout=req_data.ack_timeout if req_data.wait_for_ack else None,
376
+ user=request.user if request.user.is_authenticated else None,
377
+ caller_ip=request.META.get("REMOTE_ADDR"),
378
+ user_agent=request.META.get("HTTP_USER_AGENT"),
379
+ )
380
+ if req_data.wait_for_ack
381
+ else client.publish(
382
+ channel=req_data.channel,
383
+ data=req_data.data,
384
+ user=request.user if request.user.is_authenticated else None,
385
+ caller_ip=request.META.get("REMOTE_ADDR"),
386
+ user_agent=request.META.get("HTTP_USER_AGENT"),
387
+ )
388
+ )
389
+
390
+ # Convert PublishResponse to dict
391
+ response_data = {
392
+ "success": result.published,
393
+ "message_id": result.message_id,
394
+ "channel": req_data.channel,
395
+ "delivered": result.delivered if req_data.wait_for_ack else None,
396
+ "acks_received": result.acks_received if req_data.wait_for_ack else 0,
397
+ "logged_to_database": True, # CentrifugoClient always logs
398
+ }
399
+
400
+ return Response(response_data)
401
+
402
+ except Exception as e:
403
+ logger.error(f"Failed to publish with logging: {e}", exc_info=True)
404
+ return Response(
405
+ {"success": False, "error": str(e)},
406
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
407
+ )
408
+
409
+ def __del__(self):
410
+ """Cleanup HTTP client on deletion."""
411
+ if self._http_client:
412
+ try:
413
+ loop = asyncio.get_event_loop()
414
+ if loop.is_running():
415
+ loop.create_task(self._http_client.aclose())
416
+ else:
417
+ loop.run_until_complete(self._http_client.aclose())
418
+ except Exception:
419
+ pass
420
+
421
+
422
+ __all__ = ["CentrifugoTestingAPIViewSet"]