django-cfg 1.4.86__py3-none-any.whl → 1.4.88__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 (82) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/dashboard/permissions.py +48 -0
  3. django_cfg/apps/dashboard/serializers/__init__.py +8 -1
  4. django_cfg/apps/dashboard/serializers/commands.py +29 -0
  5. django_cfg/apps/dashboard/services/commands_security.py +228 -0
  6. django_cfg/apps/dashboard/services/commands_service.py +208 -9
  7. django_cfg/apps/dashboard/views/commands_views.py +92 -4
  8. django_cfg/apps/frontend/views.py +35 -4
  9. django_cfg/modules/django_client/core/generator/typescript/templates/client/client.ts.jinja +18 -14
  10. django_cfg/modules/django_client/core/generator/typescript/templates/utils/http.ts.jinja +8 -3
  11. django_cfg/pyproject.toml +1 -1
  12. django_cfg/static/frontend/admin/404/index.html +1 -0
  13. django_cfg/static/frontend/admin/404.html +1 -1
  14. django_cfg/static/frontend/admin/500/index.html +1 -0
  15. django_cfg/static/frontend/admin/_next/static/D_d9HRw5Yn7BRHAX5q89_/_buildManifest.js +1 -0
  16. django_cfg/static/frontend/admin/_next/static/chunks/{19430.fe7bff7372f8a256.js → 19430.c4c95603c23c17fe.js} +1 -1
  17. django_cfg/static/frontend/admin/_next/static/chunks/{43076.55dd23b6cd68edb0.js → 20695.a7d37b6c40ad3f58.js} +1 -1
  18. django_cfg/static/frontend/admin/_next/static/chunks/43076-4be6a9794e9c3e8b.js +1 -0
  19. django_cfg/static/frontend/admin/_next/static/chunks/50314-5ec79b293c2283dd.js +1 -0
  20. django_cfg/static/frontend/admin/_next/static/chunks/94141-bc6d47f419b26b21.js +1 -0
  21. django_cfg/static/frontend/admin/_next/static/chunks/main-d4b1d5245e3e8c42.js +1 -0
  22. django_cfg/static/frontend/admin/_next/static/chunks/pages/{404-cf71cd7b3cb005e5.js → 404-9c41b6ebfe67f15e.js} +1 -1
  23. django_cfg/static/frontend/admin/_next/static/chunks/pages/{500-ff19c7842e3df415.js → 500-906d92e10e7e65fb.js} +1 -1
  24. django_cfg/static/frontend/admin/_next/static/chunks/pages/_app-1c0fff0f59a6d683.js +272 -0
  25. django_cfg/static/frontend/admin/_next/static/chunks/pages/{_error-87f3fdc2aa131e77.js → _error-2550e721b11b7946.js} +1 -1
  26. django_cfg/static/frontend/admin/_next/static/chunks/pages/{index-69f737d4802cc5b7.js → index-fe4802f9df0ac052.js} +1 -1
  27. django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{cookies-b39c7f22c066e2c6.js → cookies-97d279800f12aab4.js} +1 -1
  28. django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{privacy-5aedad0cf3a4f80f.js → privacy-1d5e6cd94689247e.js} +1 -1
  29. django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{security-dbd854d0d5d483e2.js → security-55e49700e7a01f5a.js} +1 -1
  30. django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{terms-f3e1d2b9e5edf12f.js → terms-14c02bb2d3198352.js} +1 -1
  31. django_cfg/static/frontend/admin/_next/static/chunks/pages/private/centrifugo-44a8313fa040e9ad.js +1 -0
  32. django_cfg/static/frontend/admin/_next/static/chunks/pages/private/{profile-b8045f993287f1a7.js → profile-5cd57aac826857fd.js} +1 -1
  33. django_cfg/static/frontend/admin/_next/static/chunks/pages/private/{ui-373fff8b42878e64.js → ui-383e7f474cf22a18.js} +1 -1
  34. django_cfg/static/frontend/admin/_next/static/chunks/pages/private-d4ccbe1265cbd853.js +1 -0
  35. django_cfg/static/frontend/admin/_next/static/chunks/{webpack-7c456a65e96eb97e.js → webpack-5a92f81363b62aa7.js} +1 -1
  36. django_cfg/static/frontend/admin/_next/static/css/3063068f0d5a8a00.css +3 -0
  37. django_cfg/static/frontend/admin/_next/static/media/19cfc7226ec3afaa-s.woff2 +0 -0
  38. django_cfg/static/frontend/admin/_next/static/media/21350d82a1f187e9-s.p.woff2 +0 -0
  39. django_cfg/static/frontend/admin/_next/static/media/8e9860b6e62d6359-s.woff2 +0 -0
  40. django_cfg/static/frontend/admin/_next/static/media/ba9851c3c22cd980-s.woff2 +0 -0
  41. django_cfg/static/frontend/admin/_next/static/media/c5fe6dc8356a8c31-s.woff2 +0 -0
  42. django_cfg/static/frontend/admin/_next/static/media/df0a9ae256c0569c-s.woff2 +0 -0
  43. django_cfg/static/frontend/admin/_next/static/media/e4af272ccee01ff0-s.p.woff2 +0 -0
  44. django_cfg/static/frontend/admin/{private/ui.html → auth/index.html} +1 -1
  45. django_cfg/static/frontend/admin/index.html +1 -1
  46. django_cfg/static/frontend/admin/legal/cookies/index.html +1 -0
  47. django_cfg/static/frontend/admin/legal/privacy/index.html +1 -0
  48. django_cfg/static/frontend/admin/legal/security/index.html +1 -0
  49. django_cfg/static/frontend/admin/legal/terms/index.html +1 -0
  50. django_cfg/static/frontend/admin/private/centrifugo/index.html +1 -0
  51. django_cfg/static/frontend/admin/private/index.html +1 -0
  52. django_cfg/static/frontend/admin/private/{profile.html → profile/index.html} +1 -1
  53. django_cfg/static/frontend/admin/private/ui/index.html +92 -0
  54. django_cfg/templates/admin/index.html +30 -44
  55. {django_cfg-1.4.86.dist-info → django_cfg-1.4.88.dist-info}/METADATA +1 -1
  56. {django_cfg-1.4.86.dist-info → django_cfg-1.4.88.dist-info}/RECORD +60 -55
  57. django_cfg/static/frontend/admin/500.html +0 -1
  58. django_cfg/static/frontend/admin/_next/static/chunks/23004-faae121bbfecc163.js +0 -1
  59. django_cfg/static/frontend/admin/_next/static/chunks/50314-3b9d15242191c8bc.js +0 -1
  60. django_cfg/static/frontend/admin/_next/static/chunks/main-f9b6d451d9991f19.js +0 -1
  61. django_cfg/static/frontend/admin/_next/static/chunks/pages/_app-f62e5528fbcbb6b3.js +0 -272
  62. django_cfg/static/frontend/admin/_next/static/chunks/pages/private/centrifugo-f24beb6ed3955aa8.js +0 -1
  63. django_cfg/static/frontend/admin/_next/static/chunks/pages/private-fe9faa86ecdb0ce6.js +0 -1
  64. django_cfg/static/frontend/admin/_next/static/css/5f9a37b6e6a72303.css +0 -3
  65. django_cfg/static/frontend/admin/_next/static/media/438aa629764e75f3-s.woff2 +0 -0
  66. django_cfg/static/frontend/admin/_next/static/media/4c9affa5bc8f420e-s.p.woff2 +0 -0
  67. django_cfg/static/frontend/admin/_next/static/media/51251f8b9793cdb3-s.woff2 +0 -0
  68. django_cfg/static/frontend/admin/_next/static/media/875ae681bfde4580-s.p.woff2 +0 -0
  69. django_cfg/static/frontend/admin/_next/static/media/cc978ac5ee68c2b6-s.woff2 +0 -0
  70. django_cfg/static/frontend/admin/_next/static/media/e857b654a2caa584-s.woff2 +0 -0
  71. django_cfg/static/frontend/admin/_next/static/wg0mGdXjT00H_1BUxoOSH/_buildManifest.js +0 -1
  72. django_cfg/static/frontend/admin/auth.html +0 -1
  73. django_cfg/static/frontend/admin/legal/cookies.html +0 -1
  74. django_cfg/static/frontend/admin/legal/privacy.html +0 -1
  75. django_cfg/static/frontend/admin/legal/security.html +0 -1
  76. django_cfg/static/frontend/admin/legal/terms.html +0 -1
  77. django_cfg/static/frontend/admin/private/centrifugo.html +0 -1
  78. django_cfg/static/frontend/admin/private.html +0 -1
  79. /django_cfg/static/frontend/admin/_next/static/{wg0mGdXjT00H_1BUxoOSH → D_d9HRw5Yn7BRHAX5q89_}/_ssgManifest.js +0 -0
  80. {django_cfg-1.4.86.dist-info → django_cfg-1.4.88.dist-info}/WHEEL +0 -0
  81. {django_cfg-1.4.86.dist-info → django_cfg-1.4.88.dist-info}/entry_points.txt +0 -0
  82. {django_cfg-1.4.86.dist-info → django_cfg-1.4.88.dist-info}/licenses/LICENSE +0 -0
