Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

14438 database representation of scripts #15061

Merged
merged 56 commits into from
Feb 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
1034ee7
14438 script model
arthanson Feb 5, 2024
4cb3b63
14438 script model
arthanson Feb 5, 2024
fc890e3
14438 script model
arthanson Feb 5, 2024
6930e44
14438 script job mapping
arthanson Feb 6, 2024
5981fc9
14438 script job mapping
arthanson Feb 6, 2024
9543169
14438 script job run
arthanson Feb 6, 2024
d162d44
14438 fix migration
arthanson Feb 6, 2024
0bd15c3
14438 merge feature
arthanson Feb 8, 2024
0c214d3
14438 fix merge
arthanson Feb 8, 2024
41c792a
14438 fix merge
arthanson Feb 8, 2024
172d1b0
14438 check valid script for views
arthanson Feb 8, 2024
c0a5de0
14438 check valid script for views
arthanson Feb 8, 2024
f7e55a0
14438 view fixes cleanup
arthanson Feb 8, 2024
d382350
14438 view fixes cleanup
arthanson Feb 8, 2024
079dc00
14438 view fixes cleanup
arthanson Feb 8, 2024
8c17d73
14438 fix delete
arthanson Feb 9, 2024
6846ec7
14438 temp fix
arthanson Feb 9, 2024
2f8a7b9
Merge branch 'feature' into 14438-script-model
arthanson Feb 9, 2024
5290c86
14438 fix serializer and api view
arthanson Feb 9, 2024
b4c9f93
14438 fix api post
arthanson Feb 9, 2024
d9afaf9
14438 fix tests
arthanson Feb 9, 2024
84b4085
14438 fix tests
arthanson Feb 9, 2024
0100857
14438 update migration for event rules
arthanson Feb 12, 2024
1a6099f
14438 update migration for event rules
arthanson Feb 12, 2024
8c2fbce
14438 update EventRule code / form
arthanson Feb 12, 2024
4fdce65
14438 fix migration
arthanson Feb 12, 2024
63d3ce6
14438 fix migration
arthanson Feb 12, 2024
bc59634
14438 add generic relation so delete will show event rule
arthanson Feb 12, 2024
ee88c2f
14438 optimize migration
arthanson Feb 12, 2024
6cb176a
Merge branch 'feature' into 14438-script-model
arthanson Feb 13, 2024
38d2cbb
14438 review changes
arthanson Feb 13, 2024
c4ffafa
14438 review changes
arthanson Feb 13, 2024
ecd2712
14438 temp
arthanson Feb 13, 2024
e9f28dc
14438 update migration
arthanson Feb 13, 2024
613f9f2
14438 update migration
arthanson Feb 13, 2024
ba8329e
14438 review comments retain old urls
arthanson Feb 14, 2024
638c303
Update references to is_executable
jeremystretch Feb 14, 2024
6422049
Replace legacy URLs with redirects
jeremystretch Feb 14, 2024
1ea0b6d
Restore missing edit button for script modules
jeremystretch Feb 14, 2024
af2c642
14438 review changes
arthanson Feb 16, 2024
3182977
Merge branch 'feature' into 14438-script-model
arthanson Feb 16, 2024
8b54ff7
14438 update init_script_choice in form
arthanson Feb 16, 2024
cb2bf2b
14438 soft delete
arthanson Feb 16, 2024
1c14c35
14438 get_module_scripts -> module_scripts
arthanson Feb 16, 2024
1ccbe94
14438 post_save sync handler
arthanson Feb 16, 2024
34d3c12
14438 result on script model and serializer update
arthanson Feb 16, 2024
f9608f7
14438 init_vars_or_redirect
arthanson Feb 16, 2024
49b90ca
14438 fixes
arthanson Feb 16, 2024
785ff34
14438 update api doc string
arthanson Feb 16, 2024
4733376
14438 docstring
arthanson Feb 16, 2024
1060ff8
14438 add permissions
arthanson Feb 20, 2024
f7be815
14438 add schema
arthanson Feb 20, 2024
a2b2a1d
Merge branch 'feature' into 14438-script-model
jeremystretch Feb 22, 2024
2d206fb
Misc cleanup
jeremystretch Feb 22, 2024
274bbfa
Fix support for object-based permissions
jeremystretch Feb 22, 2024
890c94b
Clean up migrations
jeremystretch Feb 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 37 additions & 65 deletions netbox/extras/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,6 @@
'ImageAttachmentSerializer',
'JournalEntrySerializer',
'ObjectChangeSerializer',
'ReportDetailSerializer',
'ReportSerializer',
'ReportInputSerializer',
'SavedFilterSerializer',
'ScriptDetailSerializer',
'ScriptInputSerializer',
Expand Down Expand Up @@ -85,9 +82,9 @@ def get_action_object(self, instance):
context = {'request': self.context['request']}
# We need to manually instantiate the serializer for scripts
if instance.action_type == EventRuleActionChoices.SCRIPT:
script_name = instance.action_parameters['script_name']
script = instance.action_object.scripts[script_name]()
return NestedScriptSerializer(script, context=context).data
script = instance.action_object
instance = script.python_class() if script.python_class else None
return NestedScriptSerializer(instance, context=context).data
else:
serializer = get_serializer_for_model(
model=instance.action_object_type.model_class(),
Expand Down Expand Up @@ -512,79 +509,54 @@ class Meta:
]


