netbox-toolkit-plugin 0.1.2__py3-none-any.whl → 0.1.3__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.
- netbox_toolkit_plugin/__init__.py +1 -1
- netbox_toolkit_plugin/admin.py +11 -7
- netbox_toolkit_plugin/api/mixins.py +20 -16
- netbox_toolkit_plugin/api/schemas.py +53 -74
- netbox_toolkit_plugin/api/serializers.py +10 -11
- netbox_toolkit_plugin/api/urls.py +2 -1
- netbox_toolkit_plugin/api/views/__init__.py +4 -3
- netbox_toolkit_plugin/api/views/command_logs.py +80 -73
- netbox_toolkit_plugin/api/views/commands.py +140 -134
- netbox_toolkit_plugin/connectors/__init__.py +9 -9
- netbox_toolkit_plugin/connectors/base.py +30 -31
- netbox_toolkit_plugin/connectors/factory.py +21 -25
- netbox_toolkit_plugin/connectors/netmiko_connector.py +18 -28
- netbox_toolkit_plugin/connectors/scrapli_connector.py +17 -16
- netbox_toolkit_plugin/exceptions.py +0 -7
- netbox_toolkit_plugin/filtersets.py +26 -42
- netbox_toolkit_plugin/forms.py +13 -11
- netbox_toolkit_plugin/migrations/0008_remove_parsed_data_storage.py +26 -0
- netbox_toolkit_plugin/models.py +2 -17
- netbox_toolkit_plugin/navigation.py +3 -0
- netbox_toolkit_plugin/search.py +12 -9
- netbox_toolkit_plugin/services/__init__.py +1 -1
- netbox_toolkit_plugin/services/command_service.py +6 -9
- netbox_toolkit_plugin/services/device_service.py +40 -32
- netbox_toolkit_plugin/services/rate_limiting_service.py +4 -3
- netbox_toolkit_plugin/static/netbox_toolkit_plugin/js/toolkit.js +245 -119
- netbox_toolkit_plugin/tables.py +10 -1
- netbox_toolkit_plugin/templates/netbox_toolkit_plugin/commandlog.html +16 -84
- netbox_toolkit_plugin/templates/netbox_toolkit_plugin/device_toolkit.html +37 -33
- netbox_toolkit_plugin/urls.py +10 -3
- netbox_toolkit_plugin/utils/connection.py +54 -54
- netbox_toolkit_plugin/utils/error_parser.py +128 -109
- netbox_toolkit_plugin/utils/logging.py +1 -0
- netbox_toolkit_plugin/utils/network.py +74 -47
- netbox_toolkit_plugin/views.py +51 -22
- {netbox_toolkit_plugin-0.1.2.dist-info → netbox_toolkit_plugin-0.1.3.dist-info}/METADATA +2 -2
- netbox_toolkit_plugin-0.1.3.dist-info/RECORD +61 -0
- netbox_toolkit_plugin-0.1.2.dist-info/RECORD +0 -60
- {netbox_toolkit_plugin-0.1.2.dist-info → netbox_toolkit_plugin-0.1.3.dist-info}/WHEEL +0 -0
- {netbox_toolkit_plugin-0.1.2.dist-info → netbox_toolkit_plugin-0.1.3.dist-info}/entry_points.txt +0 -0
- {netbox_toolkit_plugin-0.1.2.dist-info → netbox_toolkit_plugin-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {netbox_toolkit_plugin-0.1.2.dist-info → netbox_toolkit_plugin-0.1.3.dist-info}/top_level.txt +0 -0
@@ -1,29 +1,33 @@
|
|
1
1
|
"""
|
2
2
|
API ViewSet for CommandLog resources
|
3
3
|
"""
|
4
|
-
|
5
|
-
from rest_framework.decorators import action
|
6
|
-
from rest_framework.response import Response
|
7
|
-
from django.utils import timezone
|
4
|
+
|
8
5
|
from datetime import timedelta
|
6
|
+
|
9
7
|
from django.db.models import Count, Q
|
10
8
|
from django.http import HttpResponse
|
11
|
-
from
|
9
|
+
from django.utils import timezone
|
10
|
+
|
12
11
|
from netbox.api.viewsets import NetBoxModelViewSet
|
13
12
|
|
14
|
-
from
|
15
|
-
from
|
13
|
+
from drf_spectacular.utils import extend_schema_view
|
14
|
+
from rest_framework import status
|
15
|
+
from rest_framework.decorators import action
|
16
|
+
from rest_framework.response import Response
|
17
|
+
|
18
|
+
from ... import filtersets, models
|
16
19
|
from ..mixins import APIResponseMixin
|
17
20
|
from ..schemas import (
|
18
|
-
COMMAND_LOG_LIST_SCHEMA,
|
19
|
-
COMMAND_LOG_RETRIEVE_SCHEMA,
|
20
21
|
COMMAND_LOG_CREATE_SCHEMA,
|
21
|
-
COMMAND_LOG_UPDATE_SCHEMA,
|
22
|
-
COMMAND_LOG_PARTIAL_UPDATE_SCHEMA,
|
23
22
|
COMMAND_LOG_DESTROY_SCHEMA,
|
24
|
-
COMMAND_LOG_STATISTICS_SCHEMA,
|
25
23
|
COMMAND_LOG_EXPORT_SCHEMA,
|
24
|
+
COMMAND_LOG_LIST_SCHEMA,
|
25
|
+
COMMAND_LOG_PARTIAL_UPDATE_SCHEMA,
|
26
|
+
COMMAND_LOG_RETRIEVE_SCHEMA,
|
27
|
+
COMMAND_LOG_STATISTICS_SCHEMA,
|
28
|
+
COMMAND_LOG_UPDATE_SCHEMA,
|
26
29
|
)
|
30
|
+
from ..serializers import CommandLogSerializer
|
27
31
|
|
28
32
|
|
29
33
|
@extend_schema_view(
|
@@ -39,132 +43,135 @@ class CommandLogViewSet(NetBoxModelViewSet, APIResponseMixin):
|
|
39
43
|
serializer_class = CommandLogSerializer
|
40
44
|
filterset_class = filtersets.CommandLogFilterSet
|
41
45
|
# NetBox automatically handles object-based permissions - no need for explicit permission_classes
|
42
|
-
|
46
|
+
|
43
47
|
def get_queryset(self):
|
44
48
|
"""NetBox will automatically filter based on user's ObjectPermissions"""
|
45
49
|
return super().get_queryset()
|
46
|
-
|
50
|
+
|
47
51
|
@COMMAND_LOG_STATISTICS_SCHEMA
|
48
|
-
@action(detail=False, methods=[
|
52
|
+
@action(detail=False, methods=["get"], url_path="statistics")
|
49
53
|
def statistics(self, request):
|
50
54
|
"""Get command execution statistics"""
|
51
55
|
queryset = self.get_queryset()
|
52
|
-
|
56
|
+
|
53
57
|
# Basic stats
|
54
58
|
total_logs = queryset.count()
|
55
59
|
successful_logs = queryset.filter(success=True).count()
|
56
60
|
success_rate = (successful_logs / total_logs * 100) if total_logs > 0 else 0
|
57
|
-
|
61
|
+
|
58
62
|
# Last 24 hours stats
|
59
63
|
last_24h = timezone.now() - timedelta(hours=24)
|
60
64
|
recent_logs = queryset.filter(created__gte=last_24h)
|
61
65
|
recent_total = recent_logs.count()
|
62
66
|
recent_successful = recent_logs.filter(success=True).count()
|
63
67
|
recent_failed = recent_total - recent_successful
|
64
|
-
|
68
|
+
|
65
69
|
# Top commands
|
66
70
|
top_commands = (
|
67
|
-
queryset.values(
|
68
|
-
.annotate(count=Count(
|
69
|
-
.order_by(
|
71
|
+
queryset.values("command__name")
|
72
|
+
.annotate(count=Count("command"))
|
73
|
+
.order_by("-count")[:10]
|
70
74
|
)
|
71
|
-
|
75
|
+
|
72
76
|
# Common errors (non-empty error messages)
|
73
77
|
common_errors = (
|
74
|
-
queryset.filter(~Q(error_message=
|
75
|
-
.values(
|
76
|
-
.annotate(count=Count(
|
77
|
-
.order_by(
|
78
|
+
queryset.filter(~Q(error_message=""), ~Q(error_message__isnull=True))
|
79
|
+
.values("error_message")
|
80
|
+
.annotate(count=Count("error_message"))
|
81
|
+
.order_by("-count")[:10]
|
78
82
|
)
|
79
|
-
|
83
|
+
|
80
84
|
return Response({
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
85
|
+
"total_logs": total_logs,
|
86
|
+
"success_rate": round(success_rate, 2),
|
87
|
+
"last_24h": {
|
88
|
+
"total": recent_total,
|
89
|
+
"successful": recent_successful,
|
90
|
+
"failed": recent_failed,
|
87
91
|
},
|
88
|
-
|
89
|
-
{
|
92
|
+
"top_commands": [
|
93
|
+
{"command_name": item["command__name"], "count": item["count"]}
|
90
94
|
for item in top_commands
|
91
95
|
],
|
92
|
-
|
93
|
-
{
|
96
|
+
"common_errors": [
|
97
|
+
{"error": item["error_message"][:100], "count": item["count"]}
|
94
98
|
for item in common_errors
|
95
|
-
]
|
99
|
+
],
|
96
100
|
})
|
97
|
-
|
101
|
+
|
98
102
|
@COMMAND_LOG_EXPORT_SCHEMA
|
99
|
-
@action(detail=False, methods=[
|
103
|
+
@action(detail=False, methods=["get"], url_path="export")
|
100
104
|
def export(self, request):
|
101
105
|
"""Export command logs"""
|
102
106
|
import csv
|
103
107
|
from datetime import datetime
|
104
|
-
|
105
|
-
export_format = request.query_params.get(
|
106
|
-
start_date = request.query_params.get(
|
107
|
-
end_date = request.query_params.get(
|
108
|
-
|
108
|
+
|
109
|
+
export_format = request.query_params.get("format", "json")
|
110
|
+
start_date = request.query_params.get("start_date")
|
111
|
+
end_date = request.query_params.get("end_date")
|
112
|
+
|
109
113
|
queryset = self.get_queryset()
|
110
|
-
|
114
|
+
|
111
115
|
# Apply date filters if provided
|
112
116
|
if start_date:
|
113
117
|
try:
|
114
|
-
start_date = datetime.strptime(start_date,
|
118
|
+
start_date = datetime.strptime(start_date, "%Y-%m-%d").date()
|
115
119
|
queryset = queryset.filter(created__date__gte=start_date)
|
116
120
|
except ValueError:
|
117
121
|
return Response(
|
118
|
-
{
|
119
|
-
status=status.HTTP_400_BAD_REQUEST
|
122
|
+
{"error": "Invalid start_date format. Use YYYY-MM-DD."},
|
123
|
+
status=status.HTTP_400_BAD_REQUEST,
|
120
124
|
)
|
121
|
-
|
125
|
+
|
122
126
|
if end_date:
|
123
127
|
try:
|
124
|
-
end_date = datetime.strptime(end_date,
|
128
|
+
end_date = datetime.strptime(end_date, "%Y-%m-%d").date()
|
125
129
|
queryset = queryset.filter(created__date__lte=end_date)
|
126
130
|
except ValueError:
|
127
131
|
return Response(
|
128
|
-
{
|
129
|
-
status=status.HTTP_400_BAD_REQUEST
|
132
|
+
{"error": "Invalid end_date format. Use YYYY-MM-DD."},
|
133
|
+
status=status.HTTP_400_BAD_REQUEST,
|
130
134
|
)
|
131
|
-
|
135
|
+
|
132
136
|
# Limit export size for performance
|
133
137
|
if queryset.count() > 10000:
|
134
138
|
return Response(
|
135
|
-
{
|
136
|
-
status=status.HTTP_400_BAD_REQUEST
|
139
|
+
{"error": "Export too large. Please use date filters to reduce size."},
|
140
|
+
status=status.HTTP_400_BAD_REQUEST,
|
137
141
|
)
|
138
|
-
|
139
|
-
if export_format ==
|
142
|
+
|
143
|
+
if export_format == "csv":
|
140
144
|
# CSV export
|
141
|
-
response = HttpResponse(content_type=
|
142
|
-
response[
|
143
|
-
|
145
|
+
response = HttpResponse(content_type="text/csv")
|
146
|
+
response["Content-Disposition"] = 'attachment; filename="command_logs.csv"'
|
147
|
+
|
144
148
|
writer = csv.writer(response)
|
145
149
|
writer.writerow([
|
146
|
-
|
147
|
-
|
150
|
+
"ID",
|
151
|
+
"Command",
|
152
|
+
"Device",
|
153
|
+
"User",
|
154
|
+
"Success",
|
155
|
+
"Created",
|
156
|
+
"Execution Time",
|
157
|
+
"Error Message",
|
148
158
|
])
|
149
|
-
|
150
|
-
for log in queryset.select_related(
|
159
|
+
|
160
|
+
for log in queryset.select_related("command", "device", "user"):
|
151
161
|
writer.writerow([
|
152
162
|
log.id,
|
153
163
|
log.command.name,
|
154
164
|
log.device.name,
|
155
|
-
log.user.username if log.user else
|
165
|
+
log.user.username if log.user else "Unknown",
|
156
166
|
log.success,
|
157
167
|
log.created.isoformat(),
|
158
168
|
log.execution_time,
|
159
|
-
log.error_message or
|
169
|
+
log.error_message or "",
|
160
170
|
])
|
161
|
-
|
171
|
+
|
162
172
|
return response
|
163
|
-
|
173
|
+
|
164
174
|
else:
|
165
175
|
# JSON export
|
166
176
|
serializer = self.get_serializer(queryset, many=True)
|
167
|
-
return Response({
|
168
|
-
'count': queryset.count(),
|
169
|
-
'results': serializer.data
|
170
|
-
})
|
177
|
+
return Response({"count": queryset.count(), "results": serializer.data})
|
@@ -1,29 +1,32 @@
|
|
1
1
|
"""
|
2
2
|
API ViewSet for Command resources
|
3
3
|
"""
|
4
|
-
|
5
|
-
from rest_framework.decorators import action
|
6
|
-
from rest_framework.response import Response
|
4
|
+
|
7
5
|
from django.db import transaction
|
8
|
-
|
6
|
+
|
9
7
|
from dcim.models import Device
|
10
8
|
from netbox.api.viewsets import NetBoxModelViewSet
|
11
9
|
|
12
|
-
from
|
10
|
+
from drf_spectacular.utils import extend_schema_view
|
11
|
+
from rest_framework import status
|
12
|
+
from rest_framework.decorators import action
|
13
|
+
from rest_framework.response import Response
|
14
|
+
|
15
|
+
from ... import filtersets, models
|
13
16
|
from ...services.command_service import CommandExecutionService
|
14
17
|
from ...services.rate_limiting_service import RateLimitingService
|
15
|
-
from ..serializers import CommandSerializer, CommandExecutionSerializer
|
16
18
|
from ..mixins import APIResponseMixin, PermissionCheckMixin
|
17
19
|
from ..schemas import (
|
18
|
-
|
19
|
-
COMMAND_RETRIEVE_SCHEMA,
|
20
|
+
COMMAND_BULK_EXECUTE_SCHEMA,
|
20
21
|
COMMAND_CREATE_SCHEMA,
|
21
|
-
COMMAND_UPDATE_SCHEMA,
|
22
|
-
COMMAND_PARTIAL_UPDATE_SCHEMA,
|
23
22
|
COMMAND_DESTROY_SCHEMA,
|
24
23
|
COMMAND_EXECUTE_SCHEMA,
|
25
|
-
|
24
|
+
COMMAND_LIST_SCHEMA,
|
25
|
+
COMMAND_PARTIAL_UPDATE_SCHEMA,
|
26
|
+
COMMAND_RETRIEVE_SCHEMA,
|
27
|
+
COMMAND_UPDATE_SCHEMA,
|
26
28
|
)
|
29
|
+
from ..serializers import CommandExecutionSerializer, CommandSerializer
|
27
30
|
|
28
31
|
|
29
32
|
@extend_schema_view(
|
@@ -40,193 +43,197 @@ class CommandViewSet(NetBoxModelViewSet, APIResponseMixin, PermissionCheckMixin)
|
|
40
43
|
filterset_class = filtersets.CommandFilterSet
|
41
44
|
# Using custom RateLimitingService instead of generic API throttling
|
42
45
|
# NetBox automatically handles object-based permissions - no need for explicit permission_classes
|
43
|
-
|
46
|
+
|
44
47
|
def get_queryset(self):
|
45
48
|
"""NetBox will automatically filter based on user's ObjectPermissions"""
|
46
49
|
# NetBox's object-based permission system will automatically filter this queryset
|
47
50
|
# based on the user's ObjectPermissions for 'view' action on Command objects
|
48
51
|
return super().get_queryset()
|
49
|
-
|
52
|
+
|
50
53
|
@COMMAND_EXECUTE_SCHEMA
|
51
|
-
@action(detail=True, methods=[
|
54
|
+
@action(detail=True, methods=["post"], url_path="execute")
|
52
55
|
def execute_command(self, request, pk=None):
|
53
56
|
"""Execute a command on a device via API"""
|
54
57
|
command = self.get_object()
|
55
|
-
|
58
|
+
|
56
59
|
# Validate input using serializer
|
57
60
|
execution_serializer = CommandExecutionSerializer(data=request.data)
|
58
61
|
if not execution_serializer.is_valid():
|
59
62
|
return Response(
|
60
|
-
{
|
61
|
-
|
62
|
-
'details': execution_serializer.errors
|
63
|
-
},
|
64
|
-
status=status.HTTP_400_BAD_REQUEST
|
63
|
+
{"error": "Invalid input data", "details": execution_serializer.errors},
|
64
|
+
status=status.HTTP_400_BAD_REQUEST,
|
65
65
|
)
|
66
|
-
|
66
|
+
|
67
67
|
validated_data = execution_serializer.validated_data
|
68
|
-
device_id = validated_data[
|
69
|
-
username = validated_data[
|
70
|
-
password = validated_data[
|
71
|
-
|
68
|
+
device_id = validated_data["device_id"]
|
69
|
+
username = validated_data["username"]
|
70
|
+
password = validated_data["password"]
|
71
|
+
|
72
72
|
# Get device object
|
73
73
|
try:
|
74
74
|
device = Device.objects.get(id=device_id)
|
75
75
|
except Device.DoesNotExist:
|
76
76
|
return Response(
|
77
|
-
{
|
78
|
-
status=status.HTTP_404_NOT_FOUND
|
77
|
+
{"error": f"Device with ID {device_id} not found"},
|
78
|
+
status=status.HTTP_404_NOT_FOUND,
|
79
79
|
)
|
80
|
-
|
80
|
+
|
81
81
|
# Check permissions based on command type using NetBox's object-based permissions
|
82
|
-
if command.command_type ==
|
83
|
-
if not self._user_has_action_permission(
|
84
|
-
|
85
|
-
|
86
|
-
status=status.HTTP_403_FORBIDDEN
|
87
|
-
)
|
88
|
-
elif command.command_type == 'show':
|
89
|
-
if not self._user_has_action_permission(request.user, command, 'execute_show'):
|
82
|
+
if command.command_type == "config":
|
83
|
+
if not self._user_has_action_permission(
|
84
|
+
request.user, command, "execute_config"
|
85
|
+
):
|
90
86
|
return Response(
|
91
|
-
{
|
92
|
-
|
87
|
+
{
|
88
|
+
"error": "You do not have permission to execute configuration commands"
|
89
|
+
},
|
90
|
+
status=status.HTTP_403_FORBIDDEN,
|
93
91
|
)
|
94
|
-
|
92
|
+
elif command.command_type == "show" and not self._user_has_action_permission(
|
93
|
+
request.user, command, "execute_show"
|
94
|
+
):
|
95
|
+
return Response(
|
96
|
+
{"error": "You do not have permission to execute show commands"},
|
97
|
+
status=status.HTTP_403_FORBIDDEN,
|
98
|
+
)
|
99
|
+
|
95
100
|
# Check custom rate limiting (device-specific with bypass rules)
|
96
101
|
rate_limiting_service = RateLimitingService()
|
97
102
|
rate_limit_check = rate_limiting_service.check_rate_limit(device, request.user)
|
98
|
-
|
99
|
-
if not rate_limit_check[
|
103
|
+
|
104
|
+
if not rate_limit_check["allowed"]:
|
100
105
|
return Response(
|
101
106
|
{
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
}
|
107
|
+
"error": "Rate limit exceeded",
|
108
|
+
"details": {
|
109
|
+
"reason": rate_limit_check["reason"],
|
110
|
+
"current_count": rate_limit_check["current_count"],
|
111
|
+
"limit": rate_limit_check["limit"],
|
112
|
+
"time_window_minutes": rate_limit_check["time_window_minutes"],
|
113
|
+
},
|
109
114
|
},
|
110
|
-
status=status.HTTP_429_TOO_MANY_REQUESTS
|
115
|
+
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
111
116
|
)
|
112
|
-
|
117
|
+
|
113
118
|
# Execute command using the service
|
114
119
|
command_service = CommandExecutionService()
|
115
120
|
result = command_service.execute_command_with_retry(
|
116
121
|
command, device, username, password, max_retries=1
|
117
122
|
)
|
118
|
-
|
123
|
+
|
119
124
|
# Determine overall success - failed if either execution failed or syntax error detected
|
120
125
|
overall_success = result.success and not result.has_syntax_error
|
121
|
-
|
126
|
+
|
122
127
|
# Prepare response data
|
123
128
|
response_data = {
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
129
|
+
"success": overall_success,
|
130
|
+
"output": result.output,
|
131
|
+
"error_message": result.error_message,
|
132
|
+
"execution_time": result.execution_time,
|
133
|
+
"command": {
|
134
|
+
"id": command.id,
|
135
|
+
"name": command.name,
|
136
|
+
"command_type": command.command_type,
|
132
137
|
},
|
133
|
-
|
134
|
-
'id': device.id,
|
135
|
-
'name': device.name
|
136
|
-
}
|
138
|
+
"device": {"id": device.id, "name": device.name},
|
137
139
|
}
|
138
|
-
|
140
|
+
|
139
141
|
# Add syntax error information if detected
|
140
142
|
if result.has_syntax_error:
|
141
|
-
response_data[
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
143
|
+
response_data["syntax_error"] = {
|
144
|
+
"detected": True,
|
145
|
+
"type": result.syntax_error_type,
|
146
|
+
"vendor": result.syntax_error_vendor,
|
147
|
+
"guidance_provided": True,
|
146
148
|
}
|
147
149
|
else:
|
148
|
-
response_data[
|
149
|
-
|
150
|
-
}
|
151
|
-
|
150
|
+
response_data["syntax_error"] = {"detected": False}
|
151
|
+
|
152
152
|
# Add parsing information if available
|
153
153
|
if result.parsing_success and result.parsed_output:
|
154
|
-
response_data[
|
155
|
-
|
156
|
-
|
157
|
-
|
154
|
+
response_data["parsed_output"] = {
|
155
|
+
"success": True,
|
156
|
+
"method": result.parsing_method,
|
157
|
+
"data": result.parsed_output,
|
158
158
|
}
|
159
159
|
else:
|
160
|
-
response_data[
|
161
|
-
|
162
|
-
|
163
|
-
|
160
|
+
response_data["parsed_output"] = {
|
161
|
+
"success": False,
|
162
|
+
"method": None,
|
163
|
+
"error": result.parsing_error,
|
164
164
|
}
|
165
|
-
|
165
|
+
|
166
166
|
# Return appropriate status code
|
167
|
-
status_code =
|
168
|
-
|
167
|
+
status_code = (
|
168
|
+
status.HTTP_200_OK if overall_success else status.HTTP_400_BAD_REQUEST
|
169
|
+
)
|
170
|
+
|
169
171
|
return Response(response_data, status=status_code)
|
170
|
-
|
172
|
+
|
171
173
|
@COMMAND_BULK_EXECUTE_SCHEMA
|
172
|
-
@action(detail=False, methods=[
|
174
|
+
@action(detail=False, methods=["post"], url_path="bulk-execute")
|
173
175
|
def bulk_execute(self, request):
|
174
176
|
"""Execute multiple commands on multiple devices"""
|
175
|
-
executions = request.data.get(
|
176
|
-
|
177
|
+
executions = request.data.get("executions", [])
|
178
|
+
|
177
179
|
if not executions:
|
178
180
|
return Response(
|
179
|
-
{
|
180
|
-
status=status.HTTP_400_BAD_REQUEST
|
181
|
+
{"error": "No executions provided"}, status=status.HTTP_400_BAD_REQUEST
|
181
182
|
)
|
182
|
-
|
183
|
+
|
183
184
|
results = []
|
184
|
-
|
185
|
+
|
185
186
|
with transaction.atomic():
|
186
187
|
for i, execution_data in enumerate(executions):
|
187
188
|
try:
|
188
189
|
# Validate each execution
|
189
|
-
command_id = execution_data.get(
|
190
|
-
device_id = execution_data.get(
|
191
|
-
username = execution_data.get(
|
192
|
-
password = execution_data.get(
|
193
|
-
|
190
|
+
command_id = execution_data.get("command_id")
|
191
|
+
device_id = execution_data.get("device_id")
|
192
|
+
username = execution_data.get("username")
|
193
|
+
password = execution_data.get("password")
|
194
|
+
|
194
195
|
if not all([command_id, device_id, username, password]):
|
195
196
|
results.append({
|
196
|
-
|
197
|
-
|
198
|
-
|
197
|
+
"execution_id": i + 1,
|
198
|
+
"success": False,
|
199
|
+
"error": "Missing required fields",
|
199
200
|
})
|
200
201
|
continue
|
201
|
-
|
202
|
+
|
202
203
|
# Get command and device objects
|
203
204
|
try:
|
204
205
|
command = models.Command.objects.get(id=command_id)
|
205
206
|
device = Device.objects.get(id=device_id)
|
206
207
|
except (models.Command.DoesNotExist, Device.DoesNotExist) as e:
|
207
208
|
results.append({
|
208
|
-
|
209
|
-
|
210
|
-
|
209
|
+
"execution_id": i + 1,
|
210
|
+
"success": False,
|
211
|
+
"error": f"Object not found: {str(e)}",
|
211
212
|
})
|
212
213
|
continue
|
213
|
-
|
214
|
+
|
214
215
|
# Check permissions
|
215
|
-
action =
|
216
|
-
|
216
|
+
action = (
|
217
|
+
"execute_config"
|
218
|
+
if command.command_type == "config"
|
219
|
+
else "execute_show"
|
220
|
+
)
|
221
|
+
if not self._user_has_action_permission(
|
222
|
+
request.user, command, action
|
223
|
+
):
|
217
224
|
results.append({
|
218
|
-
|
219
|
-
|
220
|
-
|
225
|
+
"execution_id": i + 1,
|
226
|
+
"success": False,
|
227
|
+
"error": "Insufficient permissions",
|
221
228
|
})
|
222
229
|
continue
|
223
|
-
|
230
|
+
|
224
231
|
# Execute command
|
225
232
|
command_service = CommandExecutionService()
|
226
233
|
result = command_service.execute_command_with_retry(
|
227
234
|
command, device, username, password, max_retries=1
|
228
235
|
)
|
229
|
-
|
236
|
+
|
230
237
|
# Create command log entry (this would typically be done by the service)
|
231
238
|
log_entry = models.CommandLog.objects.create(
|
232
239
|
command=command,
|
@@ -235,33 +242,32 @@ class CommandViewSet(NetBoxModelViewSet, APIResponseMixin, PermissionCheckMixin)
|
|
235
242
|
output=result.output,
|
236
243
|
error_message=result.error_message,
|
237
244
|
execution_time=result.execution_time,
|
238
|
-
success=result.success and not result.has_syntax_error
|
245
|
+
success=result.success and not result.has_syntax_error,
|
239
246
|
)
|
240
|
-
|
247
|
+
|
241
248
|
results.append({
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
249
|
+
"execution_id": i + 1,
|
250
|
+
"success": result.success and not result.has_syntax_error,
|
251
|
+
"command_log_id": log_entry.id,
|
252
|
+
"execution_time": result.execution_time,
|
246
253
|
})
|
247
|
-
|
254
|
+
|
248
255
|
except Exception as e:
|
249
256
|
results.append({
|
250
|
-
|
251
|
-
|
252
|
-
|
257
|
+
"execution_id": i + 1,
|
258
|
+
"success": False,
|
259
|
+
"error": f"Unexpected error: {str(e)}",
|
253
260
|
})
|
254
|
-
|
261
|
+
|
255
262
|
# Generate summary
|
256
263
|
total = len(results)
|
257
|
-
successful = sum(1 for r in results if r.get(
|
264
|
+
successful = sum(1 for r in results if r.get("success", False))
|
258
265
|
failed = total - successful
|
259
|
-
|
260
|
-
return Response(
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
}, status=status.HTTP_200_OK)
|
266
|
+
|
267
|
+
return Response(
|
268
|
+
{
|
269
|
+
"results": results,
|
270
|
+
"summary": {"total": total, "successful": successful, "failed": failed},
|
271
|
+
},
|
272
|
+
status=status.HTTP_200_OK,
|
273
|
+
)
|