django-cfg 1.4.82__py3-none-any.whl → 1.4.83__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.
- django_cfg/__init__.py +1 -1
- django_cfg/apps/centrifugo/serializers/channels.py +3 -0
- django_cfg/apps/centrifugo/serializers/publishes.py +2 -0
- django_cfg/apps/centrifugo/views/admin_api.py +1 -1
- django_cfg/apps/centrifugo/views/monitoring.py +116 -6
- django_cfg/apps/centrifugo/views/testing_api.py +1 -1
- django_cfg/apps/frontend/JWT_AUTO_INJECTION.md +224 -0
- django_cfg/apps/frontend/views.py +116 -7
- django_cfg/apps/tasks/api/serializers.py +82 -0
- django_cfg/apps/tasks/api/views.py +571 -0
- django_cfg/middleware/README.md +12 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +1 -1
- django_cfg/modules/django_client/core/generator/typescript/templates/main_index.ts.jinja +7 -10
- django_cfg/modules/django_client/core/parser/openapi30.py +19 -1
- django_cfg/modules/django_client/core/parser/openapi31.py +19 -1
- django_cfg/pyproject.toml +1 -1
- django_cfg/static/frontend/admin/404.html +1 -1
- django_cfg/static/frontend/admin/500.html +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/404-c283223d1afd02a2.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/pages/500-389d6d3e1f2f7fda.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/pages/{_app-9c5ca2471de6b000.js → _app-16701a4e1bc3e6ac.js} +81 -81
- django_cfg/static/frontend/admin/_next/static/chunks/pages/_error-5291033275c26d09.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/pages/index-88751d9f44a32105.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{cookies-24588bf5551f30df.js → cookies-bb5507a122775f30.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{privacy-354dae34a4c4da59.js → privacy-f8a3d8db1a197be3.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{security-0a5d7fa591ebb1ae.js → security-aba50addd2179f8f.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{terms-c3d80322f52dc112.js → terms-4aa35cd30b5c08ad.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/private/centrifugo-1c5f00c26c77a47b.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/pages/private/profile-e93a65e8e7d9022b.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/pages/ui-0e6c0e35862789ec.js +1 -0
- django_cfg/static/frontend/admin/_next/static/css/806300fb98c42afb.css +3 -0
- django_cfg/static/frontend/admin/_next/static/ibMHm1p66p0UGKsKnDWxn/_buildManifest.js +1 -0
- django_cfg/static/frontend/admin/auth.html +1 -1
- django_cfg/static/frontend/admin/index.html +1 -1
- django_cfg/static/frontend/admin/legal/cookies.html +1 -1
- django_cfg/static/frontend/admin/legal/privacy.html +1 -1
- django_cfg/static/frontend/admin/legal/security.html +1 -1
- django_cfg/static/frontend/admin/legal/terms.html +1 -1
- django_cfg/static/frontend/admin/private/centrifugo.html +1 -0
- django_cfg/static/frontend/admin/private/profile.html +1 -0
- django_cfg/static/frontend/admin/private.html +1 -1
- django_cfg/static/frontend/admin/ui.html +2 -2
- django_cfg/templatetags/django_cfg.py +57 -10
- {django_cfg-1.4.82.dist-info → django_cfg-1.4.83.dist-info}/METADATA +1 -1
- {django_cfg-1.4.82.dist-info → django_cfg-1.4.83.dist-info}/RECORD +49 -42
- django_cfg/static/frontend/admin/_next/static/chunks/pages/404-7cdad2942c3fb179.js +0 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/500-6cdb27b00678364f.js +0 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/_error-b8071a05cabe1c2d.js +0 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/index-bf88192a30e013a9.js +0 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/ui-73632f2d9c6b11ab.js +0 -1
- django_cfg/static/frontend/admin/_next/static/css/e201974f9a4d64e6.css +0 -3
- django_cfg/static/frontend/admin/_next/static/qEBrQJUidlI_maQ4xQnI0/_buildManifest.js +0 -1
- /django_cfg/static/frontend/admin/_next/static/{qEBrQJUidlI_maQ4xQnI0 → ibMHm1p66p0UGKsKnDWxn}/_ssgManifest.js +0 -0
- {django_cfg-1.4.82.dist-info → django_cfg-1.4.83.dist-info}/WHEEL +0 -0
- {django_cfg-1.4.82.dist-info → django_cfg-1.4.83.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.4.82.dist-info → django_cfg-1.4.83.dist-info}/licenses/LICENSE +0 -0
django_cfg/__init__.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Channel statistics serializers for Centrifugo monitoring API.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
5
7
|
from pydantic import BaseModel, Field
|
|
6
8
|
|
|
7
9
|
|
|
@@ -14,6 +16,7 @@ class ChannelStatsSerializer(BaseModel):
|
|
|
14
16
|
failed: int = Field(description="Failed publishes")
|
|
15
17
|
avg_duration_ms: float = Field(description="Average duration")
|
|
16
18
|
avg_acks: float = Field(description="Average ACKs received")
|
|
19
|
+
last_activity_at: Optional[str] = Field(default=None, description="Last activity timestamp (ISO format)")
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
class ChannelListSerializer(BaseModel):
|
|
@@ -11,6 +11,8 @@ class RecentPublishesSerializer(BaseModel):
|
|
|
11
11
|
publishes: list[dict] = Field(description="List of recent publishes")
|
|
12
12
|
count: int = Field(description="Number of publishes returned")
|
|
13
13
|
total_available: int = Field(description="Total publishes available")
|
|
14
|
+
offset: int = Field(default=0, description="Current offset for pagination")
|
|
15
|
+
has_more: bool = Field(default=False, description="Whether more results are available")
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
__all__ = ["RecentPublishesSerializer"]
|
|
@@ -41,7 +41,7 @@ class CentrifugoAdminAPIViewSet(viewsets.ViewSet):
|
|
|
41
41
|
All requests are authenticated via Django session and proxied to Centrifugo.
|
|
42
42
|
"""
|
|
43
43
|
|
|
44
|
-
authentication_classes = [SessionAuthentication]
|
|
44
|
+
# authentication_classes = [SessionAuthentication]
|
|
45
45
|
permission_classes = [IsAdminUser]
|
|
46
46
|
|
|
47
47
|
def __init__(self, *args, **kwargs):
|
|
@@ -4,10 +4,11 @@ Centrifugo Monitoring ViewSet.
|
|
|
4
4
|
Provides REST API endpoints for monitoring Centrifugo publish statistics.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from datetime import datetime
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
8
|
|
|
9
9
|
from django.db import models
|
|
10
|
-
from django.db.models import Avg, Count
|
|
10
|
+
from django.db.models import Avg, Count, Max
|
|
11
|
+
from django.db.models.functions import TruncHour, TruncDay
|
|
11
12
|
from django_cfg.modules.django_logging import get_logger
|
|
12
13
|
from drf_spectacular.types import OpenApiTypes
|
|
13
14
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
|
@@ -41,7 +42,7 @@ class CentrifugoMonitorViewSet(viewsets.ViewSet):
|
|
|
41
42
|
- Channel-level statistics
|
|
42
43
|
"""
|
|
43
44
|
|
|
44
|
-
authentication_classes = [SessionAuthentication]
|
|
45
|
+
# authentication_classes = [SessionAuthentication]
|
|
45
46
|
permission_classes = [IsAdminUser]
|
|
46
47
|
|
|
47
48
|
@extend_schema(
|
|
@@ -144,6 +145,20 @@ class CentrifugoMonitorViewSet(viewsets.ViewSet):
|
|
|
144
145
|
description="Filter by channel name",
|
|
145
146
|
required=False,
|
|
146
147
|
),
|
|
148
|
+
OpenApiParameter(
|
|
149
|
+
name="status",
|
|
150
|
+
type=OpenApiTypes.STR,
|
|
151
|
+
location=OpenApiParameter.QUERY,
|
|
152
|
+
description="Filter by status (success, failed, timeout, pending, partial)",
|
|
153
|
+
required=False,
|
|
154
|
+
),
|
|
155
|
+
OpenApiParameter(
|
|
156
|
+
name="offset",
|
|
157
|
+
type=OpenApiTypes.INT,
|
|
158
|
+
location=OpenApiParameter.QUERY,
|
|
159
|
+
description="Offset for pagination (default: 0)",
|
|
160
|
+
required=False,
|
|
161
|
+
),
|
|
147
162
|
],
|
|
148
163
|
responses={
|
|
149
164
|
200: RecentPublishesSerializer,
|
|
@@ -158,14 +173,24 @@ class CentrifugoMonitorViewSet(viewsets.ViewSet):
|
|
|
158
173
|
count = min(count, 200) # Max 200
|
|
159
174
|
|
|
160
175
|
channel = request.GET.get("channel")
|
|
176
|
+
status_filter = request.GET.get("status") # NEW: status filter
|
|
177
|
+
offset = int(request.GET.get("offset", 0)) # NEW: offset for pagination
|
|
161
178
|
|
|
162
179
|
queryset = CentrifugoLog.objects.all()
|
|
163
180
|
|
|
164
181
|
if channel:
|
|
165
182
|
queryset = queryset.filter(channel=channel)
|
|
166
183
|
|
|
184
|
+
# NEW: Filter by status
|
|
185
|
+
if status_filter and status_filter in ["success", "failed", "timeout", "pending", "partial"]:
|
|
186
|
+
queryset = queryset.filter(status=status_filter)
|
|
187
|
+
|
|
188
|
+
# Get total count before slicing
|
|
189
|
+
total = queryset.count()
|
|
190
|
+
|
|
191
|
+
# NEW: Apply offset and limit
|
|
167
192
|
publishes_list = list(
|
|
168
|
-
queryset.order_by("-created_at")[:count].values(
|
|
193
|
+
queryset.order_by("-created_at")[offset:offset + count].values(
|
|
169
194
|
"message_id",
|
|
170
195
|
"channel",
|
|
171
196
|
"status",
|
|
@@ -187,12 +212,12 @@ class CentrifugoMonitorViewSet(viewsets.ViewSet):
|
|
|
187
212
|
if pub["completed_at"]:
|
|
188
213
|
pub["completed_at"] = pub["completed_at"].isoformat()
|
|
189
214
|
|
|
190
|
-
total = queryset.count()
|
|
191
|
-
|
|
192
215
|
response_data = {
|
|
193
216
|
"publishes": publishes_list,
|
|
194
217
|
"count": len(publishes_list),
|
|
195
218
|
"total_available": total,
|
|
219
|
+
"offset": offset, # NEW: for pagination
|
|
220
|
+
"has_more": (offset + count) < total, # NEW: pagination helper
|
|
196
221
|
}
|
|
197
222
|
|
|
198
223
|
serializer = RecentPublishesSerializer(**response_data)
|
|
@@ -228,6 +253,89 @@ class CentrifugoMonitorViewSet(viewsets.ViewSet):
|
|
|
228
253
|
400: {"description": "Invalid parameters"},
|
|
229
254
|
},
|
|
230
255
|
)
|
|
256
|
+
@extend_schema(
|
|
257
|
+
tags=["Centrifugo Monitoring"],
|
|
258
|
+
summary="Get publish timeline",
|
|
259
|
+
description="Returns hourly or daily breakdown of publish counts for charts.",
|
|
260
|
+
parameters=[
|
|
261
|
+
OpenApiParameter(
|
|
262
|
+
name="hours",
|
|
263
|
+
type=OpenApiTypes.INT,
|
|
264
|
+
location=OpenApiParameter.QUERY,
|
|
265
|
+
description="Time period in hours (default: 24)",
|
|
266
|
+
required=False,
|
|
267
|
+
),
|
|
268
|
+
OpenApiParameter(
|
|
269
|
+
name="interval",
|
|
270
|
+
type=OpenApiTypes.STR,
|
|
271
|
+
location=OpenApiParameter.QUERY,
|
|
272
|
+
description="Time interval: 'hour' or 'day' (default: hour)",
|
|
273
|
+
required=False,
|
|
274
|
+
),
|
|
275
|
+
],
|
|
276
|
+
responses={
|
|
277
|
+
200: {"description": "Timeline data"},
|
|
278
|
+
400: {"description": "Invalid parameters"},
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
@action(detail=False, methods=["get"], url_path="timeline")
|
|
282
|
+
def timeline(self, request):
|
|
283
|
+
"""Get publish timeline breakdown for charts."""
|
|
284
|
+
try:
|
|
285
|
+
hours = int(request.GET.get("hours", 24))
|
|
286
|
+
hours = min(max(hours, 1), 168)
|
|
287
|
+
interval = request.GET.get("interval", "hour")
|
|
288
|
+
|
|
289
|
+
if interval not in ["hour", "day"]:
|
|
290
|
+
interval = "hour"
|
|
291
|
+
|
|
292
|
+
# Determine truncation function
|
|
293
|
+
trunc_func = TruncHour if interval == "hour" else TruncDay
|
|
294
|
+
|
|
295
|
+
# Get timeline data
|
|
296
|
+
timeline_data = (
|
|
297
|
+
CentrifugoLog.objects.recent(hours)
|
|
298
|
+
.annotate(period=trunc_func("created_at"))
|
|
299
|
+
.values("period")
|
|
300
|
+
.annotate(
|
|
301
|
+
count=Count("id"),
|
|
302
|
+
successful=Count("id", filter=models.Q(status="success")),
|
|
303
|
+
failed=Count("id", filter=models.Q(status="failed")),
|
|
304
|
+
timeout=Count("id", filter=models.Q(status="timeout")),
|
|
305
|
+
)
|
|
306
|
+
.order_by("period")
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
timeline_list = []
|
|
310
|
+
for item in timeline_data:
|
|
311
|
+
timeline_list.append({
|
|
312
|
+
"timestamp": item["period"].isoformat(),
|
|
313
|
+
"count": item["count"],
|
|
314
|
+
"successful": item["successful"],
|
|
315
|
+
"failed": item["failed"],
|
|
316
|
+
"timeout": item["timeout"],
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
response_data = {
|
|
320
|
+
"timeline": timeline_list,
|
|
321
|
+
"period_hours": hours,
|
|
322
|
+
"interval": interval,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return Response(response_data)
|
|
326
|
+
|
|
327
|
+
except ValueError as e:
|
|
328
|
+
logger.warning(f"Timeline validation error: {e}")
|
|
329
|
+
return Response(
|
|
330
|
+
{"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
|
|
331
|
+
)
|
|
332
|
+
except Exception as e:
|
|
333
|
+
logger.error(f"Timeline error: {e}", exc_info=True)
|
|
334
|
+
return Response(
|
|
335
|
+
{"error": "Internal server error"},
|
|
336
|
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
337
|
+
)
|
|
338
|
+
|
|
231
339
|
@action(detail=False, methods=["get"], url_path="channels")
|
|
232
340
|
def channels(self, request):
|
|
233
341
|
"""Get statistics per channel."""
|
|
@@ -245,6 +353,7 @@ class CentrifugoMonitorViewSet(viewsets.ViewSet):
|
|
|
245
353
|
failed=Count("id", filter=models.Q(status="failed")),
|
|
246
354
|
avg_duration_ms=Avg("duration_ms"),
|
|
247
355
|
avg_acks=Avg("acks_received"),
|
|
356
|
+
last_activity_at=Max("created_at"), # NEW: last activity timestamp
|
|
248
357
|
)
|
|
249
358
|
.order_by("-total")
|
|
250
359
|
)
|
|
@@ -259,6 +368,7 @@ class CentrifugoMonitorViewSet(viewsets.ViewSet):
|
|
|
259
368
|
failed=stats["failed"],
|
|
260
369
|
avg_duration_ms=round(stats["avg_duration_ms"] or 0, 2),
|
|
261
370
|
avg_acks=round(stats["avg_acks"] or 0, 2),
|
|
371
|
+
last_activity_at=stats["last_activity_at"].isoformat() if stats["last_activity_at"] else None, # NEW
|
|
262
372
|
)
|
|
263
373
|
)
|
|
264
374
|
|
|
@@ -103,7 +103,7 @@ class CentrifugoTestingAPIViewSet(viewsets.ViewSet):
|
|
|
103
103
|
publishing, and manual ACK management.
|
|
104
104
|
"""
|
|
105
105
|
|
|
106
|
-
authentication_classes = [SessionAuthentication]
|
|
106
|
+
# authentication_classes = [SessionAuthentication]
|
|
107
107
|
permission_classes = [IsAdminUser]
|
|
108
108
|
|
|
109
109
|
def __init__(self, *args, **kwargs):
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# JWT Auto-Injection для Next.js приложений
|
|
2
|
+
|
|
3
|
+
## Обзор
|
|
4
|
+
|
|
5
|
+
Django-CFG автоматически инжектирует JWT токены (`auth_token` и `refresh_token`) в `localStorage` для авторизованных пользователей при загрузке Next.js приложений через **NextJSStaticView**.
|
|
6
|
+
|
|
7
|
+
## Как это работает
|
|
8
|
+
|
|
9
|
+
### Автоматическая инжекция в Next.js apps (рекомендуется)
|
|
10
|
+
|
|
11
|
+
**NextJSStaticView** автоматически инжектирует JWT токены во все HTML ответы для авторизованных пользователей.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
# urls.py
|
|
15
|
+
from django_cfg.apps.frontend.views import AdminView
|
|
16
|
+
|
|
17
|
+
urlpatterns = [
|
|
18
|
+
path('admin/', include('django_cfg.apps.frontend.urls')), # JWT injection automatic
|
|
19
|
+
]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
При загрузке **любой страницы Next.js приложения**, если пользователь авторизован:
|
|
23
|
+
1. View обслуживает статический файл Next.js
|
|
24
|
+
2. Генерируются JWT токены (access + refresh)
|
|
25
|
+
3. Токены автоматически инжектируются в `<head>` или `<body>` через `<script>` тег
|
|
26
|
+
4. Токены сохраняются в `localStorage`
|
|
27
|
+
|
|
28
|
+
**Преимущества:**
|
|
29
|
+
- Работает только для Next.js приложений (безопасный scope)
|
|
30
|
+
- Не нужно модифицировать templates
|
|
31
|
+
- Централизованная логика в базовом view
|
|
32
|
+
- Не влияет на другие HTML responses (Django admin, etc.)
|
|
33
|
+
|
|
34
|
+
### Template Tags (для кастомных шаблонов)
|
|
35
|
+
|
|
36
|
+
Если вы используете собственные Django шаблоны, можете использовать готовые template tags:
|
|
37
|
+
|
|
38
|
+
#### 1. Полная автоматическая инжекция
|
|
39
|
+
|
|
40
|
+
```django
|
|
41
|
+
{% load django_cfg %}
|
|
42
|
+
|
|
43
|
+
<!DOCTYPE html>
|
|
44
|
+
<html>
|
|
45
|
+
<head>
|
|
46
|
+
<title>My App</title>
|
|
47
|
+
{% inject_jwt_tokens_script %} {# Автоматически инжектит оба токена #}
|
|
48
|
+
</head>
|
|
49
|
+
<body>
|
|
50
|
+
<!-- Your content -->
|
|
51
|
+
</body>
|
|
52
|
+
</html>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
#### 2. Отдельные токены
|
|
56
|
+
|
|
57
|
+
```django
|
|
58
|
+
{% load django_cfg %}
|
|
59
|
+
|
|
60
|
+
<script>
|
|
61
|
+
// Access token
|
|
62
|
+
const accessToken = '{% user_jwt_token %}';
|
|
63
|
+
|
|
64
|
+
// Refresh token
|
|
65
|
+
const refreshToken = '{% user_jwt_refresh_token %}';
|
|
66
|
+
|
|
67
|
+
// Manual storage
|
|
68
|
+
localStorage.setItem('auth_token', accessToken);
|
|
69
|
+
localStorage.setItem('refresh_token', refreshToken);
|
|
70
|
+
</script>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Использование в Next.js
|
|
74
|
+
|
|
75
|
+
После инжекции токены доступны в вашем Next.js приложении:
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// В любом Next.js компоненте или API клиенте
|
|
79
|
+
const accessToken = localStorage.getItem('auth_token');
|
|
80
|
+
const refreshToken = localStorage.getItem('refresh_token');
|
|
81
|
+
|
|
82
|
+
// Использование с API клиентом
|
|
83
|
+
import { API } from './generated/cfg';
|
|
84
|
+
|
|
85
|
+
const api = new API('http://localhost:8000', {
|
|
86
|
+
storage: {
|
|
87
|
+
getItem: (key) => localStorage.getItem(key),
|
|
88
|
+
setItem: (key, value) => localStorage.setItem(key, value),
|
|
89
|
+
removeItem: (key) => localStorage.removeItem(key),
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Безопасность
|
|
95
|
+
|
|
96
|
+
### Что инжектируется
|
|
97
|
+
- `auth_token` - JWT access token (короткий срок жизни)
|
|
98
|
+
- `refresh_token` - JWT refresh token (длинный срок жизни)
|
|
99
|
+
|
|
100
|
+
### Когда инжектируется
|
|
101
|
+
Токены генерируются **только** если:
|
|
102
|
+
1. Пользователь **авторизован** через Django session
|
|
103
|
+
2. Загружается **HTML файл** (не JS, CSS и т.д.)
|
|
104
|
+
3. `rest_framework_simplejwt` установлен
|
|
105
|
+
|
|
106
|
+
### Безопасность токенов
|
|
107
|
+
- Токены генерируются **на лету** при каждом запросе
|
|
108
|
+
- Access token имеет короткий срок жизни (настраивается в `JWTConfig`)
|
|
109
|
+
- Refresh token позволяет получить новый access token без повторной авторизации
|
|
110
|
+
- Токены хранятся только в `localStorage` на клиенте
|
|
111
|
+
|
|
112
|
+
## Конфигурация JWT
|
|
113
|
+
|
|
114
|
+
Настройка времени жизни токенов в `django_cfg`:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from django_cfg.models.api import JWTConfig
|
|
118
|
+
|
|
119
|
+
jwt_config = JWTConfig(
|
|
120
|
+
access_token_lifetime_hours=24, # Access token на 24 часа
|
|
121
|
+
refresh_token_lifetime_days=30, # Refresh token на 30 дней
|
|
122
|
+
rotate_refresh_tokens=True, # Ротация refresh токенов
|
|
123
|
+
blacklist_after_rotation=True, # Блэклист старых токенов
|
|
124
|
+
)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Отладка
|
|
128
|
+
|
|
129
|
+
Проверьте в консоли браузера:
|
|
130
|
+
|
|
131
|
+
```javascript
|
|
132
|
+
// Проверить наличие токенов
|
|
133
|
+
console.log('Access Token:', localStorage.getItem('auth_token'));
|
|
134
|
+
console.log('Refresh Token:', localStorage.getItem('refresh_token'));
|
|
135
|
+
|
|
136
|
+
// Сообщение об успешной инжекции
|
|
137
|
+
// "JWT tokens injected successfully"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Примеры
|
|
141
|
+
|
|
142
|
+
### Пример 1: Автоматическая инжекция в Next.js приложении (рекомендуется)
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
# urls.py - JWT injection работает автоматически
|
|
146
|
+
urlpatterns = [
|
|
147
|
+
path('cfg/admin/', include('django_cfg.apps.frontend.urls')), # Admin Panel with JWT
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
# views.py - создайте свой Next.js app view
|
|
151
|
+
from django_cfg.apps.frontend.views import NextJSStaticView
|
|
152
|
+
|
|
153
|
+
class MyAppView(NextJSStaticView):
|
|
154
|
+
"""Custom Next.js app with automatic JWT injection."""
|
|
155
|
+
app_name = 'my_app' # Serves from static/frontend/my_app/
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
При переходе на **любую страницу** Next.js приложения авторизованный пользователь автоматически получит JWT токены в localStorage.
|
|
159
|
+
|
|
160
|
+
**⚠️ На /cfg/admin/auth токены НЕ инжектятся** - это страница логина, пользователь ещё не авторизован!
|
|
161
|
+
|
|
162
|
+
### Пример 2: Кастомный шаблон с инжекцией
|
|
163
|
+
|
|
164
|
+
```django
|
|
165
|
+
{% load django_cfg %}
|
|
166
|
+
|
|
167
|
+
<!DOCTYPE html>
|
|
168
|
+
<html>
|
|
169
|
+
<head>
|
|
170
|
+
<meta charset="utf-8">
|
|
171
|
+
<title>Centrifugo Monitor</title>
|
|
172
|
+
|
|
173
|
+
{# Автоматическая инжекция JWT токенов #}
|
|
174
|
+
{% inject_jwt_tokens_script %}
|
|
175
|
+
</head>
|
|
176
|
+
<body>
|
|
177
|
+
<div id="root"></div>
|
|
178
|
+
<script src="/_next/static/chunks/main.js"></script>
|
|
179
|
+
</body>
|
|
180
|
+
</html>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Требования
|
|
184
|
+
|
|
185
|
+
- Django с включенной аутентификацией
|
|
186
|
+
- `rest_framework_simplejwt` установлен
|
|
187
|
+
- Пользователь авторизован через Django session
|
|
188
|
+
|
|
189
|
+
## API Reference
|
|
190
|
+
|
|
191
|
+
### Template Tags
|
|
192
|
+
|
|
193
|
+
#### `{% user_jwt_token %}`
|
|
194
|
+
Возвращает JWT access token для текущего пользователя.
|
|
195
|
+
|
|
196
|
+
#### `{% user_jwt_refresh_token %}`
|
|
197
|
+
Возвращает JWT refresh token для текущего пользователя.
|
|
198
|
+
|
|
199
|
+
#### `{% inject_jwt_tokens_script %}`
|
|
200
|
+
Генерирует полный `<script>` тег с автоматической инжекцией обоих токенов в localStorage.
|
|
201
|
+
|
|
202
|
+
### View Classes
|
|
203
|
+
|
|
204
|
+
#### `NextJSStaticView`
|
|
205
|
+
Базовый view для обслуживания Next.js статических сборок с автоматической JWT инжекцией.
|
|
206
|
+
|
|
207
|
+
**Features:**
|
|
208
|
+
- Serves Next.js static export files
|
|
209
|
+
- Automatically injects JWT tokens for authenticated users
|
|
210
|
+
- Tokens injected into HTML responses only
|
|
211
|
+
- Handles Next.js client-side routing (.html fallback)
|
|
212
|
+
|
|
213
|
+
**Usage:**
|
|
214
|
+
```python
|
|
215
|
+
from django_cfg.apps.frontend.views import NextJSStaticView
|
|
216
|
+
|
|
217
|
+
class MyAppView(NextJSStaticView):
|
|
218
|
+
app_name = 'my_app' # Serves from static/frontend/my_app/
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
#### `AdminView`
|
|
222
|
+
Специализированный view для Admin Panel (наследует `NextJSStaticView`).
|
|
223
|
+
|
|
224
|
+
**Built-in JWT injection** - no additional configuration needed.
|
|
@@ -1,20 +1,34 @@
|
|
|
1
|
-
"""Views for serving Next.js static builds.
|
|
1
|
+
"""Views for serving Next.js static builds with automatic JWT injection.
|
|
2
2
|
|
|
3
|
+
JWT tokens are automatically injected into HTML responses for authenticated users.
|
|
4
|
+
This is specific to Next.js frontend apps only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
3
8
|
from pathlib import Path
|
|
4
|
-
from django.http import Http404
|
|
9
|
+
from django.http import Http404, HttpResponse, FileResponse
|
|
5
10
|
from django.views.static import serve
|
|
6
11
|
from django.views import View
|
|
12
|
+
from rest_framework_simplejwt.tokens import RefreshToken
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
7
15
|
|
|
8
16
|
|
|
9
17
|
class NextJSStaticView(View):
|
|
10
18
|
"""
|
|
11
|
-
Serve Next.js static build files
|
|
19
|
+
Serve Next.js static build files with automatic JWT token injection.
|
|
20
|
+
|
|
21
|
+
Features:
|
|
22
|
+
- Serves Next.js static export files
|
|
23
|
+
- Automatically injects JWT tokens for authenticated users
|
|
24
|
+
- Tokens injected into HTML responses only
|
|
25
|
+
- Handles Next.js client-side routing (.html fallback)
|
|
12
26
|
"""
|
|
13
27
|
|
|
14
28
|
app_name = 'admin'
|
|
15
29
|
|
|
16
30
|
def get(self, request, path=''):
|
|
17
|
-
"""Serve static files from Next.js build."""
|
|
31
|
+
"""Serve static files from Next.js build with JWT injection."""
|
|
18
32
|
import django_cfg
|
|
19
33
|
|
|
20
34
|
base_dir = Path(django_cfg.__file__).parent / 'static' / 'frontend' / self.app_name
|
|
@@ -29,14 +43,109 @@ class NextJSStaticView(View):
|
|
|
29
43
|
# For routes without extension, try .html (Next.js static export behavior)
|
|
30
44
|
file_path = base_dir / path
|
|
31
45
|
if not file_path.exists() and not path.endswith('.html') and '.' not in Path(path).name:
|
|
32
|
-
# Try adding .html extension
|
|
33
46
|
html_path = path + '.html'
|
|
34
47
|
html_file = base_dir / html_path
|
|
35
48
|
if html_file.exists():
|
|
36
49
|
path = html_path
|
|
37
50
|
|
|
38
|
-
#
|
|
39
|
-
|
|
51
|
+
# For HTML files, remove conditional GET headers to force full response
|
|
52
|
+
# This allows JWT token injection (can't inject into 304 Not Modified responses)
|
|
53
|
+
is_html_file = path.endswith('.html')
|
|
54
|
+
if is_html_file and request.user.is_authenticated:
|
|
55
|
+
request.META.pop('HTTP_IF_MODIFIED_SINCE', None)
|
|
56
|
+
request.META.pop('HTTP_IF_NONE_MATCH', None)
|
|
57
|
+
|
|
58
|
+
# Serve the static file
|
|
59
|
+
response = serve(request, path, document_root=str(base_dir))
|
|
60
|
+
|
|
61
|
+
# Convert FileResponse to HttpResponse for HTML files to enable JWT injection
|
|
62
|
+
if isinstance(response, FileResponse):
|
|
63
|
+
content_type = response.get('Content-Type', '')
|
|
64
|
+
if 'text/html' in content_type and request.user.is_authenticated:
|
|
65
|
+
content = b''.join(response.streaming_content)
|
|
66
|
+
original_response = response
|
|
67
|
+
response = HttpResponse(
|
|
68
|
+
content=content,
|
|
69
|
+
status=original_response.status_code,
|
|
70
|
+
content_type=content_type
|
|
71
|
+
)
|
|
72
|
+
# Copy headers from original response
|
|
73
|
+
for header, value in original_response.items():
|
|
74
|
+
if header.lower() not in ('content-length', 'content-type'):
|
|
75
|
+
response[header] = value
|
|
76
|
+
|
|
77
|
+
# Inject JWT tokens for authenticated users on HTML responses
|
|
78
|
+
if self._should_inject_jwt(request, response):
|
|
79
|
+
self._inject_jwt_tokens(request, response)
|
|
80
|
+
|
|
81
|
+
return response
|
|
82
|
+
|
|
83
|
+
def _should_inject_jwt(self, request, response):
|
|
84
|
+
"""Check if JWT tokens should be injected."""
|
|
85
|
+
# Only for authenticated users
|
|
86
|
+
if not request.user or not request.user.is_authenticated:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
# Only for HttpResponse (not FileResponse or StreamingHttpResponse)
|
|
90
|
+
if not isinstance(response, HttpResponse) or isinstance(response, FileResponse):
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
# Check if response has content attribute
|
|
94
|
+
if not hasattr(response, 'content'):
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
# Only for HTML responses
|
|
98
|
+
content_type = response.get('Content-Type', '')
|
|
99
|
+
return 'text/html' in content_type
|
|
100
|
+
|
|
101
|
+
def _inject_jwt_tokens(self, request, response):
|
|
102
|
+
"""Inject JWT tokens into HTML response."""
|
|
103
|
+
try:
|
|
104
|
+
# Generate JWT tokens
|
|
105
|
+
refresh = RefreshToken.for_user(request.user)
|
|
106
|
+
access_token = str(refresh.access_token)
|
|
107
|
+
refresh_token = str(refresh)
|
|
108
|
+
|
|
109
|
+
# Create injection script
|
|
110
|
+
injection_script = f"""
|
|
111
|
+
<script>
|
|
112
|
+
(function() {{
|
|
113
|
+
try {{
|
|
114
|
+
localStorage.setItem('auth_token', '{access_token}');
|
|
115
|
+
localStorage.setItem('refresh_token', '{refresh_token}');
|
|
116
|
+
console.log('[Django-CFG] JWT tokens injected successfully');
|
|
117
|
+
}} catch (e) {{
|
|
118
|
+
console.error('[Django-CFG] Failed to inject JWT tokens:', e);
|
|
119
|
+
}}
|
|
120
|
+
}})();
|
|
121
|
+
</script>
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
# Decode response content
|
|
125
|
+
try:
|
|
126
|
+
content = response.content.decode('utf-8')
|
|
127
|
+
except UnicodeDecodeError:
|
|
128
|
+
logger.warning("Failed to decode response content as UTF-8, skipping JWT injection")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Inject before </head> or </body>
|
|
132
|
+
if '</head>' in content:
|
|
133
|
+
content = content.replace('</head>', f'{injection_script}</head>', 1)
|
|
134
|
+
logger.debug(f"JWT tokens injected before </head> for user {request.user.pk}")
|
|
135
|
+
elif '</body>' in content:
|
|
136
|
+
content = content.replace('</body>', f'{injection_script}</body>', 1)
|
|
137
|
+
logger.debug(f"JWT tokens injected before </body> for user {request.user.pk}")
|
|
138
|
+
else:
|
|
139
|
+
logger.warning(f"No </head> or </body> tag found in HTML, skipping JWT injection")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
# Update response
|
|
143
|
+
response.content = content.encode('utf-8')
|
|
144
|
+
response['Content-Length'] = len(response.content)
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
# Log error but don't break the response
|
|
148
|
+
logger.error(f"Failed to inject JWT tokens for user {request.user.pk}: {e}", exc_info=True)
|
|
40
149
|
|
|
41
150
|
|
|
42
151
|
class AdminView(NextJSStaticView):
|