django_cfg/__init__.py CHANGED
@@ -32,7 +32,7 @@ Example:
32
32
  default_app_config = "django_cfg.apps.DjangoCfgConfig"
33
33
 
34
34
  # Version information
35
- __version__ = "1.4.86"
35
+ __version__ = "1.4.88"
36
36
  __license__ = "MIT"
37
37
 
38
38
  # Import registry for organized lazy loading
@@ -0,0 +1,48 @@
1
+ """
2
+ Dashboard Permissions
3
+
4
+ Custom permission classes for dashboard API endpoints.
5
+ """
6
+
7
+ from rest_framework.permissions import BasePermission
8
+
9
+
10
+ class IsSuperAdmin(BasePermission):
11
+ """
12
+ Permission that allows access only to superusers.
13
+
14
+ More restrictive than IsAdminUser - requires is_superuser flag.
15
+ Use for sensitive operations like command execution.
16
+ """
17
+
18
+ def has_permission(self, request, view):
19
+ """Check if user is authenticated and is a superuser."""
20
+ return bool(
21
+ request.user and
22
+ request.user.is_authenticated and
23
+ request.user.is_superuser
24
+ )
25
+
26
+
27
+ class IsStaffOrReadOnly(BasePermission):
28
+ """
29
+ Permission that allows read access to staff,
30
+ but write access only to superusers.
31
+ """
32
+
33
+ def has_permission(self, request, view):
34
+ """Check permissions based on request method."""
35
+ # Read permissions are allowed to any staff user
36
+ if request.method in ['GET', 'HEAD', 'OPTIONS']:
37
+ return bool(
38
+ request.user and
39
+ request.user.is_authenticated and
40
+ request.user.is_staff
41
+ )
42
+
43
+ # Write permissions are only allowed to superusers
44
+ return bool(
45
+ request.user and
46
+ request.user.is_authenticated and
47
+ request.user.is_superuser
48
+ )
@@ -15,7 +15,12 @@ from .charts import (
15
15
  ActivityTrackerDaySerializer,
16
16
  RecentUserSerializer,
17
17
  )
