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 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.91"
35
+ __version__ = "1.4.93"
36
36
  __license__ = "MIT"
37
37
 
38
38
  # Import registry for organized lazy loading
@@ -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 on first request
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
- # Check if ZIP archive exists and extract if needed
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
- zip_path = Path(django_cfg.__file__).parent / 'static' / 'frontend' / f'{self.app_name}.zip'
58
- if zip_path.exists():
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
- # Check if ZIP archive exists and extract if needed
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
- # Try django_cfg package location first
55
- zip_path = Path(django_cfg.__file__).parent / 'static' / 'frontend' / 'nextjs_admin.zip'
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 iframe_route for empty path
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
- '' → 'private.html' (from iframe_route)
129
- 'private/centrifugo' → 'private/centrifugo/index.html'
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 - use iframe_route
133
- if not path or path == '/':
134
- iframe_route = nextjs_config.get_iframe_route().strip('/')
135
- # Try iframe_route.html or iframe_route/index.html
136
- html_file = base_dir / f"{iframe_route}.html"
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.91"
8
- description = "Django AI framework with built-in agents, type-safe Pydantic v2 configuration, and 8 enterprise apps. Replace settings.py, validate at startup, 90% less code. Production-ready AI workflows for 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)",]
@@ -79,7 +79,27 @@
79
79
  {% block content %}
80
80
  <!-- Main Container -->
81
81
  {% component "unfold/components/container.html" %}
82
- <div x-data="{ activeTab: 'builtin' }">
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="activeTab = 'builtin'"
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="activeTab = 'nextjs'"
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
- // Fallback: Show iframe after 5 seconds
237
- setTimeout(() => {
238
- if (!iframe.classList.contains('loaded')) {
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
- }, 5000);
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
- In development mode (when DJANGO_CFG_FRONTEND_DEV_MODE=true):
146
- Returns http://localhost:3000/{path}
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
- In production mode:
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 'private' %}"></iframe>
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
- # Check if frontend dev mode is enabled
160
- is_dev_mode = os.environ.get('DJANGO_CFG_FRONTEND_DEV_MODE', '').lower() in ('true', '1', 'yes')
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
- # Normalize path - ensure trailing slash for Django static view
163
- if path and not path.endswith('/'):
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 is_dev_mode:
167
- # Development mode: use Next.js dev server
168
- dev_port = os.environ.get('DJANGO_CFG_FRONTEND_DEV_PORT', '3000')
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
- # Production mode: use Django static files
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
- Returns True if DJANGO_CFG_FRONTEND_DEV_MODE environment variable is set to true.
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
- return os.environ.get('DJANGO_CFG_FRONTEND_DEV_MODE', '').lower() in ('true', '1', 'yes')
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
- Always returns relative URL (Django serves from static/nextjs_admin.zip):
220
- Returns /cfg/nextjs-admin/{route}
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().lstrip('/')
273
+ route = route.strip('/')
235
274
 
236
- # Always use relative URL - Django serves from extracted ZIP
237
- return f"/cfg/nextjs-admin/{route}" if route else "/cfg/nextjs-admin/"
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