Repository: netbox-community/netbox
Pull request: Fixes #8366 - Add job scheduling
### Fixes: #8366

This is a draft PR for adding job scheduling. Feel free to provide input.

Changes:

* Add a "Schedule at" form field to both the report and script form
* Add JobResult list view
* Add the `form-object-edit` class to both forms to match the width of forms in the rest of netbox (previously they were full width)
* Update the js `flatpickr` dependency to fix issue with the DateTimeField automatically populating when losing focus.
* Modify JobResult to delete RQ tasks to avoid running scheduled tasks with no JobResult associated
* Added new JobResultStatusChoice `Scheduled`
* Added new field `scheduled_time` to JobResult model
* Show scheduled time when viewing a job result
* Add --with-scheduler option to rqworker management command
* Added small snippet to docs about scheduling jobs
* REST API changes:
  * Added scheduled_time to JobResult

To do:
 
* Add job scheduling support to the API as well?
* `limit_choices_to=FeatureQuery('job_results'),` doesn't work at all in filter forms and it doesn't seem to work in other filterforms either.

Problem statement:
Add ability to schedule scripts & reports
### NetBox version

v3.1.5

### Feature type

New functionality

### Proposed functionality

This feature request is to add the ability to schedule report and script runs.

One idea is to create a new page/section for these schedules and possibly associated results that allows users to view all scheduled operations.

Another idea is to just add this functionality right into the existing scripts and reports page/section

### Use case

As a user, I want to be able to schedule recurring scripts and reports in NetBox itself.

Alternatively, a user could setup a schedule outside of UI using an external tool to execute an API call / POST or via the cli.  The challenge is that if you are not a user who is aware of the NetBox setup, its hard to follow how and what triggered the script or report run.

### Database changes

_No response_

### External dependencies

_No response_

Hints from issue discussion:
> Alternatively, a user could setup a schedule outside of UI using an external tool to execute an API call / POST or via the cli.

The recommended approach would be to execute the `runscript` and `runreport` management commands provided by NetBox as cron jobs.
>> Alternatively, a user could setup a schedule outside of UI using an external tool to execute an API call / POST or via the cli.
>
>The recommended approach would be to execute the runscript and runreport management commands provided by NetBox as cron jobs.

Yep, I forgot to mention that particular approach, but with all those "non-native" schedulers the downside would be that you have no way to see or update the schedule (for users not able to ssh to the host).
I have spent some time looking at external tools to do this (currently using cron), but they are either overly complex, not python based and the more important, separate authentication and authorization from NetbBox. 

The result is a napkin with some scribbles for a plugin using the Django extension for job scheduling somehow. Will maybe be my first real project coding Django/NetBox plugin. Alternatively I would go the Celery route, though my experience is that it is rather fiddly, at least with Flask. DOnt know yet if Django is more straight forward. Key is to have a single process manage the schedule, like the house keeping in netbox, or beat in Celery.

