Explorar el Código

Merge pull request #10417 from kkthxbye-code/8366-job-scheduling

Fixes #8366 - Add job scheduling
Jeremy Stretch hace 3 años
padre
commit
5d56d95fda

+ 3 - 1
docs/customization/custom-scripts.md

@@ -267,7 +267,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
 
 ### Via the Web UI
 
-Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button.
+Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button. It is possible to schedule a script to be executed at specified time in the future. A scheduled script can be canceled by deleting the associated job result object.
 
 ### Via the API
 
@@ -282,6 +282,8 @@ http://netbox/api/extras/scripts/example.MyReport/ \
 --data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
 ```
 
+Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time.
+
 ### Via the CLI
 
 Scripts can be run on the CLI by invoking the management command:

+ 3 - 1
docs/customization/reports.md

@@ -136,7 +136,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
 
 ### Via the Web UI
 
-Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view.
+Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view. It is possible to schedule a report to be executed at specified time in the future. A scheduled report can be canceled by deleting the associated job result object.
 
 ### Via the API
 
@@ -152,6 +152,8 @@ Our example report above would be called as:
     POST /api/extras/reports/devices.DeviceConnectionsReport/run/
 ```
 
+Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time.
+
 ### Via the CLI
 
 Reports can be run on the CLI by invoking the management command:

+ 0 - 21
netbox/extras/admin.py

@@ -131,24 +131,3 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
         })
 
         return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context)
-
-
-#
-# Reports & scripts
-#
-
-@admin.register(JobResult)
-class JobResultAdmin(admin.ModelAdmin):
-    list_display = [
-        'obj_type', 'name', 'created', 'completed', 'user', 'status',
-    ]
-    fields = [
-        'obj_type', 'name', 'created', 'completed', 'user', 'status', 'data', 'job_id'
-    ]
-    list_filter = [
-        'status',
-    ]
-    readonly_fields = fields
-
-    def has_add_permission(self, request):
-        return False

+ 7 - 1
netbox/extras/api/serializers.py

