Skip to content

Commit ca2ee43

Browse files
Closes #14438: Database representation of scripts
- Introduces the Script model to represent individual Python classes within a ScriptModule file - Automatically migrates jobs & event rules --------- Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
1 parent 7e7e5d5 commit ca2ee43

File tree

25 files changed

+568
-335
lines changed

25 files changed

+568
-335
lines changed

netbox/extras/api/serializers.py

Lines changed: 37 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,6 @@
4444
'ImageAttachmentSerializer',
4545
'JournalEntrySerializer',
4646
'ObjectChangeSerializer',
47-
'ReportDetailSerializer',
48-
'ReportSerializer',
49-
'ReportInputSerializer',
5047
'SavedFilterSerializer',
5148
'ScriptDetailSerializer',
5249
'ScriptInputSerializer',
@@ -85,9 +82,9 @@ def get_action_object(self, instance):
8582
context = {'request': self.context['request']}
8683
# We need to manually instantiate the serializer for scripts
8784
if instance.action_type == EventRuleActionChoices.SCRIPT:
88-
script_name = instance.action_parameters['script_name']
89-
script = instance.action_object.scripts[script_name]()
90-
return NestedScriptSerializer(script, context=context).data
85+
script = instance.action_object
86+
instance = script.python_class() if script.python_class else None
87+
return NestedScriptSerializer(instance, context=context).data
9188
else:
9289
serializer = get_serializer_for_model(
9390
model=instance.action_object_type.model_class(),
@@ -512,79 +509,54 @@ class Meta:
512509
]
513510

514511

515-
#
516-
# Reports
517-
#
518-
519-
class ReportSerializer(serializers.Serializer):
520-
url = serializers.HyperlinkedIdentityField(
521-
view_name='extras-api:report-detail',
522-
lookup_field='full_name',
523-
lookup_url_kwarg='pk'
524-
)
525-
id = serializers.CharField(read_only=True, source="full_name")
526-
module = serializers.CharField(max_length=255)
527-
name = serializers.CharField(max_length=255)
528-
description = serializers.CharField(max_length=255, required=False)
529-
test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True)
530-
result = NestedJobSerializer()
531-
display = serializers.SerializerMethodField(read_only=True)
532-
533-
@extend_schema_field(serializers.CharField())
534-
def get_display(self, obj):
535-
return f'{obj.name} ({obj.module})'
536-
537-
538-
class ReportDetailSerializer(ReportSerializer):
539-
result = JobSerializer()
540-
541-
542-
class ReportInputSerializer(serializers.Serializer):
543-
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
544-
interval = serializers.IntegerField(required=False, allow_null=True)
545-
546-
def validate_schedule_at(self, value):
547-
if value and not self.context['report'].scheduling_enabled:
548-
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
549-
return value
550-
551-
def validate_interval(self, value):
552-
if value and not self.context['report'].scheduling_enabled:
553-
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
554-
return value
555-
556-
557512
#
558513
# Scripts
559514
#
560515

561-
class ScriptSerializer(serializers.Serializer):
562-
url = serializers.HyperlinkedIdentityField(
563-
view_name='extras-api:script-detail',
564-
lookup_field='full_name',
565-
lookup_url_kwarg='pk'
566-
)
567-
id = serializers.CharField(read_only=True, source="full_name")
568-
module = serializers.CharField(max_length=255)
569-
name = serializers.CharField(read_only=True)
570-
description = serializers.CharField(read_only=True)
516+
class ScriptSerializer(ValidatedModelSerializer):
517+
url = serializers.HyperlinkedIdentityField(view_name='extras-api:script-detail')
518+
description = serializers.SerializerMethodField(read_only=True)
571519
vars = serializers.SerializerMethodField(read_only=True)
572-
result = NestedJobSerializer()
573-
display = serializers.SerializerMethodField(read_only=True)
520+
result = NestedJobSerializer(read_only=True)
521+
522+
class Meta:
523+
model = Script
524+
fields = [
525+
'id', 'url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable',
526+
]
574527

575528
@extend_schema_field(serializers.JSONField(allow_null=True))
576-
def get_vars(self, instance):
577-
return {
578-
k: v.__class__.__name__ for k, v in instance._get_vars().items()
579-
}
529+
def get_vars(self, obj):
530+
if obj.python_class:
531+
return {
532+
k: v.__class__.__name__ for k, v in obj.python_class()._get_vars().items()
533+
}
534+
else:
535+
return {}
580536

581537
@extend_schema_field(serializers.CharField())
582538
def get_display(self, obj):
583539
return f'{obj.name} ({obj.module})'
584540