But I believe this is a valid candidate for a plugin.
It looks like `django-rq` provides [a couple options](https://github.com/rq/django-rq#support-for-scheduled-jobs) for scheduling jobs.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
@jeremystretch - I might take a stab at this soonish if it's accepted. I think it would be a shame to auto-close at least. It would be blocked until #8957 is merged though in my opinion. It would be confusing to trigger a job at night, and then missing the result because someone ran the same script in the morning.
@kkthxbye-code Ok, I'll mark this as blocked for now.
Not blocked anymore.

I have been experimenting a little with this. The scheduling part is easy and works fine. My worry is that the current script/report system is already a little messy and a rework might be in order.

The split between reports and scripts is already a little thin and I think Jeremy has mentioned merging them before. 

Then we have the JobResults class which I'm not really sure is aptly named. Isn't it already more of a JobExecution, JobTask or just Job? Do we want to add a new status to JobResultStatusChoices for scheduled tasks? Otherwise we only know a task has been scheduled by asking django-rq. Do we want to duplicate the scheduled time and add it to the model, or are we satisfied only having that in redis? All this might be fine, but if a user wants to cancel the scheduled task, what does that do to the JobResult?

I can for sure implement a solution with just adding a field for scheduling scripts/reports and moving job results out of the admin panel. Then creating a new view for scheduled tasks, which pretty much just asks django-rq for a list of these, with the option of canceling a task. Not sure if it's the right way to go though.
> The split between reports and scripts is already a little thin and I think Jeremy has mentioned merging them before.

It has been brought up before.  We could definitely open an issue for this, do you think it would be wise to again blocked this issue by the merge issue?

> Then we have the JobResults class which I'm not really sure is aptly named. Isn't it already more of a JobExecution, JobTask or just Job? Do we want to add a new status to JobResultStatusChoices for scheduled tasks? Otherwise we only know a task has been scheduled by asking django-rq. Do we want to duplicate the scheduled time and add it to the model, or are we satisfied only having that in redis? All this might be fine, but if a user wants to cancel the scheduled task, what does that do to the JobResult?

So, I played with this in my backup plugin.  There are so many things that can be impacted by django-rq to cause the schedule to not kick off correctly.  If we do that, we would almost need a "watcher" job to run to make sure it doesn't get dropped into the canceled queue or something equally weird doesn't happen to it.

> I can for sure implement a solution with just adding a field for scheduling scripts/reports and moving job results out of the admin panel. Then creating a new view for scheduled tasks, which pretty much just asks django-rq for a list of these, with the option of canceling a task. Not sure if it's the right way to go though.

I will let Jeremy weigh in, but I think this might be the correct course:

1. Merge Scripts/Reports into a single "Job" workflow
2. Move "Jobs" to the main UI and removed from the admin UI
3. Add the scheduling piece
4. Update any existing views/management commands

This likely should be fleshed out in a new issue.  Should probably also be done in a 3.x release and not a patch.


This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened issues may receive direct feedback. **Do not** attempt to circumvent this process by "bumping" the issue; doing so will result in its immediate closure and you may be barred from participating in any future discussions. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).

Commit message: Merge branch 'feature' into 8366-job-scheduling

Patch to review:
diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md
index e5d5a1ef5d5..456bcf472fb 100644
--- a/docs/customization/custom-scripts.md
+++ b/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:
diff --git a/docs/customization/reports.md b/docs/customization/reports.md
index 150c32f409b..470868ea020 100644
--- a/docs/customization/reports.md
+++ b/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:
diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py
index 01011b2763a..837a8f2d311 100644
--- a/netbox/extras/admin.py
+++ b/netbox/extras/admin.py
@@ -131,24 +131,3 @@ def restore(self, request, pk):
         })
 
         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
diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py
index fd774f8ff84..b34f5fba331 100644
--- a/netbox/extras/api/serializers.py
+++ b/netbox/extras/api/serializers.py
@@ -38,6 +38,7 @@
     '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):
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index 63003bdf21a..62a0115304b 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -231,19 +231,26 @@ def run(self, request, pk):
 
         # 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 @@ def post(self, request, pk):
         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 @@ def post(self, request, pk):
                 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})
diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py
index 5afe9f33fb3..ee806f09422 100644
--- a/netbox/extras/choices.py
+++ b/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'),
diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py
index df0af3541b7..257cbdca95f 100644
--- a/netbox/extras/filtersets.py
+++ b/netbox/extras/filtersets.py
@@ -16,6 +16,7 @@
     'ConfigContextFilterSet',
     'ContentTypeFilterSet',
     'CustomFieldFilterSet',
+    'JobResultFilterSet',
     'CustomLinkFilterSet',
     'ExportTemplateFilterSet',
     'ImageAttachmentFilterSet',
@@ -434,8 +435,9 @@ class JobResultFilterSet(BaseFilterSet):
         method='search',
         label='Search',
     )
-    created = django_filters.DateTimeFilter()
-    completed = django_filters.DateTimeFilter()
+    created = django_filters.DateTimeFromToRangeFilter()
+    completed = django_filters.DateTimeFromToRangeFilter()
+    scheduled_time = django_filters.DateTimeFromToRangeFilter()
     status = django_filters.MultipleChoiceFilter(
         choices=JobResultStatusChoices,
         null_value=None
@@ -444,14 +446,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)
         )
 
 
diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py
index 526d47013aa..80ed0f2087e 100644
--- a/netbox/extras/forms/filtersets.py
+++ b/netbox/extras/forms/filtersets.py
@@ -19,6 +19,7 @@
 __all__ = (
     'ConfigContextFilterForm',
     'CustomFieldFilterForm',
+    'JobResultFilterForm',
     'CustomLinkFilterForm',
     'ExportTemplateFilterForm',
     'JournalEntryFilterForm',
@@ -65,6 +66,57 @@ 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',)),
diff --git a/netbox/extras/forms/reports.py b/netbox/extras/forms/reports.py
new file mode 100644
index 00000000000..aa4f6223b51
--- /dev/null
+++ b/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",
+    )
diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py
index 380b4364c55..de55a3ee66f 100644
--- a/netbox/extras/forms/scripts.py
+++ b/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)
diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py
index 51d50d7e1de..d2f2b1776a2 100644
--- a/netbox/extras/management/commands/housekeeping.py
+++ b/netbox/extras/management/commands/housekeeping.py
@@ -81,7 +81,7 @@ def handle(self, *args, **options):
                         ending=""
                     )
                     self.stdout.flush()
-                JobResult.objects.filter(created__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
+                JobResult.objects.filter(created__lt=cutoff).delete(using=DEFAULT_DB_ALIAS)
                 if options['verbosity']:
                     self.stdout.write("Done.", self.style.SUCCESS)
             elif options['verbosity']:
diff --git a/netbox/extras/management/commands/rqworker.py b/netbox/extras/management/commands/rqworker.py
index e2ad5b15c76..e1fb6fd1118 100644
--- a/netbox/extras/management/commands/rqworker.py
+++ b/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:
diff --git a/netbox/extras/migrations/0079_change_jobresult_order.py b/netbox/extras/migrations/0079_change_jobresult_order.py
new file mode 100644
index 00000000000..12e35bf6766
--- /dev/null
+++ b/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']},
+        ),
+    ]
diff --git a/netbox/extras/migrations/0080_add_jobresult_scheduled_time.py b/netbox/extras/migrations/0080_add_jobresult_scheduled_time.py
new file mode 100644
index 00000000000..fddde4bc583
--- /dev/null
+++ b/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),
+        ),
+    ]
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index aadb6f888f3..83036d8a154 100644
--- a/netbox/extras/models/models.py
+++ b/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:
@@ -562,7 +580,7 @@ def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs):
         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 +588,15 @@ def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs):
         )
 
         queue = django_rq.get_queue("default")
-        queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
+
+        if schedule_at := kwargs.pop("schedule_at", None):
+            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
 
diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py
index 32e4efc2d5d..525608c8633 100644
--- a/netbox/extras/reports.py
+++ b/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}")
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index 1df5c948724..8f365a58b82 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -8,6 +8,7 @@
 __all__ = (
     'ConfigContextTable',
     'CustomFieldTable',
+    'JobResultTable',
     'CustomLinkTable',
     'ExportTemplateTable',
     'JournalEntryTable',
@@ -39,6 +40,27 @@ class Meta(NetBoxTable.Meta):
         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
 #
diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py
index ecc4c116c13..0640904f217 100644
--- a/netbox/extras/urls.py
+++ b/netbox/extras/urls.py
@@ -74,6 +74,11 @@
     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'),
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 337980e94a0..a5da5a17cef 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -15,6 +15,7 @@
 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 @@ def get(self, request, module, name):
 
         return render(request, 'extras/report.html', {
             'report': report,
-            'run_form': ConfirmationForm(),
+            'form': ReportForm(),
         })
 
     def post(self, request, module, name):
@@ -605,6 +606,12 @@ def post(self, request, module, name):
         if report is None:
             raise Http404
 
+        schedule_at = None
+        form = ReportForm(request.POST)
+
+        if form.is_valid():
+            schedule_at = form.cleaned_data.get("schedule_at")
+
         # 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.")
@@ -619,7 +626,8 @@ def post(self, request, module, name):
             report.full_name,
             report_content_type,
             request.user,
-            job_timeout=report.job_timeout
+            job_timeout=report.job_timeout,
+            schedule_at=schedule_at,
         )
 
         return redirect('extras:report_result', job_result_pk=job_result.pk)
