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.

Files changed (56) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/centrifugo/serializers/channels.py +3 -0
  3. django_cfg/apps/centrifugo/serializers/publishes.py +2 -0
  4. django_cfg/apps/centrifugo/views/admin_api.py +1 -1
  5. django_cfg/apps/centrifugo/views/monitoring.py +116 -6
  6. django_cfg/apps/centrifugo/views/testing_api.py +1 -1
  7. django_cfg/apps/frontend/JWT_AUTO_INJECTION.md +224 -0
  8. django_cfg/apps/frontend/views.py +116 -7
  9. django_cfg/apps/tasks/api/serializers.py +82 -0
  10. django_cfg/apps/tasks/api/views.py +571 -0
  11. django_cfg/middleware/README.md +12 -0
  12. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +1 -1
  13. django_cfg/modules/django_client/core/generator/typescript/templates/main_index.ts.jinja +7 -10
  14. django_cfg/modules/django_client/core/parser/openapi30.py +19 -1
  15. django_cfg/modules/django_client/core/parser/openapi31.py +19 -1
  16. django_cfg/pyproject.toml +1 -1
  17. django_cfg/static/frontend/admin/404.html +1 -1
  18. django_cfg/static/frontend/admin/500.html +1 -1
  19. django_cfg/static/frontend/admin/_next/static/chunks/pages/404-c283223d1afd02a2.js +1 -0
  20. django_cfg/static/frontend/admin/_next/static/chunks/pages/500-389d6d3e1f2f7fda.js +1 -0
  21. django_cfg/static/frontend/admin/_next/static/chunks/pages/{_app-9c5ca2471de6b000.js → _app-16701a4e1bc3e6ac.js} +81 -81
  22. django_cfg/static/frontend/admin/_next/static/chunks/pages/_error-5291033275c26d09.js +1 -0
  23. django_cfg/static/frontend/admin/_next/static/chunks/pages/index-88751d9f44a32105.js +1 -0
  24. django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{cookies-24588bf5551f30df.js → cookies-bb5507a122775f30.js} +1 -1
  25. django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{privacy-354dae34a4c4da59.js → privacy-f8a3d8db1a197be3.js} +1 -1
  26. django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{security-0a5d7fa591ebb1ae.js → security-aba50addd2179f8f.js} +1 -1
  27. django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{terms-c3d80322f52dc112.js → terms-4aa35cd30b5c08ad.js} +1 -1
  28. django_cfg/static/frontend/admin/_next/static/chunks/pages/private/centrifugo-1c5f00c26c77a47b.js +1 -0
  29. django_cfg/static/frontend/admin/_next/static/chunks/pages/private/profile-e93a65e8e7d9022b.js +1 -0
  30. django_cfg/static/frontend/admin/_next/static/chunks/pages/ui-0e6c0e35862789ec.js +1 -0
  31. django_cfg/static/frontend/admin/_next/static/css/806300fb98c42afb.css +3 -0
  32. django_cfg/static/frontend/admin/_next/static/ibMHm1p66p0UGKsKnDWxn/_buildManifest.js +1 -0
  33. django_cfg/static/frontend/admin/auth.html +1 -1
  34. django_cfg/static/frontend/admin/index.html +1 -1
  35. django_cfg/static/frontend/admin/legal/cookies.html +1 -1
  36. django_cfg/static/frontend/admin/legal/privacy.html +1 -1
  37. django_cfg/static/frontend/admin/legal/security.html +1 -1
  38. django_cfg/static/frontend/admin/legal/terms.html +1 -1
  39. django_cfg/static/frontend/admin/private/centrifugo.html +1 -0
  40. django_cfg/static/frontend/admin/private/profile.html +1 -0
  41. django_cfg/static/frontend/admin/private.html +1 -1
  42. django_cfg/static/frontend/admin/ui.html +2 -2
  43. django_cfg/templatetags/django_cfg.py +57 -10
  44. {django_cfg-1.4.82.dist-info → django_cfg-1.4.83.dist-info}/METADATA +1 -1
  45. {django_cfg-1.4.82.dist-info → django_cfg-1.4.83.dist-info}/RECORD +49 -42
  46. django_cfg/static/frontend/admin/_next/static/chunks/pages/404-7cdad2942c3fb179.js +0 -1
  47. django_cfg/static/frontend/admin/_next/static/chunks/pages/500-6cdb27b00678364f.js +0 -1
  48. django_cfg/static/frontend/admin/_next/static/chunks/pages/_error-b8071a05cabe1c2d.js +0 -1
  49. django_cfg/static/frontend/admin/_next/static/chunks/pages/index-bf88192a30e013a9.js +0 -1
  50. django_cfg/static/frontend/admin/_next/static/chunks/pages/ui-73632f2d9c6b11ab.js +0 -1
  51. django_cfg/static/frontend/admin/_next/static/css/e201974f9a4d64e6.css +0 -3
  52. django_cfg/static/frontend/admin/_next/static/qEBrQJUidlI_maQ4xQnI0/_buildManifest.js +0 -1
  53. /django_cfg/static/frontend/admin/_next/static/{qEBrQJUidlI_maQ4xQnI0 → ibMHm1p66p0UGKsKnDWxn}/_ssgManifest.js +0 -0
  54. {django_cfg-1.4.82.dist-info → django_cfg-1.4.83.dist-info}/WHEEL +0 -0
  55. {django_cfg-1.4.82.dist-info → django_cfg-1.4.83.dist-info}/entry_points.txt +0 -0
  56. {django_cfg-1.4.82.dist-info → django_cfg-1.4.83.dist-info}/licenses/LICENSE +0 -0
django_cfg/__init__.py CHANGED
@@ -32,7 +32,7 @@ Example:
32
32
  default_app_config = "django_cfg.apps.DjangoCfgConfig"
33
33
 
34
34
  # Version information
35
- __version__ = "1.4.82"
35
+ __version__ = "1.4.83"
36
36
  __license__ = "MIT"
37
37
 
38
38
  # Import registry for organized lazy loading
@@ -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 using Django's built-in static file serving.
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
- # Use Django's static serve view (handles security, content-type, etc.)
39
- return serve(request, path, document_root=str(base_dir))
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):