541+
@extend_schema_field(serializers.CharField())
542+
def get_description(self, obj):
543+
if obj.python_class:
544+
return obj.python_class().description
545+
else:
546+
return None
547+
585548

586549
class ScriptDetailSerializer(ScriptSerializer):
587-
result = JobSerializer()
550+
result = serializers.SerializerMethodField(read_only=True)
551+
552+
@extend_schema_field(JobSerializer())
553+
def get_result(self, obj):
554+
job = obj.jobs.all().order_by('-created').first()
555+
context = {
556+
'request': self.context['request']
557+
}
558+
data = JobSerializer(job, context=context).data
559+
return data
588560

589561

590562
class ScriptInputSerializer(serializers.Serializer):

netbox/extras/api/views.py

Lines changed: 13 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from django.contrib.contenttypes.models import ContentType
2-
from django.http import Http404
32
from django.shortcuts import get_object_or_404
43
from django_rq.queues import get_connection
54
from rest_framework import status
@@ -9,14 +8,13 @@
98
from rest_framework.renderers import JSONRenderer
109
from rest_framework.response import Response
1110
from rest_framework.routers import APIRootView
12-
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
11+
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
1312
from rq import Worker
1413

15-
from core.choices import JobStatusChoices
1614
from core.models import Job
1715
from extras import filtersets
1816
from extras.models import *
19-
from extras.scripts import get_module_and_script, run_script
17+
from extras.scripts import run_script
2018
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
2119
from netbox.api.features import SyncedDataMixin
2220
from netbox.api.metadata import ContentTypeMetadata
@@ -209,66 +207,30 @@ def render(self, request, pk):
209207
# Scripts
210208
#
211209

212-
class ScriptViewSet(ViewSet):
210+
class ScriptViewSet(ModelViewSet):
213211
permission_classes = [IsAuthenticatedOrLoginNotRequired]
212+
queryset = Script.objects.prefetch_related('jobs')
213+
serializer_class = serializers.ScriptSerializer
214+
filterset_class = filtersets.ScriptFilterSet
215+
214216
_ignore_model_permissions = True
215-
schema = None
216217
lookup_value_regex = '[^/]+' # Allow dots
217218

218-
def _get_script(self, pk):
219-
try:
220-
module_name, script_name = pk.split('.', maxsplit=1)
221-
except ValueError:
222-
raise Http404
223-
224-
module, script = get_module_and_script(module_name, script_name)
225-
if script is None:
226-
raise Http404
227-
228-
return module, script
229-
230-
def list(self, request):
231-
results = {
232-
job.name: job
233-
for job in Job.objects.filter(
234-
object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'),
235-
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
236-
).order_by('name', '-created').distinct('name').defer('data')
237-
}
238-
239-
script_list = []
240-
for script_module in ScriptModule.objects.restrict(request.user):
241-
script_list.extend(script_module.scripts.values())
242-
243-
# Attach Job objects to each script (if any)
244-
for script in script_list:
245-
script.result = results.get(script.class_name, None)
246-
247-
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
248-
249-
return Response({'count': len(script_list), 'results': serializer.data})
250-
251219
def retrieve(self, request, pk):
252-
module, script = self._get_script(pk)
253-
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
254-
script.result = Job.objects.filter(
255-
object_type=object_type,
256-
name=script.class_name,
257-
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
258-
).first()
220+
script = get_object_or_404(self.queryset, pk=pk)
259221
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
260222

261223
return Response(serializer.data)
262224

263225
def post(self, request, pk):
264226
"""
265-
Run a Script identified as "<module>.<script>" and return the pending Job as the result
227+
Run a Script identified by the id and return the pending Job as the result
266228
"""
267229

268230
if not request.user.has_perm('extras.run_script'):
269231
raise PermissionDenied("This user does not have permission to run scripts.")
270232

