فهرست منبع

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>
Arthur Hanson 2 سال پیش
والد
کامیت
ca2ee436a0

+ 37 - 65
netbox/extras/api/serializers.py

@@ -44,9 +44,6 @@ __all__ = (
     'ImageAttachmentSerializer',
     'ImageAttachmentSerializer',
     'JournalEntrySerializer',
     'JournalEntrySerializer',
     'ObjectChangeSerializer',
     'ObjectChangeSerializer',
-    'ReportDetailSerializer',
-    'ReportSerializer',
-    'ReportInputSerializer',
     'SavedFilterSerializer',
     'SavedFilterSerializer',
     'ScriptDetailSerializer',
     'ScriptDetailSerializer',
     'ScriptInputSerializer',
     'ScriptInputSerializer',
@@ -85,9 +82,9 @@ class EventRuleSerializer(NetBoxModelSerializer):
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         # We need to manually instantiate the serializer for scripts
         # We need to manually instantiate the serializer for scripts
         if instance.action_type == EventRuleActionChoices.SCRIPT:
         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:
         else:
             serializer = get_serializer_for_model(
             serializer = get_serializer_for_model(
                 model=instance.action_object_type.model_class(),
                 model=instance.action_object_type.model_class(),
@@ -512,79 +509,54 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
         ]
         ]
 
 
 
 
-#
-# 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
 # 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)
-    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)
     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))
     @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())
     @extend_schema_field(serializers.CharField())
     def get_display(self, obj):
     def get_display(self, obj):
         return f'{obj.name} ({obj.module})'
         return f'{obj.name} ({obj.module})'
 
 
+    @extend_schema_field(serializers.CharField())
+    def get_description(self, obj):
+        if obj.python_class:
+            return obj.python_class().description
+        else:
+            return None
+
 
 
 class ScriptDetailSerializer(ScriptSerializer):
 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):
 class ScriptInputSerializer(serializers.Serializer):

+ 13 - 51
netbox/extras/api/views.py

@@ -1,5 +1,4 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from django_rq.queues import get_connection
 from django_rq.queues import get_connection
 from rest_framework import status
 from rest_framework import status
@@ -9,14 +8,13 @@ from rest_framework.generics import RetrieveUpdateDestroyAPIView
 from rest_framework.renderers import JSONRenderer
 from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 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 rq import Worker
 
 
-from core.choices import JobStatusChoices
 from core.models import Job
 from core.models import Job
 from extras import filtersets
 from extras import filtersets
 from extras.models import *
 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.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.features import SyncedDataMixin
 from netbox.api.features import SyncedDataMixin
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
@@ -209,66 +207,30 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
 # Scripts
 # Scripts
 #
 #
 
 
-class ScriptViewSet(ViewSet):
+class ScriptViewSet(ModelViewSet):
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
+    queryset = Script.objects.prefetch_related('jobs')
+    serializer_class = serializers.ScriptSerializer
+    filterset_class = filtersets.ScriptFilterSet
+
     _ignore_model_permissions = True
     _ignore_model_permissions = True
-    schema = None
     lookup_value_regex = '[^/]+'  # Allow dots
     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):
     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})
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
 
 
         return Response(serializer.data)
         return Response(serializer.data)
 
 
     def post(self, request, pk):
     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'):
         if not request.user.has_perm('extras.run_script'):
             raise PermissionDenied("This user does not have permission to run scripts.")
             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(
         input_serializer = serializers.ScriptInputSerializer(
             data=request.data,
             data=request.data,
             context={'script': script}
             context={'script': script}
@@ -281,13 +243,13 @@ class ScriptViewSet(ViewSet):
         if input_serializer.is_valid():
         if input_serializer.is_valid():
             script.result = Job.enqueue(
             script.result = Job.enqueue(
                 run_script,
                 run_script,
-                instance=module,
-                name=script.class_name,
+                instance=script.module,
+                name=script.python_class.class_name,
                 user=request.user,
                 user=request.user,
                 data=input_serializer.data['data'],
                 data=input_serializer.data['data'],
                 request=copy_safe_request(request),
                 request=copy_safe_request(request),
                 commit=input_serializer.data['commit'],
                 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'),
                 schedule_at=input_serializer.validated_data.get('schedule_at'),
                 interval=input_serializer.validated_data.get('interval')
                 interval=input_serializer.validated_data.get('interval')
             )
             )

+ 3 - 5
netbox/extras/events.py

@@ -116,15 +116,13 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
         # Scripts
         # Scripts
         elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
         elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
             # Resolve the script from action parameters
             # 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
             # Enqueue a Job to record the script's execution
             Job.enqueue(
             Job.enqueue(
                 "extras.scripts.run_script",
                 "extras.scripts.run_script",
-                instance=script_module,
-                name=script.class_name,
+                instance=script.module,
+                name=script.name,
                 user=user,
                 user=user,
                 data=data
                 data=data
             )
             )

+ 21 - 0
netbox/extras/filtersets.py

@@ -29,11 +29,32 @@ __all__ = (
     'LocalConfigContextFilterSet',
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
     'ObjectChangeFilterSet',
     'SavedFilterFilterSet',
     'SavedFilterFilterSet',
+    'ScriptFilterSet',
     'TagFilterSet',
     'TagFilterSet',
     'WebhookFilterSet',
     '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):
 class WebhookFilterSet(NetBoxModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',

+ 2 - 5
netbox/extras/forms/bulk_import.py

@@ -212,11 +212,8 @@ class EventRuleImportForm(NetBoxModelImportForm):
                     module, script = get_module_and_script(module_name, script_name)
                     module, script = get_module_and_script(module_name, script_name)
                 except ObjectDoesNotExist:
                 except ObjectDoesNotExist:
                     raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
                     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):
 class TagImportForm(CSVModelForm):

+ 12 - 29
netbox/extras/forms/model_forms.py

@@ -297,20 +297,16 @@ class EventRuleForm(NetBoxModelForm):
         }
         }
 
 
     def init_script_choice(self):
     def init_script_choice(self):
-        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):
     def init_webhook_choice(self):
         initial = None
         initial = None