#
# Reports
#

class ReportSerializer(serializers.Serializer):
url = serializers.HyperlinkedIdentityField(
view_name='extras-api:report-detail',
lookup_field='full_name',
lookup_url_kwarg='pk'
)
id = serializers.CharField(read_only=True, source="full_name")
module = serializers.CharField(max_length=255)
name = serializers.CharField(max_length=255)
description = serializers.CharField(max_length=255, required=False)
test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True)
result = NestedJobSerializer()
display = serializers.SerializerMethodField(read_only=True)

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


class ReportDetailSerializer(ReportSerializer):
result = JobSerializer()


class ReportInputSerializer(serializers.Serializer):
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
interval = serializers.IntegerField(required=False, allow_null=True)

def validate_schedule_at(self, value):
if value and not self.context['report'].scheduling_enabled:
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
return value

def validate_interval(self, value):
if value and not self.context['report'].scheduling_enabled:
raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
return value


#
# Scripts
#

class ScriptSerializer(serializers.Serializer):
url = serializers.HyperlinkedIdentityField(
view_name='extras-api:script-detail',
lookup_field='full_name',
lookup_url_kwarg='pk'
)
id = serializers.CharField(read_only=True, source="full_name")
module = serializers.CharField(max_length=255)
name = serializers.CharField(read_only=True)
arthanson marked this conversation as resolved.
Show resolved Hide resolved
description = serializers.CharField(read_only=True)
class ScriptSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:script-detail')
description = serializers.SerializerMethodField(read_only=True)
vars = serializers.SerializerMethodField(read_only=True)
result = NestedJobSerializer()
display = serializers.SerializerMethodField(read_only=True)
result = NestedJobSerializer(read_only=True)

class Meta:
model = Script
fields = [
'id', 'url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable',
]

@extend_schema_field(serializers.JSONField(allow_null=True))
def get_vars(self, instance):
return {
k: v.__class__.__name__ for k, v in instance._get_vars().items()
}
def get_vars(self, obj):
if obj.python_class:
return {
k: v.__class__.__name__ for k, v in obj.python_class()._get_vars().items()
}
else:
return {}

@extend_schema_field(serializers.CharField())
def get_display(self, obj):
arthanson marked this conversation as resolved.
Show resolved Hide resolved
return f'{obj.name} ({obj.module})'

@extend_schema_field(serializers.CharField())
def get_description(self, obj):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to save description (and maybe vars) to the database?

if obj.python_class:
return obj.python_class().description
else:
return None


class ScriptDetailSerializer(ScriptSerializer):
result = JobSerializer()
result = serializers.SerializerMethodField(read_only=True)

@extend_schema_field(JobSerializer())
def get_result(self, obj):
job = obj.jobs.all().order_by('-created').first()
context = {
'request': self.context['request']
}
data = JobSerializer(job, context=context).data
return data


class ScriptInputSerializer(serializers.Serializer):
Expand Down
64 changes: 13 additions & 51 deletions netbox/extras/api/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from rest_framework import status
Expand All @@ -9,14 +8,13 @@
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from rq import Worker

from core.choices import JobStatusChoices
from core.models import Job
from extras import filtersets
from extras.models import *
from extras.scripts import get_module_and_script, run_script
from extras.scripts import run_script
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata
Expand Down Expand Up @@ -209,66 +207,30 @@ def render(self, request, pk):
# Scripts
#

class ScriptViewSet(ViewSet):
class ScriptViewSet(ModelViewSet):
permission_classes = [IsAuthenticatedOrLoginNotRequired]
queryset = Script.objects.prefetch_related('jobs')
serializer_class = serializers.ScriptSerializer
filterset_class = filtersets.ScriptFilterSet

_ignore_model_permissions = True
schema = None
lookup_value_regex = '[^/]+' # Allow dots

def _get_script(self, pk):
try:
module_name, script_name = pk.split('.', maxsplit=1)
except ValueError:
raise Http404

module, script = get_module_and_script(module_name, script_name)
if script is None:
raise Http404

return module, script

def list(self, request):
results = {
job.name: job
for job in Job.objects.filter(
object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'),
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).order_by('name', '-created').distinct('name').defer('data')
}

script_list = []
for script_module in ScriptModule.objects.restrict(request.user):
script_list.extend(script_module.scripts.values())