@@ -38,6 +38,7 @@ __all__ = (
     'ObjectChangeSerializer',
     'ReportDetailSerializer',
     'ReportSerializer',
+    'ReportInputSerializer',
     'ScriptDetailSerializer',
     'ScriptInputSerializer',
     'ScriptLogMessageSerializer',
@@ -362,7 +363,7 @@ class JobResultSerializer(BaseModelSerializer):
     class Meta:
         model = JobResult
         fields = [
-            'id', 'url', 'display', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
+            'id', 'url', 'display', 'created', 'completed', 'scheduled_time', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
         ]
 
 
@@ -388,6 +389,10 @@ class ReportDetailSerializer(ReportSerializer):
     result = JobResultSerializer()
 
 
+class ReportInputSerializer(serializers.Serializer):
+    schedule_at = serializers.DateTimeField(required=False, allow_null=True)
+
+
 #
 # Scripts
 #
@@ -419,6 +424,7 @@ class ScriptDetailSerializer(ScriptSerializer):
 class ScriptInputSerializer(serializers.Serializer):
     data = serializers.JSONField()
     commit = serializers.BooleanField()
+    schedule_at = serializers.DateTimeField(required=False, allow_null=True)
 
 
 class ScriptLogMessageSerializer(serializers.Serializer):

+ 20 - 11
netbox/extras/api/views.py

@@ -231,19 +231,26 @@ class ReportViewSet(ViewSet):
 
         # Retrieve and run the Report. This will create a new JobResult.
         report = self._retrieve_report(pk)
-        report_content_type = ContentType.objects.get(app_label='extras', model='report')
-        job_result = JobResult.enqueue_job(
-            run_report,
-            report.full_name,
-            report_content_type,
-            request.user,
-            job_timeout=report.job_timeout
-        )
-        report.result = job_result
+        input_serializer = serializers.ReportInputSerializer(data=request.data)
 
-        serializer = serializers.ReportDetailSerializer(report, context={'request': request})
+        if input_serializer.is_valid():
+            schedule_at = input_serializer.validated_data.get('schedule_at')
 
-        return Response(serializer.data)
+            report_content_type = ContentType.objects.get(app_label='extras', model='report')
+            job_result = JobResult.enqueue_job(
+                run_report,
+                report.full_name,
+                report_content_type,
+                request.user,
+                job_timeout=report.job_timeout,
+                schedule_at=schedule_at,
+            )
+            report.result = job_result
+
+            serializer = serializers.ReportDetailSerializer(report, context={'request': request})
+
+            return Response(serializer.data)
+        return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
 
 #
@@ -312,6 +319,7 @@ class ScriptViewSet(ViewSet):
         if input_serializer.is_valid():
             data = input_serializer.data['data']
             commit = input_serializer.data['commit']
+            schedule_at = input_serializer.validated_data.get('schedule_at')
 
             script_content_type = ContentType.objects.get(app_label='extras', model='script')
             job_result = JobResult.enqueue_job(
@@ -323,6 +331,7 @@ class ScriptViewSet(ViewSet):
                 request=copy_safe_request(request),
                 commit=commit,
                 job_timeout=script.job_timeout,
+                schedule_at=schedule_at,
             )
             script.result = job_result
             serializer = serializers.ScriptDetailSerializer(script, context={'request': request})

+ 2 - 0
netbox/extras/choices.py

@@ -141,6 +141,7 @@ class LogLevelChoices(ChoiceSet):
 class JobResultStatusChoices(ChoiceSet):
 
     STATUS_PENDING = 'pending'
+    STATUS_SCHEDULED = 'scheduled'
     STATUS_RUNNING = 'running'
     STATUS_COMPLETED = 'completed'
     STATUS_ERRORED = 'errored'
@@ -148,6 +149,7 @@ class JobResultStatusChoices(ChoiceSet):
 
     CHOICES = (
         (STATUS_PENDING, 'Pending'),
+        (STATUS_SCHEDULED, 'Scheduled'),
         (STATUS_RUNNING, 'Running'),
         (STATUS_COMPLETED, 'Completed'),
         (STATUS_ERRORED, 'Errored'),

+ 29 - 2
netbox/extras/filtersets.py

@@ -16,6 +16,7 @@ __all__ = (
     'ConfigContextFilterSet',
     'ContentTypeFilterSet',
     'CustomFieldFilterSet',
+    'JobResultFilterSet',
     'CustomLinkFilterSet',
     'ExportTemplateFilterSet',
     'ImageAttachmentFilterSet',
@@ -435,7 +436,32 @@ class JobResultFilterSet(BaseFilterSet):
         label='Search',
     )
     created = django_filters.DateTimeFilter()
+    created__before = django_filters.DateTimeFilter(
+        field_name='created',
+        lookup_expr='lte'
+    )
+    created__after = django_filters.DateTimeFilter(
+        field_name='created',
+        lookup_expr='gte'
+    )
     completed = django_filters.DateTimeFilter()
+    completed__before = django_filters.DateTimeFilter(
+        field_name='completed',
+        lookup_expr='lte'
+    )
+    completed__after = django_filters.DateTimeFilter(
+        field_name='completed',
+        lookup_expr='gte'
+    )
+    scheduled_time = django_filters.DateTimeFilter()
+    scheduled_time__before = django_filters.DateTimeFilter(
+        field_name='scheduled_time',
+        lookup_expr='lte'
+    )
+    scheduled_time__after = django_filters.DateTimeFilter(
+        field_name='scheduled_time',
+        lookup_expr='gte'
+    )
     status = django_filters.MultipleChoiceFilter(
         choices=JobResultStatusChoices,
         null_value=None
@@ -444,14 +470,15 @@ class JobResultFilterSet(BaseFilterSet):
     class Meta:
         model = JobResult
         fields = [
-            'id', 'created', 'completed', 'status', 'user', 'obj_type', 'name'
+            'id', 'created', 'completed', 'scheduled_time', 'status', 'user', 'obj_type', 'name'
         ]
 
     def search(self, queryset, name, value):
         if not value.strip():
             return queryset
         return queryset.filter(
-            Q(user__username__icontains=value)
+            Q(user__username__icontains=value) |
+            Q(name__icontains=value)
         )
 
 

+ 53 - 0
netbox/extras/forms/filtersets.py

@@ -19,6 +19,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
 __all__ = (
     'ConfigContextFilterForm',
     'CustomFieldFilterForm',
+    'JobResultFilterForm',
     'CustomLinkFilterForm',
     'ExportTemplateFilterForm',
     'JournalEntryFilterForm',
@@ -65,6 +66,58 @@ class CustomFieldFilterForm(FilterForm):
     )
 
 
+class JobResultFilterForm(FilterForm):
+    fieldsets = (
+        (None, ('q',)),
+        ('Attributes', ('obj_type', 'status')),
+        ('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after',
+                      'scheduled_time__before', 'scheduled_time__after', 'user')),
+    )
+
+    obj_type = ContentTypeChoiceField(
+        label=_('Object Type'),
+        queryset=ContentType.objects.all(),
+        limit_choices_to=FeatureQuery('job_results'),  # TODO: This doesn't actually work
+        required=False,
+    )
+    status = MultipleChoiceField(
+        choices=JobResultStatusChoices,
+        required=False
+    )
+    created__after = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    created__before = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    completed__after = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    completed__before = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    scheduled_time__after = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    scheduled_time__before = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    user = DynamicModelMultipleChoiceField(
+        queryset=User.objects.all(),
+        required=False,
+        label=_('User'),
+        widget=APISelectMultiple(
+            api_url='/api/users/users/',
+        )
+    )
+
+
 class CustomLinkFilterForm(FilterForm):
     fieldsets = (
         (None, ('q',)),

+ 16 - 0
netbox/extras/forms/reports.py

@@ -0,0 +1,16 @@
+from django import forms
+
+from utilities.forms import BootstrapMixin, DateTimePicker
+
+__all__ = (
+    'ReportForm',
+)
+
+
+class ReportForm(BootstrapMixin, forms.Form):
+    schedule_at = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker(),
+        label="Schedule at",
+        help_text="Schedule execution of report to a set time",
+    )

+ 12 - 4
netbox/extras/forms/scripts.py

@@ -1,6 +1,6 @@
 from django import forms
 
-from utilities.forms import BootstrapMixin
+from utilities.forms import BootstrapMixin, DateTimePicker
 
 __all__ = (
     'ScriptForm',
@@ -14,17 +14,25 @@ class ScriptForm(BootstrapMixin, forms.Form):
         label="Commit changes",
         help_text="Commit changes to the database (uncheck for a dry-run)"
     )
+    _schedule_at = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker(),
+        label="Schedule at",
+        help_text="Schedule execution of script to a set time",
+    )
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        # Move _commit to the end of the form
+        # Move _commit and _schedule_at to the end of the form
+        schedule_at = self.fields.pop('_schedule_at')
         commit = self.fields.pop('_commit')
+        self.fields['_schedule_at'] = schedule_at
         self.fields['_commit'] = commit
 
     @property
     def requires_input(self):
         """
-        A boolean indicating whether the form requires user input (ignore the _commit field).
+        A boolean indicating whether the form requires user input (ignore the _commit and _schedule_at fields).
         """
-        return bool(len(self.fields) > 1)
+        return bool(len(self.fields) > 2)

+ 1 - 1
netbox/extras/management/commands/housekeeping.py

@@ -81,7 +81,7 @@ class Command(BaseCommand):
                         ending=""
                     )
                     self.stdout.flush()