@@ -348,26 +344,13 @@ class EventRuleForm(NetBoxModelForm):
         # Script
         # Script
         elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
         elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
             self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(
             self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(
-                ScriptModule,
+                Script,
                 for_concrete_model=False
                 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
         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):
 class TagForm(forms.ModelForm):
     slug = SlugField()
     slug = SlugField()

+ 159 - 0
netbox/extras/migrations/0109_script_model.py

@@ -0,0 +1,159 @@
+import inspect
+import os
+from importlib.machinery import SourceFileLoader
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+#
+# Note: This has a couple dependencies on the codebase if doing future modifications:
+# There are imports from extras.scripts and extras.reports as well as expecting
+# settings.SCRIPTS_ROOT and settings.REPORTS_ROOT to be in settings
+#
+
+ROOT_PATHS = {
+    'scripts': settings.SCRIPTS_ROOT,
+    'reports': settings.REPORTS_ROOT,
+}
+
+
+def get_full_path(scriptmodule):
+    """
+    Return the full path to a ScriptModule's file on disk.
+    """
+    root_path = ROOT_PATHS[scriptmodule.file_root]
+    return os.path.join(root_path, scriptmodule.file_path)
+
+
+def get_python_name(scriptmodule):
+    """
+    Return the Python name of a ScriptModule's file on disk.
+    """
+    path, filename = os.path.split(scriptmodule.file_path)
+    return os.path.splitext(filename)[0]
+
+
+def is_script(obj):
+    """
+    Returns True if the passed Python object is a Script or Report.
+    """
+    from extras.scripts import Script
+    from extras.reports import Report
+
+    try:
+        if issubclass(obj, Report) and obj != Report:
+            return True
+        if issubclass(obj, Script) and obj != Script:
+            return True
+    except TypeError:
+        pass
+    return False
+
+
+def get_module_scripts(scriptmodule):
+    """
+    Return a dictionary mapping of name and script class inside the passed ScriptModule.
+    """
+    def get_name(cls):
+        # For child objects in submodules use the full import path w/o the root module as the name
+        return cls.full_name.split(".", maxsplit=1)[1]
+
+    loader = SourceFileLoader(get_python_name(scriptmodule), get_full_path(scriptmodule))
+    module = loader.load_module()
+
+    scripts = {}
+    ordered = getattr(module, 'script_order', [])
+
+    for cls in ordered:
+        scripts[get_name(cls)] = cls
+    for name, cls in inspect.getmembers(module, is_script):
+        if cls not in ordered:
+            scripts[get_name(cls)] = cls
+
+    return scripts
+
+
+def update_scripts(apps, schema_editor):
+    """
+    Create a new Script object for each script inside each existing ScriptModule, and update any related jobs to
+    reference the new Script object.
+    """
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    Script = apps.get_model('extras', 'Script')
+    ScriptModule = apps.get_model('extras', 'ScriptModule')
+    Job = apps.get_model('core', 'Job')
+
+    script_ct = ContentType.objects.get_for_model(Script)
+    scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule)
+
+    for module in ScriptModule.objects.all():
+        for script_name in get_module_scripts(module):
+            script = Script.objects.create(
+                name=script_name,
+                module=module,
+            )
+
+            # Update all Jobs associated with this ScriptModule & script name to point to the new Script object
+            Job.objects.filter(
+                object_type=scriptmodule_ct,
+                object_id=module.pk,
+                name=script_name
+            ).update(object_type=script_ct, object_id=script.pk)
+
+
+def update_event_rules(apps, schema_editor):
+    """
+    Update any existing EventRules for scripts. Change action_object_type from ScriptModule to Script, and populate
+    the ID of the related Script object.
+    """
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    Script = apps.get_model('extras', 'Script')
+    ScriptModule = apps.get_model('extras', 'ScriptModule')
+    EventRule = apps.get_model('extras', 'EventRule')
+
+    script_ct = ContentType.objects.get_for_model(Script)
+    scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule)
+
+    for eventrule in EventRule.objects.filter(action_object_type=scriptmodule_ct):
+        name = eventrule.action_parameters.get('script_name')
+        obj, created = Script.objects.get_or_create(
+            module_id=eventrule.action_object_id,
+            name=name,
+            defaults={'is_executable': False}
+        )
+        EventRule.objects.filter(pk=eventrule.pk).update(action_object_type=script_ct, action_object_id=obj.id)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0108_convert_reports_to_scripts'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Script',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(editable=False, max_length=79)),
+                ('module', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='scripts', to='extras.scriptmodule')),
+                ('is_executable', models.BooleanField(editable=False, default=True))
+            ],
+            options={
+                'ordering': ('module', 'name'),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='script',
+            constraint=models.UniqueConstraint(fields=('name', 'module'), name='extras_script_unique_name_module'),
+        ),
+        migrations.RunPython(
+            code=update_scripts,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=update_event_rules,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 15 - 0
netbox/extras/migrations/0110_remove_eventrule_action_parameters.py

@@ -0,0 +1,15 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0109_script_model'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='eventrule',
+            name='action_parameters',
+        ),
+    ]

+ 0 - 4
netbox/extras/models/models.py

@@ -115,10 +115,6 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
         ct_field='action_object_type',
         ct_field='action_object_type',
         fk_field='action_object_id'
         fk_field='action_object_id'
     )
     )