# Attach Job objects to each script (if any)
for script in script_list:
script.result = results.get(script.class_name, None)

serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})

return Response({'count': len(script_list), 'results': serializer.data})

def retrieve(self, request, pk):
module, script = self._get_script(pk)
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
script.result = Job.objects.filter(
object_type=object_type,
name=script.class_name,
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).first()
script = get_object_or_404(self.queryset, pk=pk)
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})

return Response(serializer.data)

def post(self, request, pk):
"""
Run a Script identified as "<module>.<script>" and return the pending Job as the result
Run a Script identified by the id and return the pending Job as the result
"""

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

module, script = self._get_script(pk)
script = get_object_or_404(self.queryset, pk=pk)
input_serializer = serializers.ScriptInputSerializer(
data=request.data,
context={'script': script}
Expand All @@ -281,13 +243,13 @@ def post(self, request, pk):
if input_serializer.is_valid():
script.result = Job.enqueue(
run_script,
instance=module,
name=script.class_name,
instance=script.module,
name=script.python_class.class_name,
user=request.user,
data=input_serializer.data['data'],
request=copy_safe_request(request),
commit=input_serializer.data['commit'],
job_timeout=script.job_timeout,
job_timeout=script.python_class.job_timeout,
schedule_at=input_serializer.validated_data.get('schedule_at'),
interval=input_serializer.validated_data.get('interval')
)
Expand Down
8 changes: 3 additions & 5 deletions netbox/extras/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,13 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
# Scripts
elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
# Resolve the script from action parameters
script_module = event_rule.action_object
script_name = event_rule.action_parameters['script_name']
script = script_module.scripts[script_name]()
script = event_rule.action_object.python_class()

# Enqueue a Job to record the script's execution
Job.enqueue(
"extras.scripts.run_script",
instance=script_module,
name=script.class_name,
instance=script.module,
name=script.name,
user=user,
data=data
)
Expand Down
21 changes: 21 additions & 0 deletions netbox/extras/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,32 @@
'LocalConfigContextFilterSet',
'ObjectChangeFilterSet',
'SavedFilterFilterSet',
'ScriptFilterSet',
'TagFilterSet',
'WebhookFilterSet',
)


class ScriptFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)

class Meta:
model = Script
fields = [
'id', 'name',
]

def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
)


class WebhookFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
Expand Down
7 changes: 2 additions & 5 deletions netbox/extras/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,8 @@ def clean(self):
module, script = get_module_and_script(module_name, script_name)
except ObjectDoesNotExist:
raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
self.instance.action_object = module
self.instance.action_object_type = ContentType.objects.get_for_model(module, for_concrete_model=False)
self.instance.action_parameters = {
'script_name': script_name,
}
self.instance.action_object = script
self.instance.action_object_type = ContentType.objects.get_for_model(script, for_concrete_model=False)


class TagImportForm(CSVModelForm):
Expand Down
41 changes: 12 additions & 29 deletions netbox/extras/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,20 +297,16 @@ class Meta:
}

def init_script_choice(self):
arthanson marked this conversation as resolved.
Show resolved Hide resolved
choices = []
for module in ScriptModule.objects.all():
scripts = []
for script_name in module.scripts.keys():
name = f"{str(module.pk)}:{script_name}"
scripts.append((name, script_name))
if scripts:
choices.append((str(module), scripts))
self.fields['action_choice'].choices = choices

if self.instance.action_type == EventRuleActionChoices.SCRIPT and self.instance.action_parameters:
scriptmodule_id = self.instance.action_object_id
script_name = self.instance.action_parameters.get('script_name')
self.fields['action_choice'].initial = f'{scriptmodule_id}:{script_name}'
initial = None
if self.instance.action_type == EventRuleActionChoices.SCRIPT:
script_id = get_field_value(self, 'action_object_id')
initial = Script.objects.get(pk=script_id) if script_id else None
self.fields['action_choice'] = DynamicModelChoiceField(
label=_('Script'),
queryset=Script.objects.all(),
required=True,
initial=initial
)

def init_webhook_choice(self):
initial = None
Expand Down Expand Up @@ -348,26 +344,13 @@ def clean(self):
# Script
elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(
ScriptModule,
Script,
for_concrete_model=False
)
module_id, script_name = action_choice.split(":", maxsplit=1)
self.cleaned_data['action_object_id'] = module_id
self.cleaned_data['action_object_id'] = action_choice.id

return self.cleaned_data

def save(self, *args, **kwargs):
# Set action_parameters on the instance
if self.cleaned_data['action_type'] == EventRuleActionChoices.SCRIPT:
module_id, script_name = self.cleaned_data.get('action_choice').split(":", maxsplit=1)
self.instance.action_parameters = {
'script_name': script_name,
}
else:
self.instance.action_parameters = None

return super().save(*args, **kwargs)


class TagForm(forms.ModelForm):
slug = SlugField()
Expand Down
Loading
Loading