-                JobResult.objects.filter(created__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
+                JobResult.objects.filter(created__lt=cutoff).delete()
                 if options['verbosity']:
                     self.stdout.write("Done.", self.style.SUCCESS)
             elif options['verbosity']:

+ 2 - 0
netbox/extras/management/commands/rqworker.py

@@ -14,6 +14,8 @@ class Command(_Command):
     of only the 'default' queue).
     """
     def handle(self, *args, **options):
+        # Run the worker with scheduler functionality
+        options['with_scheduler'] = True
 
         # If no queues have been specified on the command line, listen on all configured queues.
         if len(args) < 1:

+ 17 - 0
netbox/extras/migrations/0079_change_jobresult_order.py

@@ -0,0 +1,17 @@
+# Generated by Django 4.1.1 on 2022-10-09 18:37
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0078_unique_constraints'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='jobresult',
+            options={'ordering': ['-created']},
+        ),
+    ]

+ 18 - 0
netbox/extras/migrations/0080_add_jobresult_scheduled_time.py

@@ -0,0 +1,18 @@
+# Generated by Django 4.1.1 on 2022-10-16 09:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0079_change_jobresult_order'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='jobresult',
+            name='scheduled_time',
+            field=models.DateTimeField(blank=True, null=True),
+        ),
+    ]

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

@@ -505,6 +505,10 @@ class JobResult(models.Model):
         null=True,
         blank=True
     )
+    scheduled_time = models.DateTimeField(
+        null=True,
+        blank=True
+    )
     user = models.ForeignKey(
         to=User,
         on_delete=models.SET_NULL,
@@ -525,12 +529,26 @@ class JobResult(models.Model):
         unique=True
     )
 
+    objects = RestrictedQuerySet.as_manager()
+
     class Meta:
-        ordering = ['obj_type', 'name', '-created']
+        ordering = ['-created']
 
     def __str__(self):
         return str(self.job_id)
 
+    def delete(self, *args, **kwargs):
+        super().delete(*args, **kwargs)
+
+        queue = django_rq.get_queue("default")
+        job = queue.fetch_job(str(self.job_id))
+
+        if job:
+            job.cancel()
+
+    def get_absolute_url(self):
+        return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk])
+
     @property
     def duration(self):
         if not self.completed:
@@ -551,7 +569,7 @@ class JobResult(models.Model):
             self.completed = timezone.now()
 
     @classmethod
-    def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs):
+    def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, *args, **kwargs):
         """
         Create a JobResult instance and enqueue a job using the given callable
 