-    action_parameters = models.JSONField(
-        blank=True,
-        null=True
-    )
     action_data = models.JSONField(
     action_data = models.JSONField(
         verbose_name=_('data'),
         verbose_name=_('data'),
         blank=True,
         blank=True,

+ 93 - 6
netbox/extras/models/scripts.py

@@ -2,8 +2,11 @@ import inspect
 import logging
 import logging
 from functools import cached_property
 from functools import cached_property
 
 
+from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db import models
 from django.db.models import Q
 from django.db.models import Q
+from django.db.models.signals import post_save
+from django.dispatch import receiver
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
@@ -22,12 +25,63 @@ __all__ = (
 logger = logging.getLogger('netbox.data_backends')
 logger = logging.getLogger('netbox.data_backends')
 
 
 
 
-class Script(EventRulesMixin, models.Model):
-    """
-    Dummy model used to generate permissions for custom scripts. Does not exist in the database.
-    """
+class Script(EventRulesMixin, JobsMixin):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=79,  # Maximum length for a Python class name
+        editable=False,
+    )
+    module = models.ForeignKey(
+        to='extras.ScriptModule',
+        on_delete=models.CASCADE,
+        related_name='scripts',
+        editable=False
+    )
+    is_executable = models.BooleanField(
+        default=True,
+        verbose_name=_('is executable'),
+        editable=False
+    )
+    events = GenericRelation(
+        'extras.EventRule',
+        content_type_field='action_object_type',
+        object_id_field='action_object_id'
+    )
+
+    def __str__(self):
+        return self.name
+
+    objects = RestrictedQuerySet.as_manager()
+
     class Meta:
     class Meta:
-        managed = False
+        ordering = ('module', 'name')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('name', 'module'),
+                name='extras_script_unique_name_module'
+            ),
+        )
+        verbose_name = _('script')
+        verbose_name_plural = _('scripts')
+
+    def get_absolute_url(self):
+        return reverse('extras:script', args=[self.pk])
+
+    @property
+    def result(self):
+        return self.jobs.all().order_by('-created').first()
+
+    @cached_property
+    def python_class(self):
+        return self.module.module_scripts.get(self.name)
+
+    def delete(self, soft_delete=False, **kwargs):
+        if soft_delete and self.jobs.exists():
+            self.is_executable = False
+            self.save()
+        else:
+            super().delete(**kwargs)
+            self.id = None
 
 
 
 
 class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
 class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
@@ -55,7 +109,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
         return self.python_name
         return self.python_name
 
 
     @cached_property
     @cached_property
-    def scripts(self):
+    def module_scripts(self):
 
 
         def _get_name(cls):
         def _get_name(cls):
             # For child objects in submodules use the full import path w/o the root module as the name
             # For child objects in submodules use the full import path w/o the root module as the name
@@ -78,6 +132,39 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
 
 
         return scripts
         return scripts
 
 
+    def sync_classes(self):
+        """
+        Syncs the file-based module to the database, adding and removing individual Script objects
+        in the database as needed.
+        """
+        db_classes = {
+            script.name: script for script in self.scripts.all()
+        }
+        db_classes_set = set(db_classes.keys())
+        module_classes_set = set(self.module_scripts.keys())
+
+        # remove any existing db classes if they are no longer in the file
+        removed = db_classes_set - module_classes_set
+        for name in removed:
+            db_classes[name].delete(soft_delete=True)
+
+        added = module_classes_set - db_classes_set
+        for name in added:
+            Script.objects.create(
+                module=self,
+                name=name,
+                is_executable=True,
+            )
+
+    def sync_data(self):
+        super().sync_data()
+        self.sync_classes()
+
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         self.file_root = ManagedFileRootPathChoices.SCRIPTS
         self.file_root = ManagedFileRootPathChoices.SCRIPTS
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)
+
+
+@receiver(post_save, sender=ScriptModule)
+def script_module_post_save_handler(instance, created, **kwargs):
+    instance.sync_classes()

+ 1 - 0
netbox/extras/reports.py

@@ -6,6 +6,7 @@ __all__ = (
 )
 )
 
 
 
 
+# Required by extras/migrations/0109_script_models.py
 class Report(BaseScript):
 class Report(BaseScript):
 
 
     #
     #

+ 3 - 4
netbox/extras/scripts.py

@@ -17,7 +17,7 @@ from django.utils.translation import gettext as _
 from core.choices import JobStatusChoices
 from core.choices import JobStatusChoices
 from core.models import Job
 from core.models import Job
 from extras.choices import LogLevelChoices
 from extras.choices import LogLevelChoices
-from extras.models import ScriptModule
+from extras.models import ScriptModule, Script as ScriptModel
 from extras.signals import clear_events
 from extras.signals import clear_events
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@@ -582,7 +582,7 @@ def is_variable(obj):
 
 
 def get_module_and_script(module_name, script_name):
 def get_module_and_script(module_name, script_name):
     module = ScriptModule.objects.get(file_path=f'{module_name}.py')
     module = ScriptModule.objects.get(file_path=f'{module_name}.py')
-    script = module.scripts.get(script_name)
+    script = module.scripts.get(name=script_name)
     return module, script
     return module, script
 
 
 
 
@@ -599,8 +599,7 @@ def run_script(data, job, request=None, commit=True, **kwargs):
     """
     """
     job.start()
     job.start()
 
 
-    module = ScriptModule.objects.get(pk=job.object_id)
-    script = module.scripts.get(job.name)()
+    script = ScriptModel.objects.get(pk=job.object_id).python_class()
 
 
     logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
     logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
     logger.info(f"Running script (commit={commit})")
     logger.info(f"Running script (commit={commit})")

