netbox-toolkit-plugin 0.1.0__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.
Files changed (56) hide show
  1. netbox_toolkit/__init__.py +30 -0
  2. netbox_toolkit/admin.py +16 -0
  3. netbox_toolkit/api/__init__.py +0 -0
  4. netbox_toolkit/api/mixins.py +54 -0
  5. netbox_toolkit/api/schemas.py +234 -0
  6. netbox_toolkit/api/serializers.py +158 -0
  7. netbox_toolkit/api/urls.py +10 -0
  8. netbox_toolkit/api/views/__init__.py +10 -0
  9. netbox_toolkit/api/views/command_logs.py +170 -0
  10. netbox_toolkit/api/views/commands.py +267 -0
  11. netbox_toolkit/config.py +159 -0
  12. netbox_toolkit/connectors/__init__.py +15 -0
  13. netbox_toolkit/connectors/base.py +97 -0
  14. netbox_toolkit/connectors/factory.py +301 -0
  15. netbox_toolkit/connectors/netmiko_connector.py +443 -0
  16. netbox_toolkit/connectors/scrapli_connector.py +486 -0
  17. netbox_toolkit/exceptions.py +36 -0
  18. netbox_toolkit/filtersets.py +85 -0
  19. netbox_toolkit/forms.py +31 -0
  20. netbox_toolkit/migrations/0001_initial.py +54 -0
  21. netbox_toolkit/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +66 -0
  22. netbox_toolkit/migrations/0003_permission_system_update.py +48 -0
  23. netbox_toolkit/migrations/0004_remove_django_permissions.py +77 -0
  24. netbox_toolkit/migrations/0005_alter_command_options_and_more.py +25 -0
  25. netbox_toolkit/migrations/0006_commandlog_parsed_data_commandlog_parsing_success_and_more.py +28 -0
  26. netbox_toolkit/migrations/0007_alter_commandlog_parsing_template.py +18 -0
  27. netbox_toolkit/migrations/__init__.py +0 -0
  28. netbox_toolkit/models.py +89 -0
  29. netbox_toolkit/navigation.py +30 -0
  30. netbox_toolkit/search.py +21 -0
  31. netbox_toolkit/services/__init__.py +7 -0
  32. netbox_toolkit/services/command_service.py +357 -0
  33. netbox_toolkit/services/device_service.py +87 -0
  34. netbox_toolkit/services/rate_limiting_service.py +228 -0
  35. netbox_toolkit/static/netbox_toolkit/css/toolkit.css +143 -0
  36. netbox_toolkit/static/netbox_toolkit/js/toolkit.js +657 -0
  37. netbox_toolkit/tables.py +37 -0
  38. netbox_toolkit/templates/netbox_toolkit/command.html +108 -0
  39. netbox_toolkit/templates/netbox_toolkit/command_edit.html +10 -0
  40. netbox_toolkit/templates/netbox_toolkit/command_list.html +12 -0
  41. netbox_toolkit/templates/netbox_toolkit/commandlog.html +170 -0
  42. netbox_toolkit/templates/netbox_toolkit/commandlog_list.html +4 -0
  43. netbox_toolkit/templates/netbox_toolkit/device_toolkit.html +536 -0
  44. netbox_toolkit/urls.py +22 -0
  45. netbox_toolkit/utils/__init__.py +1 -0
  46. netbox_toolkit/utils/connection.py +125 -0
  47. netbox_toolkit/utils/error_parser.py +428 -0
  48. netbox_toolkit/utils/logging.py +58 -0
  49. netbox_toolkit/utils/network.py +157 -0
  50. netbox_toolkit/views.py +385 -0
  51. netbox_toolkit_plugin-0.1.0.dist-info/METADATA +76 -0
  52. netbox_toolkit_plugin-0.1.0.dist-info/RECORD +56 -0
  53. netbox_toolkit_plugin-0.1.0.dist-info/WHEEL +5 -0
  54. netbox_toolkit_plugin-0.1.0.dist-info/entry_points.txt +2 -0
  55. netbox_toolkit_plugin-0.1.0.dist-info/licenses/LICENSE +200 -0
  56. netbox_toolkit_plugin-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,357 @@