18
- from .commands import CommandSerializer, CommandsSummarySerializer
18
+ from .commands import (
19
+ CommandSerializer,
20
+ CommandsSummarySerializer,
21
+ CommandExecuteRequestSerializer,
22
+ CommandHelpResponseSerializer,
23
+ )
19
24
  from .apizones import APIZoneSerializer, APIZonesSummarySerializer
20
25
 
21
26
  __all__ = [
@@ -48,6 +53,8 @@ __all__ = [
48
53
  # Commands
49
54
  'CommandSerializer',
50
55
  'CommandsSummarySerializer',
56
+ 'CommandExecuteRequestSerializer',
57
+ 'CommandHelpResponseSerializer',
51
58
 
52
59
  # API Zones
53
60
  'APIZoneSerializer',
@@ -14,6 +14,8 @@ class CommandSerializer(serializers.Serializer):
14
14
  help = serializers.CharField()
15
15
  is_core = serializers.BooleanField()
16
16
  is_custom = serializers.BooleanField()
17
+ is_allowed = serializers.BooleanField(required=False)
18
+ risk_level = serializers.CharField(required=False)
17
19
 
18
20
 
19
21
  class CommandsSummarySerializer(serializers.Serializer):
@@ -24,3 +26,30 @@ class CommandsSummarySerializer(serializers.Serializer):
24
26
  categories = serializers.ListField(child=serializers.CharField())
25
27
  commands = CommandSerializer(many=True)
26
28
  categorized = serializers.DictField()
29
+
30
+
31
+ class CommandExecuteRequestSerializer(serializers.Serializer):
32
+ """Request serializer for command execution."""
33
+ command = serializers.CharField(help_text="Name of the Django management command")
34
+ args = serializers.ListField(
35
+ child=serializers.CharField(),
36
+ required=False,
37
+ default=list,
38
+ help_text="Positional arguments for the command"
39
+ )
40
+ options = serializers.DictField(
41
+ required=False,
42
+ default=dict,
43
+ help_text="Named options for the command (e.g., {'verbosity': '2'})"
44
+ )
45
+
46
+
47
+ class CommandHelpResponseSerializer(serializers.Serializer):
48
+ """Response serializer for command help."""
49
+ status = serializers.CharField()
50
+ command = serializers.CharField()
51
+ app = serializers.CharField(required=False)
52
+ help_text = serializers.CharField(required=False)
53
+ is_allowed = serializers.BooleanField(required=False)
54
+ risk_level = serializers.CharField(required=False)
55
+ error = serializers.CharField(required=False)
@@ -0,0 +1,228 @@
1
+ """
2
+ Commands Security Module
3
+
4
+ Self-contained security logic for Django management commands execution.
5
+ Determines which commands are safe to execute via web interface.
6
+
7
+ TAGS: commands, security, validation, safety
8
+ """
9
+
10
+ import importlib
11
+ import inspect
12
+ import logging
13
+ from typing import Any, Dict, Set
14
+
15
+ from django.core.management.base import BaseCommand
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ # Keywords in docstring/code that indicate command should not be in web UI
21
+ DANGEROUS_KEYWORDS: Set[str] = {
22
+ 'questionary', 'input(', 'stdin', 'interactive', # Requires user input
23
+ 'destructive', 'dangerous', 'irreversible', # Explicitly marked as dangerous
24
+ 'runserver', 'testserver', # Dev servers
25
+ }
26
+
27
+ # Commands that should NEVER appear (absolute blacklist)
28
+ ABSOLUTE_BLACKLIST: Set[str] = {
29
+ # Destructive database commands
30
+ 'flush', 'sqlflush', 'dbshell',
31
+
32
+ # Shell access (security risk)
33
+ 'shell', 'shell_plus',
34
+
35
+ # Development server (not for web execution)
36
+ 'runserver', 'testserver', 'runserver_ngrok',
37
+
38
+ # Project generation (not applicable in running app)
39
+ 'startapp', 'startproject',
40
+
41
+ # SQL commands (direct SQL, potentially dangerous)
42
+ 'sqlmigrate', 'sqlsequencereset',
43
+ }
44
+
45
+
46
+ def analyze_command_safety(command_class: BaseCommand, command_name: str) -> Dict[str, Any]:
47
+ """
48
+ Analyze command to determine if it's safe for web execution.
49
+
50
+ Checks:
51
+ 1. Explicit metadata (web_executable, requires_input, is_destructive)
52
+ 2. Docstring analysis for dangerous keywords
53
+ 3. Source code analysis for interactive input
54
+ 4. Required arguments analysis
55
+
56
+ Returns:
57
+ Dict with safety analysis results
58
+ """
59
+ analysis = {
60
+ 'is_safe': True,
61
+ 'reasons': [],
62
+ 'requires_input': False,
63
+ 'is_destructive': False,
64
+ 'web_executable': None,
65
+ }
66
+
67
+ # Check explicit metadata (highest priority)
68
+ if hasattr(command_class, 'web_executable'):
69
+ analysis['web_executable'] = command_class.web_executable
70
+ if not command_class.web_executable:
71
+ analysis['is_safe'] = False
72
+ analysis['reasons'].append('Command explicitly marked as not web-executable')
73
+ return analysis
74
+
75
+ if hasattr(command_class, 'requires_input'):
76
+ analysis['requires_input'] = command_class.requires_input
77
+ if command_class.requires_input:
78
+ analysis['is_safe'] = False
79
+ analysis['reasons'].append('Command requires interactive input')
80
+ return analysis
81
+
82
+ if hasattr(command_class, 'is_destructive'):
83
+ analysis['is_destructive'] = command_class.is_destructive
84
+ if command_class.is_destructive:
85
+ analysis['is_safe'] = False
86
+ analysis['reasons'].append('Command is marked as destructive')
87
+ return analysis
88
+
89
+ # Analyze docstring for dangerous keywords
90
+ docstring = (inspect.getdoc(command_class) or '').lower()
91
+ for keyword in DANGEROUS_KEYWORDS:
92
+ if keyword in docstring:
93
+ analysis['is_safe'] = False
94
+ analysis['reasons'].append(f'Docstring contains dangerous keyword: {keyword}')
95
+ return analysis
96
+
97
+ # Analyze source code for interactive input
98
+ try:
99
+ source = inspect.getsource(command_class)
100
+ if 'questionary' in source or 'input(' in source:
101
+ analysis['is_safe'] = False
102
+ analysis['requires_input'] = True
103
+ analysis['reasons'].append('Command requires interactive user input')
104
+ return analysis
105
+ except Exception:
106
+ # Can't analyze source, be safe
107
+ pass
108
+
109
+ return analysis
110
+
111
+
112
+ def is_command_allowed(command_name: str, app_name: str) -> bool:
113
+ """
114
+ Check if command should be allowed for web execution.
115
+
116
+ Priority:
117
+ 1. Custom blacklist from settings (DJANGO_CFG_COMMANDS_BLACKLIST)
118
+ 2. Absolute blacklist - always exclude
119
+ 3. Custom whitelist from settings (DJANGO_CFG_COMMANDS_WHITELIST)
120
+ 4. Command metadata analysis (web_executable, requires_input, etc.)
121
+ 5. django_cfg apps - analyze each command
122
+ 6. Django core - be selective (only safe utility commands)
123
+ 7. Third party - only if explicitly whitelisted
124
+
125
+ You can customize filtering in settings.py:
126
+
127
+ DJANGO_CFG_COMMANDS_BLACKLIST = {'my_dangerous_command'}
128
+ DJANGO_CFG_COMMANDS_WHITELIST = {'my_safe_command'}
129
+
130
+ Or add metadata to your Command class:
131
+
132
+ class Command(BaseCommand):
133
+ web_executable = True # Allow in web UI
134
+ requires_input = False # Doesn't need interactive input
135
+ is_destructive = False # Not destructive
136
+ """
137
+ from django.conf import settings
138
+
139
+ # Custom blacklist from settings (highest priority)
140
+ custom_blacklist = getattr(settings, 'DJANGO_CFG_COMMANDS_BLACKLIST', set())
141
+ if command_name in custom_blacklist:
142
+ logger.info(f"Command {command_name} blocked by custom blacklist")
143
+ return False
144
+
145
+ # Absolute blacklist
146
+ if command_name in ABSOLUTE_BLACKLIST:
147
+ logger.info(f"Command {command_name} blocked by absolute blacklist")
148
+ return False
149
+
150
+ # Custom whitelist from settings
151
+ custom_whitelist = getattr(settings, 'DJANGO_CFG_COMMANDS_WHITELIST', set())
152
+ if command_name in custom_whitelist:
153
+ logger.info(f"Command {command_name} allowed by custom whitelist")
154
+ return True
155
+
156
+ # Load and analyze command
157
+ try:
158
+ # Determine module path
159
+ if app_name == 'django_cfg':
160
+ module_path = f'django_cfg.management.commands.{command_name}'
161
+ elif app_name.startswith('django.'):
162
+ module_path = f'{app_name}.management.commands.{command_name}'
163
+ else:
164
+ module_path = f'{app_name}.management.commands.{command_name}'
165
+
166
+ command_module = importlib.import_module(module_path)
167
+ if hasattr(command_module, 'Command'):
168
+ command_class = command_module.Command
169
+
170
+ # Analyze command safety
171
+ analysis = analyze_command_safety(command_class, command_name)
172
+
173
+ # If command has explicit web_executable metadata, use it
174
+ if analysis['web_executable'] is not None:
175
+ return analysis['web_executable']
176
+
177
+ # If analysis says it's unsafe, exclude
178
+ if not analysis['is_safe']:
179
+ logger.debug(f"Command {command_name} excluded: {', '.join(analysis['reasons'])}")
180
+ return False
181
+ except Exception as e:
182
+ # Can't load/analyze command
183
+ logger.debug(f"Could not analyze command {command_name}: {e}")
184
+ # Be conservative - if we can't analyze, exclude unless from trusted source
185
+ pass
186
+
187
+ # Django CFG commands - include if analysis passed
188
+ if app_name == 'django_cfg':
189
+ return True
190
+
191
+ # Safe Django core commands (utility/read-only)
192
+ safe_django_core = {
193
+ 'check', 'diffsettings', 'showmigrations',
194
+ 'createcachetable', 'sendtestemail',
195
+ }
196
+ if app_name.startswith('django.') and command_name in safe_django_core:
197
+ return True
198
+
199
+ # Exclude other Django core by default
200
+ if app_name.startswith('django.'):
201
+ logger.debug(f"Command {command_name} excluded (Django core, not in safe list)")
202
+ return False
203
+
204
+ # Third-party apps - only whitelisted
205
+ logger.debug(f"Command {command_name} excluded (third-party, not whitelisted)")
206
+ return False
207
+
208
+
209
+ def get_command_risk_level(command_name: str, app_name: str) -> str:
210
+ """
211
+ Get risk level for a command.
212
+
213
+ Returns:
214
+ 'safe', 'caution', or 'dangerous'
215
+ """
216
+ if command_name in ABSOLUTE_BLACKLIST:
217
+ return 'dangerous'
218
+
219
+ if app_name == 'django_cfg':
220
+ return 'safe'
221
+
222
+ if app_name.startswith('django.'):
223
+ safe_commands = {'check', 'diffsettings', 'showmigrations'}
224
+ if command_name in safe_commands:
225
+ return 'safe'
226
+ return 'caution'
227
+
228
+ return 'caution'
@@ -1,13 +1,18 @@
1
1
  """
2
2
  Commands Service
3
3
 
4
- Django management commands discovery and documentation.
4
+ Django management commands discovery, documentation, and execution.
5
5
  """
6
6
 
7
7
  import logging
8
- from typing import Any, Dict, List
8
+ import sys
9
+ import time
10
+ from io import StringIO
11
+ from typing import Any, Dict, Generator, List
9
12
 
10
- from django.core.management import get_commands, load_command_class
13
+ from django.core.management import call_command, get_commands, load_command_class
14
+
15
+ from .commands_security import is_command_allowed, get_command_risk_level
11
16
 
12
17
  logger = logging.getLogger(__name__)
13
18
 
@@ -26,20 +31,29 @@ class CommandsService:
26
31
  """Initialize commands service."""
27
32
  self.logger = logger
28
33
 
29
- def get_all_commands(self) -> List[Dict[str, Any]]:
34
+ def get_all_commands(self, include_unsafe: bool = False) -> List[Dict[str, Any]]:
30
35
  """
31
- Get all available Django management commands.
36
+ Get all available Django management commands (filtered for safety by default).
37
+
38
+ Args:
39
+ include_unsafe: If True, include all commands. If False, only safe commands.
32
40
 
33
41
  Returns:
34
- List of command dictionaries with name, app, help text
42
+ List of command dictionaries with name, app, help text, safety info
35
43
 
36
- %%AI_HINT: Uses Django's get_commands() for command discovery%%
44
+ %%AI_HINT: Uses Django's get_commands() for command discovery with security filtering%%
37
45
  """
38
46
  try:
39
47
  commands_dict = get_commands()
40
48
  commands_list = []
41
49
 
42
50
  for command_name, app_name in commands_dict.items():
51
+ # Check if command is allowed (unless explicitly including unsafe)
52
+ is_allowed = is_command_allowed(command_name, app_name)
53
+
54
+ if not include_unsafe and not is_allowed:
55
+ continue
56
+
43
57
  try:
44
58
  # Try to load command to get help text
45
59
  command = load_command_class(app_name, command_name)
@@ -47,7 +61,10 @@ class CommandsService:
47
61
 
48
62
  # Determine if it's a core Django command or custom
49
63
  is_core = app_name.startswith('django.')
50
- is_custom = not is_core
64
+ is_custom = app_name == 'django_cfg'
65
+
66
+ # Get risk level
67
+ risk_level = get_command_risk_level(command_name, app_name)
51
68
 
52
69
  commands_list.append({
53
70
  'name': command_name,
@@ -55,6 +72,8 @@ class CommandsService:
55
72
  'help': help_text,
56
73
  'is_core': is_core,
57
74
  'is_custom': is_custom,
75
+ 'is_allowed': is_allowed,
76
+ 'risk_level': risk_level,
58
77
  })
59
78
  except Exception as e:
60
79
  # If we can't load the command, still include basic info
@@ -64,7 +83,9 @@ class CommandsService:
64
83
  'app': app_name,
65
84
  'help': 'Description unavailable',
66
85
  'is_core': app_name.startswith('django.'),
67
- 'is_custom': not app_name.startswith('django.'),
86
+ 'is_custom': app_name == 'django_cfg',
87
+ 'is_allowed': is_allowed,
88
+ 'risk_level': get_command_risk_level(command_name, app_name),
68
89
  })
