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.
- netbox_toolkit/__init__.py +30 -0
- netbox_toolkit/admin.py +16 -0
- netbox_toolkit/api/__init__.py +0 -0
- netbox_toolkit/api/mixins.py +54 -0
- netbox_toolkit/api/schemas.py +234 -0
- netbox_toolkit/api/serializers.py +158 -0
- netbox_toolkit/api/urls.py +10 -0
- netbox_toolkit/api/views/__init__.py +10 -0
- netbox_toolkit/api/views/command_logs.py +170 -0
- netbox_toolkit/api/views/commands.py +267 -0
- netbox_toolkit/config.py +159 -0
- netbox_toolkit/connectors/__init__.py +15 -0
- netbox_toolkit/connectors/base.py +97 -0
- netbox_toolkit/connectors/factory.py +301 -0
- netbox_toolkit/connectors/netmiko_connector.py +443 -0
- netbox_toolkit/connectors/scrapli_connector.py +486 -0
- netbox_toolkit/exceptions.py +36 -0
- netbox_toolkit/filtersets.py +85 -0
- netbox_toolkit/forms.py +31 -0
- netbox_toolkit/migrations/0001_initial.py +54 -0
- netbox_toolkit/migrations/0002_alter_command_options_alter_command_unique_together_and_more.py +66 -0
- netbox_toolkit/migrations/0003_permission_system_update.py +48 -0
- netbox_toolkit/migrations/0004_remove_django_permissions.py +77 -0
- netbox_toolkit/migrations/0005_alter_command_options_and_more.py +25 -0
- netbox_toolkit/migrations/0006_commandlog_parsed_data_commandlog_parsing_success_and_more.py +28 -0
- netbox_toolkit/migrations/0007_alter_commandlog_parsing_template.py +18 -0
- netbox_toolkit/migrations/__init__.py +0 -0
- netbox_toolkit/models.py +89 -0
- netbox_toolkit/navigation.py +30 -0
- netbox_toolkit/search.py +21 -0
- netbox_toolkit/services/__init__.py +7 -0
- netbox_toolkit/services/command_service.py +357 -0
- netbox_toolkit/services/device_service.py +87 -0
- netbox_toolkit/services/rate_limiting_service.py +228 -0
- netbox_toolkit/static/netbox_toolkit/css/toolkit.css +143 -0
- netbox_toolkit/static/netbox_toolkit/js/toolkit.js +657 -0
- netbox_toolkit/tables.py +37 -0
- netbox_toolkit/templates/netbox_toolkit/command.html +108 -0
- netbox_toolkit/templates/netbox_toolkit/command_edit.html +10 -0
- netbox_toolkit/templates/netbox_toolkit/command_list.html +12 -0
- netbox_toolkit/templates/netbox_toolkit/commandlog.html +170 -0
- netbox_toolkit/templates/netbox_toolkit/commandlog_list.html +4 -0
- netbox_toolkit/templates/netbox_toolkit/device_toolkit.html +536 -0
- netbox_toolkit/urls.py +22 -0
- netbox_toolkit/utils/__init__.py +1 -0
- netbox_toolkit/utils/connection.py +125 -0
- netbox_toolkit/utils/error_parser.py +428 -0
- netbox_toolkit/utils/logging.py +58 -0
- netbox_toolkit/utils/network.py +157 -0
- netbox_toolkit/views.py +385 -0
- netbox_toolkit_plugin-0.1.0.dist-info/METADATA +76 -0
- netbox_toolkit_plugin-0.1.0.dist-info/RECORD +56 -0
- netbox_toolkit_plugin-0.1.0.dist-info/WHEEL +5 -0
- netbox_toolkit_plugin-0.1.0.dist-info/entry_points.txt +2 -0
- netbox_toolkit_plugin-0.1.0.dist-info/licenses/LICENSE +200 -0
- netbox_toolkit_plugin-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
from netbox.plugins import PluginConfig
|
2
|
+
|
3
|
+
class ToolkitConfig(PluginConfig):
|
4
|
+
name = 'netbox_toolkit_plugin'
|
5
|
+
verbose_name = 'Netbox Command Toolkit Plugin'
|
6
|
+
description = 'NetBox plugin for running pre-defined commands on network devices'
|
7
|
+
version = '0.1.0'
|
8
|
+
author = 'Andy Norwood'
|
9
|
+
base_url = 'toolkit'
|
10
|
+
|
11
|
+
# Database migrations
|
12
|
+
required_settings = []
|
13
|
+
|
14
|
+
# Default plugin settings
|
15
|
+
default_settings = {
|
16
|
+
'rate_limiting_enabled': True,
|
17
|
+
'device_command_limit': 10,
|
18
|
+
'time_window_minutes': 5,
|
19
|
+
'bypass_users': [],
|
20
|
+
'bypass_groups': [],
|
21
|
+
'debug_logging': False, # Enable debug logging for this plugin
|
22
|
+
}
|
23
|
+
|
24
|
+
# Middleware
|
25
|
+
middleware = []
|
26
|
+
|
27
|
+
# Django apps to load when plugin is activated
|
28
|
+
django_apps = []
|
29
|
+
|
30
|
+
config = ToolkitConfig
|
netbox_toolkit/admin.py
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
from django.contrib import admin
|
2
|
+
from netbox.admin import NetBoxModelAdmin
|
3
|
+
from .models import Command, CommandLog
|
4
|
+
|
5
|
+
@admin.register(Command)
|
6
|
+
class CommandAdmin(NetBoxModelAdmin):
|
7
|
+
list_display = ('name', 'platform', 'command_type', 'description')
|
8
|
+
list_filter = ('platform', 'command_type')
|
9
|
+
search_fields = ('name', 'command', 'description')
|
10
|
+
|
11
|
+
@admin.register(CommandLog)
|
12
|
+
class CommandLogAdmin(NetBoxModelAdmin):
|
13
|
+
list_display = ('command', 'device', 'username', 'execution_time')
|
14
|
+
list_filter = ('command', 'device', 'username', 'execution_time')
|
15
|
+
search_fields = ('command__name', 'device__name', 'username', 'output')
|
16
|
+
readonly_fields = ('output', 'execution_time')
|
File without changes
|
@@ -0,0 +1,54 @@
|
|
1
|
+
"""
|
2
|
+
Common mixins and utilities for NetBox Toolkit API views
|
3
|
+
"""
|
4
|
+
from rest_framework.response import Response
|
5
|
+
from django.contrib.contenttypes.models import ContentType
|
6
|
+
from users.models import ObjectPermission
|
7
|
+
|
8
|
+
|
9
|
+
class APIResponseMixin:
|
10
|
+
"""Mixin to add consistent API response headers"""
|
11
|
+
|
12
|
+
def finalize_response(self, request, response, *args, **kwargs):
|
13
|
+
"""Add custom headers to all responses"""
|
14
|
+
response = super().finalize_response(request, response, *args, **kwargs)
|
15
|
+
|
16
|
+
# Add API version header
|
17
|
+
response['X-NetBox-Toolkit-API-Version'] = '1.0'
|
18
|
+
|
19
|
+
return response
|
20
|
+
|
21
|
+
|
22
|
+
class PermissionCheckMixin:
|
23
|
+
"""Mixin for NetBox ObjectPermission checking"""
|
24
|
+
|
25
|
+
def _user_has_action_permission(self, user, obj, action):
|
26
|
+
"""Check if user has permission for a specific action on an object using NetBox's ObjectPermission system"""
|
27
|
+
# Get content type for the object
|
28
|
+
content_type = ContentType.objects.get_for_model(obj)
|
29
|
+
|
30
|
+
# Check if user has any ObjectPermissions with the required action
|
31
|
+
user_permissions = ObjectPermission.objects.filter(
|
32
|
+
object_types__in=[content_type],
|
33
|
+
actions__contains=[action],
|
34
|
+
enabled=True
|
35
|
+
)
|
36
|
+
|
37
|
+
# Check if user is assigned to any groups with this permission
|
38
|
+
user_groups = user.groups.all()
|
39
|
+
for permission in user_permissions:
|
40
|
+
# Check if permission applies to user or user's groups
|
41
|
+
if (permission.users.filter(id=user.id).exists() or
|
42
|
+
permission.groups.filter(id__in=user_groups.values_list('id', flat=True)).exists()):
|
43
|
+
|
44
|
+
# If there are constraints, evaluate them
|
45
|
+
if permission.constraints:
|
46
|
+
# Create a queryset with the constraints and check if the object matches
|
47
|
+
queryset = content_type.model_class().objects.filter(**permission.constraints)
|
48
|
+
if queryset.filter(id=obj.id).exists():
|
49
|
+
return True
|
50
|
+
else:
|
51
|
+
# No constraints means permission applies to all objects of this type
|
52
|
+
return True
|
53
|
+
|
54
|
+
return False
|
@@ -0,0 +1,234 @@
|
|
1
|
+
"""
|
2
|
+
OpenAPI schema definitions for NetBox Toolkit API
|
3
|
+
"""
|
4
|
+
from drf_spectacular.utils import extend_schema, OpenApiResponse
|
5
|
+
|
6
|
+
|
7
|
+
# Command ViewSet Schemas
|
8
|
+
COMMAND_LIST_SCHEMA = extend_schema(
|
9
|
+
summary="List commands",
|
10
|
+
description="Retrieve a list of all commands available in the system.",
|
11
|
+
tags=["Commands"]
|
12
|
+
)
|
13
|
+
|
14
|
+
COMMAND_RETRIEVE_SCHEMA = extend_schema(
|
15
|
+
summary="Retrieve command",
|
16
|
+
description="Retrieve details of a specific command.",
|
17
|
+
tags=["Commands"]
|
18
|
+
)
|
19
|
+
|
20
|
+
COMMAND_CREATE_SCHEMA = extend_schema(
|
21
|
+
summary="Create command",
|
22
|
+
description="Create a new command.",
|
23
|
+
tags=["Commands"]
|
24
|
+
)
|
25
|
+
|
26
|
+
COMMAND_UPDATE_SCHEMA = extend_schema(
|
27
|
+
summary="Update command",
|
28
|
+
description="Update an existing command.",
|
29
|
+
tags=["Commands"]
|
30
|
+
)
|
31
|
+
|
32
|
+
COMMAND_PARTIAL_UPDATE_SCHEMA = extend_schema(
|
33
|
+
summary="Partial update command",
|
34
|
+
description="Partially update an existing command.",
|
35
|
+
tags=["Commands"]
|
36
|
+
)
|
37
|
+
|
38
|
+
COMMAND_DESTROY_SCHEMA = extend_schema(
|
39
|
+
summary="Delete command",
|
40
|
+
description="Delete a command.",
|
41
|
+
tags=["Commands"]
|
42
|
+
)
|
43
|
+
|
44
|
+
COMMAND_EXECUTE_SCHEMA = extend_schema(
|
45
|
+
summary="Execute command on device",
|
46
|
+
description="Execute a specific command on a target device with authentication credentials.",
|
47
|
+
tags=["Commands"],
|
48
|
+
responses={
|
49
|
+
200: OpenApiResponse(
|
50
|
+
description="Command executed successfully",
|
51
|
+
examples=[
|
52
|
+
{
|
53
|
+
"success": True,
|
54
|
+
"output": "interface status output...",
|
55
|
+
"error_message": None,
|
56
|
+
"execution_time": 1.23,
|
57
|
+
"command": {
|
58
|
+
"id": 1,
|
59
|
+
"name": "show interfaces",
|
60
|
+
"command_type": "show"
|
61
|
+
},
|
62
|
+
"device": {
|
63
|
+
"id": 1,
|
64
|
+
"name": "switch01.example.com"
|
65
|
+
},
|
66
|
+
"syntax_error": {
|
67
|
+
"detected": False
|
68
|
+
},
|
69
|
+
"parsed_output": {
|
70
|
+
"success": True,
|
71
|
+
"method": "textfsm",
|
72
|
+
"data": [{"interface": "GigabitEthernet1/0/1", "status": "up"}]
|
73
|
+
}
|
74
|
+
}
|
75
|
+
]
|
76
|
+
),
|
77
|
+
400: OpenApiResponse(
|
78
|
+
description="Bad request - validation errors or command execution failed"
|
79
|
+
),
|
80
|
+
403: OpenApiResponse(
|
81
|
+
description="Forbidden - insufficient permissions"
|
82
|
+
),
|
83
|
+
404: OpenApiResponse(
|
84
|
+
description="Not found - command or device not found"
|
85
|
+
),
|
86
|
+
429: OpenApiResponse(
|
87
|
+
description="Too many requests - rate limit exceeded"
|
88
|
+
)
|
89
|
+
}
|
90
|
+
)
|
91
|
+
|
92
|
+
COMMAND_BULK_EXECUTE_SCHEMA = extend_schema(
|
93
|
+
summary="Bulk execute commands",
|
94
|
+
description="Execute multiple commands on multiple devices in a single API call.",
|
95
|
+
tags=["Commands"],
|
96
|
+
request={
|
97
|
+
"type": "object",
|
98
|
+
"properties": {
|
99
|
+
"executions": {
|
100
|
+
"type": "array",
|
101
|
+
"items": {
|
102
|
+
"type": "object",
|
103
|
+
"properties": {
|
104
|
+
"command_id": {"type": "integer"},
|
105
|
+
"device_id": {"type": "integer"},
|
106
|
+
"username": {"type": "string"},
|
107
|
+
"password": {"type": "string", "format": "password"}
|
108
|
+
},
|
109
|
+
"required": ["command_id", "device_id", "username", "password"]
|
110
|
+
}
|
111
|
+
}
|
112
|
+
},
|
113
|
+
"required": ["executions"]
|
114
|
+
},
|
115
|
+
responses={
|
116
|
+
200: OpenApiResponse(
|
117
|
+
description="Bulk execution completed",
|
118
|
+
examples=[
|
119
|
+
{
|
120
|
+
"results": [
|
121
|
+
{"execution_id": 1, "success": True, "command_log_id": 123},
|
122
|
+
{"execution_id": 2, "success": False, "error": "Permission denied"}
|
123
|
+
],
|
124
|
+
"summary": {
|
125
|
+
"total": 2,
|
126
|
+
"successful": 1,
|
127
|
+
"failed": 1
|
128
|
+
}
|
129
|
+
}
|
130
|
+
]
|
131
|
+
)
|
132
|
+
}
|
133
|
+
)
|
134
|
+
|
135
|
+
# Command Log ViewSet Schemas
|
136
|
+
COMMAND_LOG_LIST_SCHEMA = extend_schema(
|
137
|
+
summary="List command logs",
|
138
|
+
description="Retrieve a list of command execution logs with filtering and search capabilities.",
|
139
|
+
tags=["Command Logs"]
|
140
|
+
)
|
141
|
+
|
142
|
+
COMMAND_LOG_RETRIEVE_SCHEMA = extend_schema(
|
143
|
+
summary="Retrieve command log",
|
144
|
+
description="Retrieve details of a specific command execution log.",
|
145
|
+
tags=["Command Logs"]
|
146
|
+
)
|
147
|
+
|
148
|
+
COMMAND_LOG_CREATE_SCHEMA = extend_schema(
|
149
|
+
summary="Create command log",
|
150
|
+
description="Create a new command execution log entry.",
|
151
|
+
tags=["Command Logs"]
|
152
|
+
)
|
153
|
+
|
154
|
+
COMMAND_LOG_UPDATE_SCHEMA = extend_schema(
|
155
|
+
summary="Update command log",
|
156
|
+
description="Update an existing command log entry.",
|
157
|
+
tags=["Command Logs"]
|
158
|
+
)
|
159
|
+
|
160
|
+
COMMAND_LOG_PARTIAL_UPDATE_SCHEMA = extend_schema(
|
161
|
+
summary="Partial update command log",
|
162
|
+
description="Partially update an existing command log entry.",
|
163
|
+
tags=["Command Logs"]
|
164
|
+
)
|
165
|
+
|
166
|
+
COMMAND_LOG_DESTROY_SCHEMA = extend_schema(
|
167
|
+
summary="Delete command log",
|
168
|
+
description="Delete a command log entry.",
|
169
|
+
tags=["Command Logs"]
|
170
|
+
)
|
171
|
+
|
172
|
+
COMMAND_LOG_STATISTICS_SCHEMA = extend_schema(
|
173
|
+
summary="Get command log statistics",
|
174
|
+
description="Retrieve statistics about command execution logs including success rates and common errors.",
|
175
|
+
tags=["Command Logs"],
|
176
|
+
responses={
|
177
|
+
200: OpenApiResponse(
|
178
|
+
description="Statistics retrieved successfully",
|
179
|
+
examples=[
|
180
|
+
{
|
181
|
+
"total_logs": 1000,
|
182
|
+
"success_rate": 85.5,
|
183
|
+
"last_24h": {
|
184
|
+
"total": 50,
|
185
|
+
"successful": 45,
|
186
|
+
"failed": 5
|
187
|
+
},
|
188
|
+
"top_commands": [
|
189
|
+
{"command_name": "show interfaces", "count": 150},
|
190
|
+
{"command_name": "show version", "count": 120}
|
191
|
+
],
|
192
|
+
"common_errors": [
|
193
|
+
{"error": "Connection timeout", "count": 10},
|
194
|
+
{"error": "Invalid command", "count": 5}
|
195
|
+
]
|
196
|
+
}
|
197
|
+
]
|
198
|
+
)
|
199
|
+
}
|
200
|
+
)
|
201
|
+
|
202
|
+
from drf_spectacular.utils import OpenApiParameter
|
203
|
+
|
204
|
+
COMMAND_LOG_EXPORT_SCHEMA = extend_schema(
|
205
|
+
summary="Export command logs",
|
206
|
+
description="Export command logs in various formats (CSV, JSON).",
|
207
|
+
tags=["Command Logs"],
|
208
|
+
parameters=[
|
209
|
+
OpenApiParameter(
|
210
|
+
name='format',
|
211
|
+
description='Export format',
|
212
|
+
required=False,
|
213
|
+
type=str,
|
214
|
+
enum=['csv', 'json'],
|
215
|
+
default='json'
|
216
|
+
),
|
217
|
+
OpenApiParameter(
|
218
|
+
name='start_date',
|
219
|
+
description='Start date for export (YYYY-MM-DD)',
|
220
|
+
required=False,
|
221
|
+
type=str
|
222
|
+
),
|
223
|
+
OpenApiParameter(
|
224
|
+
name='end_date',
|
225
|
+
description='End date for export (YYYY-MM-DD)',
|
226
|
+
required=False,
|
227
|
+
type=str
|
228
|
+
),
|
229
|
+
],
|
230
|
+
responses={
|
231
|
+
200: OpenApiResponse(description="Export data"),
|
232
|
+
400: OpenApiResponse(description="Invalid parameters")
|
233
|
+
}
|
234
|
+
)
|
@@ -0,0 +1,158 @@
|
|
1
|
+
from rest_framework import serializers
|
2
|
+
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
|
3
|
+
from dcim.api.serializers import PlatformSerializer, DeviceSerializer
|
4
|
+
from ..models import Command, CommandLog
|
5
|
+
|
6
|
+
class CommandExecutionSerializer(serializers.Serializer):
|
7
|
+
"""Serializer for command execution input validation"""
|
8
|
+
device_id = serializers.IntegerField(
|
9
|
+
help_text="ID of the device to execute the command on"
|
10
|
+
)
|
11
|
+
username = serializers.CharField(
|
12
|
+
max_length=100,
|
13
|
+
help_text="Username for device authentication",
|
14
|
+
trim_whitespace=True
|
15
|
+
)
|
16
|
+
password = serializers.CharField(
|
17
|
+
max_length=255,
|
18
|
+
style={'input_type': 'password'},
|
19
|
+
help_text="Password for device authentication",
|
20
|
+
trim_whitespace=False
|
21
|
+
)
|
22
|
+
timeout = serializers.IntegerField(
|
23
|
+
required=False,
|
24
|
+
default=30,
|
25
|
+
min_value=5,
|
26
|
+
max_value=300,
|
27
|
+
help_text="Command execution timeout in seconds (5-300)"
|
28
|
+
)
|
29
|
+
|
30
|
+
def validate_device_id(self, value):
|
31
|
+
"""Validate that the device exists and has required attributes"""
|
32
|
+
from dcim.models import Device
|
33
|
+
try:
|
34
|
+
device = Device.objects.get(id=value)
|
35
|
+
if not device.platform:
|
36
|
+
raise serializers.ValidationError(
|
37
|
+
"Device must have a platform assigned for command execution"
|
38
|
+
)
|
39
|
+
if not device.primary_ip:
|
40
|
+
raise serializers.ValidationError(
|
41
|
+
"Device must have a primary IP address for command execution"
|
42
|
+
)
|
43
|
+
return value
|
44
|
+
except Device.DoesNotExist:
|
45
|
+
raise serializers.ValidationError("Device not found")
|
46
|
+
|
47
|
+
def validate(self, data):
|
48
|
+
"""Cross-field validation and object retrieval"""
|
49
|
+
from dcim.models import Device
|
50
|
+
|
51
|
+
# Get the actual device object for use in views
|
52
|
+
device = Device.objects.get(id=data['device_id'])
|
53
|
+
data['device'] = device
|
54
|
+
|
55
|
+
return data
|
56
|
+
|
57
|
+
def validate_username(self, value):
|
58
|
+
"""Validate username format"""
|
59
|
+
if not value.strip():
|
60
|
+
raise serializers.ValidationError("Username cannot be empty")
|
61
|
+
if len(value.strip()) < 2:
|
62
|
+
raise serializers.ValidationError("Username must be at least 2 characters")
|
63
|
+
return value.strip()
|
64
|
+
|
65
|
+
def validate_password(self, value):
|
66
|
+
"""Validate password"""
|
67
|
+
if not value:
|
68
|
+
raise serializers.ValidationError("Password cannot be empty")
|
69
|
+
if len(value) < 3:
|
70
|
+
raise serializers.ValidationError("Password must be at least 3 characters")
|
71
|
+
return value
|
72
|
+
|
73
|
+
class NestedCommandSerializer(WritableNestedSerializer):
|
74
|
+
url = serializers.HyperlinkedIdentityField(
|
75
|
+
view_name='plugins-api:netbox_toolkit-api:command-detail'
|
76
|
+
)
|
77
|
+
|
78
|
+
class Meta:
|
79
|
+
model = Command
|
80
|
+
fields = ('id', 'url', 'name', 'display')
|
81
|
+
|
82
|
+
class CommandSerializer(NetBoxModelSerializer):
|
83
|
+
url = serializers.HyperlinkedIdentityField(
|
84
|
+
view_name='plugins-api:netbox_toolkit-api:command-detail'
|
85
|
+
)
|
86
|
+
platform = PlatformSerializer(nested=True)
|
87
|
+
|
88
|
+
class Meta:
|
89
|
+
model = Command
|
90
|
+
fields = (
|
91
|
+
'id', 'url', 'display', 'name', 'command', 'description',
|
92
|
+
'platform', 'command_type',
|
93
|
+
'tags', 'custom_fields', 'created', 'last_updated'
|
94
|
+
)
|
95
|
+
brief_fields = ('id', 'url', 'display', 'name', 'command_type', 'platform')
|
96
|
+
|
97
|
+
class CommandLogSerializer(NetBoxModelSerializer):
|
98
|
+
url = serializers.HyperlinkedIdentityField(
|
99
|
+
view_name='plugins-api:netbox_toolkit-api:commandlog-detail'
|
100
|
+
)
|
101
|
+
command = NestedCommandSerializer()
|
102
|
+
device = DeviceSerializer(nested=True)
|
103
|
+
|
104
|
+
class Meta:
|
105
|
+
model = CommandLog
|
106
|
+
fields = (
|
107
|
+
'id', 'url', 'display', 'command', 'device', 'output',
|
108
|
+
'username', 'execution_time', 'success', 'error_message', 'execution_duration',
|
109
|
+
'parsed_data', 'parsing_success', 'parsing_template',
|
110
|
+
'created', 'last_updated'
|
111
|
+
)
|
112
|
+
brief_fields = ('id', 'url', 'display', 'command', 'device', 'username', 'execution_time', 'success')
|
113
|
+
|
114
|
+
class BulkCommandExecutionSerializer(serializers.Serializer):
|
115
|
+
"""Serializer for bulk command execution validation"""
|
116
|
+
command_id = serializers.IntegerField(
|
117
|
+
help_text="ID of the command to execute"
|
118
|
+
)
|
119
|
+
device_id = serializers.IntegerField(
|
120
|
+
help_text="ID of the device to execute the command on"
|
121
|
+
)
|
122
|
+
username = serializers.CharField(
|
123
|
+
max_length=100,
|
124
|
+
help_text="Username for device authentication"
|
125
|
+
)
|
126
|
+
password = serializers.CharField(
|
127
|
+
max_length=255,
|
128
|
+
style={'input_type': 'password'},
|
129
|
+
help_text="Password for device authentication"
|
130
|
+
)
|
131
|
+
timeout = serializers.IntegerField(
|
132
|
+
required=False,
|
133
|
+
default=30,
|
134
|
+
min_value=5,
|
135
|
+
max_value=300,
|
136
|
+
help_text="Command execution timeout in seconds"
|
137
|
+
)
|
138
|
+
|
139
|
+
def validate_command_id(self, value):
|
140
|
+
"""Validate that the command exists"""
|
141
|
+
try:
|
142
|
+
Command.objects.get(id=value)
|
143
|
+
return value
|
144
|
+
except Command.DoesNotExist:
|
145
|
+
raise serializers.ValidationError("Command not found")
|
146
|
+
|
147
|
+
def validate_device_id(self, value):
|
148
|
+
"""Validate that the device exists"""
|
149
|
+
from dcim.models import Device
|
150
|
+
try:
|
151
|
+
device = Device.objects.get(id=value)
|
152
|
+
if not device.platform:
|
153
|
+
raise serializers.ValidationError(
|
154
|
+
"Device must have a platform assigned"
|
155
|
+
)
|
156
|
+
return value
|
157
|
+
except Device.DoesNotExist:
|
158
|
+
raise serializers.ValidationError("Device not found")
|
@@ -0,0 +1,10 @@
|
|
1
|
+
from netbox.api.routers import NetBoxRouter
|
2
|
+
from .views import CommandViewSet, CommandLogViewSet
|
3
|
+
|
4
|
+
app_name = 'netbox_toolkit'
|
5
|
+
|
6
|
+
router = NetBoxRouter()
|
7
|
+
router.register('commands', CommandViewSet)
|
8
|
+
router.register('command-logs', CommandLogViewSet)
|
9
|
+
|
10
|
+
urlpatterns = router.urls
|
@@ -0,0 +1,170 @@
|
|
1
|
+
"""
|
2
|
+
API ViewSet for CommandLog resources
|
3
|
+
"""
|
4
|
+
from rest_framework import status
|
5
|
+
from rest_framework.decorators import action
|
6
|
+
from rest_framework.response import Response
|
7
|
+
from django.utils import timezone
|
8
|
+
from datetime import timedelta
|
9
|
+
from django.db.models import Count, Q
|
10
|
+
from django.http import HttpResponse
|
11
|
+
from drf_spectacular.utils import extend_schema_view
|
12
|
+
from netbox.api.viewsets import NetBoxModelViewSet
|
13
|
+
|
14
|
+
from ... import models, filtersets
|
15
|
+
from ..serializers import CommandLogSerializer
|
16
|
+
from ..mixins import APIResponseMixin
|
17
|
+
from ..schemas import (
|
18
|
+
COMMAND_LOG_LIST_SCHEMA,
|
19
|
+
COMMAND_LOG_RETRIEVE_SCHEMA,
|
20
|
+
COMMAND_LOG_CREATE_SCHEMA,
|
21
|
+
COMMAND_LOG_UPDATE_SCHEMA,
|
22
|
+
COMMAND_LOG_PARTIAL_UPDATE_SCHEMA,
|
23
|
+
COMMAND_LOG_DESTROY_SCHEMA,
|
24
|
+
COMMAND_LOG_STATISTICS_SCHEMA,
|
25
|
+
COMMAND_LOG_EXPORT_SCHEMA,
|
26
|
+
)
|
27
|
+
|
28
|
+
|
29
|
+
@extend_schema_view(
|
30
|
+
list=COMMAND_LOG_LIST_SCHEMA,
|
31
|
+
retrieve=COMMAND_LOG_RETRIEVE_SCHEMA,
|
32
|
+
create=COMMAND_LOG_CREATE_SCHEMA,
|
33
|
+
update=COMMAND_LOG_UPDATE_SCHEMA,
|
34
|
+
partial_update=COMMAND_LOG_PARTIAL_UPDATE_SCHEMA,
|
35
|
+
destroy=COMMAND_LOG_DESTROY_SCHEMA,
|
36
|
+
)
|
37
|
+
class CommandLogViewSet(NetBoxModelViewSet, APIResponseMixin):
|
38
|
+
queryset = models.CommandLog.objects.all()
|
39
|
+
serializer_class = CommandLogSerializer
|
40
|
+
filterset_class = filtersets.CommandLogFilterSet
|
41
|
+
# NetBox automatically handles object-based permissions - no need for explicit permission_classes
|
42
|
+
|
43
|
+
def get_queryset(self):
|
44
|
+
"""NetBox will automatically filter based on user's ObjectPermissions"""
|
45
|
+
return super().get_queryset()
|
46
|
+
|
47
|
+
@COMMAND_LOG_STATISTICS_SCHEMA
|
48
|
+
@action(detail=False, methods=['get'], url_path='statistics')
|
49
|
+
def statistics(self, request):
|
50
|
+
"""Get command execution statistics"""
|
51
|
+
queryset = self.get_queryset()
|
52
|
+
|
53
|
+
# Basic stats
|
54
|
+
total_logs = queryset.count()
|
55
|
+
successful_logs = queryset.filter(success=True).count()
|
56
|
+
success_rate = (successful_logs / total_logs * 100) if total_logs > 0 else 0
|
57
|
+
|
58
|
+
# Last 24 hours stats
|
59
|
+
last_24h = timezone.now() - timedelta(hours=24)
|
60
|
+
recent_logs = queryset.filter(created__gte=last_24h)
|
61
|
+
recent_total = recent_logs.count()
|
62
|
+
recent_successful = recent_logs.filter(success=True).count()
|
63
|
+
recent_failed = recent_total - recent_successful
|
64
|
+
|
65
|
+
# Top commands
|
66
|
+
top_commands = (
|
67
|
+
queryset.values('command__name')
|
68
|
+
.annotate(count=Count('command'))
|
69
|
+
.order_by('-count')[:10]
|
70
|
+
)
|
71
|
+
|
72
|
+
# Common errors (non-empty error messages)
|
73
|
+
common_errors = (
|
74
|
+
queryset.filter(~Q(error_message=''), ~Q(error_message__isnull=True))
|
75
|
+
.values('error_message')
|
76
|
+
.annotate(count=Count('error_message'))
|
77
|
+
.order_by('-count')[:10]
|
78
|
+
)
|
79
|
+
|
80
|
+
return Response({
|
81
|
+
'total_logs': total_logs,
|
82
|
+
'success_rate': round(success_rate, 2),
|
83
|
+
'last_24h': {
|
84
|
+
'total': recent_total,
|
85
|
+
'successful': recent_successful,
|
86
|
+
'failed': recent_failed
|
87
|
+
},
|
88
|
+
'top_commands': [
|
89
|
+
{'command_name': item['command__name'], 'count': item['count']}
|
90
|
+
for item in top_commands
|
91
|
+
],
|
92
|
+
'common_errors': [
|
93
|
+
{'error': item['error_message'][:100], 'count': item['count']}
|
94
|
+
for item in common_errors
|
95
|
+
]
|
96
|
+
})
|
97
|
+
|
98
|
+
@COMMAND_LOG_EXPORT_SCHEMA
|
99
|
+
@action(detail=False, methods=['get'], url_path='export')
|
100
|
+
def export(self, request):
|
101
|
+
"""Export command logs"""
|
102
|
+
import csv
|
103
|
+
from datetime import datetime
|
104
|
+
|
105
|
+
export_format = request.query_params.get('format', 'json')
|
106
|
+
start_date = request.query_params.get('start_date')
|
107
|
+
end_date = request.query_params.get('end_date')
|
108
|
+
|
109
|
+
queryset = self.get_queryset()
|
110
|
+
|
111
|
+
# Apply date filters if provided
|
112
|
+
if start_date:
|
113
|
+
try:
|
114
|
+
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
|
115
|
+
queryset = queryset.filter(created__date__gte=start_date)
|
116
|
+
except ValueError:
|
117
|
+
return Response(
|
118
|
+
{'error': 'Invalid start_date format. Use YYYY-MM-DD.'},
|
119
|
+
status=status.HTTP_400_BAD_REQUEST
|
120
|
+
)
|
121
|
+
|
122
|
+
if end_date:
|
123
|
+
try:
|
124
|
+
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
|
125
|
+
queryset = queryset.filter(created__date__lte=end_date)
|
126
|
+
except ValueError:
|
127
|
+
return Response(
|
128
|
+
{'error': 'Invalid end_date format. Use YYYY-MM-DD.'},
|
129
|
+
status=status.HTTP_400_BAD_REQUEST
|
130
|
+
)
|
131
|
+
|
132
|
+
# Limit export size for performance
|
133
|
+
if queryset.count() > 10000:
|
134
|
+
return Response(
|
135
|
+
{'error': 'Export too large. Please use date filters to reduce size.'},
|
136
|
+
status=status.HTTP_400_BAD_REQUEST
|
137
|
+
)
|
138
|
+
|
139
|
+
if export_format == 'csv':
|
140
|
+
# CSV export
|
141
|
+
response = HttpResponse(content_type='text/csv')
|
142
|
+
response['Content-Disposition'] = 'attachment; filename="command_logs.csv"'
|
143
|
+
|
144
|
+
writer = csv.writer(response)
|
145
|
+
writer.writerow([
|
146
|
+
'ID', 'Command', 'Device', 'User', 'Success',
|
147
|
+
'Created', 'Execution Time', 'Error Message'
|
148
|
+
])
|
149
|
+
|
150
|
+
for log in queryset.select_related('command', 'device', 'user'):
|
151
|
+
writer.writerow([
|
152
|
+
log.id,
|
153
|
+
log.command.name,
|
154
|
+
log.device.name,
|
155
|
+
log.user.username if log.user else 'Unknown',
|
156
|
+
log.success,
|
157
|
+
log.created.isoformat(),
|
158
|
+
log.execution_time,
|
159
|
+
log.error_message or ''
|
160
|
+
])
|
161
|
+
|
162
|
+
return response
|
163
|
+
|
164
|
+
else:
|
165
|
+
# JSON export
|
166
|
+
serializer = self.get_serializer(queryset, many=True)
|
167
|
+
return Response({
|
168
|
+
'count': queryset.count(),
|
169
|
+
'results': serializer.data
|
170
|
+
})
|