1
+ """Service for handling command execution on devices."""
2
+ import traceback
3
+ from typing import Optional, Any
4
+
5
+ from dcim.models import Device
6
+
7
+ from ..models import Command, CommandLog
8
+ from ..connectors.factory import ConnectorFactory
9
+ from ..connectors.base import CommandResult
10
+ from ..connectors.netmiko_connector import NetmikoConnector
11
+ from ..exceptions import DeviceConnectionError, CommandExecutionError
12
+ from ..config import ToolkitConfig
13
+ from ..utils.logging import get_toolkit_logger
14
+
15
+ logger = get_toolkit_logger(__name__)
16
+
17
+
18
+ class CommandExecutionService:
19
+ """Service for executing commands on devices."""
20
+
21
+ def __init__(self):
22
+ self.connector_factory = ConnectorFactory()
23
+
24
+ def execute_command_with_retry(
25
+ self,
26
+ command: "PredefinedCommand",
27
+ device: Any,
28
+ username: str,
29
+ password: str,
30
+ max_retries: int = 1
31
+ ) -> "CommandResult":
32
+ """
33
+ Execute a command with connection retry capability.
34
+
35
+ Args:
36
+ command: Command to execute
37
+ device: Target device
38
+ username: Authentication username
39
+ password: Authentication password
40
+ max_retries: Maximum number of retry attempts
41
+
42
+ Returns:
43
+ CommandResult with execution details
44
+ """
45
+ last_error = None
46
+
47
+ logger.info("Executing command '%s' on device %s (max_retries=%d)",
48
+ command.name, device.name, max_retries)
49
+
50
+ for attempt in range(max_retries + 1):
51
+ try:
52
+ logger.debug("Attempt %d/%d for command execution", attempt + 1, max_retries + 1)
53
+
54
+ # Create appropriate connector for the device
55
+ connector = self.connector_factory.create_connector(device, username, password)
56
+ logger.debug("Created %s connector for device %s",
57
+ type(connector).__name__, device.name)
58
+
59
+ # Execute command using context manager for proper cleanup
60
+ with connector:
61
+ result = connector.execute_command(command.command, command.command_type)
62
+ logger.debug("Command executed successfully, output length: %d chars",
63
+ len(result.output) if result.output else 0)
64
+
65
+ # If successful, log and return
66
+ logger.info("Command execution completed successfully on %s", device.name)
67
+ self._log_command_execution(command, device, result, username)
68
+ return result
69
+
70
+ except Exception as e:
71
+ last_error = e
72
+ error_msg = str(e)
73
+ logger.warning("Command execution attempt %d failed: %s", attempt + 1, error_msg)
74
+
75
+ # Check for fast-fail scenario and automatically retry with Netmiko
76
+ if ("Fast-fail to Netmiko" in error_msg or
77
+ ToolkitConfig.should_fast_fail_to_netmiko(error_msg)):
78
+ logger.info("Fast-fail pattern detected, attempting fallback to Netmiko for device %s", device.name)
79
+ try:
80
+ # Create Netmiko connector directly for fallback
81
+ base_config = self.connector_factory._build_connection_config(device, username, password)
82
+ netmiko_config = self.connector_factory._prepare_connector_config(base_config, NetmikoConnector)
83
+ fallback_connector = NetmikoConnector(netmiko_config)
84
+
85
+ # Execute command using Netmiko fallback connector
86
+ with fallback_connector:
87
+ result = fallback_connector.execute_command(command.command, command.command_type)
88
+ logger.info("Command executed successfully using Netmiko fallback on %s", device.name)
89
+ self._log_command_execution(command, device, result, username)
90
+ return result
91
+
92
+ except Exception as fallback_error:
93
+ logger.warning("Netmiko fallback also failed for device %s: %s", device.name, str(fallback_error))
94
+ last_error = fallback_error
95
+ break # Don't retry after fallback failure
96
+
97
+ # If this was a socket/connection error and we have retries left, continue
98
+ elif (attempt < max_retries and
99
+ ("socket" in error_msg.lower() or
100
+ "connection" in error_msg.lower() or
101
+ "Bad file descriptor" in error_msg)):
102
+ logger.debug("Connection error detected, will retry")
103
+ continue
104
+ else:
105
+ logger.error("Max retries reached or non-retryable error")
106
+ break
107
+
108
+ # All attempts failed, create error result
109
+ logger.error("All command execution attempts failed for device %s", device.name)
110
+ error_result = CommandResult(
111
+ command=command.command,
112
+ output="",
113
+ success=False,
114
+ error_message=str(last_error)
115
+ )
116
+
117
+ # Add detailed error information
118
+ error_result = self._enhance_error_result(error_result, last_error, device)
119
+
120
+ # Log the failed execution
121
+ self._log_command_execution(command, device, error_result, username)
122
+
123
+ return error_result
124
+
125
+ def execute_command(
126
+ self,
127
+ command: Command,
128
+ device: Device,
129
+ username: str,
130
+ password: str
131
+ ) -> CommandResult:
132
+ """
133
+ Execute a command on a device and log the result.
134
+
135
+ Args:
136
+ command: Command to execute
137
+ device: Target device
138
+ username: Authentication username
139
+ password: Authentication password
140
+
141
+ Returns:
142
+ CommandResult with execution details
143
+ """
144
+ try:
145
+ # Create appropriate connector for the device
146
+ connector = self.connector_factory.create_connector(device, username, password)
147
+
148
+ # Execute command using context manager for proper cleanup
149
+ with connector:
150
+ result = connector.execute_command(command.command, command.command_type)
151
+
152
+ # Log the execution
153
+ self._log_command_execution(command, device, result, username)
154
+
155
+ return result
156
+
157
+ except Exception as e:
158
+ # Create error result
159
+ error_result = CommandResult(
160
+ command=command.command,
161
+ output="",
162
+ success=False,
163
+ error_message=str(e)
164
+ )
165
+
166
+ # Add detailed error information
167
+ error_result = self._enhance_error_result(error_result, e, device)
168
+
169
+ # Log the failed execution
170
+ self._log_command_execution(command, device, error_result, username)
171
+
172
+ return error_result
173
+
174
+ def _log_command_execution(
175
+ self,
176
+ command: Command,
177
+ device: Device,
178
+ result: CommandResult,
179
+ username: str
180
+ ) -> CommandLog:
181
+ """Log command execution to database."""
182
+ if result.success:
183
+ output = result.output
184
+ # If syntax error was detected, note it in the success flag
185
+ if result.has_syntax_error:
186
+ success = False # Mark as failed due to syntax error
187
+ error_message = f"Syntax error detected: {result.syntax_error_type}"
188
+ else:
189
+ success = True
190
+ error_message = ""
191
+ else:
192
+ output = f"Error executing command: {result.error_message}"
193
+ if result.output:
194
+ output += f"\n\nOutput: {result.output}"
195
+ success = False
196
+ error_message = result.error_message or ''
197
+
198
+ # Create log entry with execution details
199
+ command_log = CommandLog.objects.create(
200
+ command=command,
201
+ device=device,
202
+ output=output,
203
+ username=username,
204
+ success=success,
205
+ error_message=error_message,
206
+ execution_duration=result.execution_time,
207
+ parsed_data=result.parsed_output,
208
+ parsing_success=result.parsing_success,
209
+ parsing_template=result.parsing_method
210
+ )
211
+
212
+ if result.has_syntax_error:
213
+ pass # Syntax error detected but not logging
214
+ else:
215
+ pass # Command executed successfully but not logging
216
+
217
+ return command_log
218
+
219
+ def _enhance_error_result(self, result: CommandResult, error: Exception, device: Device) -> CommandResult:
220
+ """Enhance error result with detailed troubleshooting information."""
221
+ error_message = str(error)
222
+ error_details = traceback.format_exc()
223
+
224
+ enhanced_output = f"Error executing command: {error_message}"
225
+
226
+ # Add specific guidance for common errors
227
+ guidance_added = False
228
+
229
+ if isinstance(error, DeviceConnectionError):
230
+ enhanced_output += self._get_connection_error_guidance(error_message, device)
231
+ guidance_added = True
232
+ elif "Bad file descriptor" in error_details:
233
+ enhanced_output += self._get_bad_descriptor_guidance(device)
234
+ guidance_added = True
235
+ elif "Error reading SSH protocol banner" in error_details:
236
+ enhanced_output += self._get_banner_error_guidance(device)
237
+ guidance_added = True
238
+
239
+ # Check for connection/authentication errors in the error message even if not DeviceConnectionError
240
+ if not guidance_added:
241
+ error_lower = error_message.lower()
242
+ if any(error_term in error_lower for error_term in [
243
+ "connect", "connection", "authentication", "failed to connect",
244
+ "ssh", "timeout", "unreachable", "refused"
245
+ ]):
246
+ enhanced_output += self._get_connection_error_guidance(error_message, device)
247
+ guidance_added = True
248
+
249
+ # Add general troubleshooting if no specific guidance was provided
250
+ if not guidance_added:
251
+ enhanced_output += (
252
+ "\n\nGeneral Troubleshooting:"
253
+ "\n- Verify device connectivity and SSH service status"
254
+ "\n- Check credentials and device configuration"
255
+ "\n- Review the debug information below for more details"
256
+ )
257
+
258
+ enhanced_output += f"\n\nDebug information:\n{error_details}"
259
+
260
+ return CommandResult(
261
+ command=result.command,
262
+ output=enhanced_output,
263
+ success=False,
264
+ error_message=result.error_message,
265
+ execution_time=result.execution_time
266
+ )
267
+
268
+ def _get_connection_error_guidance(self, error_message: str, device: Device) -> str:
269
+ """Get guidance for connection errors."""
270
+ hostname = str(device.primary_ip.address.ip) if device.primary_ip else device.name
271
+
272
+ guidance = "\n\nConnection Error Troubleshooting:"
273
+
274
+ # Convert to lowercase for case-insensitive matching
275
+ error_lower = error_message.lower()
276
+
277
+ if "no matching key exchange" in error_lower:
278
+ guidance += (
279
+ "\n- This is an SSH key exchange error"
280
+ )
281
+ elif any(conn_error in error_lower for conn_error in [
282
+ "connection not opened",
283
+ "connection refused",
284
+ "connection timed out",
285
+ "network is unreachable",
286
+ "no route to host"
287
+ ]):
288
+ guidance += (
289
+ "\n- Verify the device is reachable on the network"
290
+ "\n- Check that SSH service is running on the device"
291
+ "\n- Verify there's no firewall blocking the connection"
292
+ "\n- Ensure the correct NetBox has correct device details (IP, Hostname)"
293
+ )
294
+ elif any(auth_error in error_lower for auth_error in [
295
+ "authentication failed",
296
+ "all authentication methods failed",
297
+ "permission denied",
298
+ "invalid user",
299
+ "login incorrect",
300
+ "authentication error"
301
+ ]):
302
+ guidance += (
303
+ "\n- Verify username and password are correct"
304
+ "\n- Ensure the user has SSH access permissions on the device"
305
+ "\n- Check if the device requires specific authentication methods"
306
+ )
307
+ elif any(timeout_error in error_lower for timeout_error in [
308
+ "timeout",
309
+ "timed out",
310
+ "operation timed out"
311
+ ]):
312
+ guidance += (
313
+ "\n- The connection or operation timed out"
314
+ "\n- Check network connectivity to the device"
315
+ "\n- Verify the device is responding"
316
+ )
317
+ else:
318
+ # Generic connection guidance
319
+ guidance += (
320
+ "\n- Verify the device IP address is correct and reachable"
321
+ "\n- Check that SSH service is running on the device (usually port 22)"
322
+ "\n- Verify network connectivity and firewall settings"
323
+ "\n- Ensure your credentials are correct"
324
+ )
325
+
326
+ guidance += f"\n- Try connecting manually: ssh {hostname}"
327
+
328
+ return guidance
329
+
330
+ def _get_bad_descriptor_guidance(self, device: Device) -> str:
331
+ """Get guidance for 'Bad file descriptor' errors."""
332
+ hostname = str(device.primary_ip.address.ip) if device.primary_ip else device.name
333
+
334
+ return (
335
+ "\n\n'Bad file descriptor' Error Guidance:"
336
+ "\n- This often indicates network connectivity issues"
337
+ "\n- Verify the device IP address is correct"
338
+ "\n- Check that the device is reachable (try pinging it)"
339
+ "\n- Confirm SSH service is running on the device"
340
+ f"\n- Try connecting manually: ssh {hostname}"
341
+ )
342
+
343
+ def _get_banner_error_guidance(self, device: Device) -> str:
344
+ """Get guidance for SSH banner errors."""
345
+ hostname = str(device.primary_ip.address.ip) if device.primary_ip else device.name
346
+
347
+ return (
348
+ "\n\nSSH Banner Error Guidance:"
349
+ "\n- The device accepts connections but doesn't provide an SSH banner"
350
+ "\n- This could indicate:"
351
+ "\n * A different service is running on port 22"
352
+ "\n * The SSH server is very slow to respond"
353
+ "\n * A firewall is intercepting the connection"
354
+ "\n * The SSH implementation is non-standard"
355
+ f"\n- Try manual SSH with verbose logging: ssh -v {hostname}"
356
+ "\n- Check what service is on port 22: nmap -sV -p 22 " + hostname
357
+ )
@@ -0,0 +1,87 @@
1
+ """Service for device-related operations."""
2
+ from typing import List, Optional
3
+
4
+ from dcim.models import Device
5
+
6
+ from ..models import Command
7
+
8
+
9
+ class DeviceService:
10
+ """Service for device-related operations."""
11
+
12
+ @staticmethod
13
+ def get_available_commands(device: Device) -> List[Command]:
14
+ """
15
+ Get all commands available for a device based on its platform.
16
+
17
+ Args:
18
+ device: The device to get commands for
19
+
20
+ Returns:
21
+ List of available commands
22
+ """
23
+ if not device.platform:
24
+ return []
25
+
26
+ commands = Command.objects.filter(platform=device.platform)
27
+
28
+ return list(commands)
29
+
30
+ @staticmethod
31
+ def get_device_connection_info(device: Device) -> dict:
32
+ """
33
+ Get connection information for a device.
34
+
35
+ Args:
36
+ device: The device to get connection info for
37
+
38
+ Returns:
39
+ Dictionary with connection information
40
+ """
41
+ hostname = str(device.primary_ip.address.ip) if device.primary_ip else device.name
42
+ platform = str(device.platform).lower() if device.platform else None
43
+
44
+ info = {
45
+ 'hostname': hostname,
46
+ 'platform': platform,
47
+ 'has_primary_ip': device.primary_ip is not None,
48
+ }
49
+
50
+ return info
51
+
52
+ @staticmethod
53
+ def validate_device_for_commands(device: Device) -> tuple[bool, Optional[str], dict]:
54
+ """
55
+ Validate if a device is ready for command execution.
56
+
57
+ Args:
58
+ device: The device to validate
59
+
60
+ Returns:
61
+ Tuple of (is_valid, error_message, validation_checks)
62
+ validation_checks is a dict with check names as keys and booleans as values
63
+ """
64
+ # Initialize validation checks
65
+ validation_checks = {
66
+ 'has_platform': device.platform is not None,
67
+ 'has_primary_ip': device.primary_ip is not None,
68
+ 'has_hostname': bool(device.name), # Check if device has a name
69
+ 'platform_supported': False, # Will be set below if platform exists
70
+ }
71
+
72
+ # Additional logic for more complex checks
73
+ if validation_checks['has_platform']:
74
+ validation_checks['platform_supported'] = True # If platform exists, we consider it supported
75
+
76
+ # Determine overall validity and error message
77
+ is_valid = True
78
+ error_message = None
79
+
80
+ if not validation_checks['has_platform']:
81
+ is_valid = False
82
+ error_message = "Device has no platform assigned"
83
+ elif not validation_checks['has_primary_ip'] and not validation_checks['has_hostname']:
84
+ is_valid = False
85
+ error_message = "Device has no primary IP address or hostname"
86
+
87
+ return is_valid, error_message, validation_checks
@@ -0,0 +1,228 @@
1
+ from datetime import datetime, timedelta
2
+ from django.utils import timezone
3
+ from django.conf import settings
4
+ from django.contrib.auth.models import User, Group
5
+ from ..models import CommandLog
6
+
7
+
8
+ class RateLimitingService:
9
+ """Service for managing command execution rate limiting"""
10
+
11
+ def __init__(self):
12
+ """Initialize the rate limiting service with plugin settings"""
13
+ self.plugin_settings = getattr(settings, 'PLUGINS_CONFIG', {}).get('netbox_toolkit', {})
14
+
15
+ def is_rate_limiting_enabled(self):
16
+ """Check if rate limiting is enabled in plugin settings"""
17
+ return self.plugin_settings.get('rate_limiting_enabled', False)
18
+
19
+ def get_device_command_limit(self):
20
+ """Get the maximum number of commands allowed per device within the time window"""
21
+ return self.plugin_settings.get('device_command_limit', 10)
22
+
23
+ def get_time_window_minutes(self):
24
+ """Get the time window in minutes for rate limiting"""
25
+ return self.plugin_settings.get('time_window_minutes', 5)
26
+
27
+ def get_bypass_users(self):
28
+ """Get list of usernames that bypass rate limiting"""
29
+ return self.plugin_settings.get('bypass_users', [])
30
+
31
+ def get_bypass_groups(self):
32
+ """Get list of group names that bypass rate limiting"""
33
+ return self.plugin_settings.get('bypass_groups', [])
34
+
35
+ def user_bypasses_rate_limiting(self, user):
36
+ """
37
+ Check if a user bypasses rate limiting based on username or group membership
38
+
39
+ Args:
40
+ user: Django User object
41
+
42
+ Returns:
43
+ bool: True if user bypasses rate limiting, False otherwise
44
+ """
45
+ # Check if user is in bypass users list
46
+ bypass_users = self.get_bypass_users()
47
+ if user.username in bypass_users:
48
+ return True
49
+
50
+ # Check if user is in any bypass groups
51
+ bypass_groups = self.get_bypass_groups()
52
+ if bypass_groups:
53
+ user_groups = user.groups.values_list('name', flat=True)
54
+ if any(group in bypass_groups for group in user_groups):
55
+ return True
56
+
57
+ return False
58
+
59
+ def get_recent_command_count(self, device, user=None):
60
+ """
61
+ Get the number of successful commands executed on a device within the time window
62
+
63
+ Args:
64
+ device: Device object
65
+ user: Optional User object to count only commands by this user
66
+
67
+ Returns:
68
+ int: Number of recent successful commands
69
+ """
70
+ time_window = self.get_time_window_minutes()
71
+ cutoff_time = timezone.now() - timedelta(minutes=time_window)
72
+
73
+ query = CommandLog.objects.filter(
74
+ device=device,
75
+ execution_time__gte=cutoff_time,
76
+ success=True # Only count successful commands
77
+ )
78
+
79
+ if user:
80
+ query = query.filter(username=user.username)
81
+
82
+ return query.count()
83
+
84
+ def check_rate_limit(self, device, user):
85
+ """
86
+ Check if a command execution would exceed rate limits
87
+
88
+ Args:
89
+ device: Device object to check rate limits for
90
+ user: User object attempting to execute the command
91
+
92
+ Returns:
93
+ dict: {
94
+ 'allowed': bool,
95
+ 'current_count': int,
96
+ 'limit': int,
97
+ 'time_window_minutes': int,
98
+ 'reason': str (if not allowed)
99
+ }
100
+ """
101
+ # If rate limiting is disabled, always allow
102
+ if not self.is_rate_limiting_enabled():
103
+ return {
104
+ 'allowed': True,
105
+ 'current_count': 0,
106
+ 'limit': self.get_device_command_limit(),
107
+ 'time_window_minutes': self.get_time_window_minutes(),
108
+ 'reason': 'Rate limiting disabled'
109
+ }
110
+
111
+ # If user bypasses rate limiting, always allow
112
+ if self.user_bypasses_rate_limiting(user):
113
+ return {
114
+ 'allowed': True,
115
+ 'current_count': 0,
116
+ 'limit': self.get_device_command_limit(),
117
+ 'time_window_minutes': self.get_time_window_minutes(),
118
+ 'reason': 'User bypasses rate limiting'
119
+ }
120
+
121
+ # Check current command count
122
+ current_count = self.get_recent_command_count(device)
123
+ limit = self.get_device_command_limit()
124
+ time_window = self.get_time_window_minutes()
125
+
126
+ if current_count >= limit:
127
+ return {
128
+ 'allowed': False,
129
+ 'current_count': current_count,
130
+ 'limit': limit,
131
+ 'time_window_minutes': time_window,
132
+ 'reason': f'Rate limit exceeded: {current_count}/{limit} successful commands in last {time_window} minutes'
133
+ }
134
+
135
+ return {
136
+ 'allowed': True,
137
+ 'current_count': current_count,
138
+ 'limit': limit,
139
+ 'time_window_minutes': time_window,
140
+ 'reason': 'Within rate limits'
141
+ }
142
+
143
+ def get_rate_limit_status(self, device, user):
144
+ """
145
+ Get rate limit status for display in UI
146
+
147
+ Args:
148
+ device: Device object
149
+ user: User object
150
+
151
+ Returns:
152
+ dict: Rate limit status information for UI display
153
+ """
154
+ if not self.is_rate_limiting_enabled():
155
+ return {
156
+ 'enabled': False,
157
+ 'message': 'Rate limiting is disabled'
158
+ }
159
+
160
+ if self.user_bypasses_rate_limiting(user):
161
+ return {
162
+ 'enabled': True,
163
+ 'bypassed': True,
164
+ 'message': 'You have unlimited command execution (bypass enabled)'
165
+ }
166
+
167
+ current_count = self.get_recent_command_count(device)
168
+ limit = self.get_device_command_limit()
169
+ time_window = self.get_time_window_minutes()
170
+ remaining = max(0, limit - current_count)
171
+
172
+ # Determine status and appropriate message
173
+ if current_count >= limit:
174
+ status = 'exceeded'
175
+ # Get time until reset for exceeded status
176
+ time_until_reset = self.get_time_until_reset(device)
177
+ if time_until_reset:
178
+ minutes_until_reset = int(time_until_reset.total_seconds() / 60) + 1
179
+ message = f'Rate limit exceeded! ({current_count}/{limit} successful commands) - Try again in {minutes_until_reset} minutes'
180
+ else:
181
+ message = f'Rate limit exceeded! ({current_count}/{limit} successful commands in last {time_window} minutes)'
182
+ elif remaining <= 2 and current_count < limit: # Only warning if we haven't exceeded the limit
183
+ status = 'warning'
184
+ message = f'{remaining} commands remaining ({current_count}/{limit} successful commands in last {time_window} minutes)'
185
+ else:
186
+ status = 'normal'
187
+ message = f'{remaining} commands remaining ({current_count}/{limit} successful commands in last {time_window} minutes)'
188
+
189
+ return {
190
+ 'enabled': True,
191
+ 'bypassed': False,
192
+ 'current_count': current_count,
193
+ 'limit': limit,
194
+ 'remaining': remaining,
195
+ 'time_window_minutes': time_window,
196
+ 'status': status,
197
+ 'message': message,
198
+ 'is_exceeded': current_count >= limit,
199
+ 'is_warning': remaining <= 2 and current_count < limit,
200
+ 'time_until_reset': self.get_time_until_reset(device) if current_count >= limit else None,
201
+ }
202
+
203
+ def get_time_until_reset(self, device):
204
+ """
205
+ Get the time until the rate limit resets (oldest successful command in window expires)
206
+
207
+ Args:
208
+ device: Device object
209
+
210
+ Returns:
211
+ timedelta or None: Time until oldest successful command expires, None if no recent successful commands
212
+ """
213
+ time_window = self.get_time_window_minutes()
214
+ cutoff_time = timezone.now() - timedelta(minutes=time_window)
215
+
216
+ oldest_command = CommandLog.objects.filter(
217
+ device=device,
218
+ execution_time__gte=cutoff_time,
219
+ success=True # Only consider successful commands
220
+ ).order_by('execution_time').first()
221
+
222
+ if not oldest_command:
223
+ return None
224
+
225
+ reset_time = oldest_command.execution_time + timedelta(minutes=time_window)
226
+ time_until_reset = reset_time - timezone.now()
227
+
228
+ return time_until_reset if time_until_reset > timedelta(0) else None