@@ -737,6 +745,7 @@ def post(self, request, module, name):
 
         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 +758,7 @@ def post(self, request, module, name):
                 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 +798,29 @@ def get(self, request, job_result_pk):
             'result': result,
             'class_name': script.__class__.__name__
         })
+
+
+#
+# Job results
+#
+
+class JobResultView(generic.ObjectView):
+    queryset = JobResult.objects.all()
+
+
+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
diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py
index 400a7bf5a40..65c2ec7fcef 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/netbox/netbox/navigation/menu.py
@@ -294,6 +294,11 @@
                     link_text='Scripts',
                     permissions=['extras.view_script']
                 ),
+                MenuItem(
+                    link='extras:jobresult_list',
+                    link_text='Job Results',
+                    permissions=['extras.view_jobresult'],
+                ),
             ),
         ),
         MenuGroup(
diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html
index c20bf5fe2e6..a51b2663d40 100644
--- a/netbox/templates/extras/htmx/report_result.html
+++ b/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 %}
diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html
index 425f358987b..d2af99c9b94 100644
--- a/netbox/templates/extras/htmx/script_result.html
+++ b/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 %}
diff --git a/netbox/templates/extras/inc/job_label.html b/netbox/templates/extras/inc/job_label.html
index d74931111bb..7ff788edef5 100644
--- a/netbox/templates/extras/inc/job_label.html
+++ b/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' %}
diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html
index 391de6614fd..94f37571bc4 100644
--- a/netbox/templates/extras/report.html
+++ b/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">
diff --git a/netbox/templates/extras/report_result.html b/netbox/templates/extras/report_result.html
index b4a0c0f122f..0c61c63f900 100644
--- a/netbox/templates/extras/report_result.html
+++ b/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 %}
\ No newline at end of file
diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html
index 99eade0a09e..6fbcde32252 100644
--- a/netbox/templates/extras/script.html
+++ b/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 %}
diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html
index 1c311ff2607..2fc01e9fa25 100644
--- a/netbox/templates/extras/script_result.html
+++ b/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 %}
\ No newline at end of file


Code review comments:
diff_hunk: @@ -434,8 +435,9 @@ class JobResultFilterSet(BaseFilterSet):
         method='search',
         label='Search',
     )
-    created = django_filters.DateTimeFilter()
-    completed = django_filters.DateTimeFilter()
+    created = django_filters.DateTimeFromToRangeFilter()
+    completed = django_filters.DateTimeFromToRangeFilter()
path: netbox/extras/filtersets.py
text: @user1:
Is this a breaking change, or is the behavior of the original filter retained?

@author:
I looked into this now, and I'm not sure what is the right solution here and I'm frankly a little confused.

Current behavior (develop branch) is that there's a created and completed DateTimeFilter.

https://github.com/netbox-community/netbox/blob/96c469641796b2b12ce643363d41d823670b68db/netbox/extras/filtersets.py#L437-L438

In practice this means that you can filter on the exact datetime value, so like `created=2022-10-21T09:23:57.593918`. Neither gte or lte works here. I think for those to work the filterset had to look like this?

https://github.com/netbox-community/netbox/blob/96c469641796b2b12ce643363d41d823670b68db/netbox/users/filtersets.py#L79-L87

Now with my change to DateTimeFromToRangeFilter created_after, created_before, completed_after, completed_before are added. So there's an issue here that the base created/completed actually doesn't exist anymore. So how should this be added to the filterset to make the swagger definition correct?

If we look at another place where DateTimeFromToRange is used, it shows the same issue:

https://github.com/netbox-community/netbox/blob/96c469641796b2b12ce643363d41d823670b68db/netbox/extras/filtersets.py#L399

The `time` filter that shows up in swagger doesn't actually work at all, but `time_before` and `time_after` does.

