django-cfg 1.4.119__py3-none-any.whl → 1.5.1__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 (84) hide show
  1. django_cfg/__init__.py +8 -4
  2. django_cfg/apps/centrifugo/admin/centrifugo_log.py +33 -71
  3. django_cfg/apps/grpc/__init__.py +9 -0
  4. django_cfg/apps/grpc/admin/__init__.py +11 -0
  5. django_cfg/apps/grpc/admin/config.py +89 -0
  6. django_cfg/apps/grpc/admin/grpc_request_log.py +252 -0
  7. django_cfg/apps/grpc/apps.py +28 -0
  8. django_cfg/apps/grpc/auth/__init__.py +9 -0
  9. django_cfg/apps/grpc/auth/jwt_auth.py +295 -0
  10. django_cfg/apps/grpc/interceptors/__init__.py +19 -0
  11. django_cfg/apps/grpc/interceptors/errors.py +241 -0
  12. django_cfg/apps/grpc/interceptors/logging.py +270 -0
  13. django_cfg/apps/grpc/interceptors/metrics.py +306 -0
  14. django_cfg/apps/grpc/interceptors/request_logger.py +515 -0
  15. django_cfg/apps/grpc/management/__init__.py +1 -0
  16. django_cfg/apps/grpc/management/commands/__init__.py +0 -0
  17. django_cfg/apps/grpc/management/commands/rungrpc.py +302 -0
  18. django_cfg/apps/grpc/managers/__init__.py +10 -0
  19. django_cfg/apps/grpc/managers/grpc_request_log.py +310 -0
  20. django_cfg/apps/grpc/migrations/0001_initial.py +69 -0
  21. django_cfg/apps/grpc/migrations/0002_rename_django_cfg__service_4c4a8e_idx_django_cfg__service_584308_idx_and_more.py +38 -0
  22. django_cfg/apps/grpc/migrations/__init__.py +0 -0
  23. django_cfg/apps/grpc/models/__init__.py +9 -0
  24. django_cfg/apps/grpc/models/grpc_request_log.py +219 -0
  25. django_cfg/apps/grpc/serializers/__init__.py +23 -0
  26. django_cfg/apps/grpc/serializers/health.py +18 -0
  27. django_cfg/apps/grpc/serializers/requests.py +18 -0
  28. django_cfg/apps/grpc/serializers/services.py +50 -0
  29. django_cfg/apps/grpc/serializers/stats.py +22 -0
  30. django_cfg/apps/grpc/services/__init__.py +16 -0
  31. django_cfg/apps/grpc/services/base.py +375 -0
  32. django_cfg/apps/grpc/services/discovery.py +415 -0
  33. django_cfg/apps/grpc/urls.py +23 -0
  34. django_cfg/apps/grpc/utils/__init__.py +13 -0
  35. django_cfg/apps/grpc/utils/proto_gen.py +423 -0
  36. django_cfg/apps/grpc/views/__init__.py +9 -0
  37. django_cfg/apps/grpc/views/monitoring.py +497 -0
  38. django_cfg/apps/maintenance/admin/api_key_admin.py +7 -8
  39. django_cfg/apps/maintenance/admin/site_admin.py +5 -4
  40. django_cfg/apps/payments/admin/balance_admin.py +26 -36
  41. django_cfg/apps/payments/admin/payment_admin.py +65 -85
  42. django_cfg/apps/payments/admin/withdrawal_admin.py +65 -100
  43. django_cfg/apps/tasks/admin/task_log.py +20 -47
  44. django_cfg/apps/urls.py +7 -1
  45. django_cfg/config.py +106 -0
  46. django_cfg/core/base/config_model.py +6 -0
  47. django_cfg/core/builders/apps_builder.py +3 -0
  48. django_cfg/core/generation/integration_generators/grpc_generator.py +318 -0
  49. django_cfg/core/generation/orchestrator.py +10 -0
  50. django_cfg/models/api/grpc/__init__.py +59 -0
  51. django_cfg/models/api/grpc/config.py +364 -0
  52. django_cfg/modules/base.py +15 -0
  53. django_cfg/modules/django_admin/__init__.py +2 -0
  54. django_cfg/modules/django_admin/base/pydantic_admin.py +2 -2
  55. django_cfg/modules/django_admin/config/__init__.py +2 -0
  56. django_cfg/modules/django_admin/config/field_config.py +24 -0
  57. django_cfg/modules/django_admin/utils/__init__.py +41 -3
  58. django_cfg/modules/django_admin/utils/badges/__init__.py +13 -0
  59. django_cfg/modules/django_admin/utils/{badges.py → badges/status_badges.py} +3 -3
  60. django_cfg/modules/django_admin/utils/displays/__init__.py +13 -0
  61. django_cfg/modules/django_admin/utils/{displays.py → displays/data_displays.py} +2 -2
  62. django_cfg/modules/django_admin/utils/html/__init__.py +26 -0
  63. django_cfg/modules/django_admin/utils/html/badges.py +47 -0
  64. django_cfg/modules/django_admin/utils/html/base.py +167 -0
  65. django_cfg/modules/django_admin/utils/html/code.py +87 -0
  66. django_cfg/modules/django_admin/utils/html/composition.py +198 -0
  67. django_cfg/modules/django_admin/utils/html/formatting.py +231 -0
  68. django_cfg/modules/django_admin/utils/html/keyvalue.py +219 -0
  69. django_cfg/modules/django_admin/utils/html/markdown_integration.py +108 -0
  70. django_cfg/modules/django_admin/utils/html/progress.py +127 -0
  71. django_cfg/modules/django_admin/utils/html_builder.py +55 -408
  72. django_cfg/modules/django_admin/utils/markdown/__init__.py +21 -0
  73. django_cfg/modules/django_admin/widgets/registry.py +42 -0
  74. django_cfg/modules/django_unfold/navigation.py +28 -0
  75. django_cfg/pyproject.toml +3 -5
  76. django_cfg/registry/modules.py +6 -0
  77. {django_cfg-1.4.119.dist-info → django_cfg-1.5.1.dist-info}/METADATA +10 -1
  78. {django_cfg-1.4.119.dist-info → django_cfg-1.5.1.dist-info}/RECORD +83 -34
  79. django_cfg/modules/django_admin/utils/CODE_BLOCK_DOCS.md +0 -396
  80. /django_cfg/modules/django_admin/utils/{mermaid_plugin.py → markdown/mermaid_plugin.py} +0 -0
  81. /django_cfg/modules/django_admin/utils/{markdown_renderer.py → markdown/renderer.py} +0 -0
  82. {django_cfg-1.4.119.dist-info → django_cfg-1.5.1.dist-info}/WHEEL +0 -0
  83. {django_cfg-1.4.119.dist-info → django_cfg-1.5.1.dist-info}/entry_points.txt +0 -0
  84. {django_cfg-1.4.119.dist-info → django_cfg-1.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,497 @@
1
+ """
2
+ gRPC Monitoring ViewSet.
3
+
4
+ Provides REST API endpoints for monitoring gRPC request statistics.
5
+ """
6
+
7
+ from datetime import datetime
8
+
9
+ from django.conf import settings
10
+ from django.db import models
11
+ from django.db.models import Avg, Count, Max
12
+ from django.db.models.functions import TruncDay, TruncHour
13
+ from django_cfg.mixins import AdminAPIMixin
14
+ from django_cfg.modules.django_logging import get_logger
15
+ from drf_spectacular.types import OpenApiTypes
16
+ from drf_spectacular.utils import OpenApiParameter, extend_schema
17
+ from rest_framework import status, viewsets
18
+ from rest_framework.decorators import action
19
+ from rest_framework.response import Response
20
+
21
+ from ..models import GRPCRequestLog
22
+ from ..serializers import (
23
+ HealthCheckSerializer,
24
+ MethodListSerializer,
25
+ MethodStatsSerializer,
26
+ OverviewStatsSerializer,
27
+ RecentRequestsSerializer,
28
+ ServiceListSerializer,
29
+ ServiceStatsSerializer,
30
+ )
31
+
32
+ logger = get_logger("grpc.monitoring")
33
+
34
+
35
+ class GRPCMonitorViewSet(AdminAPIMixin, viewsets.ViewSet):
36
+ """
37
+ ViewSet for gRPC monitoring and statistics.
38
+
39
+ Provides comprehensive monitoring data for gRPC requests including:
40
+ - Health checks
41
+ - Overview statistics
42
+ - Recent requests
43
+ - Service-level statistics
44
+ - Method-level statistics
45
+ - Timeline data
46
+
47
+ Requires admin authentication (JWT, Session, or Basic Auth).
48
+ """
49
+
50
+ @extend_schema(
51
+ tags=["gRPC Monitoring"],
52
+ summary="Get gRPC health status",
53
+ description="Returns the current health status of the gRPC server.",
54
+ responses={
55
+ 200: HealthCheckSerializer,
56
+ 503: {"description": "Service unavailable"},
57
+ },
58
+ )
59
+ @action(detail=False, methods=["get"], url_path="health")
60
+ def health(self, request):
61
+ """Get health status of gRPC server."""
62
+ try:
63
+ grpc_server_config = getattr(settings, "GRPC_SERVER", {})
64
+ grpc_framework_config = getattr(settings, "GRPC_FRAMEWORK", {})
65
+
66
+ if not grpc_server_config:
67
+ return Response(
68
+ {"error": "gRPC not configured"},
69
+ status=status.HTTP_503_SERVICE_UNAVAILABLE,
70
+ )
71
+
72
+ health_data = {
73
+ "status": "healthy",
74
+ "server_host": grpc_server_config.get("host", "[::]"),
75
+ "server_port": grpc_server_config.get("port", 50051),
76
+ "enabled": True,
77
+ "timestamp": datetime.now().isoformat(),
78
+ }
79
+
80
+ serializer = HealthCheckSerializer(**health_data)
81
+ return Response(serializer.model_dump())
82
+
83
+ except Exception as e:
84
+ logger.error(f"Health check error: {e}", exc_info=True)
85
+ return Response(
86
+ {"error": "Internal server error"},
87
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
88
+ )
89
+
90
+ @extend_schema(
91
+ tags=["gRPC Monitoring"],
92
+ summary="Get overview statistics",
93
+ description="Returns overview statistics for gRPC requests.",
94
+ parameters=[
95
+ OpenApiParameter(
96
+ name="hours",
97
+ type=OpenApiTypes.INT,
98
+ location=OpenApiParameter.QUERY,
99
+ description="Statistics period in hours (default: 24)",
100
+ required=False,
101
+ ),
102
+ ],
103
+ responses={
104
+ 200: OverviewStatsSerializer,
105
+ 400: {"description": "Invalid parameters"},
106
+ },
107
+ )
108
+ @action(detail=False, methods=["get"], url_path="overview")
109
+ def overview(self, request):
110
+ """Get overview statistics for gRPC requests."""
111
+ try:
112
+ hours = int(request.GET.get("hours", 24))
113
+ hours = min(max(hours, 1), 168) # 1 hour to 1 week
114
+
115
+ stats = GRPCRequestLog.objects.get_statistics(hours=hours)
116
+ stats["period_hours"] = hours
117
+
118
+ serializer = OverviewStatsSerializer(**stats)
119
+ return Response(serializer.model_dump())
120
+
121
+ except ValueError as e:
122
+ logger.warning(f"Overview stats validation error: {e}")
123
+ return Response(
124
+ {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
125
+ )
126
+ except Exception as e:
127
+ logger.error(f"Overview stats error: {e}", exc_info=True)
128
+ return Response(
129
+ {"error": "Internal server error"},
130
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
131
+ )
132
+
133
+ @extend_schema(
134
+ tags=["gRPC Monitoring"],
135
+ summary="Get recent requests",
136
+ description="Returns a list of recent gRPC requests with their details.",
137
+ parameters=[
138
+ OpenApiParameter(
139
+ name="count",
140
+ type=OpenApiTypes.INT,
141
+ location=OpenApiParameter.QUERY,
142
+ description="Number of requests to return (default: 50, max: 200)",
143
+ required=False,
144
+ ),
145
+ OpenApiParameter(
146
+ name="service",
147
+ type=OpenApiTypes.STR,
148
+ location=OpenApiParameter.QUERY,
149
+ description="Filter by service name",
150
+ required=False,
151
+ ),
152
+ OpenApiParameter(
153
+ name="method",
154
+ type=OpenApiTypes.STR,
155
+ location=OpenApiParameter.QUERY,
156
+ description="Filter by method name",
157
+ required=False,
158
+ ),
159
+ OpenApiParameter(
160
+ name="status",
161
+ type=OpenApiTypes.STR,
162
+ location=OpenApiParameter.QUERY,
163
+ description="Filter by status (success, error, timeout, pending, cancelled)",
164
+ required=False,
165
+ ),
166
+ OpenApiParameter(
167
+ name="offset",
168
+ type=OpenApiTypes.INT,
169
+ location=OpenApiParameter.QUERY,
170
+ description="Offset for pagination (default: 0)",
171
+ required=False,
172
+ ),
173
+ ],
174
+ responses={
175
+ 200: RecentRequestsSerializer,
176
+ 400: {"description": "Invalid parameters"},
177
+ },
178
+ )
179
+ @action(detail=False, methods=["get"], url_path="requests")
180
+ def requests(self, request):
181
+ """Get recent gRPC requests."""
182
+ try:
183
+ count = int(request.GET.get("count", 50))
184
+ count = min(count, 200) # Max 200
185
+
186
+ service_filter = request.GET.get("service")
187
+ method_filter = request.GET.get("method")
188
+ status_filter = request.GET.get("status")
189
+ offset = int(request.GET.get("offset", 0))
190
+
191
+ queryset = GRPCRequestLog.objects.all()
192
+
193
+ if service_filter:
194
+ queryset = queryset.filter(service_name=service_filter)
195
+
196
+ if method_filter:
197
+ queryset = queryset.filter(method_name=method_filter)
198
+
199
+ if status_filter and status_filter in ["success", "error", "timeout", "pending", "cancelled"]:
200
+ queryset = queryset.filter(status=status_filter)
201
+
202
+ # Get total count before slicing
203
+ total = queryset.count()
204
+
205
+ # Apply offset and limit
206
+ requests_list = list(
207
+ queryset.order_by("-created_at")[offset:offset + count].values(
208
+ "request_id",
209
+ "service_name",
210
+ "method_name",
211
+ "full_method",
212
+ "status",
213
+ "grpc_status_code",
214
+ "is_authenticated",
215
+ "duration_ms",
216
+ "created_at",
217
+ "completed_at",
218
+ "error_message",
219
+ "user__username",
220
+ )
221
+ )
222
+
223
+ # Convert datetime to ISO format
224
+ for req in requests_list:
225
+ if req["created_at"]:
226
+ req["created_at"] = req["created_at"].isoformat()
227
+ if req["completed_at"]:
228
+ req["completed_at"] = req["completed_at"].isoformat()
229
+
230
+ response_data = {
231
+ "requests": requests_list,
232
+ "count": len(requests_list),
233
+ "total_available": total,
234
+ "offset": offset,
235
+ "has_more": (offset + count) < total,
236
+ }
237
+
238
+ serializer = RecentRequestsSerializer(**response_data)
239
+ return Response(serializer.model_dump())
240
+
241
+ except ValueError as e:
242
+ logger.warning(f"Recent requests validation error: {e}")
243
+ return Response(
244
+ {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
245
+ )
246
+ except Exception as e:
247
+ logger.error(f"Recent requests error: {e}", exc_info=True)
248
+ return Response(
249
+ {"error": "Internal server error"},
250
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
251
+ )
252
+
253
+ @extend_schema(
254
+ tags=["gRPC Monitoring"],
255
+ summary="Get service statistics",
256
+ description="Returns statistics grouped by service.",
257
+ parameters=[
258
+ OpenApiParameter(
259
+ name="hours",
260
+ type=OpenApiTypes.INT,
261
+ location=OpenApiParameter.QUERY,
262
+ description="Statistics period in hours (default: 24)",
263
+ required=False,
264
+ ),
265
+ ],
266
+ responses={
267
+ 200: ServiceListSerializer,
268
+ 400: {"description": "Invalid parameters"},
269
+ },
270
+ )
271
+ @action(detail=False, methods=["get"], url_path="services")
272
+ def services(self, request):
273
+ """Get statistics per service."""
274
+ try:
275
+ hours = int(request.GET.get("hours", 24))
276
+ hours = min(max(hours, 1), 168)
277
+
278
+ # Get service statistics
279
+ service_stats = (
280
+ GRPCRequestLog.objects.recent(hours)
281
+ .values("service_name")
282
+ .annotate(
283
+ total=Count("id"),
284
+ successful=Count("id", filter=models.Q(status="success")),
285
+ errors=Count("id", filter=models.Q(status="error")),
286
+ avg_duration_ms=Avg("duration_ms"),
287
+ last_activity_at=Max("created_at"),
288
+ )
289
+ .order_by("-total")
290
+ )
291
+
292
+ services_list = []
293
+ for stats in service_stats:
294
+ services_list.append(
295
+ ServiceStatsSerializer(
296
+ service_name=stats["service_name"],
297
+ total=stats["total"],
298
+ successful=stats["successful"],
299
+ errors=stats["errors"],
300
+ avg_duration_ms=round(stats["avg_duration_ms"] or 0, 2),
301
+ last_activity_at=stats["last_activity_at"].isoformat() if stats["last_activity_at"] else None,
302
+ )
303
+ )
304
+
305
+ response_data = {
306
+ "services": [svc.model_dump() for svc in services_list],
307
+ "total_services": len(services_list),
308
+ }
309
+
310
+ serializer = ServiceListSerializer(**response_data)
311
+ return Response(serializer.model_dump())
312
+
313
+ except ValueError as e:
314
+ logger.warning(f"Service stats validation error: {e}")
315
+ return Response(
316
+ {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
317
+ )
318
+ except Exception as e:
319
+ logger.error(f"Service stats error: {e}", exc_info=True)
320
+ return Response(
321
+ {"error": "Internal server error"},
322
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
323
+ )
324
+
325
+ @extend_schema(
326
+ tags=["gRPC Monitoring"],
327
+ summary="Get method statistics",
328
+ description="Returns statistics grouped by method.",
329
+ parameters=[
330
+ OpenApiParameter(
331
+ name="hours",
332
+ type=OpenApiTypes.INT,
333
+ location=OpenApiParameter.QUERY,
334
+ description="Statistics period in hours (default: 24)",
335
+ required=False,
336
+ ),
337
+ OpenApiParameter(
338
+ name="service",
339
+ type=OpenApiTypes.STR,
340
+ location=OpenApiParameter.QUERY,
341
+ description="Filter by service name",
342
+ required=False,
343
+ ),
344
+ ],
345
+ responses={
346
+ 200: MethodListSerializer,
347
+ 400: {"description": "Invalid parameters"},
348
+ },
349
+ )
350
+ @action(detail=False, methods=["get"], url_path="methods")
351
+ def methods(self, request):
352
+ """Get statistics per method."""
353
+ try:
354
+ hours = int(request.GET.get("hours", 24))
355
+ hours = min(max(hours, 1), 168)
356
+ service_filter = request.GET.get("service")
357
+
358
+ # Get method statistics
359
+ queryset = GRPCRequestLog.objects.recent(hours)
360
+
361
+ if service_filter:
362
+ queryset = queryset.filter(service_name=service_filter)
363
+
364
+ method_stats = (
365
+ queryset
366
+ .values("service_name", "method_name")
367
+ .annotate(
368
+ total=Count("id"),
369
+ successful=Count("id", filter=models.Q(status="success")),
370
+ errors=Count("id", filter=models.Q(status="error")),
371
+ avg_duration_ms=Avg("duration_ms"),
372
+ last_activity_at=Max("created_at"),
373
+ )
374
+ .order_by("-total")
375
+ )
376
+
377
+ methods_list = []
378
+ for stats in method_stats:
379
+ methods_list.append(
380
+ MethodStatsSerializer(
381
+ method_name=stats["method_name"],
382
+ service_name=stats["service_name"],
383
+ total=stats["total"],
384
+ successful=stats["successful"],
385
+ errors=stats["errors"],
386
+ avg_duration_ms=round(stats["avg_duration_ms"] or 0, 2),
387
+ last_activity_at=stats["last_activity_at"].isoformat() if stats["last_activity_at"] else None,
388
+ )
389
+ )
390
+
391
+ response_data = {
392
+ "methods": [method.model_dump() for method in methods_list],
393
+ "total_methods": len(methods_list),
394
+ }
395
+
396
+ serializer = MethodListSerializer(**response_data)
397
+ return Response(serializer.model_dump())
398
+
399
+ except ValueError as e:
400
+ logger.warning(f"Method stats validation error: {e}")
401
+ return Response(
402
+ {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
403
+ )
404
+ except Exception as e:
405
+ logger.error(f"Method stats error: {e}", exc_info=True)
406
+ return Response(
407
+ {"error": "Internal server error"},
408
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
409
+ )
410
+
411
+ @extend_schema(
412
+ tags=["gRPC Monitoring"],
413
+ summary="Get request timeline",
414
+ description="Returns hourly or daily breakdown of request counts for charts.",
415
+ parameters=[
416
+ OpenApiParameter(
417
+ name="hours",
418
+ type=OpenApiTypes.INT,
419
+ location=OpenApiParameter.QUERY,
420
+ description="Time period in hours (default: 24)",
421
+ required=False,
422
+ ),
423
+ OpenApiParameter(
424
+ name="interval",
425
+ type=OpenApiTypes.STR,
426
+ location=OpenApiParameter.QUERY,
427
+ description="Time interval: 'hour' or 'day' (default: hour)",
428
+ required=False,
429
+ ),
430
+ ],
431
+ responses={
432
+ 200: {"description": "Timeline data"},
433
+ 400: {"description": "Invalid parameters"},
434
+ },
435
+ )
436
+ @action(detail=False, methods=["get"], url_path="timeline")
437
+ def timeline(self, request):
438
+ """Get request timeline breakdown for charts."""
439
+ try:
440
+ hours = int(request.GET.get("hours", 24))
441
+ hours = min(max(hours, 1), 168)
442
+ interval = request.GET.get("interval", "hour")
443
+
444
+ if interval not in ["hour", "day"]:
445
+ interval = "hour"
446
+
447
+ # Determine truncation function
448
+ trunc_func = TruncHour if interval == "hour" else TruncDay
449
+
450
+ # Get timeline data
451
+ timeline_data = (
452
+ GRPCRequestLog.objects.recent(hours)
453
+ .annotate(period=trunc_func("created_at"))
454
+ .values("period")
455
+ .annotate(
456
+ count=Count("id"),
457
+ successful=Count("id", filter=models.Q(status="success")),
458
+ errors=Count("id", filter=models.Q(status="error")),
459
+ timeout=Count("id", filter=models.Q(status="timeout")),
460
+ cancelled=Count("id", filter=models.Q(status="cancelled")),
461
+ )
462
+ .order_by("period")
463
+ )
464
+
465
+ timeline_list = []
466
+ for item in timeline_data:
467
+ timeline_list.append({
468
+ "timestamp": item["period"].isoformat(),
469
+ "count": item["count"],
470
+ "successful": item["successful"],
471
+ "errors": item["errors"],
472
+ "timeout": item["timeout"],
473
+ "cancelled": item["cancelled"],
474
+ })
475
+
476
+ response_data = {
477
+ "timeline": timeline_list,
478
+ "period_hours": hours,
479
+ "interval": interval,
480
+ }
481
+
482
+ return Response(response_data)
483
+
484
+ except ValueError as e:
485
+ logger.warning(f"Timeline validation error: {e}")
486
+ return Response(
487
+ {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
488
+ )
489
+ except Exception as e:
490
+ logger.error(f"Timeline error: {e}", exc_info=True)
491
+ return Response(
492
+ {"error": "Internal server error"},
493
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
494
+ )
495
+
496
+
497
+ __all__ = ["GRPCMonitorViewSet"]
@@ -177,18 +177,17 @@ class CloudflareApiKeyAdmin(PydanticAdmin):
177
177
  if not sites:
178
178
  return "No sites using this key"
179
179
 
180
- site_list = []
181
- for site in sites:
182
- status_emoji = "Maintenance" if site.maintenance_active else "Active"
183
- site_list.append(f"{status_emoji} {site.name} ({site.domain})")
184
-
185
- result = "\n".join(site_list)
180
+ # Declarative site list generation
181
+ site_items = [
182
+ f"{'🔧 Maintenance' if site.maintenance_active else '✅ Active'} {site.name} ({site.domain})"
183
+ for site in sites
184
+ ]
186
185
 
187
186
  total_count = obj.cloudflaresite_set.count()
188
187
  if total_count > 10:
189
- result += f"\n... and {total_count - 10} more sites"
188
+ site_items.append(f"... and {total_count - 10} more sites")
190
189
 
191
- return result
190
+ return "\n".join(site_items)
192
191
  sites_using_key.short_description = "Sites Using This Key"
193
192
 
194
193
  def changelist_view(self, request, extra_context=None):
@@ -256,10 +256,11 @@ class CloudflareSiteAdmin(PydanticAdmin):
256
256
  if not logs:
257
257
  return "No maintenance logs yet"
258
258
 
259
- log_list = []
260
- for log in logs:
261
- status_text = "Success" if log.status == MaintenanceLog.Status.SUCCESS else "Failed" if log.status == MaintenanceLog.Status.FAILED else "Pending"
262
- log_list.append(f"{status_text} {log.action} - {log.created_at.strftime('%Y-%m-%d %H:%M')}")
259
+ # Declarative log list generation
260
+ log_list = [
261
+ f"{('✅ Success' if log.status == MaintenanceLog.Status.SUCCESS else '❌ Failed' if log.status == MaintenanceLog.Status.FAILED else '⏳ Pending')} {log.action} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"
262
+ for log in logs
263
+ ]
263
264
 
264
265
  return "\n".join(log_list)
265
266
  logs_preview.short_description = "Recent Logs"
@@ -187,48 +187,38 @@ class UserBalanceAdmin(PydanticAdmin):
187
187
  if not obj.pk:
188
188
  return "Save to see breakdown"
189
189
 
190
- # Build breakdown list
191
- details = []
192
-
193
- # Current Balance
194
- details.append(self.html.inline([
195
- self.html.span("Current Balance:", "font-semibold"),
196
- self.html.span(f"${obj.balance_usd:.2f} USD", "")
197
- ], separator=" "))
198
-
199
- # Total Deposited
200
- details.append(self.html.inline([
201
- self.html.span("Total Deposited:", "font-semibold"),
202
- self.html.span(f"${obj.total_deposited:.2f} USD", "")
203
- ], separator=" "))
204
-
205
- # Total Withdrawn
206
- details.append(self.html.inline([
207
- self.html.span("Total Withdrawn:", "font-semibold"),
208
- self.html.span(f"${obj.total_withdrawn:.2f} USD", "")
209
- ], separator=" "))
210
-
211
190
  # Calculate net
212
191
  net = obj.total_deposited - obj.total_withdrawn
213
- details.append(self.html.inline([
214
- self.html.span("Net Deposits:", "font-semibold"),
215
- self.html.span(f"${net:.2f} USD", "")
216
- ], separator=" "))
217
-
218
- if obj.last_transaction_at:
219
- details.append(self.html.inline([
220
- self.html.span("Last Transaction:", "font-semibold"),
221
- self.html.span(str(obj.last_transaction_at), "")
222
- ], separator=" "))
223
192
 
224
193
  # Transaction count
225
194
  txn_count = Transaction.objects.filter(user=obj.user).count()
226
- details.append(self.html.inline([
227
- self.html.span("Total Transactions:", "font-semibold"),
228
- self.html.span(str(txn_count), "")
229
- ], separator=" "))
230
195
 
231
- return "<br>".join(details)
196
+ return self.html.breakdown(
197
+ self.html.key_value(
198
+ "Current Balance",
199
+ self.html.number(obj.balance_usd, precision=2, prefix="$", suffix=" USD")
200
+ ),
201
+ self.html.key_value(
202
+ "Total Deposited",
203
+ self.html.number(obj.total_deposited, precision=2, prefix="$", suffix=" USD")
204
+ ),
205
+ self.html.key_value(
206
+ "Total Withdrawn",
207
+ self.html.number(obj.total_withdrawn, precision=2, prefix="$", suffix=" USD")
208
+ ),
209
+ self.html.key_value(
210
+ "Net Deposits",
211
+ self.html.number(net, precision=2, prefix="$", suffix=" USD")
212
+ ),
213
+ self.html.key_value(
214
+ "Last Transaction",
215
+ str(obj.last_transaction_at)
216
+ ) if obj.last_transaction_at else None,
217
+ self.html.key_value(
218
+ "Total Transactions",
219
+ str(txn_count)
220
+ )
221
+ )
232
222
 
233
223
  balance_breakdown_display.short_description = "Balance Breakdown"
234
224