@@ -559,10 +577,11 @@ class JobResult(models.Model):
         name: Name for the JobResult instance
         obj_type: ContentType to link to the JobResult instance obj_type
         user: User object to link to the JobResult instance
+        schedule_at: Schedule the job to be executed at the passed date and time
         args: additional args passed to the callable
         kwargs: additional kargs passed to the callable
         """
-        job_result = cls.objects.create(
+        job_result: JobResult = cls.objects.create(
             name=name,
             obj_type=obj_type,
             user=user,
@@ -570,7 +589,15 @@ class JobResult(models.Model):
         )
 
         queue = django_rq.get_queue("default")
-        queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
+
+        if schedule_at:
+            job_result.status = JobResultStatusChoices.STATUS_SCHEDULED
+            job_result.scheduled_time = schedule_at
+            job_result.save()
+
+            queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
+        else:
+            queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
 
         return job_result
 

+ 0 - 1
netbox/extras/reports.py

@@ -85,7 +85,6 @@ def run_report(job_result, *args, **kwargs):
     try:
         report.run(job_result)
     except Exception as e:
-        print(e)
         job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
         job_result.save()
         logging.error(f"Error during execution of report {job_result.name}")

+ 22 - 0
netbox/extras/tables/tables.py

@@ -8,6 +8,7 @@ from .template_code import *
 __all__ = (
     'ConfigContextTable',
     'CustomFieldTable',
+    'JobResultTable',
     'CustomLinkTable',
     'ExportTemplateTable',
     'JournalEntryTable',
@@ -39,6 +40,27 @@ class CustomFieldTable(NetBoxTable):
         default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
 
 
+#
+# Custom fields
+#
+
+class JobResultTable(NetBoxTable):
+    name = tables.Column(
+        linkify=True
+    )
+
+    actions = columns.ActionsColumn(
+        actions=('delete',)
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = JobResult
+        fields = (
+            'pk', 'id', 'name', 'obj_type', 'job_id', 'created', 'completed', 'scheduled_time', 'user', 'status',
+        )
+        default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'completed', 'user',)
+
+
 #
 # Custom links
 #

+ 5 - 0
netbox/extras/urls.py

@@ -74,6 +74,11 @@ urlpatterns = [
     path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
     re_path(r'^reports/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ReportView.as_view(), name='report'),
 
+    # Job results
+    path('job-results/', views.JobResultListView.as_view(), name='jobresult_list'),
+    path('job-results/delete/', views.JobResultBulkDeleteView.as_view(), name='jobresult_bulk_delete'),
+    path('job-results/<int:pk>/delete/', views.JobResultDeleteView.as_view(), name='jobresult_delete'),
+
     # Scripts
     path('scripts/', views.ScriptListView.as_view(), name='script_list'),
     path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),

+ 54 - 17
netbox/extras/views.py

@@ -15,6 +15,7 @@ from utilities.utils import copy_safe_request, count_related, get_viewname, norm
 from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
 from . import filtersets, forms, tables
 from .choices import JobResultStatusChoices
+from .forms.reports import ReportForm
 from .models import *
 from .reports import get_report, get_reports, run_report
 from .scripts import get_scripts, run_script
@@ -592,7 +593,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
 
         return render(request, 'extras/report.html', {
             'report': report,
-            'run_form': ConfirmationForm(),
+            'form': ReportForm(),
         })
 
     def post(self, request, module, name):
@@ -605,24 +606,36 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
         if report is None:
             raise Http404
 
-        # Allow execution only if RQ worker process is running
-        if not Worker.count(get_connection('default')):
-            messages.error(request, "Unable to run report: RQ worker process not running.")
-            return render(request, 'extras/report.html', {
-                'report': report,
-            })
+        schedule_at = None
+        form = ReportForm(request.POST)
 
-        # Run the Report. A new JobResult is created.
-        report_content_type = ContentType.objects.get(app_label='extras', model='report')
-        job_result = JobResult.enqueue_job(
-            run_report,
-            report.full_name,
-            report_content_type,
-            request.user,
-            job_timeout=report.job_timeout
-        )
+        if form.is_valid():
+            schedule_at = form.cleaned_data.get("schedule_at")
 
-        return redirect('extras:report_result', job_result_pk=job_result.pk)
+            # Allow execution only if RQ worker process is running
+            if not Worker.count(get_connection('default')):
+                messages.error(request, "Unable to run report: RQ worker process not running.")
+                return render(request, 'extras/report.html', {
+                    'report': report,
+                })
+
+            # Run the Report. A new JobResult is created.
+            report_content_type = ContentType.objects.get(app_label='extras', model='report')
+            job_result = JobResult.enqueue_job(
+                run_report,
+                report.full_name,
+                report_content_type,
+                request.user,
+                job_timeout=report.job_timeout,
+                schedule_at=schedule_at,
+            )
+
+            return redirect('extras:report_result', job_result_pk=job_result.pk)
+
+        return render(request, 'extras/report.html', {
+            'report': report,
+            'form': form,
+        })
 
 
 class ReportResultView(ContentTypePermissionRequiredMixin, View):
@@ -737,6 +750,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
 
         elif form.is_valid():
             commit = form.cleaned_data.pop('_commit')
+            schedule_at = form.cleaned_data.pop("_schedule_at")
 
             script_content_type = ContentType.objects.get(app_label='extras', model='script')
 
@@ -749,6 +763,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
                 request=copy_safe_request(request),
                 commit=commit,
                 job_timeout=script.job_timeout,
+                schedule_at=schedule_at,
             )
 
             return redirect('extras:script_result', job_result_pk=job_result.pk)
@@ -788,3 +803,25 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View)
             'result': result,
             'class_name': script.__class__.__name__
         })
+
+
+#
+# Job results
+#
+
+class JobResultListView(generic.ObjectListView):
+    queryset = JobResult.objects.all()
+    filterset = filtersets.JobResultFilterSet
+    filterset_form = forms.JobResultFilterForm
+    table = tables.JobResultTable
+    actions = ('export', 'delete', 'bulk_delete', )
+
+
+class JobResultDeleteView(generic.ObjectDeleteView):
+    queryset = JobResult.objects.all()
+
+
+class JobResultBulkDeleteView(generic.BulkDeleteView):
+    queryset = JobResult.objects.all()
+    filterset = filtersets.JobResultFilterSet
+    table = tables.JobResultTable

+ 5 - 0
netbox/netbox/navigation/menu.py

@@ -294,6 +294,11 @@ OTHER_MENU = Menu(
                     link_text='Scripts',
                     permissions=['extras.view_script']
                 ),
+                MenuItem(
+                    link='extras:jobresult_list',
+                    link_text='Job Results',
+                    permissions=['extras.view_jobresult'],
+                ),
             ),
         ),
         MenuGroup(

+ 3 - 0
netbox/templates/extras/htmx/report_result.html

@@ -2,6 +2,9 @@
 
 <p>
   Initiated: <strong>{{ result.created|annotated_date }}</strong>
+  {% if result.scheduled_time %}
+    Scheduled for: <strong>{{ result.scheduled_time|annotated_date }}</strong>
+  {% endif %}
   {% if result.completed %}
     Duration: <strong>{{ result.duration }}</strong>
   {% endif %}

+ 3 - 0
netbox/templates/extras/htmx/script_result.html

@@ -3,6 +3,9 @@
 
 <p>
   Initiated: <strong>{{ result.created|annotated_date }}</strong>
+  {% if result.scheduled_time %}
+    Scheduled for: <strong>{{ result.scheduled_time|annotated_date }}</strong>
+  {% endif %}
   {% if result.completed %}
     Duration: <strong>{{ result.duration }}</strong>
   {% endif %}

+ 2 - 0
netbox/templates/extras/inc/job_label.html

@@ -4,6 +4,8 @@
     <span class="badge bg-danger">Errored</span>
 {% elif result.status == 'pending' %}
     <span class="badge bg-info">Pending</span>
+{% elif result.status == 'scheduled' %}
+    <span class="badge bg-info">Scheduled</span>
 {% elif result.status == 'running' %}
     <span class="badge bg-warning">Running</span>
 {% elif result.status == 'completed' %}

+ 16 - 9
netbox/templates/extras/report.html

@@ -1,5 +1,6 @@
 {% extends 'generic/object.html' %}
 {% load helpers %}
+{% load form_helpers %}
 
 {% block title %}{{ report.name }}{% endblock %}
 
@@ -33,18 +34,24 @@
 {% block content %}
   <div role="tabpanel" class="tab-pane active" id="report">
     {% if perms.extras.run_report %}
-      <div class="float-end noprint">
-          <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
+    <div class="row">
+      <div class="col">
+          <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post" class="form-object-edit">
               {% csrf_token %}
-              <button type="submit" name="_run" class="btn btn-primary">
-                  {% if report.result %}
-                      <i class="mdi mdi-replay"></i> Run Again
-                  {% else %}
-                      <i class="mdi mdi-play"></i> Run Report
-                  {% endif %}
-              </button>
+              {% render_form form %}
+              <div class="float-end">
+                <button type="submit" name="_run" class="btn btn-primary">
+                    {% if report.result %}
+                        <i class="mdi mdi-replay"></i> Run Again
+                    {% else %}
+                        <i class="mdi mdi-play"></i> Run Report
+                    {% endif %}
+                </button>
+              </div>
           </form>
+        </div>
       </div>
+
     {% endif %}
     <div class="row">
         <div class="col col-md-12">

+ 12 - 0
netbox/templates/extras/report_result.html

@@ -1,4 +1,6 @@
 {% extends 'extras/report.html' %}
+{% load buttons %}
+{% load perms %}
 
 {% block content-wrapper %}
   <div class="row p-3">
@@ -7,3 +9,13 @@
     </div>
   </div>
 {% endblock %}
+
+{% block controls %}
+  <div class="controls">
+    <div class="control-group">
+      {% if request.user|can_delete:result %}
+        {% delete_button result %}
+      {% endif %}
+    </div>
+  </div>
+{% endblock controls %}

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

@@ -43,7 +43,7 @@
             You do not have permission to run scripts.
           </div>
         {% endif %}
-        <form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
+        <form action="" method="post" enctype="multipart/form-data" class="form form-object-edit">
           {% csrf_token %}
           <div class="field-group my-4">
             {% if form.requires_input %}

+ 16 - 0
netbox/templates/extras/script_result.html

@@ -1,5 +1,7 @@
 {% extends 'base/layout.html' %}
 {% load helpers %}
+{% load buttons %}
+{% load perms %}
 
 {% block title %}{{ script }}{% endblock %}
 
@@ -23,6 +25,16 @@
   {{ block.super }}
 {% endblock header %}
 
+{% block controls %}
+  <div class="controls">
+    <div class="control-group">
+      {% if request.user|can_delete:result %}
+        {% delete_button result %}
+      {% endif %}
+    </div>
+  </div>
+{% endblock controls %}
+
 {% block content-wrapper %}
   <ul class="nav nav-tabs px-3" role="tablist">
     <li class="nav-item" role="presentation">
@@ -46,3 +58,7 @@
     </div>
   </div>
 {% endblock content-wrapper %}
+
+{% block modals %}
+  {% include 'inc/htmx_modal.html' %}
+{% endblock modals %}