I'm really not sure what to do here, it needs to be DateTimeFromToRangeFilter for the filter form to work like for example the object change filter form. But the filters need to be different for the API for the swagger docs to match. I don't think it makes sense to have a filter that only matches on exact date, but __gte and __lte works some places like the TokenFilterSet I linked above, should we use a normal DateTimeFilter with those instead?

@user1:
Agreed, this is frustrating.

> The time filter that shows up in swagger doesn't actually work at all, but time_before and time_after does.

AFAICT this is because DateTimeFromToRangeFilter doesn't actually register the before/after lookups on the filterset, so they can't be inspected. `get_filters()` returns a single filter for each:

```
created <django_filters.filters.DateTimeFromToRangeFilter object at 0x7fcf591b2c40>
completed <django_filters.filters.DateTimeFromToRangeFilter object at 0x7fcf591b2c70>
```

It's not ideal, but the simplest approach until we can come up with something more elegant would be to add the before & after lookups explicitly, like under TokenFilterSet. However I do think we should stick with the before/after nomenclature as they make more sense in the context of time than lte/gte. Something like this:

```
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'
)
```

It's probably worth auditing how we handle datetime filtering in NetBox overall, but this approach would let us knock out this PR without breaking either of the existing filters. This also ensures the filters are picked up during introspection. What do you think?

@author:
I added your suggested changes as I understood them, please take another look. Do note that it is now inconsistent with the ObjectChangeFilterSet and FilterForm as DateTimeFromToRangeFilter adds `_before` and `_after` with a single underscore and not the dual underscore like we normally do for lookup expressions.

https://github.com/netbox-community/netbox/blob/96c469641796b2b12ce643363d41d823670b68db/netbox/extras/forms/filtersets.py#L306-L315

https://github.com/netbox-community/netbox/blob/96c469641796b2b12ce643363d41d823670b68db/netbox/extras/filtersets.py#L399

@user1:
Looks perfect, thanks!

> Do note that it is now inconsistent with the ObjectChangeFilterSet and FilterForm

Yep, and I think that's fine for now. Ultimately (possibly in v3.4) I expect we'll bring them in line with what you have here, to be more consistent with other filter lookups in general.

diff_hunk: @@ -562,15 +580,23 @@ def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs):
         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,
             job_id=uuid.uuid4()
         )
 
         queue = django_rq.get_queue("default")
-        queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
+
+        if schedule_at := kwargs.pop("schedule_at", None):
path: netbox/extras/models/models.py
text: @user1:
Can we expose `scheduled_time` as an explicit optional kwarg for `enqueue_job()` (rather than using the `schedule_at` var as a proxy)?

diff_hunk: @@ -788,3 +798,29 @@ def get(self, request, job_result_pk):
             'result': result,
             'class_name': script.__class__.__name__
         })
+
+
+#
+# Job results
+#
+
+class JobResultView(generic.ObjectView):
path: netbox/extras/views.py
text: @user1:
This doesn't appear to be used?

@author:
Thanks, it was a leftover from an early idea. Removed!

diff_hunk: @@ -81,7 +81,7 @@ def handle(self, *args, **options):
                         ending=""
                     )
                     self.stdout.flush()
-                JobResult.objects.filter(created__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
+                JobResult.objects.filter(created__lt=cutoff).delete(using=DEFAULT_DB_ALIAS)
path: netbox/extras/management/commands/housekeeping.py
text: @user1:
Should be fine to omit `using` here; it's only needed for the raw method. (Should also delete the import above.)
```suggestion
                JobResult.objects.filter(created__lt=cutoff).delete()
```

@author:
Removed the using parameter.

> Should also delete the import above

Could you elaborate, not sure I follow.

@user1:
Sorry, I mean the the import of `DEFAULT_DB_ALIAS` can be removed.

@author:
It is still being used for the cleanup of ObjectChange's, so I'll let it stay for now at least. Not sure how _raw_delete actually functions, but if it doesn't respect cascade deletions, maybe you want to use the nomal delete method for ObjectChanges as well to ensure search indexes are deleted? Just a thought, I'm gonna resolve this for now.