69
90
 
70
91
  # Sort by name
@@ -140,3 +161,181 @@ class CommandsService:
140
161
  'commands': [],
141
162
  'categorized': {},
142
163
  }
164
+
165
+ def get_command_help(self, command_name: str) -> Dict[str, Any]:
166
+ """
167
+ Get detailed help text for a specific command.
168
+
169
+ Args:
170
+ command_name: Name of the Django management command
171
+
172
+ Returns:
173
+ Dictionary with help text and command info
174
+ """
175
+ try:
176
+ commands_dict = get_commands()
177
+
178
+ if command_name not in commands_dict:
179
+ return {
180
+ 'status': 'error',
181
+ 'error': f"Command '{command_name}' not found",
182
+ }
183
+
184
+ app_name = commands_dict[command_name]
185
+
186
+ # Load command class
187
+ command_class = load_command_class(app_name, command_name)
188
+ command_instance = command_class()
189
+
190
+ # Get parser to extract help
191
+ parser = command_instance.create_parser('manage.py', command_name)
192
+ help_text = parser.format_help()
193
+
194
+ return {
195
+ 'status': 'success',
196
+ 'command': command_name,
197
+ 'app': app_name,
198
+ 'help_text': help_text,
199
+ 'is_allowed': is_command_allowed(command_name, app_name),
200
+ 'risk_level': get_command_risk_level(command_name, app_name),
201
+ }
202
+
203
+ except Exception as e:
204
+ self.logger.error(f"Error getting help for command {command_name}: {e}")
205
+ return {
206
+ 'status': 'error',
207
+ 'error': str(e),
208
+ 'command': command_name,
209
+ }
210
+
211
+ def execute_command(
212
+ self,
213
+ command_name: str,
214
+ args: List[str] = None,
215
+ options: Dict[str, Any] = None,
216
+ user=None
217
+ ) -> Generator[Dict[str, Any], None, None]:
218
+ """
219
+ Execute a Django management command and stream output.
220
+
221
+ Args:
222
+ command_name: Name of the command to execute
223
+ args: List of positional arguments
224
+ options: Dictionary of command options
225
+ user: User executing the command (for logging)
226
+
227
+ Yields:
228
+ Dict with execution events (start, output, complete, error)
229
+
230
+ Security:
231
+ - Validates command is allowed via is_command_allowed()
232
+ - Logs all execution attempts
233
+ - Captures and streams output safely
234
+ """
235
+ args = args or []
236
+ options = options or {}
237
+ start_time = time.time()
238
+
239
+ try:
240
+ # Validate command exists
241
+ commands_dict = get_commands()
242
+ if command_name not in commands_dict:
243
+ yield {
244
+ 'type': 'error',
245
+ 'error': f"Command '{command_name}' not found",
246
+ 'available_commands': list(commands_dict.keys())[:20], # First 20 for reference
247
+ }
248
+ return
249
+
250
+ app_name = commands_dict[command_name]
251
+
252
+ # Security check - validate command is allowed
253
+ if not is_command_allowed(command_name, app_name):
254
+ self.logger.warning(
255
+ f"Attempted execution of forbidden command '{command_name}' by user {user}"
256
+ )
257
+ yield {
258
+ 'type': 'error',
259
+ 'error': f"Command '{command_name}' is not allowed via web interface for security reasons",
260
+ 'suggestion': 'Only safe django_cfg commands and whitelisted utilities can be executed via web.',
261
+ 'risk_level': get_command_risk_level(command_name, app_name),
262
+ }
263
+ return
264
+
265
+ # Send start event
266
+ yield {
267
+ 'type': 'start',
268
+ 'command': command_name,
269
+ 'args': args,
270
+ 'options': options,
271
+ }
272
+
273
+ # Capture command output
274
+ output_buffer = StringIO()
275
+ old_stdout = sys.stdout
276
+ old_stderr = sys.stderr
277
+
278
+ try:
279
+ # Redirect stdout/stderr to buffer
280
+ sys.stdout = output_buffer
281
+ sys.stderr = output_buffer
282
+
283
+ # Execute command
284
+ call_command(command_name, *args, **options)
285
+ return_code = 0
286
+
287
+ except Exception as e:
288
+ # Command execution failed
289
+ output_buffer.write(f"\nError: {str(e)}\n")
290
+ return_code = 1
291
+
292
+ finally:
293
+ # Restore stdout/stderr
294
+ sys.stdout = old_stdout
295
+ sys.stderr = old_stderr
296
+
297
+ # Get all output
298
+ output = output_buffer.getvalue()
299
+
300
+ # Stream output line by line
301
+ if output:
302
+ for line in output.split('\n'):
303
+ if line.strip():
304
+ yield {
305
+ 'type': 'output',
306
+ 'line': line,
307
+ }
308
+
309
+ execution_time = time.time() - start_time
310
+
311
+ # Send completion event
312
+ yield {
313
+ 'type': 'complete',
314
+ 'return_code': return_code,
315
+ 'execution_time': round(execution_time, 2),
316
+ }
317
+
318
+ # Log command execution
319
+ if return_code == 0:
320
+ self.logger.info(
321
+ f"Command executed: {command_name} {' '.join(args)} "
322
+ f"by user {user} in {execution_time:.2f}s"
323
+ )
324
+ else:
325
+ self.logger.error(
326
+ f"Command failed: {command_name} {' '.join(args)} "
327
+ f"by user {user} in {execution_time:.2f}s"
328
+ )
329
+
330
+ except Exception as e:
331
+ execution_time = time.time() - start_time
332
+
333
+ self.logger.error(
334
+ f"Command execution error: {command_name} by user {user}: {e}"
335
+ )
336
+
337
+ yield {
338
+ 'type': 'error',
339
+ 'error': str(e),
340
+ 'execution_time': round(execution_time, 2),
341
+ }