django-cfg 1.4.91__py3-none-any.whl → 1.4.93__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/frontend/views.py +99 -16
- django_cfg/modules/django_client/management/commands/generate_client.py +7 -1
- django_cfg/modules/django_unfold/navigation.py +0 -1
- django_cfg/modules/nextjs_admin/views.py +33 -40
- django_cfg/pyproject.toml +3 -3
- django_cfg/static/frontend/nextjs_admin.zip +0 -0
- django_cfg/templates/admin/index.html +69 -7
- django_cfg/templatetags/django_cfg.py +74 -29
- {django_cfg-1.4.91.dist-info → django_cfg-1.4.93.dist-info}/METADATA +228 -171
- {django_cfg-1.4.91.dist-info → django_cfg-1.4.93.dist-info}/RECORD +14 -13
- {django_cfg-1.4.91.dist-info → django_cfg-1.4.93.dist-info}/WHEEL +0 -0
- {django_cfg-1.4.91.dist-info → django_cfg-1.4.93.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.4.91.dist-info → django_cfg-1.4.93.dist-info}/licenses/LICENSE +0 -0
django_cfg/__init__.py
CHANGED
|
@@ -4,14 +4,17 @@ JWT tokens are automatically injected into HTML responses for authenticated user
|
|
|
4
4
|
This is specific to Next.js frontend apps only.
|
|
5
5
|
|
|
6
6
|
Features:
|
|
7
|
-
- Automatic extraction of ZIP archives
|
|
7
|
+
- Automatic extraction of ZIP archives with smart update detection
|
|
8
|
+
- Auto-reextraction when ZIP is modified within last 5 minutes
|
|
8
9
|
- SPA routing with fallback strategies
|
|
9
10
|
- JWT token injection for authenticated users
|
|
10
11
|
"""
|
|
11
12
|
|
|
12
13
|
import logging
|
|
13
14
|
import zipfile
|
|
15
|
+
import shutil
|
|
14
16
|
from pathlib import Path
|
|
17
|
+
from datetime import datetime, timedelta
|
|
15
18
|
from django.http import Http404, HttpResponse, FileResponse
|
|
16
19
|
from django.views.static import serve
|
|
17
20
|
from django.views import View
|
|
@@ -23,19 +26,105 @@ from rest_framework_simplejwt.tokens import RefreshToken
|
|
|
23
26
|
logger = logging.getLogger(__name__)
|
|
24
27
|
|
|
25
28
|
|
|
29
|
+
class ZipExtractionMixin:
|
|
30
|
+
"""
|
|
31
|
+
Mixin for automatic ZIP extraction with smart update detection.
|
|
32
|
+
|
|
33
|
+
Provides intelligent ZIP archive handling:
|
|
34
|
+
- Auto-extraction when directory doesn't exist
|
|
35
|
+
- Auto-reextraction when ZIP is modified < 5 minutes ago
|
|
36
|
+
- Timestamp comparison to avoid unnecessary extractions
|
|
37
|
+
|
|
38
|
+
Usage:
|
|
39
|
+
class MyView(ZipExtractionMixin, View):
|
|
40
|
+
app_name = 'myapp' # Will look for myapp.zip
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def extract_zip_if_needed(self, base_dir: Path, zip_path: Path, app_name: str) -> bool:
|
|
44
|
+
"""
|
|
45
|
+
Extract ZIP archive if needed based on modification times.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
base_dir: Target directory for extraction
|
|
49
|
+
zip_path: Path to ZIP archive
|
|
50
|
+
app_name: Name of the app (for logging)
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
bool: True if extraction succeeded or not needed, False if failed
|
|
54
|
+
"""
|
|
55
|
+
should_extract = False
|
|
56
|
+
|
|
57
|
+
# Priority 1: If directory doesn't exist at all - always extract
|
|
58
|
+
if not base_dir.exists():
|
|
59
|
+
should_extract = True
|
|
60
|
+
logger.info(f"[{app_name}] Directory {base_dir} doesn't exist, will extract")
|
|
61
|
+
|
|
62
|
+
# Priority 2: Directory exists - check if ZIP is fresh and needs update
|
|
63
|
+
elif zip_path.exists():
|
|
64
|
+
# Get ZIP modification time
|
|
65
|
+
zip_mtime = datetime.fromtimestamp(zip_path.stat().st_mtime)
|
|
66
|
+
time_since_modification = (datetime.now() - zip_mtime).total_seconds()
|
|
67
|
+
|
|
68
|
+
# If ZIP was modified less than 5 minutes ago - check for updates
|
|
69
|
+
if time_since_modification < 300: # 5 minutes = 300 seconds
|
|
70
|
+
# Compare ZIP time with directory time
|
|
71
|
+
dir_mtime = datetime.fromtimestamp(base_dir.stat().st_mtime)
|
|
72
|
+
|
|
73
|
+
# If ZIP is newer than directory, re-extract
|
|
74
|
+
if zip_mtime > dir_mtime:
|
|
75
|
+
logger.info(f"[{app_name}] ZIP is fresh ({time_since_modification:.0f}s) and newer, re-extracting")
|
|
76
|
+
try:
|
|
77
|
+
shutil.rmtree(base_dir)
|
|
78
|
+
should_extract = True
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.error(f"[{app_name}] Failed to remove old directory: {e}")
|
|
81
|
+
return False
|
|
82
|
+
else:
|
|
83
|
+
logger.debug(f"[{app_name}] ZIP is fresh but directory is up-to-date")
|
|
84
|
+
else:
|
|
85
|
+
# ZIP is old (> 5 min) - use existing directory, no checks needed
|
|
86
|
+
logger.debug(f"[{app_name}] ZIP is old ({time_since_modification:.0f}s), using existing")
|
|
87
|
+
|
|
88
|
+
# Extract ZIP if needed
|
|
89
|
+
if should_extract:
|
|
90
|
+
if zip_path.exists():
|
|
91
|
+
logger.info(f"[{app_name}] Extracting {zip_path.name} to {base_dir}...")
|
|
92
|
+
try:
|
|
93
|
+
base_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
95
|
+
zip_ref.extractall(base_dir)
|
|
96
|
+
logger.info(f"[{app_name}] Successfully extracted {zip_path.name}")
|
|
97
|
+
return True
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.error(f"[{app_name}] Failed to extract: {e}")
|
|
100
|
+
return False
|
|
101
|
+
else:
|
|
102
|
+
logger.error(f"[{app_name}] ZIP not found: {zip_path}")
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
# Directory exists and is up-to-date
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
|
|
26
109
|
@method_decorator(xframe_options_exempt, name='dispatch')
|
|
27
|
-
class NextJSStaticView(View):
|
|
110
|
+
class NextJSStaticView(ZipExtractionMixin, View):
|
|
28
111
|
"""
|
|
29
112
|
Serve Next.js static build files with automatic JWT token injection.
|
|
30
113
|
|
|
31
114
|
Features:
|
|
32
115
|
- Serves Next.js static export files like a static file server
|
|
116
|
+
- Smart ZIP extraction: auto-updates when archive modified < 5 minutes ago
|
|
33
117
|
- Automatically injects JWT tokens for authenticated users
|
|
34
118
|
- Tokens injected into HTML responses only
|
|
35
119
|
- Handles Next.js client-side routing (.html fallback)
|
|
36
120
|
- Automatically serves index.html for directory paths
|
|
37
121
|
- X-Frame-Options exempt to allow embedding in iframes
|
|
38
122
|
|
|
123
|
+
ZIP Update Logic:
|
|
124
|
+
- If ZIP modified < 5 minutes ago: removes old files and re-extracts
|
|
125
|
+
- If ZIP modified > 5 minutes ago: uses existing extraction
|
|
126
|
+
- This enables hot-reload during development while staying fast in production
|
|
127
|
+
|
|
39
128
|
Path resolution examples:
|
|
40
129
|
- /cfg/admin/ → /cfg/admin/index.html
|
|
41
130
|
- /cfg/admin/private/ → /cfg/admin/private/index.html (if exists)
|
|
@@ -51,22 +140,16 @@ class NextJSStaticView(View):
|
|
|
51
140
|
import django_cfg
|
|
52
141
|
|
|
53
142
|
base_dir = Path(django_cfg.__file__).parent / 'static' / 'frontend' / self.app_name
|
|
143
|
+
zip_path = Path(django_cfg.__file__).parent / 'static' / 'frontend' / f'{self.app_name}.zip'
|
|
54
144
|
|
|
55
|
-
#
|
|
145
|
+
# Extract ZIP if needed using mixin
|
|
146
|
+
if not self.extract_zip_if_needed(base_dir, zip_path, self.app_name):
|
|
147
|
+
return render(request, 'frontend/404.html', status=404)
|
|
148
|
+
|
|
149
|
+
# Ensure directory exists
|
|
56
150
|
if not base_dir.exists():
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
logger.info(f"Extracting {self.app_name}.zip to {base_dir}...")
|
|
60
|
-
try:
|
|
61
|
-
base_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
-
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
63
|
-
zip_ref.extractall(base_dir)
|
|
64
|
-
logger.info(f"Successfully extracted {self.app_name}.zip")
|
|
65
|
-
except Exception as e:
|
|
66
|
-
logger.error(f"Failed to extract {self.app_name}.zip: {e}")
|
|
67
|
-
return render(request, 'frontend/404.html', status=404)
|
|
68
|
-
else:
|
|
69
|
-
return render(request, 'frontend/404.html', status=404)
|
|
151
|
+
logger.error(f"[{self.app_name}] Directory doesn't exist after extraction attempt")
|
|
152
|
+
return render(request, 'frontend/404.html', status=404)
|
|
70
153
|
|
|
71
154
|
original_path = path # Store for logging
|
|
72
155
|
|
|
@@ -571,13 +571,19 @@ class Command(BaseCommand):
|
|
|
571
571
|
|
|
572
572
|
self.stdout.write(f"\n📦 Copying TypeScript clients to Next.js admin...")
|
|
573
573
|
|
|
574
|
-
# Copy each group
|
|
574
|
+
# Copy each group (exclude 'cfg' for Next.js admin)
|
|
575
575
|
copied_count = 0
|
|
576
576
|
for group_dir in ts_source.iterdir():
|
|
577
577
|
if not group_dir.is_dir():
|
|
578
578
|
continue
|
|
579
579
|
|
|
580
580
|
group_name = group_dir.name
|
|
581
|
+
|
|
582
|
+
# Skip 'cfg' group for Next.js admin
|
|
583
|
+
if group_name == 'cfg':
|
|
584
|
+
self.stdout.write(f" ⏭️ Skipping 'cfg' group (excluded from Next.js admin)")
|
|
585
|
+
continue
|
|
586
|
+
|
|
581
587
|
target_dir = api_output_path / group_name
|
|
582
588
|
|
|
583
589
|
# Remove old
|
|
@@ -48,7 +48,6 @@ class NavigationManager(BaseCfgModule):
|
|
|
48
48
|
collapsible=True,
|
|
49
49
|
items=[
|
|
50
50
|
NavigationItem(title="Overview", icon=Icons.DASHBOARD, link=str(reverse_lazy("admin:index"))),
|
|
51
|
-
NavigationItem(title="Frontend Admin", icon=Icons.WEB_ASSET, link="/cfg/admin/"),
|
|
52
51
|
NavigationItem(title="Settings", icon=Icons.SETTINGS, link=str(reverse_lazy("admin:constance_config_changelist"))),
|
|
53
52
|
NavigationItem(title="Health Check", icon=Icons.HEALTH_AND_SAFETY, link=str(reverse_lazy("django_cfg_drf_health"))),
|
|
54
53
|
NavigationItem(title="Endpoints Status", icon=Icons.API, link=str(reverse_lazy("endpoints_status_drf"))),
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
Views for Next.js admin integration.
|
|
3
3
|
|
|
4
4
|
Serves Next.js static files with SPA routing support and JWT injection.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Automatic extraction of ZIP archives with smart update detection
|
|
8
|
+
- Auto-reextraction when ZIP is modified within last 5 minutes
|
|
9
|
+
- SPA routing with fallback strategies
|
|
10
|
+
- JWT token injection for authenticated users
|
|
5
11
|
"""
|
|
6
12
|
|
|
7
13
|
import logging
|
|
@@ -13,12 +19,13 @@ from django.views.decorators.clickjacking import xframe_options_exempt
|
|
|
13
19
|
from django.utils.decorators import method_decorator
|
|
14
20
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
15
21
|
from django.shortcuts import render
|
|
22
|
+
from django_cfg.apps.frontend.views import ZipExtractionMixin
|
|
16
23
|
|
|
17
24
|
logger = logging.getLogger(__name__)
|
|
18
25
|
|
|
19
26
|
|
|
20
27
|
@method_decorator(xframe_options_exempt, name='dispatch')
|
|
21
|
-
class NextJsAdminView(LoginRequiredMixin, View):
|
|
28
|
+
class NextJsAdminView(ZipExtractionMixin, LoginRequiredMixin, View):
|
|
22
29
|
"""
|
|
23
30
|
Serve Next.js admin panel with JWT injection and SPA routing.
|
|
24
31
|
|
|
@@ -38,7 +45,6 @@ class NextJsAdminView(LoginRequiredMixin, View):
|
|
|
38
45
|
"""Serve Next.js files with JWT injection and SPA routing."""
|
|
39
46
|
from django_cfg.core.config import get_current_config
|
|
40
47
|
import django_cfg
|
|
41
|
-
import zipfile
|
|
42
48
|
|
|
43
49
|
config = get_current_config()
|
|
44
50
|
if not config or not config.nextjs_admin:
|
|
@@ -48,33 +54,24 @@ class NextJsAdminView(LoginRequiredMixin, View):
|
|
|
48
54
|
|
|
49
55
|
# Use extracted directory from static/frontend/nextjs_admin/
|
|
50
56
|
base_dir = Path(django_cfg.__file__).parent / 'static' / 'frontend' / 'nextjs_admin'
|
|
57
|
+
zip_path = Path(django_cfg.__file__).parent / 'static' / 'frontend' / 'nextjs_admin.zip'
|
|
58
|
+
|
|
59
|
+
# Fallback: Try solution project static directory
|
|
60
|
+
if not zip_path.exists():
|
|
61
|
+
from django.conf import settings
|
|
62
|
+
solution_zip = Path(settings.BASE_DIR) / 'static' / 'nextjs_admin.zip'
|
|
63
|
+
if solution_zip.exists():
|
|
64
|
+
zip_path = solution_zip
|
|
65
|
+
logger.info(f"[nextjs_admin] Using ZIP from solution project: {solution_zip}")
|
|
51
66
|
|
|
52
|
-
#
|
|
67
|
+
# Extract ZIP if needed using mixin
|
|
68
|
+
if not self.extract_zip_if_needed(base_dir, zip_path, 'nextjs_admin'):
|
|
69
|
+
return render(request, 'frontend/404.html', status=404)
|
|
70
|
+
|
|
71
|
+
# Ensure directory exists
|
|
53
72
|
if not base_dir.exists():
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# Fallback: Try solution project static directory
|
|
58
|
-
if not zip_path.exists():
|
|
59
|
-
from django.conf import settings
|
|
60
|
-
solution_zip = Path(settings.BASE_DIR) / 'static' / 'nextjs_admin.zip'
|
|
61
|
-
if solution_zip.exists():
|
|
62
|
-
zip_path = solution_zip
|
|
63
|
-
logger.info(f"Using ZIP from solution project: {solution_zip}")
|
|
64
|
-
|
|
65
|
-
if zip_path.exists():
|
|
66
|
-
logger.info(f"Extracting nextjs_admin.zip to {base_dir}...")
|
|
67
|
-
try:
|
|
68
|
-
base_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
-
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
70
|
-
zip_ref.extractall(base_dir)
|
|
71
|
-
logger.info(f"Successfully extracted nextjs_admin.zip from {zip_path}")
|
|
72
|
-
except Exception as e:
|
|
73
|
-
logger.error(f"Failed to extract nextjs_admin.zip: {e}")
|
|
74
|
-
return render(request, 'frontend/404.html', status=404)
|
|
75
|
-
else:
|
|
76
|
-
logger.error(f"nextjs_admin.zip not found in django_cfg or solution project")
|
|
77
|
-
return render(request, 'frontend/404.html', status=404)
|
|
73
|
+
logger.error(f"[nextjs_admin] Directory doesn't exist after extraction attempt")
|
|
74
|
+
return render(request, 'frontend/404.html', status=404)
|
|
78
75
|
|
|
79
76
|
static_dir = base_dir
|
|
80
77
|
|
|
@@ -118,27 +115,23 @@ class NextJsAdminView(LoginRequiredMixin, View):
|
|
|
118
115
|
Resolve SPA path with Next.js routing conventions.
|
|
119
116
|
|
|
120
117
|
Resolution order:
|
|
121
|
-
1. Default to
|
|
118
|
+
1. Default to /admin for empty path or /admin path
|
|
122
119
|
2. Exact file match (static assets)
|
|
123
120
|
3. path/index.html (SPA routes)
|
|
124
121
|
4. path.html (single page)
|
|
125
122
|
5. Fallback to index.html
|
|
126
123
|
|
|
127
124
|
Examples:
|
|
128
|
-
'' → '
|
|
129
|
-
'
|
|
125
|
+
'' → 'admin/index.html'
|
|
126
|
+
'admin' → 'admin/index.html'
|
|
127
|
+
'admin/centrifugo' → 'admin/centrifugo/index.html'
|
|
130
128
|
'_next/static/...' → '_next/static/...' (exact)
|
|
131
129
|
"""
|
|
132
|
-
# Empty path -
|
|
133
|
-
if not path or path == '/':
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if html_file.exists():
|
|
138
|
-
return f"{iframe_route}.html"
|
|
139
|
-
index_file = base_dir / iframe_route / 'index.html'
|
|
140
|
-
if index_file.exists():
|
|
141
|
-
return f"{iframe_route}/index.html"
|
|
130
|
+
# Empty path or 'admin' - serve /admin route
|
|
131
|
+
if not path or path == '/' or path == 'admin' or path == 'admin/':
|
|
132
|
+
admin_index = base_dir / 'admin' / 'index.html'
|
|
133
|
+
if admin_index.exists():
|
|
134
|
+
return 'admin/index.html'
|
|
142
135
|
# Fallback to root index.html
|
|
143
136
|
return 'index.html'
|
|
144
137
|
|
django_cfg/pyproject.toml
CHANGED
|
@@ -4,10 +4,10 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "django-cfg"
|
|
7
|
-
version = "1.4.
|
|
8
|
-
description = "Django
|
|
7
|
+
version = "1.4.93"
|
|
8
|
+
description = "Modern Django framework with type-safe Pydantic v2 configuration, Next.js admin integration, real-time WebSockets, and 8 enterprise apps. Replace settings.py with validated models, 90% less code. Production-ready with AI agents, auto-generated TypeScript clients, and zero-config features."
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
keywords = [ "django", "configuration", "pydantic", "settings", "type-safety", "pydantic-settings", "django-environ", "startup-validation", "ide-autocomplete", "ai-agents", "enterprise-django", "django-settings", "type-safe-config",]
|
|
10
|
+
keywords = [ "django", "configuration", "pydantic", "settings", "type-safety", "pydantic-settings", "django-environ", "startup-validation", "ide-autocomplete", "nextjs-admin", "react-admin", "websocket", "centrifugo", "real-time", "typescript-generation", "ai-agents", "enterprise-django", "django-settings", "type-safe-config", "modern-django",]
|
|
11
11
|
classifiers = [ "Development Status :: 4 - Beta", "Framework :: Django", "Framework :: Django :: 5.2", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Systems Administration", "Typing :: Typed",]
|
|
12
12
|
requires-python = ">=3.12,<3.14"
|
|
13
13
|
dependencies = [ "pydantic>=2.11.0,<3.0", "pydantic[email]>=2.11.0,<3.0", "PyYAML>=6.0,<7.0", "click>=8.2.0,<9.0", "questionary>=2.1.0,<3.0", "rich>=14.0.0,<15.0", "cloudflare>=4.3.0,<5.0", "loguru>=0.7.0,<1.0", "colorlog>=6.9.0,<7.0", "cachetools>=5.3.0,<7.0", "toml>=0.10.2,<0.11.0", "ngrok>=1.5.1; python_version>='3.12'", "psycopg[binary,pool]>=3.2.0,<4.0", "dj-database-url>=3.0.0,<4.0", "whitenoise>=6.8.0,<7.0", "django-cors-headers>=4.7.0,<5.0", "djangorestframework>=3.16.0,<4.0", "djangorestframework-simplejwt>=5.5.0,<6.0", "djangorestframework-simplejwt[token-blacklist]>=5.5.0,<6.0", "drf-nested-routers>=0.94.0,<1.0", "django-filter>=25.0,<26.0", "django-ratelimit>=4.1.0,<5.0.0", "drf-spectacular>=0.28.0,<1.0", "drf-spectacular-sidecar>=2025.8.0,<2026.0", "django-json-widget>=2.0.0,<3.0", "django-import-export>=4.3.0,<5.0", "django-extensions>=4.1.0,<5.0", "django-constance>=4.3.0,<5.0", "django-unfold>=0.64.0,<1.0", "django-redis>=6.0.0,<7.0", "redis>=6.4.0,<7.0", "hiredis>=2.0.0,<4.0", "dramatiq[redis]>=1.18.0,<2.0", "django-dramatiq>=0.14.0,<1.0", "pyTelegramBotAPI>=4.28.0,<5.0", "coolname>=2.2.0,<3.0", "django-admin-rangefilter>=0.13.0,<1.0", "python-json-logger>=3.3.0,<4.0", "requests>=2.32.0,<3.0", "tiktoken>=0.11.0,<1.0", "openai>=1.107.0,<2.0", "twilio>=9.8.0,<10.0", "sendgrid>=6.12.0,<7.0", "beautifulsoup4>=4.13.0,<5.0", "lxml>=6.0.0,<7.0", "pgvector>=0.4.0,<1.0", "tenacity>=9.1.2,<10.0.0", "mypy (>=1.18.2,<2.0.0)", "django-tailwind[reload] (>=4.2.0,<5.0.0)", "jinja2 (>=3.1.6,<4.0.0)", "django-axes[ipware] (>=8.0.0,<9.0.0)", "pydantic-settings (>=2.11.0,<3.0.0)",]
|
|
Binary file
|
|
@@ -79,7 +79,27 @@
|
|
|
79
79
|
{% block content %}
|
|
80
80
|
<!-- Main Container -->
|
|
81
81
|
{% component "unfold/components/container.html" %}
|
|
82
|
-
<div x-data="{
|
|
82
|
+
<div x-data="{
|
|
83
|
+
activeTab: 'builtin',
|
|
84
|
+
previousTab: 'builtin',
|
|
85
|
+
switchTab(tab) {
|
|
86
|
+
if (this.previousTab !== tab) {
|
|
87
|
+
// Reset iframe to initial URL when switching tabs
|
|
88
|
+
this.resetIframe(this.previousTab);
|
|
89
|
+
this.previousTab = tab;
|
|
90
|
+
}
|
|
91
|
+
this.activeTab = tab;
|
|
92
|
+
},
|
|
93
|
+
resetIframe(tab) {
|
|
94
|
+
// Reset the iframe that was just hidden
|
|
95
|
+
const iframeId = tab === 'builtin' ? 'nextjs-dashboard-iframe-builtin' : 'nextjs-dashboard-iframe-nextjs';
|
|
96
|
+
const iframe = document.getElementById(iframeId);
|
|
97
|
+
if (iframe) {
|
|
98
|
+
const originalSrc = iframe.getAttribute('data-original-src') || iframe.src;
|
|
99
|
+
iframe.src = originalSrc;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}">
|
|
83
103
|
{% if is_frontend_dev_mode %}
|
|
84
104
|
<!-- Development Mode Badge -->
|
|
85
105
|
<div class="bg-yellow-100 dark:bg-yellow-900 border-l-4 border-yellow-500 text-yellow-700 dark:text-yellow-300 p-4 mb-4" role="alert">
|
|
@@ -106,7 +126,7 @@
|
|
|
106
126
|
}
|
|
107
127
|
</style>
|
|
108
128
|
<nav class="-mb-px flex space-x-8 px-4" aria-label="Dashboard Tabs">
|
|
109
|
-
<button @click="
|
|
129
|
+
<button @click="switchTab('builtin')"
|
|
110
130
|
class="whitespace-nowrap py-4 px-2 border-b-2 font-medium text-sm flex items-center gap-2 transition-all duration-200"
|
|
111
131
|
:class="activeTab === 'builtin'
|
|
112
132
|
? 'border-primary-600 text-primary-600 dark:border-primary-500 dark:text-primary-500'
|
|
@@ -115,7 +135,7 @@
|
|
|
115
135
|
<span>Built-in Dashboard</span>
|
|
116
136
|
</button>
|
|
117
137
|
|
|
118
|
-
<button @click="
|
|
138
|
+
<button @click="switchTab('nextjs')"
|
|
119
139
|
class="whitespace-nowrap py-4 px-2 border-b-2 font-medium text-sm flex items-center gap-2 transition-all duration-200"
|
|
120
140
|
:class="activeTab === 'nextjs'
|
|
121
141
|
? 'border-primary-600 text-primary-600 dark:border-primary-500 dark:text-primary-500'
|
|
@@ -140,6 +160,7 @@
|
|
|
140
160
|
id="nextjs-dashboard-iframe-builtin"
|
|
141
161
|
class="nextjs-dashboard-iframe"
|
|
142
162
|
src="{% nextjs_admin_url %}"
|
|
163
|
+
data-original-src="{% nextjs_admin_url %}"
|
|
143
164
|
title="Next.js Dashboard"
|
|
144
165
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation"
|
|
145
166
|
scrolling="no"
|
|
@@ -160,6 +181,7 @@
|
|
|
160
181
|
id="nextjs-dashboard-iframe-nextjs"
|
|
161
182
|
class="nextjs-dashboard-iframe nextjs-external-iframe"
|
|
162
183
|
src="{% nextjs_external_admin_url %}"
|
|
184
|
+
data-original-src="{% nextjs_external_admin_url %}"
|
|
163
185
|
title="{% nextjs_external_admin_title %}"
|
|
164
186
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation"
|
|
165
187
|
scrolling="no"
|
|
@@ -225,22 +247,62 @@
|
|
|
225
247
|
|
|
226
248
|
// Debounce timer for resize events (prevents jittery iframe height changes)
|
|
227
249
|
let resizeTimer = null;
|
|
250
|
+
let loadTimeout = null;
|
|
228
251
|
|
|
229
252
|
// iframe load event
|
|
230
253
|
iframe.addEventListener('load', function() {
|
|
254
|
+
// Clear the fallback timeout
|
|
255
|
+
if (loadTimeout) {
|
|
256
|
+
clearTimeout(loadTimeout);
|
|
257
|
+
loadTimeout = null;
|
|
258
|
+
}
|
|
259
|
+
|
|
231
260
|
setTimeout(() => {
|
|
232
261
|
sendDataToIframe();
|
|
233
262
|
}, 100);
|
|
234
263
|
});
|
|
235
264
|
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
265
|
+
// iframe error event - fallback to static files
|
|
266
|
+
iframe.addEventListener('error', function() {
|
|
267
|
+
console.warn('[Django-CFG] iframe failed to load, falling back to static files');
|
|
268
|
+
handleLoadFailure();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Timeout fallback - if iframe doesn't load in 3 seconds
|
|
272
|
+
loadTimeout = setTimeout(() => {
|
|
273
|
+
if (!iframe.classList.contains('loaded') && isDevMode) {
|
|
274
|
+
console.warn('[Django-CFG] Dev server timeout, falling back to static files');
|
|
275
|
+
handleLoadFailure();
|
|
276
|
+
} else if (!iframe.classList.contains('loaded')) {
|
|
239
277
|
console.log('[Django-CFG] Timeout - showing iframe anyway');
|
|
240
278
|
if (loading) loading.classList.add('hidden');
|
|
241
279
|
iframe.classList.add('loaded');
|
|
242
280
|
}
|
|
243
|
-
},
|
|
281
|
+
}, 3000);
|
|
282
|
+
|
|
283
|
+
// Handle load failure - switch to static files
|
|
284
|
+
function handleLoadFailure() {
|
|
285
|
+
if (!isDevMode) return; // Already using static files
|
|
286
|
+
|
|
287
|
+
const originalSrc = iframe.getAttribute('data-original-src');
|
|
288
|
+
if (!originalSrc) return;
|
|
289
|
+
|
|
290
|
+
// Extract path from dev server URL
|
|
291
|
+
const devUrl = new URL(originalSrc);
|
|
292
|
+
const pathPart = devUrl.pathname.replace('/admin', '').replace(/^\//, '');
|
|
293
|
+
|
|
294
|
+
// Build static files URL
|
|
295
|
+
const staticUrl = `/cfg/admin/admin/${pathPart}`;
|
|
296
|
+
|
|
297
|
+
console.log('[Django-CFG] Switching to static files:', staticUrl);
|
|
298
|
+
iframe.src = staticUrl;
|
|
299
|
+
|
|
300
|
+
// Show iframe after switching
|
|
301
|
+
setTimeout(() => {
|
|
302
|
+
if (loading) loading.classList.add('hidden');
|
|
303
|
+
iframe.classList.add('loaded');
|
|
304
|
+
}, 500);
|
|
305
|
+
}
|
|
244
306
|
|
|
245
307
|
// Send theme and auth data to iframe
|
|
246
308
|
function sendDataToIframe() {
|
|
@@ -5,13 +5,40 @@ Provides template tags for accessing django-cfg configuration constants.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import os
|
|
8
|
+
import socket
|
|
8
9
|
from django import template
|
|
10
|
+
from django.conf import settings
|
|
9
11
|
from django.utils.safestring import mark_safe
|
|
10
12
|
from rest_framework_simplejwt.tokens import RefreshToken
|
|
11
13
|
|
|
12
14
|
register = template.Library()
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
def _is_port_available(host: str, port: int, timeout: float = 0.1) -> bool:
|
|
18
|
+
"""
|
|
19
|
+
Check if a port is available (listening) on the specified host.
|
|
20
|
+
|
|
21
|
+
Uses a quick socket connection test with minimal timeout.
|
|
22
|
+
Returns True if the port is open and accepting connections.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
host: Host to check (e.g., 'localhost', '127.0.0.1')
|
|
26
|
+
port: Port number to check
|
|
27
|
+
timeout: Connection timeout in seconds (default: 0.1s)
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
bool: True if port is available, False otherwise
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
34
|
+
sock.settimeout(timeout)
|
|
35
|
+
result = sock.connect_ex((host, port))
|
|
36
|
+
sock.close()
|
|
37
|
+
return result == 0
|
|
38
|
+
except Exception:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
15
42
|
@register.simple_tag
|
|
16
43
|
def lib_name():
|
|
17
44
|
"""Get the library name."""
|
|
@@ -140,37 +167,36 @@ def inject_jwt_tokens_script(context):
|
|
|
140
167
|
@register.simple_tag
|
|
141
168
|
def nextjs_admin_url(path=''):
|
|
142
169
|
"""
|
|
143
|
-
Get the URL for Next.js Admin Panel.
|
|
170
|
+
Get the URL for Next.js Admin Panel (Built-in Dashboard - Tab 1).
|
|
144
171
|
|
|
145
|
-
|
|
146
|
-
|
|
172
|
+
Auto-detects development mode with priority:
|
|
173
|
+
1. If port 3001 is available → http://localhost:3001/admin/{path} (dev server)
|
|
174
|
+
2. Otherwise → /cfg/admin/admin/{path} (static files)
|
|
147
175
|
|
|
148
|
-
|
|
149
|
-
Returns /cfg/admin/{path}
|
|
176
|
+
Note: Port 3000 is reserved for external Next.js admin (Tab 2).
|
|
150
177
|
|
|
151
178
|
Usage in template:
|
|
152
179
|
{% load django_cfg %}
|
|
153
|
-
<iframe src="{% nextjs_admin_url
|
|
154
|
-
|
|
155
|
-
Environment variable:
|
|
156
|
-
DJANGO_CFG_FRONTEND_DEV_MODE=true # Enable dev mode
|
|
157
|
-
DJANGO_CFG_FRONTEND_DEV_PORT=3000 # Custom dev port (default: 3000)
|
|
180
|
+
<iframe src="{% nextjs_admin_url %}"></iframe>
|
|
181
|
+
<iframe src="{% nextjs_admin_url 'centrifugo' %}"></iframe>
|
|
158
182
|
"""
|
|
159
|
-
#
|
|
160
|
-
|
|
183
|
+
# Normalize path - remove leading/trailing slashes
|
|
184
|
+
path = path.strip('/')
|
|
185
|
+
|
|
186
|
+
if not settings.DEBUG:
|
|
187
|
+
# Production mode: always use static files
|
|
188
|
+
return f'/cfg/admin/admin/{path}' if path else '/cfg/admin/admin/'
|
|
161
189
|
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
path = f'{path}/'
|
|
190
|
+
# Check if port 3001 is available for Tab 1 (built-in admin)
|
|
191
|
+
port_3001_available = _is_port_available('localhost', 3001)
|
|
165
192
|
|
|
166
|
-
if
|
|
167
|
-
#
|
|
168
|
-
|
|
169
|
-
base_url = f'http://localhost:{dev_port}'
|
|
193
|
+
if port_3001_available:
|
|
194
|
+
# Dev server is running on 3001 - use it
|
|
195
|
+
base_url = 'http://localhost:3001/admin'
|
|
170
196
|
return f'{base_url}/{path}' if path else base_url
|
|
171
197
|
else:
|
|
172
|
-
#
|
|
173
|
-
return f'/cfg/admin/{path}' if path else '/cfg/admin/'
|
|
198
|
+
# No dev server or dev server stopped - use static files
|
|
199
|
+
return f'/cfg/admin/admin/{path}' if path else '/cfg/admin/admin/'
|
|
174
200
|
|
|
175
201
|
|
|
176
202
|
@register.simple_tag
|
|
@@ -178,7 +204,11 @@ def is_frontend_dev_mode():
|
|
|
178
204
|
"""
|
|
179
205
|
Check if frontend is in development mode.
|
|
180
206
|
|
|
181
|
-
|
|
207
|
+
Auto-detects by checking:
|
|
208
|
+
- DEBUG=True
|
|
209
|
+
- AND (port 3000 OR port 3001 is available)
|
|
210
|
+
|
|
211
|
+
Returns True if any Next.js dev server is detected.
|
|
182
212
|
|
|
183
213
|
Usage in template:
|
|
184
214
|
{% load django_cfg %}
|
|
@@ -186,7 +216,12 @@ def is_frontend_dev_mode():
|
|
|
186
216
|
<div class="dev-badge">Dev Mode</div>
|
|
187
217
|
{% endif %}
|
|
188
218
|
"""
|
|
189
|
-
|
|
219
|
+
if not settings.DEBUG:
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
# Check if either dev server is running
|
|
223
|
+
return (_is_port_available('localhost', 3000) or
|
|
224
|
+
_is_port_available('localhost', 3001))
|
|
190
225
|
|
|
191
226
|
|
|
192
227
|
@register.simple_tag
|
|
@@ -214,10 +249,14 @@ def has_nextjs_external_admin():
|
|
|
214
249
|
@register.simple_tag
|
|
215
250
|
def nextjs_external_admin_url(route=''):
|
|
216
251
|
"""
|
|
217
|
-
Get URL for external Next.js admin (Tab 2).
|
|
252
|
+
Get URL for external Next.js admin (Tab 2 - solution project).
|
|
253
|
+
|
|
254
|
+
Auto-detects development mode:
|
|
255
|
+
- If DEBUG=True AND localhost:3000 is available → http://localhost:3000/admin/{route}
|
|
256
|
+
- Otherwise → /cfg/nextjs-admin/admin/{route} (static files)
|
|
218
257
|
|
|
219
|
-
|
|
220
|
-
|
|
258
|
+
This is for the external admin panel (solution project).
|
|
259
|
+
No env variable needed - automatically detects running dev server!
|
|
221
260
|
|
|
222
261
|
Usage in template:
|
|
223
262
|
{% load django_cfg %}
|
|
@@ -231,10 +270,16 @@ def nextjs_external_admin_url(route=''):
|
|
|
231
270
|
if not config or not config.nextjs_admin:
|
|
232
271
|
return ''
|
|
233
272
|
|
|
234
|
-
route = route.strip(
|
|
273
|
+
route = route.strip('/')
|
|
235
274
|
|
|
236
|
-
#
|
|
237
|
-
|
|
275
|
+
# Auto-detect development mode: DEBUG=True + port 3000 available
|
|
276
|
+
if settings.DEBUG and _is_port_available('localhost', 3000):
|
|
277
|
+
# Development mode: solution project on port 3000
|
|
278
|
+
base_url = 'http://localhost:3000/admin'
|
|
279
|
+
return f'{base_url}/{route}' if route else base_url
|
|
280
|
+
else:
|
|
281
|
+
# Production mode: use relative URL - Django serves from extracted ZIP with /admin prefix
|
|
282
|
+
return f"/cfg/nextjs-admin/admin/{route}" if route else "/cfg/nextjs-admin/admin/"
|
|
238
283
|
except Exception:
|
|
239
284
|
return ''
|
|
240
285
|
|