+ 19 - 10
netbox/extras/tests/test_api.py

@@ -11,7 +11,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Loca
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
 from extras.reports import Report
 from extras.reports import Report
-from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
+from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
 User = get_user_model()
 User = get_user_model()
@@ -748,7 +748,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
 
 
 class ScriptTest(APITestCase):
 class ScriptTest(APITestCase):
 
 
-    class TestScript(Script):
+    class TestScriptClass(PythonClass):
 
 
         class Meta:
         class Meta:
             name = "Test script"
             name = "Test script"
@@ -767,27 +767,36 @@ class ScriptTest(APITestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-        ScriptModule.objects.create(
+        module = ScriptModule.objects.create(
             file_root=ManagedFileRootPathChoices.SCRIPTS,
             file_root=ManagedFileRootPathChoices.SCRIPTS,
             file_path='/var/tmp/script.py'
             file_path='/var/tmp/script.py'
         )
         )
+        Script.objects.create(
+            module=module,
+            name="Test script",
+            is_executable=True,
+        )
 
 
-    def get_test_script(self, *args):
-        return ScriptModule.objects.first(), self.TestScript
+    def python_class(self):
+        return self.TestScriptClass
 
 
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
 
 
-        # Monkey-patch the API viewset's _get_script() method to return our test Script above
+        # Monkey-patch the Script model to return our TestScriptClass above
         from extras.api.views import ScriptViewSet
         from extras.api.views import ScriptViewSet
-        ScriptViewSet._get_script = self.get_test_script
+        Script.python_class = self.python_class
 
 
     def test_get_script(self):
     def test_get_script(self):
-
-        url = reverse('extras-api:script-detail', kwargs={'pk': None})
+        module = ScriptModule.objects.get(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path='/var/tmp/script.py'
+        )
+        script = module.scripts.all().first()
+        url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
 
 
-        self.assertEqual(response.data['name'], self.TestScript.Meta.name)
+        self.assertEqual(response.data['name'], self.TestScriptClass.Meta.name)
         self.assertEqual(response.data['vars']['var1'], 'StringVar')
         self.assertEqual(response.data['vars']['var1'], 'StringVar')
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
         self.assertEqual(response.data['vars']['var3'], 'BooleanVar')

+ 9 - 4
netbox/extras/urls.py

@@ -120,10 +120,15 @@ urlpatterns = [
     path('scripts/', views.ScriptListView.as_view(), name='script_list'),
     path('scripts/', views.ScriptListView.as_view(), name='script_list'),
     path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
     path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
     path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'),
     path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'),
-    path('scripts/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
-    path('scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
-    path('scripts/<str:module>/<str:name>/source/', views.ScriptSourceView.as_view(), name='script_source'),
-    path('scripts/<str:module>/<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
+    path('scripts/<int:pk>/', views.ScriptView.as_view(), name='script'),
+    path('scripts/<int:pk>/source/', views.ScriptSourceView.as_view(), name='script_source'),
+    path('scripts/<int:pk>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
+    path('script-modules/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
+
+    # Redirects for legacy script URLs
+    # TODO: Remove in NetBox v4.1
+    path('scripts/<str:module>/<str:name>/', views.LegacyScriptRedirectView.as_view()),
+    path('scripts/<str:module>/<str:name>/<path:path>/', views.LegacyScriptRedirectView.as_view()),
 
 
     # Markdown
     # Markdown
     path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"),
     path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"),

+ 61 - 62
netbox/extras/views.py

@@ -920,7 +920,7 @@ class DashboardWidgetAddView(LoginRequiredMixin, View):
                 widget = widget_class(**data)
                 widget = widget_class(**data)
                 request.user.dashboard.add_widget(widget)
                 request.user.dashboard.add_widget(widget)
                 request.user.dashboard.save()
                 request.user.dashboard.save()
-                messages.success(request, f'Added widget {widget.id}')
+                messages.success(request, _('Added widget: ') + str(widget.id))
 
 
                 return HttpResponse(headers={
                 return HttpResponse(headers={
                     'HX-Redirect': reverse('home'),
                     'HX-Redirect': reverse('home'),
@@ -961,7 +961,7 @@ class DashboardWidgetConfigView(LoginRequiredMixin, View):
             data['config'] = config_form.cleaned_data
             data['config'] = config_form.cleaned_data
             request.user.dashboard.config[str(id)].update(data)
             request.user.dashboard.config[str(id)].update(data)
             request.user.dashboard.save()
             request.user.dashboard.save()
-            messages.success(request, f'Updated widget {widget.id}')
+            messages.success(request, _('Updated widget: ') + str(widget.id))
 
 
             return HttpResponse(headers={
             return HttpResponse(headers={
                 'HX-Redirect': reverse('home'),
                 'HX-Redirect': reverse('home'),
@@ -997,9 +997,9 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
         if form.is_valid():
         if form.is_valid():
             request.user.dashboard.delete_widget(id)
             request.user.dashboard.delete_widget(id)
             request.user.dashboard.save()
             request.user.dashboard.save()
-            messages.success(request, f'Deleted widget {id}')
+            messages.success(request, _('Deleted widget: ') + str(id))
         else:
         else:
-            messages.error(request, f'Error deleting widget: {form.errors[0]}')
+            messages.error(request, _('Error deleting widget: ') + str(form.errors[0]))
 
 
         return redirect(reverse('home'))
         return redirect(reverse('home'))
 
 
@@ -1030,7 +1030,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         return 'extras.view_script'
         return 'extras.view_script'
 
 
     def get(self, request):
     def get(self, request):
-        script_modules = ScriptModule.objects.restrict(request.user)
+        script_modules = ScriptModule.objects.restrict(request.user).prefetch_related('jobs')
 
 
         return render(request, 'extras/script_list.html', {
         return render(request, 'extras/script_list.html', {
             'model': ScriptModule,
             'model': ScriptModule,
@@ -1038,123 +1038,122 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         })
         })
 
 
 
 
-def get_script_module(module, request):
-    return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
+class ScriptView(generic.ObjectView):
+    queryset = Script.objects.all()
 
 
-
-class ScriptView(ContentTypePermissionRequiredMixin, View):
-
-    def get_required_permission(self):
-        return 'extras.view_script'
-
-    def get(self, request, module, name):
-        module = get_script_module(module, request)
-        script = module.scripts[name]()
-        jobs = module.get_jobs(script.class_name)
-        form = script.as_form(initial=normalize_querydict(request.GET))
+    def get(self, request, **kwargs):
+        script = self.get_object(**kwargs)
+        script_class = script.python_class()
+        form = script_class.as_form(initial=normalize_querydict(request.GET))
 
 
         return render(request, 'extras/script.html', {
         return render(request, 'extras/script.html', {
-            'job_count': jobs.count(),
-            'module': module,
             'script': script,
             'script': script,
+            'script_class': script_class,
             'form': form,
             'form': form,
+            'job_count': script.jobs.count(),
         })
         })
 
 
-    def post(self, request, module, name):
-        if not request.user.has_perm('extras.run_script'):
+    def post(self, request, **kwargs):
+        script = self.get_object(**kwargs)
+        script_class = script.python_class()
+
+        if not request.user.has_perm('extras.run_script', obj=script):
             return HttpResponseForbidden()
             return HttpResponseForbidden()
 
 
-        module = get_script_module(module, request)
-        script = module.scripts[name]()
-        jobs = module.get_jobs(script.class_name)
-        form = script.as_form(request.POST, request.FILES)
+        form = script_class.as_form(request.POST, request.FILES)
 
 
         # Allow execution only if RQ worker process is running
         # Allow execution only if RQ worker process is running
         if not get_workers_for_queue('default'):
         if not get_workers_for_queue('default'):
-            messages.error(request, "Unable to run script: RQ worker process not running.")
-
+            messages.error(request, _("Unable to run script: RQ worker process not running."))
         elif form.is_valid():
         elif form.is_valid():
             job = Job.enqueue(
             job = Job.enqueue(
                 run_script,
                 run_script,
-                instance=module,
-                name=script.class_name,
+                instance=script,
+                name=script_class.class_name,
                 user=request.user,
                 user=request.user,
                 schedule_at=form.cleaned_data.pop('_schedule_at'),
                 schedule_at=form.cleaned_data.pop('_schedule_at'),
                 interval=form.cleaned_data.pop('_interval'),
                 interval=form.cleaned_data.pop('_interval'),
                 data=form.cleaned_data,
                 data=form.cleaned_data,
                 request=copy_safe_request(request),
                 request=copy_safe_request(request),
-                job_timeout=script.job_timeout,
+                job_timeout=script.python_class.job_timeout,
                 commit=form.cleaned_data.pop('_commit')
                 commit=form.cleaned_data.pop('_commit')
             )
             )
 
 
             return redirect('extras:script_result', job_pk=job.pk)
             return redirect('extras:script_result', job_pk=job.pk)
 
 
         return render(request, 'extras/script.html', {
         return render(request, 'extras/script.html', {
-            'job_count': jobs.count(),
-            'module': module,
             'script': script,
             'script': script,
+            'script_class': script.python_class(),
             'form': form,
             'form': form,
+            'job_count': script.jobs.count(),
         })
         })
 
 
 
 
-class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
+class ScriptSourceView(generic.ObjectView):
+    queryset = Script.objects.all()
 
 
-    def get_required_permission(self):
-        return 'extras.view_script'
-
-    def get(self, request, module, name):
-        module = get_script_module(module, request)
-        script = module.scripts[name]()
-        jobs = module.get_jobs(script.class_name)
+    def get(self, request, **kwargs):
+        script = self.get_object(**kwargs)
 
 
         return render(request, 'extras/script/source.html', {
         return render(request, 'extras/script/source.html', {
-            'job_count': jobs.count(),
-            'module': module,
             'script': script,
             'script': script,
+            'script_class': script.python_class(),
+            'job_count': script.jobs.count(),
             'tab': 'source',
             'tab': 'source',
         })
         })
 
 
 
 
-class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
-
-    def get_required_permission(self):
-        return 'extras.view_script'
+class ScriptJobsView(generic.ObjectView):
+    queryset = Script.objects.all()
 
 
-    def get(self, request, module, name):
-        module = get_script_module(module, request)
-        script = module.scripts[name]()
-        jobs = module.get_jobs(script.class_name)
+    def get(self, request, **kwargs):
+        script = self.get_object(**kwargs)
 
 
         jobs_table = JobTable(
         jobs_table = JobTable(
-            data=jobs,
+            data=script.jobs.all(),
             orderable=False,
             orderable=False,
             user=request.user
             user=request.user
         )
         )
         jobs_table.configure(request)
         jobs_table.configure(request)
 
 
         return render(request, 'extras/script/jobs.html', {
         return render(request, 'extras/script/jobs.html', {
-            'job_count': jobs.count(),
-            'module': module,
             'script': script,
             'script': script,
             'table': jobs_table,
             'table': jobs_table,
+            'job_count': script.jobs.count(),
             'tab': 'jobs',
             'tab': 'jobs',
         })
         })
 
 
 
 
-class ScriptResultView(ContentTypePermissionRequiredMixin, View):
-
+class LegacyScriptRedirectView(ContentTypePermissionRequiredMixin, View):
+    """
+    Redirect legacy (pre-v4.0) script URLs. Examples:
+        /extras/scripts/<module>/<name>/         -->  /extras/scripts/<id>/
+        /extras/scripts/<module>/<name>/source/  -->  /extras/scripts/<id>/source/
+        /extras/scripts/<module>/<name>/jobs/    -->  /extras/scripts/<id>/jobs/
+    """
     def get_required_permission(self):
     def get_required_permission(self):
         return 'extras.view_script'
         return 'extras.view_script'
 
 
-    def get(self, request, job_pk):
-        object_type = ContentType.objects.get_by_natural_key(app_label='extras', model='scriptmodule')
-        job = get_object_or_404(Job.objects.all(), pk=job_pk, object_type=object_type)
+    def get(self, request, module, name, path=''):
+        module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
+        script = get_object_or_404(Script.objects.all(), module=module, name=name)
+
+        url = reverse('extras:script', kwargs={'pk': script.pk})
+
+        return redirect(f'{url}{path}')
+
 
 
-        module = job.object
-        script = module.scripts[job.name]()
+class ScriptResultView(generic.ObjectView):
+    queryset = Job.objects.all()
+
+    def get_required_permission(self):
+        return 'extras.view_script'
+
+    def get(self, request, **kwargs):
+        job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk'))
 
 
         context = {
         context = {
-            'script': script,
+            'script': job.object,
             'job': job,
             'job': job,
         }
         }
         if job.data and 'log' in job.data:
         if job.data and 'log' in job.data:

+ 1 - 0
netbox/netbox/settings.py

@@ -156,6 +156,7 @@ REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', [])
 REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
 REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
 REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
 REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
 REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
 REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
+# Required by extras/migrations/0109_script_models.py
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
 RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)

+ 0 - 6
netbox/templates/extras/eventrule.html

@@ -83,13 +83,7 @@
           <tr>
           <tr>
             <th scope="row">{% trans "Object" %}</th>
             <th scope="row">{% trans "Object" %}</th>
             <td>
             <td>
-              {% if object.action_type == 'script' %}
-                <a href="{% url 'extras:script' module=object.action_object.python_name name=object.action_parameters.script_name %}">
-                 {{ object.action_object }} / {{ object.action_parameters.script_name }}
-                </a>
-              {% else %}
                 {{ object.action_object|linkify }}
                 {{ object.action_object|linkify }}
-              {% endif %}
             </td>
             </td>
           </tr>
           </tr>
           <tr>
           <tr>

+ 12 - 3
netbox/templates/extras/script.html

@@ -2,6 +2,7 @@
 {% load helpers %}
 {% load helpers %}
 {% load form_helpers %}
 {% load form_helpers %}
 {% load log_levels %}
 {% load log_levels %}
+{% load perms %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block content %}
 {% block content %}
@@ -17,7 +18,7 @@
         {% csrf_token %}
         {% csrf_token %}
         <div class="field-group my-4">
         <div class="field-group my-4">
           {# Render grouped fields according to declared fieldsets #}
           {# Render grouped fields according to declared fieldsets #}
-          {% for group, fields in script.get_fieldsets %}
+          {% for group, fields in script_class.get_fieldsets %}
             {% if fields %}
             {% if fields %}
               <div class="field-group mb-5">
               <div class="field-group mb-5">
                 <div class="row">
                 <div class="row">
@@ -32,9 +33,17 @@
             {% endif %}
             {% endif %}
           {% endfor %}
           {% endfor %}
         </div>
         </div>
-        <div class="float-end">
+        <div class="text-end">
           <a href="{% url 'extras:script_list' %}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
           <a href="{% url 'extras:script_list' %}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
-          <button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="mdi mdi-play"></i> {% trans "Run Script" %}</button>
+          {% if not request.user|can_run:script or not script.is_executable %}
+            <button class="btn btn-primary" disabled>
+              <i class="mdi mdi-play"></i> {% trans "Run Script" %}
+            </button>
+          {% else %}
+            <button type="submit" name="_run" class="btn btn-primary">
+              <i class="mdi mdi-play"></i> {% trans "Run Script" %}
+            </button>
+          {% endif %}
         </div>
         </div>
       </form>
       </form>
     </div>
     </div>

+ 4 - 4
netbox/templates/extras/script/base.html

@@ -12,7 +12,7 @@
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">{% trans "Scripts" %}</a></li>
   <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">{% trans "Scripts" %}</a></li>
-  <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module{{ module.pk }}">{{ module|bettertitle }}</a></li>
+  <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module{{ script.module.pk }}">{{ script.module|bettertitle }}</a></li>
 {% endblock breadcrumbs %}
 {% endblock breadcrumbs %}
 
 
 {% block subtitle %}
 {% block subtitle %}
@@ -26,13 +26,13 @@
 {% block tabs %}
 {% block tabs %}
   <ul class="nav nav-tabs">
   <ul class="nav nav-tabs">
     <li class="nav-item" role="presentation">
     <li class="nav-item" role="presentation">
-      <a class="nav-link{% if not tab %} active{% endif %}" href="{% url 'extras:script' module=script.module name=script.class_name %}">{% trans "Script" %}</a>
+      <a class="nav-link{% if not tab %} active{% endif %}{% if not script.is_executable %} disabled{% endif %}" href="{{ script.get_absolute_url }}">{% trans "Script" %}</a>
     </li>
     </li>
     <li class="nav-item" role="presentation">
     <li class="nav-item" role="presentation">
-      <a class="nav-link{% if tab == 'source' %} active{% endif %}" href="{% url 'extras:script_source' module=script.module name=script.class_name %}">{% trans "Source" %}</a>
+      <a class="nav-link{% if tab == 'source' %} active{% endif %}{% if not script.is_executable %} disabled{% endif %}" href="{% url 'extras:script_source' script.id %}">{% trans "Source" %}</a>
     </li>
     </li>
     <li class="nav-item" role="presentation">
     <li class="nav-item" role="presentation">
-      <a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:script_jobs' module=script.module name=script.class_name %}">
+      <a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:script_jobs' script.id %}">
         {% trans "Jobs" %} {% badge job_count %}
         {% trans "Jobs" %} {% badge job_count %}
       </a>
       </a>
     </li>
     </li>

+ 13 - 0
netbox/templates/extras/script/jobs.html

@@ -1,11 +1,24 @@
 {% extends 'extras/script/base.html' %}
 {% extends 'extras/script/base.html' %}
 {% load render_table from django_tables2 %}
 {% load render_table from django_tables2 %}
+{% load i18n %}
 
 
 {% block content %}
 {% block content %}
   <div class="row mb-3">
   <div class="row mb-3">
     <div class="col col-md-12">
     <div class="col col-md-12">
       <div class="card">
       <div class="card">
         <div class="card-body table-responsive">
         <div class="card-body table-responsive">
+
+          {% if not script.is_executable %}
+            <div class="alert alert-warning" role="alert">
+              <div class="d-flex justify-content-between">
+                <div>
+                  <i class="mdi mdi-alert"></i>
+                  {% trans "Script no longer exists in the source file." %}
+                </div>
+              </div>
+            </div>
+          {% endif %}
+
           {% render_table table 'inc/table.html' %}
           {% render_table table 'inc/table.html' %}
           {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
           {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
         </div>
         </div>

+ 2 - 2
netbox/templates/extras/script/source.html

@@ -1,6 +1,6 @@
 {% extends 'extras/script/base.html' %}
 {% extends 'extras/script/base.html' %}
 
 
 {% block content %}
 {% block content %}
-  <code class="h6 my-3 d-block">{{ script.filename }}</code>
-  <pre class="block">{{ script.source }}</pre>
+  <code class="h6 my-3 d-block">{{ script_class.filename }}</code>
+  <pre class="block">{{ script_class.source }}</pre>
 {% endblock %}
 {% endblock %}

+ 81 - 69
netbox/templates/extras/script_list.html

@@ -26,11 +26,18 @@
         <div>
         <div>
           <i class="mdi mdi-file-document-outline"></i> {{ module }}
           <i class="mdi mdi-file-document-outline"></i> {{ module }}
         </div>
         </div>
-        {% if perms.extras.delete_scriptmodule %}
-          <a href="{% url 'extras:scriptmodule_delete' pk=module.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </a>
-        {% endif %}
+        <div>
+          {% if perms.extras.edit_scriptmodule %}
+            <a href="{% url 'extras:scriptmodule_edit' pk=module.pk %}" class="btn btn-warning btn-sm">
+              <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
+            </a>
+          {% endif %}
+          {% if perms.extras.delete_scriptmodule %}
+            <a href="{% url 'extras:scriptmodule_delete' pk=module.pk %}" class="btn btn-danger btn-sm">
+              <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
+            </a>
+          {% endif %}
+        </div>
       </h5>
       </h5>
       {% if module.scripts %}
       {% if module.scripts %}
         <table class="table table-hover scripts">
         <table class="table table-hover scripts">
@@ -44,75 +51,80 @@
             </tr>
             </tr>
           </thead>
           </thead>
           <tbody>
           <tbody>
-            {% with jobs=module.get_latest_jobs %}
-              {% for script_name, script in module.scripts.items %}
-                {% with last_job=jobs|get_key:script.class_name %}
-                  <tr>
-                    <td>
-                      <a href="{% url 'extras:script' module=module.python_name name=script.class_name %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.name }}</a>
-                    </td>
-                    <td>{{ script.description|markdown|placeholder }}</td>
-                    {% if last_job %}
-                      <td>
-                        <a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
-                      </td>
-                      <td>
-                        {% badge last_job.get_status_display last_job.get_status_color %}
-                      </td>
+            {% for script in module.scripts.all %}
+              {% with last_job=script.get_latest_jobs|get_key:script.name %}
+                <tr>
+                  <td>
+                    {% if script.is_executable %}
+                      <a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.name }}</a>
                     {% else %}
                     {% else %}
-                      <td class="text-muted">{% trans "Never" %}</td>
-                      <td>{{ ''|placeholder }}</td>
+                      <a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.name }}</a>
+                      <span class="text-danger">
+                        <i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
+                      </span>
                     {% endif %}
                     {% endif %}
+                  </td>
+                  <td>{{ script.description|markdown|placeholder }}</td>
+                  {% if last_job %}
                     <td>
                     <td>
-                      {% if perms.extras.run_script %}
-                        <div class="float-end d-print-none">
-                          <form action="{% url 'extras:script' module=script.module name=script.class_name %}" method="post">
-                            {% csrf_token %}
-                            <button type="submit" name="_run" class="btn btn-primary btn-sm">
-                              {% if last_job %}
-                                <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
-                              {% else %}
-                                <i class="mdi mdi-play"></i> {% trans "Run Script" %}
-                              {% endif %}
-                            </button>
-                          </form>
-                        </div>
-                      {% endif %}
+                      <a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
                     </td>
                     </td>
-                  </tr>
-                  {% if last_job %}
-                    {% for test_name, data in last_job.data.tests.items %}
-                      <tr>
-                        <td colspan="4" class="method">
-                          <span class="ps-3">{{ test_name }}</span>
-                        </td>
-                        <td class="text-end text-nowrap script-stats">
-                          <span class="badge text-bg-success">{{ data.success }}</span>
-                          <span class="badge text-bg-info">{{ data.info }}</span>
-                          <span class="badge text-bg-warning">{{ data.warning }}</span>
-                          <span class="badge text-bg-danger">{{ data.failure }}</span>
-                        </td>
-                      </tr>
-                    {% endfor %}
-                  {% elif not last_job.data.log %}
-                    {# legacy #}
-                    {% for method, stats in last_job.data.items %}
-                      <tr>
-                        <td colspan="4" class="method">
-                          <span class="ps-3">{{ method }}</span>
-                        </td>
-                        <td class="text-end text-nowrap report-stats">
-                          <span class="badge bg-success">{{ stats.success }}</span>
-                          <span class="badge bg-info">{{ stats.info }}</span>
-                          <span class="badge bg-warning">{{ stats.warning }}</span>
-                          <span class="badge bg-danger">{{ stats.failure }}</span>
-                        </td>
-                      </tr>
-                    {% endfor %}
+                    <td>
+                      {% badge last_job.get_status_display last_job.get_status_color %}
+                    </td>
+                  {% else %}
+                    <td class="text-muted">{% trans "Never" %}</td>
+                    <td>{{ ''|placeholder }}</td>
                   {% endif %}
                   {% endif %}
-                {% endwith %}
-              {% endfor %}
-            {% endwith %}
+                  <td>
+                    {% if request.user|can_run:script and script.is_executable %}
+                      <div class="float-end d-print-none">
+                        <form action="{% url 'extras:script' script.pk %}" method="post">
+                          {% csrf_token %}
+                          <button type="submit" name="_run" class="btn btn-primary btn-sm">
+                            {% if last_job %}
+                              <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
+                            {% else %}
+                              <i class="mdi mdi-play"></i> {% trans "Run Script" %}
+                            {% endif %}
+                          </button>
+                        </form>
+                      </div>
+                    {% endif %}
+                  </td>
+                </tr>
+                {% if last_job %}
+                  {% for test_name, data in last_job.data.tests.items %}
+                    <tr>
+                      <td colspan="4" class="method">
+                        <span class="ps-3">{{ test_name }}</span>
+                      </td>
+                      <td class="text-end text-nowrap script-stats">
+                        <span class="badge text-bg-success">{{ data.success }}</span>
+                        <span class="badge text-bg-info">{{ data.info }}</span>
+                        <span class="badge text-bg-warning">{{ data.warning }}</span>
+                        <span class="badge text-bg-danger">{{ data.failure }}</span>
+                      </td>
+                    </tr>
+                  {% endfor %}
+                {% elif not last_job.data.log %}
+                  {# legacy #}
+                  {% for method, stats in last_job.data.items %}
+                    <tr>
+                      <td colspan="4" class="method">
+                        <span class="ps-3">{{ method }}</span>
+                      </td>
+                      <td class="text-end text-nowrap report-stats">
+                        <span class="badge bg-success">{{ stats.success }}</span>
+                        <span class="badge bg-info">{{ stats.info }}</span>
+                        <span class="badge bg-warning">{{ stats.warning }}</span>
+                        <span class="badge bg-danger">{{ stats.failure }}</span>
+                      </td>
+                    </tr>
+                  {% endfor %}
+                {% endif %}
+              {% endwith %}
+            {% endfor %}
           </tbody>
           </tbody>
         </table>
         </table>
       {% else %}
       {% else %}

+ 1 - 1
netbox/templates/extras/script_result.html

@@ -16,7 +16,7 @@
       <ol class="breadcrumb">
       <ol class="breadcrumb">
         <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">{% trans "Scripts" %}</a></li>
         <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">{% trans "Scripts" %}</a></li>
         <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</a></li>
         <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</a></li>
-        <li class="breadcrumb-item"><a href="{% url 'extras:script' module=script.module name=script.class_name %}">{{ script }}</a></li>
+        <li class="breadcrumb-item"><a href="{{ script.get_absolute_url }}">{{ script }}</a></li>
         <li class="breadcrumb-item">{{ job.created|annotated_date }}</li>
         <li class="breadcrumb-item">{{ job.created|annotated_date }}</li>
       </ol>
       </ol>
     </nav>
     </nav>

+ 0 - 5
netbox/users/forms/model_forms.py

@@ -376,11 +376,6 @@ class ObjectPermissionForm(forms.ModelForm):
             for ct in object_types:
             for ct in object_types:
                 model = ct.model_class()
                 model = ct.model_class()
 
 
-                if model._meta.model_name in ['script', 'report']:
-                    raise forms.ValidationError({
-                        'constraints': _('Constraints are not supported for this object type.')
-                    })
-
                 try:
                 try:
                     tokens = {
                     tokens = {
                         CONSTRAINT_TOKEN_USER: 0,  # Replace token with a null user ID
                         CONSTRAINT_TOKEN_USER: 0,  # Replace token with a null user ID

+ 6 - 0
netbox/utilities/templatetags/perms.py

@@ -6,6 +6,7 @@ __all__ = (
     'can_add',
     'can_add',
     'can_change',
     'can_change',
     'can_delete',
     'can_delete',
+    'can_run',
     'can_sync',
     'can_sync',
     'can_view',
     'can_view',
 )
 )
@@ -42,3 +43,8 @@ def can_delete(user, instance):
 @register.filter()
 @register.filter()
 def can_sync(user, instance):
 def can_sync(user, instance):
     return _check_permission(user, instance, 'sync')
     return _check_permission(user, instance, 'sync')
+
+
+@register.filter()
+def can_run(user, instance):
+    return _check_permission(user, instance, 'run')