271-
module, script = self._get_script(pk)
233+
script = get_object_or_404(self.queryset, pk=pk)
272234
input_serializer = serializers.ScriptInputSerializer(
273235
data=request.data,
274236
context={'script': script}
@@ -281,13 +243,13 @@ def post(self, request, pk):
281243
if input_serializer.is_valid():
282244
script.result = Job.enqueue(
283245
run_script,
284-
instance=module,
285-
name=script.class_name,
246+
instance=script.module,
247+
name=script.python_class.class_name,
286248
user=request.user,
287249
data=input_serializer.data['data'],
288250
request=copy_safe_request(request),
289251
commit=input_serializer.data['commit'],
290-
job_timeout=script.job_timeout,
252+
job_timeout=script.python_class.job_timeout,
291253
schedule_at=input_serializer.validated_data.get('schedule_at'),
292254
interval=input_serializer.validated_data.get('interval')
293255
)

netbox/extras/events.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,13 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
116116
# Scripts
117117
elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
118118
# Resolve the script from action parameters
119-
script_module = event_rule.action_object
120-
script_name = event_rule.action_parameters['script_name']
121-
script = script_module.scripts[script_name]()
119+
script = event_rule.action_object.python_class()
122120

123121
# Enqueue a Job to record the script's execution
124122
Job.enqueue(
125123
"extras.scripts.run_script",
126-
instance=script_module,
127-
name=script.class_name,
124+
instance=script.module,
125+
name=script.name,
128126
user=user,
129127
data=data
130128
)

netbox/extras/filtersets.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,32 @@
2929
'LocalConfigContextFilterSet',
3030
'ObjectChangeFilterSet',
3131
'SavedFilterFilterSet',
32+
'ScriptFilterSet',
3233
'TagFilterSet',
3334
'WebhookFilterSet',
3435
)
3536

3637

38+
class ScriptFilterSet(BaseFilterSet):
39+
q = django_filters.CharFilter(
40+
method='search',
41+
label=_('Search'),
42+
)
43+
44+
class Meta:
45+
model = Script
46+
fields = [
47+
'id', 'name',
48+
]
49+
50+
def search(self, queryset, name, value):
51+
if not value.strip():
52+
return queryset
53+
return queryset.filter(
54+
Q(name__icontains=value)
55+
)
56+
57+
3758
class WebhookFilterSet(NetBoxModelFilterSet):
3859
q = django_filters.CharFilter(
3960
method='search',

netbox/extras/forms/bulk_import.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,8 @@ def clean(self):
212212
module, script = get_module_and_script(module_name, script_name)
213213
except ObjectDoesNotExist:
214214
raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
215-
self.instance.action_object = module
216-
self.instance.action_object_type = ContentType.objects.get_for_model(module, for_concrete_model=False)
217-
self.instance.action_parameters = {
218-
'script_name': script_name,
219-
}
215+
self.instance.action_object = script
216+
self.instance.action_object_type = ContentType.objects.get_for_model(script, for_concrete_model=False)
220217

221218

222219
class TagImportForm(CSVModelForm):

netbox/extras/forms/model_forms.py

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -297,20 +297,16 @@ class Meta:
297297
}
298298

299299
def init_script_choice(self):
300-
choices = []
301-
for module in ScriptModule.objects.all():
302-
scripts = []
303-
for script_name in module.scripts.keys():
304-
name = f"{str(module.pk)}:{script_name}"
305-
scripts.append((name, script_name))
306-
if scripts:
307-
choices.append((str(module), scripts))
308-
self.fields['action_choice'].choices = choices
309-
310-
if self.instance.action_type == EventRuleActionChoices.SCRIPT and self.instance.action_parameters:
311-
scriptmodule_id = self.instance.action_object_id
312-
script_name = self.instance.action_parameters.get('script_name')
313-
self.fields['action_choice'].initial = f'{scriptmodule_id}:{script_name}'
300+
initial = None
301+
if self.instance.action_type == EventRuleActionChoices.SCRIPT:
302+
script_id = get_field_value(self, 'action_object_id')
303+
initial = Script.objects.get(pk=script_id) if script_id else None
304+
self.fields['action_choice'] = DynamicModelChoiceField(
305+
label=_('Script'),
306+
queryset=Script.objects.all(),
307+
required=True,
308+
initial=initial
309+
)
314310

315311
def init_webhook_choice(self):
316312
initial = None
@@ -348,26 +344,13 @@ def clean(self):
348344
# Script
349345
elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
350346
self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(
351-
ScriptModule,
347+
Script,
352348
for_concrete_model=False
353349
)
354-
module_id, script_name = action_choice.split(":", maxsplit=1)
355-
self.cleaned_data['action_object_id'] = module_id
350+
self.cleaned_data['action_object_id'] = action_choice.id
356351

357352
return self.cleaned_data
358353

359-
def save(self, *args, **kwargs):
360-
# Set action_parameters on the instance
361-
if self.cleaned_data['action_type'] == EventRuleActionChoices.SCRIPT:
362-
module_id, script_name = self.cleaned_data.get('action_choice').split(":", maxsplit=1)
363-
self.instance.action_parameters = {
364-
'script_name': script_name,
365-
}
366-
else:
367-
self.instance.action_parameters = None
368-
369-
return super().save(*args, **kwargs)
370-
371354

372355
class TagForm(forms.ModelForm):
373356
slug = SlugField()

0 commit comments

Comments
 (0)