django-cfg 1.4.87__py3-none-any.whl → 1.4.89__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 (177) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/centrifugo/views/__init__.py +0 -2
  3. django_cfg/apps/dashboard/permissions.py +48 -0
  4. django_cfg/apps/dashboard/serializers/__init__.py +8 -1
  5. django_cfg/apps/dashboard/serializers/commands.py +29 -0
  6. django_cfg/apps/dashboard/services/__init__.py +2 -0
  7. django_cfg/{modules/django_unfold/callbacks/base.py → apps/dashboard/services/commands_security.py} +28 -90
  8. django_cfg/apps/dashboard/services/commands_service.py +208 -9
  9. django_cfg/apps/dashboard/services/overview_service.py +205 -0
  10. django_cfg/apps/dashboard/views/commands_views.py +92 -4
  11. django_cfg/apps/frontend/test_routing.py +134 -0
  12. django_cfg/apps/frontend/views.py +73 -28
  13. django_cfg/apps/urls.py +0 -1
  14. django_cfg/core/builders/apps_builder.py +0 -58
  15. django_cfg/modules/django_unfold/__init__.py +5 -24
  16. django_cfg/modules/django_unfold/models/__init__.py +0 -23
  17. django_cfg/modules/django_unfold/models/config.py +11 -65
  18. django_cfg/modules/django_unfold/{dashboard.py → navigation.py} +21 -152
  19. django_cfg/modules/django_unfold/tailwind.py +2 -4
  20. django_cfg/pyproject.toml +1 -1
  21. django_cfg/registry/third_party.py +0 -9
  22. django_cfg/routing/callbacks.py +1 -43
  23. django_cfg/static/frontend/admin/404/index.html +1 -1
  24. django_cfg/static/frontend/admin/404.html +1 -1
  25. django_cfg/static/frontend/admin/500/index.html +1 -1
  26. django_cfg/static/frontend/admin/_next/static/{ZJZBgOL9mO1koHrgaaLEV → 0sN9ktsgXH48ygtGSrhfu}/_buildManifest.js +1 -1
  27. django_cfg/static/frontend/admin/_next/static/chunks/{19430.fe7bff7372f8a256.js → 19430.c4c95603c23c17fe.js} +1 -1
  28. django_cfg/static/frontend/admin/_next/static/chunks/50314-9443faa6df24aebf.js +1 -0
  29. django_cfg/static/frontend/admin/_next/static/chunks/94141-bc6d47f419b26b21.js +1 -0
  30. django_cfg/static/frontend/admin/_next/static/chunks/pages/{_app-c336f254967dd101.js → _app-c7dcd3aa616fab68.js} +6 -6
  31. django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{cookies-b39c7f22c066e2c6.js → cookies-97d279800f12aab4.js} +1 -1
  32. django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{privacy-5aedad0cf3a4f80f.js → privacy-1d5e6cd94689247e.js} +1 -1
  33. django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{security-dbd854d0d5d483e2.js → security-55e49700e7a01f5a.js} +1 -1
  34. django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{terms-f3e1d2b9e5edf12f.js → terms-14c02bb2d3198352.js} +1 -1
  35. django_cfg/static/frontend/admin/_next/static/chunks/pages/private/{centrifugo-22532c65971225eb.js → centrifugo-f9ecbc3ae0052a03.js} +1 -1
  36. django_cfg/static/frontend/admin/_next/static/chunks/pages/private-d4ccbe1265cbd853.js +1 -0
  37. django_cfg/static/frontend/admin/_next/static/chunks/{webpack-da114020a6b940f5.js → webpack-5a92f81363b62aa7.js} +1 -1
  38. django_cfg/static/frontend/admin/_next/static/css/3063068f0d5a8a00.css +3 -0
  39. django_cfg/static/frontend/admin/_next/static/media/19cfc7226ec3afaa-s.woff2 +0 -0
  40. django_cfg/static/frontend/admin/_next/static/media/21350d82a1f187e9-s.p.woff2 +0 -0
  41. django_cfg/static/frontend/admin/_next/static/media/8e9860b6e62d6359-s.woff2 +0 -0
  42. django_cfg/static/frontend/admin/_next/static/media/ba9851c3c22cd980-s.woff2 +0 -0
  43. django_cfg/static/frontend/admin/_next/static/media/c5fe6dc8356a8c31-s.woff2 +0 -0
  44. django_cfg/static/frontend/admin/_next/static/media/df0a9ae256c0569c-s.woff2 +0 -0
  45. django_cfg/static/frontend/admin/_next/static/media/e4af272ccee01ff0-s.p.woff2 +0 -0
  46. django_cfg/static/frontend/admin/auth/index.html +1 -1
  47. django_cfg/static/frontend/admin/index.html +1 -1
  48. django_cfg/static/frontend/admin/legal/cookies/index.html +1 -1
  49. django_cfg/static/frontend/admin/legal/privacy/index.html +1 -1
  50. django_cfg/static/frontend/admin/legal/security/index.html +1 -1
  51. django_cfg/static/frontend/admin/legal/terms/index.html +1 -1
  52. django_cfg/static/frontend/admin/private/centrifugo/index.html +1 -1
  53. django_cfg/static/frontend/admin/private/index.html +1 -1
  54. django_cfg/static/frontend/admin/private/profile/index.html +1 -1
  55. django_cfg/static/frontend/admin/private/ui/index.html +2 -2
  56. django_cfg/templates/admin/index.html +1 -1
  57. {django_cfg-1.4.87.dist-info → django_cfg-1.4.89.dist-info}/METADATA +1 -1
  58. {django_cfg-1.4.87.dist-info → django_cfg-1.4.89.dist-info}/RECORD +62 -163
  59. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/css/dashboard.css +0 -260
  60. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/live_channels.mjs +0 -313
  61. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/live_testing.mjs +0 -803
  62. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/main.mjs +0 -341
  63. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/overview.mjs +0 -432
  64. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/testing.mjs +0 -33
  65. django_cfg/apps/centrifugo/static/django_cfg_centrifugo/js/dashboard/websocket.mjs +0 -210
  66. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/channels_content.html +0 -46
  67. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/live_channels_content.html +0 -123
  68. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/overview_content.html +0 -45
  69. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/publishes_content.html +0 -84
  70. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/stat_cards.html +0 -53
  71. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/system_status.html +0 -91
  72. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/tab_navigation.html +0 -29
  73. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/components/testing_tools.html +0 -415
  74. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/layout/base.html +0 -61
  75. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/pages/dashboard.html +0 -58
  76. django_cfg/apps/centrifugo/templates/django_cfg_centrifugo/tags/connection_script.html +0 -48
  77. django_cfg/apps/centrifugo/templatetags/__init__.py +0 -1
  78. django_cfg/apps/centrifugo/templatetags/centrifugo_tags.py +0 -81
  79. django_cfg/apps/centrifugo/urls_admin.py +0 -20
  80. django_cfg/apps/centrifugo/views/dashboard.py +0 -28
  81. django_cfg/modules/django_dashboard/__init__.py +0 -23
  82. django_cfg/modules/django_dashboard/components.py +0 -312
  83. django_cfg/modules/django_dashboard/debug.py +0 -174
  84. django_cfg/modules/django_dashboard/management/__init__.py +0 -0
  85. django_cfg/modules/django_dashboard/management/commands/__init__.py +0 -0
  86. django_cfg/modules/django_dashboard/management/commands/debug_dashboard.py +0 -109
  87. django_cfg/modules/django_dashboard/sections/__init__.py +0 -1
  88. django_cfg/modules/django_dashboard/sections/base.py +0 -129
  89. django_cfg/modules/django_dashboard/sections/commands.py +0 -33
  90. django_cfg/modules/django_dashboard/sections/documentation.py +0 -393
  91. django_cfg/modules/django_dashboard/sections/overview.py +0 -398
  92. django_cfg/modules/django_dashboard/sections/stats.py +0 -48
  93. django_cfg/modules/django_dashboard/sections/system.py +0 -74
  94. django_cfg/modules/django_dashboard/sections/widgets.py +0 -222
  95. django_cfg/modules/django_unfold/callbacks/__init__.py +0 -9
  96. django_cfg/modules/django_unfold/callbacks/actions.py +0 -51
  97. django_cfg/modules/django_unfold/callbacks/apizones.py +0 -122
  98. django_cfg/modules/django_unfold/callbacks/charts.py +0 -223
  99. django_cfg/modules/django_unfold/callbacks/commands.py +0 -40
  100. django_cfg/modules/django_unfold/callbacks/main.py +0 -322
  101. django_cfg/modules/django_unfold/callbacks/statistics.py +0 -240
  102. django_cfg/modules/django_unfold/callbacks/system.py +0 -180
  103. django_cfg/modules/django_unfold/callbacks/users.py +0 -65
  104. django_cfg/modules/django_unfold/models/dashboard.py +0 -207
  105. django_cfg/modules/django_unfold/models/tabs.py +0 -26
  106. django_cfg/modules/django_unfold/models.py +0 -98
  107. django_cfg/modules/django_unfold/templates/unfold/helpers/app_list.html +0 -102
  108. django_cfg/static/frontend/admin/_next/static/chunks/23004-faae121bbfecc163.js +0 -1
  109. django_cfg/static/frontend/admin/_next/static/chunks/50314-48bd5701f62faf27.js +0 -1
  110. django_cfg/static/frontend/admin/_next/static/chunks/pages/private-fe9faa86ecdb0ce6.js +0 -1
  111. django_cfg/static/frontend/admin/_next/static/css/5f9a37b6e6a72303.css +0 -3
  112. django_cfg/static/frontend/admin/_next/static/media/438aa629764e75f3-s.woff2 +0 -0
  113. django_cfg/static/frontend/admin/_next/static/media/4c9affa5bc8f420e-s.p.woff2 +0 -0
  114. django_cfg/static/frontend/admin/_next/static/media/51251f8b9793cdb3-s.woff2 +0 -0
  115. django_cfg/static/frontend/admin/_next/static/media/875ae681bfde4580-s.p.woff2 +0 -0
  116. django_cfg/static/frontend/admin/_next/static/media/cc978ac5ee68c2b6-s.woff2 +0 -0
  117. django_cfg/static/frontend/admin/_next/static/media/e857b654a2caa584-s.woff2 +0 -0
  118. django_cfg/templates/admin/sections/commands_section.html +0 -5
  119. django_cfg/templates/admin/sections/documentation_section.html +0 -5
  120. django_cfg/templates/admin/sections/overview_section.html +0 -5
  121. django_cfg/templates/admin/sections/stats_section.html +0 -5
  122. django_cfg/templates/admin/sections/system_section.html +0 -5
  123. django_cfg/templates/admin/sections/widgets_section.html +0 -11
  124. django_cfg/templates/admin_old/components/action_grid.html +0 -49
  125. django_cfg/templates/admin_old/components/card.html +0 -50
  126. django_cfg/templates/admin_old/components/data_table.html +0 -67
  127. django_cfg/templates/admin_old/components/metric_card.html +0 -39
  128. django_cfg/templates/admin_old/components/modal.html +0 -58
  129. django_cfg/templates/admin_old/components/progress_bar.html +0 -20
  130. django_cfg/templates/admin_old/components/section_header.html +0 -26
  131. django_cfg/templates/admin_old/components/stat_item.html +0 -32
  132. django_cfg/templates/admin_old/components/stats_grid.html +0 -72
  133. django_cfg/templates/admin_old/components/status_badge.html +0 -28
  134. django_cfg/templates/admin_old/components/user_avatar.html +0 -27
  135. django_cfg/templates/admin_old/constance/change_list.html +0 -74
  136. django_cfg/templates/admin_old/constance/includes/default_value.html +0 -24
  137. django_cfg/templates/admin_old/constance/includes/fieldset_header.html +0 -15
  138. django_cfg/templates/admin_old/constance/includes/results_list.html +0 -16
  139. django_cfg/templates/admin_old/constance/includes/setting_row.html +0 -50
  140. django_cfg/templates/admin_old/constance/includes/table_headers.html +0 -10
  141. django_cfg/templates/admin_old/examples/component_class_example.html +0 -156
  142. django_cfg/templates/admin_old/import_export/change_list_export.html +0 -24
  143. django_cfg/templates/admin_old/import_export/change_list_import.html +0 -24
  144. django_cfg/templates/admin_old/import_export/change_list_import_export.html +0 -34
  145. django_cfg/templates/admin_old/index.html +0 -80
  146. django_cfg/templates/admin_old/index_new.html +0 -119
  147. django_cfg/templates/admin_old/layouts/base_dashboard.html +0 -62
  148. django_cfg/templates/admin_old/layouts/dashboard_with_tabs.html +0 -176
  149. django_cfg/templates/admin_old/sections/commands_section.html +0 -549
  150. django_cfg/templates/admin_old/sections/documentation_section.html +0 -152
  151. django_cfg/templates/admin_old/sections/overview_section.html +0 -112
  152. django_cfg/templates/admin_old/sections/stats_section.html +0 -35
  153. django_cfg/templates/admin_old/sections/system_section.html +0 -99
  154. django_cfg/templates/admin_old/sections/widgets_section.html +0 -129
  155. django_cfg/templates/admin_old/snippets/components/activity_tracker.html +0 -70
  156. django_cfg/templates/admin_old/snippets/components/charts_section.html +0 -113
  157. django_cfg/templates/admin_old/snippets/components/django_commands.html +0 -270
  158. django_cfg/templates/admin_old/snippets/components/quick_actions.html +0 -66
  159. django_cfg/templates/admin_old/snippets/components/recent_activity_improved.html +0 -25
  160. django_cfg/templates/admin_old/snippets/components/recent_users_table.html +0 -102
  161. django_cfg/templates/admin_old/snippets/components/stats_cards.html +0 -4
  162. django_cfg/templates/admin_old/snippets/components/stats_tiles.html +0 -92
  163. django_cfg/templates/admin_old/snippets/components/system_health.html +0 -22
  164. django_cfg/templates/admin_old/snippets/components/system_metrics.html +0 -199
  165. django_cfg/templates/admin_old/snippets/components/user_permissions.html +0 -57
  166. django_cfg/templates/admin_old/snippets/tabs/app_stats_tab.html +0 -201
  167. django_cfg/templates/admin_old/snippets/tabs/commands_tab.html +0 -114
  168. django_cfg/templates/admin_old/snippets/tabs/documentation_tab.html +0 -42
  169. django_cfg/templates/admin_old/snippets/tabs/overview_tab.html +0 -116
  170. django_cfg/templates/admin_old/snippets/tabs/stats_tab.html +0 -89
  171. django_cfg/templates/admin_old/snippets/tabs/users_tab.html +0 -51
  172. django_cfg/templates/admin_old/snippets/tabs/widgets_tab.html +0 -38
  173. django_cfg/templates/admin_old/snippets/zones/zones_table.html +0 -176
  174. /django_cfg/static/frontend/admin/_next/static/{ZJZBgOL9mO1koHrgaaLEV → 0sN9ktsgXH48ygtGSrhfu}/_ssgManifest.js +0 -0
  175. {django_cfg-1.4.87.dist-info → django_cfg-1.4.89.dist-info}/WHEEL +0 -0
  176. {django_cfg-1.4.87.dist-info → django_cfg-1.4.89.dist-info}/entry_points.txt +0 -0
  177. {django_cfg-1.4.87.dist-info → django_cfg-1.4.89.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,205 @@
1
+ """
2
+ Overview Service
3
+
4
+ Provides overview data including recent users and activity tracking.
5
+ Extracted from django_dashboard for API-based dashboard.
6
+ """
7
+
8
+ import logging
9
+ from datetime import timedelta
10
+ from typing import Any, Dict, List
11
+
12
+ from django.contrib.auth import get_user_model
13
+ from django.utils import timezone
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class OverviewService:
19
+ """
20
+ Service for dashboard overview data.
21
+
22
+ %%PRIORITY:HIGH%%
23
+ %%AI_HINT: Provides recent activity and user registration tracking%%
24
+
25
+ TAGS: overview, dashboard, activity, service
26
+ DEPENDS_ON: [django.contrib.auth]
27
+ """
28
+
29
+ def __init__(self):
30
+ """Initialize overview service."""
31
+ self.logger = logger
32
+
33
+ def _get_user_model(self):
34
+ """Get the user model safely."""
35
+ return get_user_model()
36
+
37
+ def get_recent_users(self, limit: int = 10) -> List[Dict[str, Any]]:
38
+ """
39
+ Get recent users for activity section.
40
+
41
+ Args:
42
+ limit: Maximum number of users to return (default: 10)
43
+
44
+ Returns:
45
+ List of user dictionaries with id, username, email, is_active, date_joined
46
+
47
+ %%AI_HINT: Real Django ORM query for recent users%%
48
+ """
49
+ try:
50
+ User = self._get_user_model()
51
+ recent_users = User.objects.order_by('-date_joined')[:limit]
52
+
53
+ return [
54
+ {
55
+ 'id': user.id,
56
+ 'username': user.username,
57
+ 'email': user.email or '',
58
+ 'is_active': user.is_active,
59
+ 'is_staff': user.is_staff,
60
+ 'date_joined': user.date_joined.isoformat() if user.date_joined else None,
61
+ }
62
+ for user in recent_users
63
+ ]
64
+ except Exception as e:
65
+ self.logger.error(f"Error getting recent users: {e}")
66
+ return []
67
+
68
+ def get_activity_tracker(self, days: int = 365) -> List[Dict[str, Any]]:
69
+ """
70
+ Get activity tracker data for GitHub-style heatmap.
71
+
72
+ Returns list of dicts with date and count for last N days.
73
+
74
+ Args:
75
+ days: Number of days to track (default: 365)
76
+
77
+ Returns:
78
+ List of activity data: [{'date': 'YYYY-MM-DD', 'count': int, 'level': int}, ...]
79
+ level: 0-4 (0=no activity, 4=very high activity)
80
+
81
+ %%AI_HINT: Generates daily activity counts from user registrations and logins%%
82
+ """
83
+ try:
84
+ User = self._get_user_model()
85
+ today = timezone.now().date()
86
+ activity_data = []
87
+
88
+ for days_ago in range(days - 1, -1, -1): # Newest last
89
+ date = today - timedelta(days=days_ago)
90
+
91
+ # Count user registrations on this day
92
+ registrations = User.objects.filter(
93
+ date_joined__date=date
94
+ ).count()
95
+
96
+ # Count logins on this day (if last_login exists)
97
+ logins = 0
98
+ if hasattr(User, 'last_login'):
99
+ logins = User.objects.filter(
100
+ last_login__date=date
101
+ ).count()
102
+
103
+ # Total activity for the day
104
+ total_activity = registrations + logins
105
+
106
+ activity_data.append({
107
+ 'date': date.isoformat(),
108
+ 'count': total_activity,
109
+ 'level': self._get_activity_level(total_activity),
110
+ })
111
+
112
+ return activity_data
113
+
114
+ except Exception as e:
115
+ self.logger.error(f"Error getting activity tracker: {e}")
116
+ return []
117
+
118
+ def _get_activity_level(self, count: int) -> int:
119
+ """
120
+ Convert activity count to level (0-4) for heatmap colors.
121
+
122
+ Args:
123
+ count: Number of activities
124
+
125
+ Returns:
126
+ Activity level (0-4):
127
+ - 0 = no activity (gray)
128
+ - 1 = low (light green)
129
+ - 2 = medium (green)
130
+ - 3 = high (dark green)
131
+ - 4 = very high (darkest green)
132
+ """
133
+ if count == 0:
134
+ return 0
135
+ elif count <= 2:
136
+ return 1
137
+ elif count <= 5:
138
+ return 2
139
+ elif count <= 10:
140
+ return 3
141
+ else:
142
+ return 4
143
+
144
+ def get_key_stats(self) -> Dict[str, Any]:
145
+ """
146
+ Get key statistics for overview.
147
+
148
+ Returns:
149
+ Dictionary with users, databases, apps counts
150
+
151
+ %%AI_HINT: Provides high-level system statistics%%
152
+ """
153
+ try:
154
+ User = self._get_user_model()
155
+
156
+ # Get database count
157
+ from django.db import connection
158
+ db_count = len(connection.settings_dict.get('DATABASES', {})) if hasattr(connection, 'settings_dict') else 1
159
+
160
+ # Get app count
161
+ from django.apps import apps
162
+ app_count = len(apps.get_app_configs())
163
+
164
+ return {
165
+ 'users': User.objects.count(),
166
+ 'databases': db_count,
167
+ 'apps': app_count,
168
+ }
169
+ except Exception as e:
170
+ self.logger.error(f"Error getting key stats: {e}")
171
+ return {
172
+ 'users': 0,
173
+ 'databases': 0,
174
+ 'apps': 0,
175
+ 'error': str(e)
176
+ }
177
+
178
+ def get_system_info(self) -> Dict[str, Any]:
179
+ """
180
+ Get system information.
181
+
182
+ Returns:
183
+ Dictionary with Python version and system metrics
184
+
185
+ %%AI_HINT: System-level information using psutil%%
186
+ """
187
+ import sys
188
+ import psutil
189
+
190
+ try:
191
+ return {
192
+ 'python_version': f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
193
+ 'cpu_percent': round(psutil.cpu_percent(interval=0.1), 1),
194
+ 'memory_percent': round(psutil.virtual_memory().percent, 1),
195
+ 'disk_percent': round(psutil.disk_usage('/').percent, 1),
196
+ }
197
+ except Exception as e:
198
+ self.logger.error(f"Error getting system info: {e}")
199
+ return {
200
+ 'python_version': f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
201
+ 'cpu_percent': 0,
202
+ 'memory_percent': 0,
203
+ 'disk_percent': 0,
204
+ 'error': str(e)
205
+ }
@@ -4,19 +4,29 @@ Commands ViewSet
4
4
  Endpoints for Django management commands:
5
5
  - GET /commands/ - All available commands
6
6
  - GET /commands/summary/ - Commands summary with statistics
7
+ - POST /commands/execute/ - Execute a command with streaming output
8
+ - GET /commands/{name}/help/ - Get help for a specific command
7
9
  """
8
10
 
11
+ import json
9
12
  import logging
10
13
 
14
+ from django.http import StreamingHttpResponse
11
15
  from drf_spectacular.utils import extend_schema
12
16
  from rest_framework import status, viewsets
13
17
  from rest_framework.authentication import BasicAuthentication, SessionAuthentication
14
18
  from rest_framework.decorators import action
15
- from rest_framework.permissions import IsAdminUser
16
19
  from rest_framework.response import Response
20
+ from rest_framework_simplejwt.authentication import JWTAuthentication
17
21
 
22
+ from ..permissions import IsSuperAdmin
18
23
  from ..services import CommandsService
19
- from ..serializers import CommandSerializer, CommandsSummarySerializer
24
+ from ..serializers import (
25
+ CommandSerializer,
26
+ CommandsSummarySerializer,
27
+ CommandExecuteRequestSerializer,
28
+ CommandHelpResponseSerializer,
29
+ )
20
30
 
21
31
  logger = logging.getLogger(__name__)
22
32
 
@@ -28,8 +38,8 @@ class CommandsViewSet(viewsets.GenericViewSet):
28
38
  Provides endpoints for Django management commands discovery.
29
39
  """
30
40
 
31
- authentication_classes = [SessionAuthentication, BasicAuthentication]
32
- permission_classes = [IsAdminUser]
41
+ authentication_classes = [JWTAuthentication, SessionAuthentication, BasicAuthentication]
42
+ permission_classes = [IsSuperAdmin] # Only superusers can access commands
33
43
  serializer_class = CommandSerializer
34
44
  pagination_class = None # Disable pagination for commands list
35
45
 
@@ -71,3 +81,81 @@ class CommandsViewSet(viewsets.GenericViewSet):
71
81
  return Response({
72
82
  'error': str(e)
73
83
  }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
84
+
85
+ @extend_schema(
86
+ summary="Execute command",
87
+ description="Execute a Django management command and stream output in Server-Sent Events format",
88
+ request=CommandExecuteRequestSerializer,
89
+ responses={
90
+ 200: {"description": "Command execution started (SSE stream)"},
91
+ 400: {"description": "Invalid request"},
92
+ 403: {"description": "Command not allowed"},
93
+ },
94
+ tags=["Dashboard - Commands"]
95
+ )
96
+ @action(detail=False, methods=['post'], url_path='execute', serializer_class=CommandExecuteRequestSerializer)
97
+ def execute(self, request):
98
+ """Execute a Django management command with streaming output."""
99
+ try:
100
+ # Validate request data
101
+ serializer = CommandExecuteRequestSerializer(data=request.data)
102
+ if not serializer.is_valid():
103
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
104
+
105
+ command_name = serializer.validated_data['command']
106
+ args = serializer.validated_data.get('args', [])
107
+ options = serializer.validated_data.get('options', {})
108
+
109
+ # Create streaming response
110
+ def stream_execution():
111
+ """Generator that streams command output as Server-Sent Events."""
112
+ commands_service = CommandsService()
113
+
114
+ for event in commands_service.execute_command(
115
+ command_name=command_name,
116
+ args=args,
117
+ options=options,
118
+ user=request.user
119
+ ):
120
+ # Format as Server-Sent Event
121
+ yield f"data: {json.dumps(event)}\n\n"
122
+
123
+ response = StreamingHttpResponse(
124
+ stream_execution(),
125
+ content_type='text/event-stream'
126
+ )
127
+ response['Cache-Control'] = 'no-cache'
128
+ response['X-Accel-Buffering'] = 'no' # Disable nginx buffering
129
+
130
+ return response
131
+
132
+ except Exception as e:
133
+ logger.error(f"Command execution API error: {e}")
134
+ return Response({
135
+ 'error': str(e)
136
+ }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
137
+
138
+ @extend_schema(
139
+ summary="Get command help",
140
+ description="Get detailed help text for a specific Django management command",
141
+ responses={200: CommandHelpResponseSerializer},
142
+ tags=["Dashboard - Commands"]
143
+ )
144
+ @action(detail=True, methods=['get'], url_path='help', serializer_class=CommandHelpResponseSerializer)
145
+ def help(self, request, pk=None):
146
+ """Get help text for a specific command."""
147
+ try:
148
+ commands_service = CommandsService()
149
+ help_data = commands_service.get_command_help(pk)
150
+
151
+ if help_data.get('status') == 'error':
152
+ return Response(help_data, status=status.HTTP_404_NOT_FOUND)
153
+
154
+ return Response(help_data)
155
+
156
+ except Exception as e:
157
+ logger.error(f"Command help API error: {e}")
158
+ return Response({
159
+ 'status': 'error',
160
+ 'error': str(e)
161
+ }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@@ -0,0 +1,134 @@
1
+ """
2
+ Test script for SPA routing logic.
3
+
4
+ Run this to verify that URL paths resolve correctly to static files.
5
+ """
6
+
7
+ from pathlib import Path
8
+ import django_cfg
9
+
10
+
11
+ def test_resolve_spa_path():
12
+ """Test the SPA path resolution logic."""
13
+
14
+ # Simulate the base directory
15
+ base_dir = Path(django_cfg.__file__).parent / 'static' / 'frontend' / 'admin'
16
+
17
+ print(f"Base directory: {base_dir}")
18
+ print(f"Base directory exists: {base_dir.exists()}\n")
19
+
20
+ # Test cases
21
+ test_cases = [
22
+ # (input_path, expected_output_path, description)
23
+ ('', 'index.html', 'Root path'),
24
+ ('/', 'index.html', 'Root path with slash'),
25
+ ('private/centrifugo', 'private/centrifugo/index.html', 'Nested route without trailing slash'),
26
+ ('private/centrifugo/', 'private/centrifugo/index.html', 'Nested route with trailing slash'),
27
+ ('private', 'private.html', 'Single segment route'),
28
+ ('private/', 'private/index.html', 'Single segment with trailing slash'),
29
+ ('_next/static/chunks/app.js', '_next/static/chunks/app.js', 'Static asset (exact match)'),
30
+ ('favicon.ico', 'favicon.ico', 'Favicon (exact match)'),
31
+ ('unknown/route', 'index.html', 'Unknown route (SPA fallback)'),
32
+ ]
33
+
34
+ def resolve_spa_path(base_dir, path):
35
+ """
36
+ Exact copy of the view's _resolve_spa_path method.
37
+
38
+ This should match the logic in views.py exactly.
39
+ """
40
+ # Handle empty path (done in view before calling this method)
41
+ if not path or path == '/':
42
+ path = 'index.html'
43
+ return path
44
+
45
+ file_path = base_dir / path
46
+ path_normalized = path.rstrip('/')
47
+
48
+ # Strategy 1: Exact file match (for static assets like JS, CSS, images)
49
+ if file_path.exists() and file_path.is_file():
50
+ return path
51
+
52
+ # Strategy 2: Try path/index.html (most common for SPA routes)
53
+ index_in_dir = base_dir / path_normalized / 'index.html'
54
+ if index_in_dir.exists():
55
+ resolved_path = f"{path_normalized}/index.html"
56
+ return resolved_path
57
+
58
+ # Strategy 3: Try with trailing slash + index.html
59
+ if path.endswith('/'):
60
+ index_path = path + 'index.html'
61
+ if (base_dir / index_path).exists():
62
+ return index_path
63
+
64
+ # Strategy 4: Try path.html (Next.js static export behavior)
65
+ html_file = base_dir / (path_normalized + '.html')
66
+ if html_file.exists():
67
+ resolved_path = path_normalized + '.html'
68
+ return resolved_path
69
+
70
+ # Strategy 5: Check if it's a directory without index.html
71
+ if file_path.exists() and file_path.is_dir():
72
+ # Try index.html in that directory
73
+ index_in_existing_dir = file_path / 'index.html'
74
+ if index_in_existing_dir.exists():
75
+ resolved_path = f"{path_normalized}/index.html"
76
+ return resolved_path
77
+
78
+ # Strategy 6: SPA fallback - serve root index.html
79
+ # This allows client-side routing to handle unknown routes
80
+ root_index = base_dir / 'index.html'
81
+ if root_index.exists():
82
+ return 'index.html'
83
+
84
+ # Strategy 7: Nothing found - return original path (will 404)
85
+ return path
86
+
87
+ print("=" * 80)
88
+ print("SPA ROUTING TEST RESULTS")
89
+ print("=" * 80)
90
+
91
+ passed = 0
92
+ failed = 0
93
+
94
+ for input_path, expected, description in test_cases:
95
+ result = resolve_spa_path(base_dir, input_path)
96
+
97
+ # Check if file exists
98
+ resolved_file = base_dir / result
99
+ file_exists = resolved_file.exists() and resolved_file.is_file()
100
+
101
+ # Determine test status
102
+ if result == expected:
103
+ status = "✅ PASS"
104
+ passed += 1
105
+ else:
106
+ status = "❌ FAIL"
107
+ failed += 1
108
+
109
+ print(f"\n{status} - {description}")
110
+ print(f" Input: '{input_path}'")
111
+ print(f" Expected: '{expected}'")
112
+ print(f" Got: '{result}'")
113
+ print(f" File exists: {file_exists}")
114
+
115
+ if result != expected:
116
+ print(f" ⚠️ Mismatch detected!")
117
+
118
+ print("\n" + "=" * 80)
119
+ print(f"TEST SUMMARY: {passed} passed, {failed} failed out of {len(test_cases)} tests")
120
+ print("=" * 80)
121
+
122
+ # Additional info
123
+ print("\n📁 File structure check:")
124
+ if base_dir.exists():
125
+ print(f" - index.html: {(base_dir / 'index.html').exists()}")
126
+ print(f" - private.html: {(base_dir / 'private.html').exists()}")
127
+ print(f" - private/index.html: {(base_dir / 'private' / 'index.html').exists()}")
128
+ print(f" - private/centrifugo/index.html: {(base_dir / 'private' / 'centrifugo' / 'index.html').exists()}")
129
+ else:
130
+ print(f" ⚠️ Base directory does not exist!")
131
+
132
+
133
+ if __name__ == '__main__':
134
+ test_resolve_spa_path()
@@ -9,11 +9,14 @@ from pathlib import Path
9
9
  from django.http import Http404, HttpResponse, FileResponse
10
10
  from django.views.static import serve
11
11
  from django.views import View
12
+ from django.views.decorators.clickjacking import xframe_options_exempt
13
+ from django.utils.decorators import method_decorator
12
14
  from rest_framework_simplejwt.tokens import RefreshToken
13
15
 
14
16
  logger = logging.getLogger(__name__)
15
17
 
16
18
 
19
+ @method_decorator(xframe_options_exempt, name='dispatch')
17
20
  class NextJSStaticView(View):
18
21
  """
19
22
  Serve Next.js static build files with automatic JWT token injection.
@@ -24,6 +27,7 @@ class NextJSStaticView(View):
24
27
  - Tokens injected into HTML responses only
25
28
  - Handles Next.js client-side routing (.html fallback)
26
29
  - Automatically serves index.html for directory paths
30
+ - X-Frame-Options exempt to allow embedding in iframes
27
31
 
28
32
  Path resolution examples:
29
33
  - /cfg/admin/ → /cfg/admin/index.html
@@ -51,34 +55,8 @@ class NextJSStaticView(View):
51
55
  path = 'index.html'
52
56
  logger.debug(f"Root path requested, serving: {path}")
53
57
 
54
- # Handle trailing slash - try multiple strategies like a static file server
55
- if path.endswith('/') and path != '/':
56
- path_without_slash = path.rstrip('/')
57
-
58
- # Strategy 1: Try directory/index.html (most common for static servers)
59
- index_path = path + 'index.html'
60
- if (base_dir / index_path).exists():
61
- path = index_path
62
- # Strategy 2: Try path.html (Next.js static export behavior)
63
- elif (base_dir / (path_without_slash + '.html')).exists():
64
- path = path_without_slash + '.html'
65
- # Strategy 3: Keep as directory path (will fail later if directory doesn't exist)
66
- else:
67
- path = index_path # Default to index.html in directory
68
-
69
- # For routes without extension, try .html (Next.js static export behavior)
70
- file_path = base_dir / path
71
- if not file_path.exists() and not path.endswith('.html') and '.' not in Path(path).name:
72
- html_path = path + '.html'
73
- html_file = base_dir / html_path
74
- if html_file.exists():
75
- path = html_path
76
- else:
77
- # Try path/index.html as fallback
78
- index_path = path + '/index.html'
79
- index_file = base_dir / index_path
80
- if index_file.exists():
81
- path = index_path
58
+ # Resolve file path with SPA routing fallback strategy
59
+ path = self._resolve_spa_path(base_dir, path)
82
60
 
83
61
  # For HTML files, remove conditional GET headers to force full response
84
62
  # This allows JWT token injection (can't inject into 304 Not Modified responses)
@@ -112,6 +90,73 @@ class NextJSStaticView(View):
112
90
 
113
91
  return response
114
92
 
93
+ def _resolve_spa_path(self, base_dir, path):
94
+ """
95
+ Resolve SPA path with multiple fallback strategies.
96
+
97
+ Resolution order:
98
+ 1. Exact file match (e.g., script.js, style.css)
99
+ 2. path/index.html (e.g., private/centrifugo/index.html)
100
+ 3. path.html (e.g., private.html for /private)
101
+ 4. Fallback to root index.html for SPA routing
102
+
103
+ Examples:
104
+ /private/centrifugo → private/centrifugo/index.html
105
+ /private → private.html OR private/index.html
106
+ /_next/static/... → _next/static/... (exact match)
107
+ /unknown/route → index.html (SPA fallback)
108
+ """
109
+ file_path = base_dir / path
110
+
111
+ # Remove trailing slash for processing
112
+ path_normalized = path.rstrip('/')
113
+
114
+ # Strategy 1: Exact file match (for static assets like JS, CSS, images)
115
+ if file_path.exists() and file_path.is_file():
116
+ logger.debug(f"[SPA Router] Exact match: {path}")
117
+ return path
118
+
119
+ # Strategy 2: Try path/index.html (most common for SPA routes)
120
+ index_in_dir = base_dir / path_normalized / 'index.html'
121
+ if index_in_dir.exists():
122
+ resolved_path = f"{path_normalized}/index.html"
123
+ logger.debug(f"[SPA Router] Resolved {path} → {resolved_path}")
124
+ return resolved_path
125
+
126
+ # Strategy 3: Try with trailing slash + index.html
127
+ if path.endswith('/'):
128
+ index_path = path + 'index.html'
129
+ if (base_dir / index_path).exists():
130
+ logger.debug(f"[SPA Router] Trailing slash resolved: {index_path}")
131
+ return index_path
132
+
133
+ # Strategy 4: Try path.html (Next.js static export behavior)
134
+ html_file = base_dir / (path_normalized + '.html')
135
+ if html_file.exists():
136
+ resolved_path = path_normalized + '.html'
137
+ logger.debug(f"[SPA Router] HTML file match: {resolved_path}")
138
+ return resolved_path
139
+
140
+ # Strategy 5: Check if it's a directory without index.html
141
+ if file_path.exists() and file_path.is_dir():
142
+ # Try index.html in that directory
143
+ index_in_existing_dir = file_path / 'index.html'
144
+ if index_in_existing_dir.exists():
145
+ resolved_path = f"{path_normalized}/index.html"
146
+ logger.debug(f"[SPA Router] Directory with index: {resolved_path}")
147
+ return resolved_path
148
+
149
+ # Strategy 6: SPA fallback - serve root index.html
150
+ # This allows client-side routing to handle unknown routes
151
+ root_index = base_dir / 'index.html'
152
+ if root_index.exists():
153
+ logger.debug(f"[SPA Router] Fallback to index.html for route: {path}")
154
+ return 'index.html'
155
+
156
+ # Strategy 7: Nothing found - return original path (will 404)
157
+ logger.warning(f"[SPA Router] No match found for: {path}")
158
+ return path
159
+
115
160
  def _should_inject_jwt(self, request, response):
116
161
  """Check if JWT tokens should be injected."""
117
162
  # Only for authenticated users
django_cfg/apps/urls.py CHANGED
@@ -170,7 +170,6 @@ APP_URL_MAP = {
170
170
  ],
171
171
  "django_cfg.apps.centrifugo": [
172
172
  ("cfg/centrifugo/", "django_cfg.apps.centrifugo.urls"),
173
- ("cfg/centrifugo/admin/", "django_cfg.apps.centrifugo.urls_admin"),
174
173
  ],
175
174
  }
176
175
 
@@ -183,66 +183,8 @@ class InstalledAppsBuilder:
183
183
  # django-browser-reload not installed, skip it
184
184
  pass
185
185
 
186
- # Auto-detect dashboard apps from Unfold callback
187
- dashboard_apps = self._get_dashboard_apps_from_callback()
188
- apps.extend(dashboard_apps)
189
-
190
186
  return apps
191
187
 
192
- def _get_dashboard_apps_from_callback(self) -> List[str]:
193
- """
194
- Auto-detect dashboard apps from Unfold dashboard_callback setting.
195
-
196
- Extracts app names from callback paths like:
197
- - "api.dashboard.callbacks.main_dashboard_callback" → ["api.dashboard"]
198
- - "myproject.admin.callbacks.dashboard" → ["myproject.admin"]
199
-
200
- This allows django-cfg to automatically add dashboard apps to INSTALLED_APPS
201
- without requiring manual configuration.
202
-
203
- Returns:
204
- List of dashboard app names to add to INSTALLED_APPS
205
- """
206
- dashboard_apps = []
207
-
208
- # Check if Unfold is configured with a theme
209
- if not self.config.unfold or not self.config.unfold.theme:
210
- return dashboard_apps
211
-
212
- # Get dashboard callback path from theme
213
- callback_path = getattr(self.config.unfold.theme, "dashboard_callback", None)
214
- if not callback_path:
215
- return dashboard_apps
216
-
217
- try:
218
- # Parse callback path: "api.dashboard.callbacks.main_dashboard_callback"
219
- # Extract app part: "api.dashboard"
220
- parts = callback_path.split(".")
221
-
222
- # Look for common callback patterns
223
- callback_indicators = ["callbacks", "views", "handlers"]
224
-
225
- # Find the callback indicator and extract app path before it
226
- app_parts = []
227
- for i, part in enumerate(parts):
228
- if part in callback_indicators:
229
- app_parts = parts[:i] # Everything before the callback indicator
230
- break
231
-
232
- # If no callback indicator found, assume last part is function name
233
- if not app_parts and len(parts) > 1:
234
- app_parts = parts[:-1] # Everything except the last part
235
-
236
- if app_parts:
237
- app_name = ".".join(app_parts)
238
- dashboard_apps.append(app_name)
239
-
240
- except Exception:
241
- # If parsing fails, silently continue - dashboard callback is optional
242
- pass
243
-
244
- return dashboard_apps
245
-
246
188
  def _deduplicate(self, apps: List[str]) -> List[str]:
247
189
  """
248
190
  Remove duplicate apps while preserving order.