Răsfoiți Sursa

Rename JobResult to Job and move to core

jeremystretch 2 ani în urmă
părinte
comite
40572b543f
41 a modificat fișierele cu 650 adăugiri și 361 ștergeri
  1. 1 1
      docs/development/models.md
  2. 1 1
      docs/features/background-jobs.md
  3. 2 2
      docs/models/core/job.md
  4. 1 1
      mkdocs.yml
  5. 18 2
      netbox/core/api/nested_serializers.py
  6. 23 2
      netbox/core/api/serializers.py
  7. 3 0
      netbox/core/api/urls.py
  8. 10 4
      netbox/core/api/views.py
  9. 29 0
      netbox/core/choices.py
  10. 61 1
      netbox/core/filtersets.py
  11. 69 1
      netbox/core/forms/filtersets.py
  12. 2 2
      netbox/core/jobs.py
  13. 40 0
      netbox/core/migrations/0003_move_jobresult_to_core.py
  14. 1 0
      netbox/core/models/__init__.py
  15. 2 3
      netbox/core/models/data.py
  16. 219 0
      netbox/core/models/jobs.py
  17. 1 0
      netbox/core/tables/__init__.py
  18. 34 0
      netbox/core/tables/jobs.py
  19. 5 0
      netbox/core/urls.py
  20. 22 0
      netbox/core/views.py
  21. 1 1
      netbox/extras/admin.py
  22. 1 16
      netbox/extras/api/nested_serializers.py
  23. 6 28
      netbox/extras/api/serializers.py
  24. 0 1
      netbox/extras/api/urls.py
  25. 22 38
      netbox/extras/api/views.py
  26. 0 64
      netbox/extras/filtersets.py
  27. 2 64
      netbox/extras/forms/filtersets.py
  28. 5 5
      netbox/extras/management/commands/housekeeping.py
  29. 11 10
      netbox/extras/management/commands/runreport.py
  30. 6 6
      netbox/extras/management/commands/runscript.py
  31. 1 1
      netbox/extras/migrations/0001_squashed.py
  32. 1 1
      netbox/extras/models/models.py
  33. 2 2
      netbox/extras/models/reports.py
  34. 2 2
      netbox/extras/models/scripts.py
  35. 10 8
      netbox/extras/reports.py
  36. 6 4
      netbox/extras/scripts.py
  37. 0 31
      netbox/extras/tables/tables.py
  38. 0 5
      netbox/extras/urls.py
  39. 25 49
      netbox/extras/views.py
  40. 3 3
      netbox/netbox/models/features.py
  41. 2 2
      netbox/netbox/navigation/menu.py

+ 1 - 1
docs/development/models.md

@@ -18,7 +18,7 @@ Depending on its classification, each NetBox model may support various features
 | [Custom links](../customization/custom-links.md)           | `CustomLinksMixin`      | `custom_links`     | These models support the assignment of custom links                            |
 | [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | -                  | Supports the enforcement of custom validation rules                            |
 | [Export templates](../customization/export-templates.md)   | `ExportTemplatesMixin`  | `export_templates` | Users can create custom export templates for these models                      |
-| [Job results](../features/background-jobs.md)              | `JobResultsMixin`       | `job_results`      | Users can create custom export templates for these models                      |
+| [Job results](../features/background-jobs.md)              | `JobsMixin`             | `jobs`             | Users can create custom export templates for these models                      |
 | [Journaling](../features/journaling.md)                    | `JournalingMixin`       | `journaling`       | These models support persistent historical commentary                          |
 | [Synchronized data](../integrations/synchronized-data.md)  | `SyncedDataMixin`       | `synced_data`      | Certain model data can be automatically synchronized from a remote data source |
 | [Tagging](../models/extras/tag.md)                         | `TagsMixin`             | `tags`             | The models can be tagged with user-defined tags                                |

+ 1 - 1
docs/features/background-jobs.md

@@ -6,7 +6,7 @@ NetBox includes the ability to execute certain functions as background tasks. Th
 * [Custom script](../customization/custom-scripts.md) execution
 * Synchronization of [remote data sources](../integrations/synchronized-data.md)
 
-Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [JobResult model](../models/extras/jobresult.md). Background tasks are executed by the `rqworker` process(es).
+Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [Job model](../models/core/job.md). Background tasks are executed by the `rqworker` process(es).
 
 ## Scheduled Jobs
 

+ 2 - 2
docs/models/extras/jobresult.md → docs/models/core/job.md

@@ -1,6 +1,6 @@
-# Job Results
+# Jobs
 
-The JobResult model is used to schedule and record the execution of [background tasks](../../features/background-jobs.md).
+The Job model is used to schedule and record the execution of [background tasks](../../features/background-jobs.md).
 
 ## Fields
 

+ 1 - 1
mkdocs.yml

@@ -159,6 +159,7 @@ nav:
         - Core:
             - DataFile: 'models/core/datafile.md'
             - DataSource: 'models/core/datasource.md'
+            - Job: 'models/core/job.md'
         - DCIM:
             - Cable: 'models/dcim/cable.md'
             - ConsolePort: 'models/dcim/consoleport.md'
@@ -208,7 +209,6 @@ nav:
             - CustomLink: 'models/extras/customlink.md'
             - ExportTemplate: 'models/extras/exporttemplate.md'
             - ImageAttachment: 'models/extras/imageattachment.md'
-            - JobResult: 'models/extras/jobresult.md'
             - JournalEntry: 'models/extras/journalentry.md'
             - SavedFilter: 'models/extras/savedfilter.md'
             - StagedChange: 'models/extras/stagedchange.md'

+ 18 - 2
netbox/core/api/nested_serializers.py

@@ -1,12 +1,16 @@
 from rest_framework import serializers
 
+from core.choices import JobStatusChoices
 from core.models import *
+from netbox.api.fields import ChoiceField
 from netbox.api.serializers import WritableNestedSerializer
+from users.api.nested_serializers import NestedUserSerializer
 
-__all__ = [
+__all__ = (
     'NestedDataFileSerializer',
     'NestedDataSourceSerializer',
-]
+    'NestedJobSerializer',
+)
 
 
 class NestedDataSourceSerializer(WritableNestedSerializer):
@@ -23,3 +27,15 @@ class NestedDataFileSerializer(WritableNestedSerializer):
     class Meta:
         model = DataFile
         fields = ['id', 'url', 'display', 'path']
+
+
+class NestedJobSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail')
+    status = ChoiceField(choices=JobStatusChoices)
+    user = NestedUserSerializer(
+        read_only=True
+    )
+
+    class Meta:
+        model = Job
+        fields = ['url', 'created', 'completed', 'user', 'status']

+ 23 - 2
netbox/core/api/serializers.py

@@ -2,12 +2,15 @@ from rest_framework import serializers
 
 from core.choices import *
 from core.models import *
-from netbox.api.fields import ChoiceField
-from netbox.api.serializers import NetBoxModelSerializer
+from netbox.api.fields import ChoiceField, ContentTypeField
+from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
+from users.api.nested_serializers import NestedUserSerializer
 from .nested_serializers import *
 
 __all__ = (
+    'DataFileSerializer',
     'DataSourceSerializer',
+    'JobSerializer',
 )
 
 
@@ -49,3 +52,21 @@ class DataFileSerializer(NetBoxModelSerializer):
         fields = [
             'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
         ]
+
+
+class JobSerializer(BaseModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail')
+    user = NestedUserSerializer(
+        read_only=True
+    )
+    status = ChoiceField(choices=JobStatusChoices, read_only=True)
+    object_type = ContentTypeField(
+        read_only=True
+    )
+
+    class Meta:
+        model = Job
+        fields = [
+            'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name',
+            'object_type', 'user', 'data', 'job_id',
+        ]

+ 3 - 0
netbox/core/api/urls.py

@@ -9,5 +9,8 @@ router.APIRootView = views.CoreRootView
 router.register('data-sources', views.DataSourceViewSet)
 router.register('data-files', views.DataFileViewSet)
 
+# Jobs
+router.register('job-results', views.JobViewSet)
+
 app_name = 'core-api'
 urlpatterns = router.urls

+ 10 - 4
netbox/core/api/views.py

@@ -4,6 +4,7 @@ from rest_framework.decorators import action
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
+from rest_framework.viewsets import ReadOnlyModelViewSet
 
 from core import filtersets
 from core.models import *
@@ -20,10 +21,6 @@ class CoreRootView(APIRootView):
         return 'Core'
 
 
-#
-# Data sources
-#
-
 class DataSourceViewSet(NetBoxModelViewSet):
     queryset = DataSource.objects.annotate(
         file_count=count_related(DataFile, 'source')
@@ -50,3 +47,12 @@ class DataFileViewSet(NetBoxReadOnlyModelViewSet):
     queryset = DataFile.objects.defer('data').prefetch_related('source')
     serializer_class = serializers.DataFileSerializer
     filterset_class = filtersets.DataFileFilterSet
+
+
+class JobViewSet(ReadOnlyModelViewSet):
+    """
+    Retrieve a list of job results
+    """
+    queryset = Job.objects.prefetch_related('user')
+    serializer_class = serializers.JobSerializer
+    filterset_class = filtersets.JobFilterSet

+ 29 - 0
netbox/core/choices.py

@@ -47,3 +47,32 @@ class ManagedFileRootPathChoices(ChoiceSet):
         (SCRIPTS, _('Scripts')),
         (REPORTS, _('Reports')),
     )
+
+
+#
+# Jobs
+#
+
+class JobStatusChoices(ChoiceSet):
+
+    STATUS_PENDING = 'pending'
+    STATUS_SCHEDULED = 'scheduled'
+    STATUS_RUNNING = 'running'
+    STATUS_COMPLETED = 'completed'
+    STATUS_ERRORED = 'errored'
+    STATUS_FAILED = 'failed'
+
+    CHOICES = (
+        (STATUS_PENDING, 'Pending', 'cyan'),
+        (STATUS_SCHEDULED, 'Scheduled', 'gray'),
+        (STATUS_RUNNING, 'Running', 'blue'),
+        (STATUS_COMPLETED, 'Completed', 'green'),
+        (STATUS_ERRORED, 'Errored', 'red'),
+        (STATUS_FAILED, 'Failed', 'red'),
+    )
+
+    TERMINAL_STATE_CHOICES = (
+        STATUS_COMPLETED,
+        STATUS_ERRORED,
+        STATUS_FAILED,
+    )

+ 61 - 1
netbox/core/filtersets.py

@@ -3,13 +3,14 @@ from django.utils.translation import gettext as _
 
 import django_filters
 
-from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet
+from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from .choices import *
 from .models import *
 
 __all__ = (
     'DataFileFilterSet',
     'DataSourceFilterSet',
+    'JobFilterSet',
 )
 
 
@@ -62,3 +63,62 @@ class DataFileFilterSet(ChangeLoggedModelFilterSet):
         return queryset.filter(
             Q(path__icontains=value)
         )
+
+
+class JobFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        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'
+    )
+    scheduled = django_filters.DateTimeFilter()
+    scheduled__before = django_filters.DateTimeFilter(
+        field_name='scheduled',
+        lookup_expr='lte'
+    )
+    scheduled__after = django_filters.DateTimeFilter(
+        field_name='scheduled',
+        lookup_expr='gte'
+    )
+    started = django_filters.DateTimeFilter()
+    started__before = django_filters.DateTimeFilter(
+        field_name='started',
+        lookup_expr='lte'
+    )
+    started__after = django_filters.DateTimeFilter(
+        field_name='started',
+        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'
+    )
+    status = django_filters.MultipleChoiceFilter(
+        choices=JobStatusChoices,
+        null_value=None
+    )
+
+    class Meta:
+        model = Job
+        fields = ('id', 'interval', 'status', 'user', 'object_type', 'name')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(user__username__icontains=value) |
+            Q(name__icontains=value)
+        )

+ 69 - 1
netbox/core/forms/filtersets.py

@@ -1,14 +1,22 @@
 from django import forms
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 
 from core.choices import *
 from core.models import *
+from extras.forms.mixins import SavedFiltersMixin
+from extras.utils import FeatureQuery
 from netbox.forms import NetBoxModelFilterSetForm
-from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, DynamicModelMultipleChoiceField
+from utilities.forms import (
+    APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeChoiceField, DateTimePicker,
+    DynamicModelMultipleChoiceField, FilterForm,
+)
 
 __all__ = (
     'DataFileFilterForm',
     'DataSourceFilterForm',
+    'JobFilterForm',
 )
 
 
@@ -45,3 +53,63 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
         required=False,
         label=_('Data source')
     )
+
+
+class JobFilterForm(SavedFiltersMixin, FilterForm):
+    fieldsets = (
+        (None, ('q', 'filter_id')),
+        ('Attributes', ('object_type', 'status')),
+        ('Creation', (
+            'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
+            'started__after', 'completed__before', 'completed__after', 'user',
+        )),
+    )
+    object_type = ContentTypeChoiceField(
+        label=_('Object Type'),
+        queryset=ContentType.objects.filter(FeatureQuery('jobs').get_query()),
+        required=False,
+    )
+    status = forms.MultipleChoiceField(
+        choices=JobStatusChoices,
+        required=False
+    )
+    created__after = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    created__before = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    scheduled__after = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    scheduled__before = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    started__after = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    started__before = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    completed__after = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    completed__before = forms.DateTimeField(
+        required=False,
+        widget=DateTimePicker()
+    )
+    user = DynamicModelMultipleChoiceField(
+        queryset=User.objects.all(),
+        required=False,
+        label=_('User'),
+        widget=APISelectMultiple(
+            api_url='/api/users/users/',
+        )
+    )

+ 2 - 2
netbox/core/jobs.py

@@ -1,6 +1,6 @@
 import logging
 
-from extras.choices import JobResultStatusChoices
+from .choices import JobStatusChoices
 from netbox.search.backends import search_backend
 from .choices import *
 from .exceptions import SyncError
@@ -25,6 +25,6 @@ def sync_datasource(job_result, *args, **kwargs):
         job_result.terminate()
 
     except SyncError as e:
-        job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
+        job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
         DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
         logging.error(e)

+ 40 - 0
netbox/core/migrations/0003_move_jobresult_to_core.py

@@ -0,0 +1,40 @@
+# Generated by Django 4.1.7 on 2023-03-27 15:02
+
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import extras.utils
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('core', '0002_managedfile'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Job',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('object_id', models.PositiveBigIntegerField(blank=True, null=True)),
+                ('name', models.CharField(max_length=200)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('scheduled', models.DateTimeField(blank=True, null=True)),
+                ('interval', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)])),
+                ('started', models.DateTimeField(blank=True, null=True)),
+                ('completed', models.DateTimeField(blank=True, null=True)),
+                ('status', models.CharField(default='pending', max_length=30)),
+                ('data', models.JSONField(blank=True, null=True)),
+                ('job_id', models.UUIDField(unique=True)),
+                ('object_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
+                ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'ordering': ['-created'],
+            },
+        ),
+    ]

+ 1 - 0
netbox/core/models/__init__.py

@@ -1,2 +1,3 @@
 from .data import *
 from .files import *
+from .jobs import *

+ 2 - 3
netbox/core/models/data.py

@@ -21,6 +21,7 @@ from utilities.querysets import RestrictedQuerySet
 from ..choices import *
 from ..exceptions import SyncError
 from ..signals import post_sync, pre_sync
+from .jobs import Job
 
 __all__ = (
     'DataFile',
@@ -112,14 +113,12 @@ class DataSource(PrimaryModel):
         """
         Enqueue a background job to synchronize the DataSource by calling sync().
         """
-        from extras.models import JobResult
-
         # Set the status to "syncing"
         self.status = DataSourceStatusChoices.QUEUED
         DataSource.objects.filter(pk=self.pk).update(status=self.status)
 
         # Enqueue a sync job
-        job_result = JobResult.enqueue_job(
+        job_result = Job.enqueue_job(
             import_string('core.jobs.sync_datasource'),
             name=self.name,
             obj_type=ContentType.objects.get_for_model(DataSource),

+ 219 - 0
netbox/core/models/jobs.py

@@ -0,0 +1,219 @@
+import uuid
+
+import django_rq
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.core.validators import MinValueValidator
+from django.db import models
+from django.urls import reverse
+from django.urls.exceptions import NoReverseMatch
+from django.utils import timezone
+from django.utils.translation import gettext as _
+
+from core.choices import JobStatusChoices
+from extras.constants import EVENT_JOB_END, EVENT_JOB_START
+from extras.utils import FeatureQuery
+from netbox.config import get_config
+from netbox.constants import RQ_QUEUE_DEFAULT
+from utilities.querysets import RestrictedQuerySet
+from utilities.rqworker import get_queue_for_model
+
+__all__ = (
+    'Job',
+)
+
+
+class Job(models.Model):
+    """
+    Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script).
+    """
+    object_type = models.ForeignKey(
+        to=ContentType,
+        related_name='jobs',
+        limit_choices_to=FeatureQuery('jobs'),
+        on_delete=models.CASCADE,
+    )
+    object_id = models.PositiveBigIntegerField(
+        blank=True,
+        null=True
+    )
+    object = GenericForeignKey(
+        ct_field='object_type',
+        fk_field='object_id'
+    )
+    name = models.CharField(
+        max_length=200
+    )
+    created = models.DateTimeField(
+        auto_now_add=True
+    )
+    scheduled = models.DateTimeField(
+        null=True,
+        blank=True
+    )
+    interval = models.PositiveIntegerField(
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(1),
+        ),
+        help_text=_("Recurrence interval (in minutes)")
+    )
+    started = models.DateTimeField(
+        null=True,
+        blank=True
+    )
+    completed = models.DateTimeField(
+        null=True,
+        blank=True
+    )
+    user = models.ForeignKey(
+        to=User,
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    status = models.CharField(
+        max_length=30,
+        choices=JobStatusChoices,
+        default=JobStatusChoices.STATUS_PENDING
+    )
+    data = models.JSONField(
+        null=True,
+        blank=True
+    )
+    job_id = models.UUIDField(
+        unique=True
+    )
+
+    objects = RestrictedQuerySet.as_manager()
+
+    class Meta:
+        ordering = ['-created']
+
+    def __str__(self):
+        return str(self.job_id)
+
+    def delete(self, *args, **kwargs):
+        super().delete(*args, **kwargs)
+
+        rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.object_type.model, RQ_QUEUE_DEFAULT)
+        queue = django_rq.get_queue(rq_queue_name)
+        job = queue.fetch_job(str(self.job_id))
+
+        if job:
+            job.cancel()
+
+    def get_absolute_url(self):
+        try:
+            return reverse(f'extras:{self.object_type.model}_result', args=[self.pk])
+        except NoReverseMatch:
+            return None
+
+    def get_status_color(self):
+        return JobStatusChoices.colors.get(self.status)
+
+    @property
+    def duration(self):
+        if not self.completed:
+            return None
+
+        start_time = self.started or self.created
+
+        if not start_time:
+            return None
+
+        duration = self.completed - start_time
+        minutes, seconds = divmod(duration.total_seconds(), 60)
+
+        return f"{int(minutes)} minutes, {seconds:.2f} seconds"
+
+    def start(self):
+        """
+        Record the job's start time and update its status to "running."
+        """
+        if self.started is not None:
+            return
+
+        # Start the job
+        self.started = timezone.now()
+        self.status = JobStatusChoices.STATUS_RUNNING
+        Job.objects.filter(pk=self.pk).update(started=self.started, status=self.status)
+
+        # Handle webhooks
+        self.trigger_webhooks(event=EVENT_JOB_START)
+
+    def terminate(self, status=JobStatusChoices.STATUS_COMPLETED):
+        """
+        Mark the job as completed, optionally specifying a particular termination status.
+        """
+        valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
+        if status not in valid_statuses:
+            raise ValueError(f"Invalid status for job termination. Choices are: {', '.join(valid_statuses)}")
+
+        # Mark the job as completed
+        self.status = status
+        self.completed = timezone.now()
+        Job.objects.filter(pk=self.pk).update(status=self.status, completed=self.completed)
+
+        # Handle webhooks
+        self.trigger_webhooks(event=EVENT_JOB_END)
+
+    @classmethod
+    def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs):
+        """
+        Create a Job instance and enqueue a job using the given callable
+
+        Args:
+            func: The callable object to be enqueued for execution
+            name: Name for the job (optional)
+            obj_type: ContentType to link to the Job instance object_type
+            user: User object to link to the Job instance
+            schedule_at: Schedule the job to be executed at the passed date and time
+            interval: Recurrence interval (in minutes)
+        """
+        rq_queue_name = get_queue_for_model(obj_type.model)
+        queue = django_rq.get_queue(rq_queue_name)
+        status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING
+        job = Job.objects.create(
+            name=name,
+            status=status,
+            object_type=obj_type,
+            scheduled=schedule_at,
+            interval=interval,
+            user=user,
+            job_id=uuid.uuid4()
+        )
+
+        if schedule_at:
+            queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job_result=job, **kwargs)
+        else:
+            queue.enqueue(func, job_id=str(job.job_id), job_result=job, **kwargs)
+
+        return job
+
+    def trigger_webhooks(self, event):
+        from extras.models import Webhook
+
+        rq_queue_name = get_config().QUEUE_MAPPINGS.get('webhook', RQ_QUEUE_DEFAULT)
+        rq_queue = django_rq.get_queue(rq_queue_name, is_async=False)
+
+        # Fetch any webhooks matching this object type and action
+        webhooks = Webhook.objects.filter(
+            **{f'type_{event}': True},
+            content_types=self.object_type,
+            enabled=True
+        )
+
+        for webhook in webhooks:
+            rq_queue.enqueue(
+                "extras.webhooks_worker.process_webhook",
+                webhook=webhook,
+                model_name=self.object_type.model,
+                event=event,
+                data=self.data,
+                timestamp=str(timezone.now()),
+                username=self.user.username
+            )

+ 1 - 0
netbox/core/tables/__init__.py

@@ -1 +1,2 @@
 from .data import *
+from .jobs import *

+ 34 - 0
netbox/core/tables/jobs.py

@@ -0,0 +1,34 @@
+import django_tables2 as tables
+from django.utils.translation import gettext as _
+
+from netbox.tables import NetBoxTable, columns
+from ..models import Job
+
+
+class JobTable(NetBoxTable):
+    name = tables.Column(
+        linkify=True
+    )
+    object_type = columns.ContentTypeColumn(
+        verbose_name=_('Type')
+    )
+    status = columns.ChoiceFieldColumn()
+    created = columns.DateTimeColumn()
+    scheduled = columns.DateTimeColumn()
+    interval = columns.DurationColumn()
+    started = columns.DateTimeColumn()
+    completed = columns.DateTimeColumn()
+    actions = columns.ActionsColumn(
+        actions=('delete',)
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = Job
+        fields = (
+            'pk', 'id', 'object_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
+            'user', 'job_id',
+        )
+        default_columns = (
+            'pk', 'id', 'object_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
+            'user',
+        )

+ 5 - 0
netbox/core/urls.py

@@ -19,4 +19,9 @@ urlpatterns = (
     path('data-files/delete/', views.DataFileBulkDeleteView.as_view(), name='datafile_bulk_delete'),
     path('data-files/<int:pk>/', include(get_model_urls('core', 'datafile'))),
 
+    # Job results
+    path('jobs/', views.JobListView.as_view(), name='job_list'),
+    path('jobs/delete/', views.JobBulkDeleteView.as_view(), name='job_bulk_delete'),
+    path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
+
 )

+ 22 - 0
netbox/core/views.py

@@ -120,3 +120,25 @@ class DataFileBulkDeleteView(generic.BulkDeleteView):
     queryset = DataFile.objects.defer('data')
     filterset = filtersets.DataFileFilterSet
     table = tables.DataFileTable
+
+
+#
+# Jobs
+#
+
+class JobListView(generic.ObjectListView):
+    queryset = Job.objects.all()
+    filterset = filtersets.JobFilterSet
+    filterset_form = forms.JobFilterForm
+    table = tables.JobTable
+    actions = ('export', 'delete', 'bulk_delete', )
+
+
+class JobDeleteView(generic.ObjectDeleteView):
+    queryset = Job.objects.all()
+
+
+class JobBulkDeleteView(generic.BulkDeleteView):
+    queryset = Job.objects.all()
+    filterset = filtersets.JobFilterSet
+    table = tables.JobTable

+ 1 - 1
netbox/extras/admin.py

@@ -6,7 +6,7 @@ from django.utils.html import format_html
 
 from netbox.config import get_config, PARAMS
 from .forms import ConfigRevisionForm
-from .models import ConfigRevision, JobResult
+from .models import ConfigRevision
 
 
 @admin.register(ConfigRevision)

+ 1 - 16
netbox/extras/api/nested_serializers.py

@@ -1,9 +1,7 @@
 from rest_framework import serializers
 
-from extras import choices, models
-from netbox.api.fields import ChoiceField
+from extras import models
 from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
-from users.api.nested_serializers import NestedUserSerializer
 
 __all__ = [
     'NestedConfigContextSerializer',
@@ -12,7 +10,6 @@ __all__ = [
     'NestedCustomLinkSerializer',
     'NestedExportTemplateSerializer',
     'NestedImageAttachmentSerializer',
-    'NestedJobResultSerializer',
     'NestedJournalEntrySerializer',
     'NestedSavedFilterSerializer',
     'NestedTagSerializer',  # Defined in netbox.api.serializers
@@ -90,15 +87,3 @@ class NestedJournalEntrySerializer(WritableNestedSerializer):
     class Meta:
         model = models.JournalEntry
         fields = ['id', 'url', 'display', 'created']
-
-
-class NestedJobResultSerializer(serializers.ModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
-    status = ChoiceField(choices=choices.JobResultStatusChoices)
-    user = NestedUserSerializer(
-        read_only=True
-    )
-
-    class Meta:
-        model = models.JobResult
-        fields = ['url', 'created', 'completed', 'user', 'status']

+ 6 - 28
netbox/extras/api/serializers.py

@@ -4,7 +4,8 @@ from django.core.exceptions import ObjectDoesNotExist
 from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 
-from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer
+from core.api.serializers import JobSerializer
+from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
 from dcim.api.nested_serializers import (
     NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
     NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
@@ -37,7 +38,6 @@ __all__ = (
     'DashboardSerializer',
     'ExportTemplateSerializer',
     'ImageAttachmentSerializer',
-    'JobResultSerializer',
     'JournalEntrySerializer',
     'ObjectChangeSerializer',
     'ReportDetailSerializer',
@@ -409,28 +409,6 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
         ]
 
 
-#
-# Job Results
-#
-
-class JobResultSerializer(BaseModelSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
-    user = NestedUserSerializer(
-        read_only=True
-    )
-    status = ChoiceField(choices=JobResultStatusChoices, read_only=True)
-    obj_type = ContentTypeField(
-        read_only=True
-    )
-
-    class Meta:
-        model = JobResult
-        fields = [
-            'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name',
-            'obj_type', 'user', 'data', 'job_id',
-        ]
-
-
 #
 # Reports
 #
@@ -446,11 +424,11 @@ class ReportSerializer(serializers.Serializer):
     name = serializers.CharField(max_length=255)
     description = serializers.CharField(max_length=255, required=False)
     test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
-    result = NestedJobResultSerializer()
+    result = NestedJobSerializer()
 
 
 class ReportDetailSerializer(ReportSerializer):
-    result = JobResultSerializer()
+    result = JobSerializer()
 
 
 class ReportInputSerializer(serializers.Serializer):
@@ -473,7 +451,7 @@ class ScriptSerializer(serializers.Serializer):
     name = serializers.CharField(read_only=True)
     description = serializers.CharField(read_only=True)
     vars = serializers.SerializerMethodField(read_only=True)
-    result = NestedJobResultSerializer()
+    result = NestedJobSerializer()
 
     @swagger_serializer_method(serializer_or_field=serializers.JSONField)
     def get_vars(self, instance):
@@ -483,7 +461,7 @@ class ScriptSerializer(serializers.Serializer):
 
 
 class ScriptDetailSerializer(ScriptSerializer):
-    result = JobResultSerializer()
+    result = JobSerializer()
 
 
 class ScriptInputSerializer(serializers.Serializer):

+ 0 - 1
netbox/extras/api/urls.py

@@ -20,7 +20,6 @@ router.register('config-templates', views.ConfigTemplateViewSet)
 router.register('reports', views.ReportViewSet, basename='report')
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('object-changes', views.ObjectChangeViewSet)
-router.register('job-results', views.JobResultViewSet)
 router.register('content-types', views.ContentTypeViewSet)
 
 app_name = 'extras-api'

+ 22 - 38
netbox/extras/api/views.py

@@ -12,10 +12,10 @@ from rest_framework.routers import APIRootView
 from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
 from rq import Worker
 
+from core.choices import JobStatusChoices
+from core.models import Job
 from extras import filtersets
-from extras.choices import JobResultStatusChoices
 from extras.models import *
-from extras.models import CustomField
 from extras.reports import get_report, run_report
 from extras.scripts import get_script, run_script
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -191,9 +191,9 @@ class ReportViewSet(ViewSet):
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
         results = {
             r.name: r
-            for r in JobResult.objects.filter(
-                obj_type=report_content_type,
-                status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            for r in Job.objects.filter(
+                object_type=report_content_type,
+                status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
             ).order_by('name', '-created').distinct('name').defer('data')
         }
 
@@ -201,7 +201,7 @@ class ReportViewSet(ViewSet):
         for report_module in ReportModule.objects.restrict(request.user):
             report_list.extend([report() for report in report_module.reports.values()])
 
-        # Attach JobResult objects to each report (if any)
+        # Attach Job objects to each report (if any)
         for report in report_list:
             report.result = results.get(report.full_name, None)
 
@@ -216,13 +216,13 @@ class ReportViewSet(ViewSet):
         Retrieve a single Report identified as "<module>.<report>".
         """
 
-        # Retrieve the Report and JobResult, if any.
+        # Retrieve the Report and Job, if any.
         report = self._retrieve_report(pk)
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
-        report.result = JobResult.objects.filter(
-            obj_type=report_content_type,
+        report.result = Job.objects.filter(
+            object_type=report_content_type,
             name=report.full_name,
-            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
 
         serializer = serializers.ReportDetailSerializer(report, context={
@@ -234,7 +234,7 @@ class ReportViewSet(ViewSet):
     @action(detail=True, methods=['post'])
     def run(self, request, pk):
         """
-        Run a Report identified as "<module>.<script>" and return the pending JobResult as the result
+        Run a Report identified as "<module>.<script>" and return the pending Job as the result
         """
         # Check that the user has permission to run reports.
         if not request.user.has_perm('extras.run_report'):
@@ -244,12 +244,12 @@ class ReportViewSet(ViewSet):
         if not Worker.count(get_connection('default')):
             raise RQWorkerNotRunningException()
 
-        # Retrieve and run the Report. This will create a new JobResult.
+        # Retrieve and run the Report. This will create a new Job.
         report = self._retrieve_report(pk)
         input_serializer = serializers.ReportInputSerializer(data=request.data)
 
         if input_serializer.is_valid():
-            job_result = JobResult.enqueue_job(
+            report.result = Job.enqueue_job(
                 run_report,
                 name=report.full_name,
                 obj_type=ContentType.objects.get_for_model(Report),
@@ -258,8 +258,6 @@ class ReportViewSet(ViewSet):
                 schedule_at=input_serializer.validated_data.get('schedule_at'),
                 interval=input_serializer.validated_data.get('interval')
             )
-            report.result = job_result
-
             serializer = serializers.ReportDetailSerializer(report, context={'request': request})
 
             return Response(serializer.data)
@@ -288,9 +286,9 @@ class ScriptViewSet(ViewSet):
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
         results = {
             r.name: r
-            for r in JobResult.objects.filter(
-                obj_type=script_content_type,
-                status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            for r in Job.objects.filter(
+                object_type=script_content_type,
+                status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
             ).order_by('name', '-created').distinct('name').defer('data')
         }
 
@@ -298,7 +296,7 @@ class ScriptViewSet(ViewSet):
         for script_module in ScriptModule.objects.restrict(request.user):
             script_list.extend(script_module.scripts.values())
 
-        # Attach JobResult objects to each script (if any)
+        # Attach Job objects to each script (if any)
         for script in script_list:
             script.result = results.get(script.full_name, None)
 
@@ -309,10 +307,10 @@ class ScriptViewSet(ViewSet):
     def retrieve(self, request, pk):
         script = self._get_script(pk)
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
-        script.result = JobResult.objects.filter(
-            obj_type=script_content_type,
+        script.result = Job.objects.filter(
+            object_type=script_content_type,
             name=script.full_name,
-            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
 
@@ -320,7 +318,7 @@ class ScriptViewSet(ViewSet):
 
     def post(self, request, pk):
         """
-        Run a Script identified as "<module>.<script>" and return the pending JobResult as the result
+        Run a Script identified as "<module>.<script>" and return the pending Job as the result
         """
 
         if not request.user.has_perm('extras.run_script'):
@@ -334,7 +332,7 @@ class ScriptViewSet(ViewSet):
             raise RQWorkerNotRunningException()
 
         if input_serializer.is_valid():
-            job_result = JobResult.enqueue_job(
+            script.result = Job.enqueue_job(
                 run_script,
                 name=script.full_name,
                 obj_type=ContentType.objects.get_for_model(Script),
@@ -346,7 +344,6 @@ class ScriptViewSet(ViewSet):
                 schedule_at=input_serializer.validated_data.get('schedule_at'),
                 interval=input_serializer.validated_data.get('interval')
             )
-            script.result = job_result
             serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
 
             return Response(serializer.data)
@@ -368,19 +365,6 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
     filterset_class = filtersets.ObjectChangeFilterSet
 
 
-#
-# Job Results
-#
-
-class JobResultViewSet(ReadOnlyModelViewSet):
-    """
-    Retrieve a list of job results
-    """
-    queryset = JobResult.objects.prefetch_related('user')
-    serializer_class = serializers.JobResultSerializer
-    filterset_class = filtersets.JobResultFilterSet
-
-
 #
 # ContentTypes
 #

+ 0 - 64
netbox/extras/filtersets.py

@@ -22,7 +22,6 @@ __all__ = (
     'CustomLinkFilterSet',
     'ExportTemplateFilterSet',
     'ImageAttachmentFilterSet',
-    'JobResultFilterSet',
     'JournalEntryFilterSet',
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
@@ -537,69 +536,6 @@ class ObjectChangeFilterSet(BaseFilterSet):
         )
 
 
-#
-# Job Results
-#
-
-class JobResultFilterSet(BaseFilterSet):
-    q = django_filters.CharFilter(
-        method='search',
-        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'
-    )
-    scheduled = django_filters.DateTimeFilter()
-    scheduled__before = django_filters.DateTimeFilter(
-        field_name='scheduled',
-        lookup_expr='lte'
-    )
-    scheduled__after = django_filters.DateTimeFilter(
-        field_name='scheduled',
-        lookup_expr='gte'
-    )
-    started = django_filters.DateTimeFilter()
-    started__before = django_filters.DateTimeFilter(
-        field_name='started',
-        lookup_expr='lte'
-    )
-    started__after = django_filters.DateTimeFilter(
-        field_name='started',
-        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'
-    )
-    status = django_filters.MultipleChoiceFilter(
-        choices=JobResultStatusChoices,
-        null_value=None
-    )
-
-    class Meta:
-        model = JobResult
-        fields = ('id', 'interval', '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(name__icontains=value)
-        )
-
-
 #
 # ContentTypes
 #

+ 2 - 64
netbox/extras/forms/filtersets.py

@@ -11,9 +11,8 @@ from extras.utils import FeatureQuery
 from netbox.forms.base import NetBoxModelFilterSetForm
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
-    add_blank_choice, APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeChoiceField,
-    ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, FilterForm,
-    TagFilterField,
+    add_blank_choice, APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeMultipleChoiceField, DateTimePicker,
+    DynamicModelMultipleChoiceField, FilterForm, TagFilterField,
 )
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from .mixins import SavedFiltersMixin
@@ -24,7 +23,6 @@ __all__ = (
     'CustomFieldFilterForm',
     'CustomLinkFilterForm',
     'ExportTemplateFilterForm',
-    'JobResultFilterForm',
     'JournalEntryFilterForm',
     'LocalConfigContextFilterForm',
     'ObjectChangeFilterForm',
@@ -76,66 +74,6 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
     )
 
 
-class JobResultFilterForm(SavedFiltersMixin, FilterForm):
-    fieldsets = (
-        (None, ('q', 'filter_id')),
-        ('Attributes', ('obj_type', 'status')),
-        ('Creation', (
-            'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
-            'started__after', 'completed__before', 'completed__after', 'user',
-        )),
-    )
-    obj_type = ContentTypeChoiceField(
-        label=_('Object Type'),
-        queryset=ContentType.objects.filter(FeatureQuery('job_results').get_query()),
-        required=False,
-    )
-    status = forms.MultipleChoiceField(
-        choices=JobResultStatusChoices,
-        required=False
-    )
-    created__after = forms.DateTimeField(
-        required=False,
-        widget=DateTimePicker()
-    )
-    created__before = forms.DateTimeField(
-        required=False,
-        widget=DateTimePicker()
-    )
-    scheduled__after = forms.DateTimeField(
-        required=False,
-        widget=DateTimePicker()
-    )
-    scheduled__before = forms.DateTimeField(
-        required=False,
-        widget=DateTimePicker()
-    )
-    started__after = forms.DateTimeField(
-        required=False,
-        widget=DateTimePicker()
-    )
-    started__before = forms.DateTimeField(
-        required=False,
-        widget=DateTimePicker()
-    )
-    completed__after = forms.DateTimeField(
-        required=False,
-        widget=DateTimePicker()
-    )
-    completed__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(SavedFiltersMixin, FilterForm):
     fieldsets = (
         (None, ('q', 'filter_id')),

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

@@ -9,7 +9,7 @@ from django.db import DEFAULT_DB_ALIAS
 from django.utils import timezone
 from packaging import version
 
-from extras.models import JobResult
+from core.models import Job
 from extras.models import ObjectChange
 from netbox.config import Config
 
@@ -64,15 +64,15 @@ class Command(BaseCommand):
                 f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})"
             )
 
-        # Delete expired JobResults
+        # Delete expired Jobs
         if options['verbosity']:
-            self.stdout.write("[*] Checking for expired jobresult records")
+            self.stdout.write("[*] Checking for expired jobs")
         if config.JOBRESULT_RETENTION:
             cutoff = timezone.now() - timedelta(days=config.JOBRESULT_RETENTION)
             if options['verbosity'] >= 2:
                 self.stdout.write(f"\tRetention period: {config.JOBRESULT_RETENTION} days")
                 self.stdout.write(f"\tCut-off time: {cutoff}")
-            expired_records = JobResult.objects.filter(created__lt=cutoff).count()
+            expired_records = Job.objects.filter(created__lt=cutoff).count()
             if expired_records:
                 if options['verbosity']:
                     self.stdout.write(
@@ -81,7 +81,7 @@ class Command(BaseCommand):
                         ending=""
                     )
                     self.stdout.flush()
-                JobResult.objects.filter(created__lt=cutoff).delete()
+                Job.objects.filter(created__lt=cutoff).delete()
                 if options['verbosity']:
                     self.stdout.write("Done.", self.style.SUCCESS)
             elif options['verbosity']:

+ 11 - 10
netbox/extras/management/commands/runreport.py

@@ -4,8 +4,9 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.management.base import BaseCommand
 from django.utils import timezone
 
-from extras.choices import JobResultStatusChoices
-from extras.models import JobResult, ReportModule
+from core.choices import JobStatusChoices
+from core.models import Job
+from extras.models import ReportModule
 from extras.reports import run_report
 
 
@@ -21,13 +22,13 @@ class Command(BaseCommand):
             for report in module.reports.values():
                 if module.name in options['reports'] or report.full_name in options['reports']:
 
-                    # Run the report and create a new JobResult
+                    # Run the report and create a new Job
                     self.stdout.write(
                         "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
                     )
 
                     report_content_type = ContentType.objects.get(app_label='extras', model='report')
-                    job_result = JobResult.enqueue_job(
+                    job = Job.enqueue_job(
                         run_report,
                         report.full_name,
                         report_content_type,
@@ -36,19 +37,19 @@ class Command(BaseCommand):
                     )
 
                     # Wait on the job to finish
-                    while job_result.status not in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
+                    while job.status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
                         time.sleep(1)
-                        job_result = JobResult.objects.get(pk=job_result.pk)
+                        job = Job.objects.get(pk=job.pk)
 
                     # Report on success/failure
-                    if job_result.status == JobResultStatusChoices.STATUS_FAILED:
+                    if job.status == JobStatusChoices.STATUS_FAILED:
                         status = self.style.ERROR('FAILED')
-                    elif job_result == JobResultStatusChoices.STATUS_ERRORED:
+                    elif job == JobStatusChoices.STATUS_ERRORED:
                         status = self.style.ERROR('ERRORED')
                     else:
                         status = self.style.SUCCESS('SUCCESS')
 
-                    for test_name, attrs in job_result.data.items():
+                    for test_name, attrs in job.data.items():
                         self.stdout.write(
                             "\t{}: {} success, {} info, {} warning, {} failure".format(
                                 test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
@@ -58,7 +59,7 @@ class Command(BaseCommand):
                         "[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
                     )
                     self.stdout.write(
-                        "[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), report.full_name, job_result.duration)
+                        "[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), report.full_name, job.duration)
                     )
 
         # Wrap things up

+ 6 - 6
netbox/extras/management/commands/runscript.py

@@ -9,10 +9,10 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.management.base import BaseCommand, CommandError
 from django.db import transaction
 
+from core.choices import JobStatusChoices
+from core.models import Job
 from extras.api.serializers import ScriptOutputSerializer
-from extras.choices import JobResultStatusChoices
 from extras.context_managers import change_logging
-from extras.models import JobResult
 from extras.scripts import get_script
 from extras.signals import clear_webhooks
 from utilities.exceptions import AbortTransaction
@@ -60,7 +60,7 @@ class Command(BaseCommand):
                 logger.error(f"Exception raised during script execution: {e}")
                 clear_webhooks.send(request)
                 job_result.data = ScriptOutputSerializer(script).data
-                job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
+                job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
 
             logger.info(f"Script completed in {job_result.duration}")
 
@@ -113,7 +113,7 @@ class Command(BaseCommand):
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
 
         # Create the job result
-        job_result = JobResult.objects.create(
+        job_result = Job.objects.create(
             name=script.full_name,
             obj_type=script_content_type,
             user=User.objects.filter(is_superuser=True).order_by('pk')[0],
@@ -131,7 +131,7 @@ class Command(BaseCommand):
         })
 
         if form.is_valid():
-            job_result.status = JobResultStatusChoices.STATUS_RUNNING
+            job_result.status = JobStatusChoices.STATUS_RUNNING
             job_result.save()
 
             logger.info(f"Running script (commit={commit})")
@@ -146,5 +146,5 @@ class Command(BaseCommand):
             for field, errors in form.errors.get_json_data().items():
                 for error in errors:
                     logger.error(f'\t{field}: {error.get("message")}')
-            job_result.status = JobResultStatusChoices.STATUS_ERRORED
+            job_result.status = JobStatusChoices.STATUS_ERRORED
             job_result.save()

+ 1 - 1
netbox/extras/migrations/0001_squashed.py

@@ -151,7 +151,7 @@ class Migration(migrations.Migration):
                 ('status', models.CharField(default='pending', max_length=30)),
                 ('data', models.JSONField(blank=True, null=True)),
                 ('job_id', models.UUIDField(unique=True)),
-                ('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('job_results'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')),
+                ('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')),
                 ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
             ],
             options={

+ 1 - 1
netbox/extras/models/models.py

@@ -594,7 +594,7 @@ class JobResult(models.Model):
         to=ContentType,
         related_name='job_results',
         verbose_name='Object types',
-        limit_choices_to=FeatureQuery('job_results'),
+        limit_choices_to=FeatureQuery('jobs'),
         help_text=_("The object type to which this job result applies"),
         on_delete=models.CASCADE,
     )

+ 2 - 2
netbox/extras/models/reports.py

@@ -7,7 +7,7 @@ from django.urls import reverse
 from core.choices import ManagedFileRootPathChoices
 from core.models import ManagedFile
 from extras.utils import is_report
-from netbox.models.features import JobResultsMixin, WebhooksMixin
+from netbox.models.features import JobsMixin, WebhooksMixin
 from utilities.querysets import RestrictedQuerySet
 from .mixins import PythonModuleMixin
 
@@ -17,7 +17,7 @@ __all__ = (
 )
 
 
-class Report(JobResultsMixin, WebhooksMixin, models.Model):
+class Report(JobsMixin, WebhooksMixin, models.Model):
     """
     Dummy model used to generate permissions for reports. Does not exist in the database.
     """

+ 2 - 2
netbox/extras/models/scripts.py

@@ -7,7 +7,7 @@ from django.urls import reverse
 from core.choices import ManagedFileRootPathChoices
 from core.models import ManagedFile
 from extras.utils import is_script
-from netbox.models.features import JobResultsMixin, WebhooksMixin
+from netbox.models.features import JobsMixin, WebhooksMixin
 from utilities.querysets import RestrictedQuerySet
 from .mixins import PythonModuleMixin
 
@@ -17,7 +17,7 @@ __all__ = (
 )
 
 
-class Script(JobResultsMixin, WebhooksMixin, models.Model):
+class Script(JobsMixin, WebhooksMixin, models.Model):
     """
     Dummy model used to generate permissions for custom scripts. Does not exist in the database.
     """

+ 10 - 8
netbox/extras/reports.py

@@ -6,8 +6,10 @@ from django.utils import timezone
 from django.utils.functional import classproperty
 from django_rq import job
 
-from .choices import JobResultStatusChoices, LogLevelChoices
-from .models import JobResult, ReportModule
+from core.choices import JobStatusChoices
+from core.models import Job
+from .choices import LogLevelChoices
+from .models import ReportModule
 
 logger = logging.getLogger(__name__)
 
@@ -33,14 +35,14 @@ def run_report(job_result, *args, **kwargs):
         job_result.start()
         report.run(job_result)
     except Exception:
-        job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
+        job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
         logging.error(f"Error during execution of report {job_result.name}")
     finally:
         # Schedule the next job if an interval has been set
         start_time = job_result.scheduled or job_result.started
         if start_time and job_result.interval:
             new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
-            JobResult.enqueue_job(
+            Job.enqueue_job(
                 run_report,
                 name=job_result.name,
                 obj_type=job_result.obj_type,
@@ -189,7 +191,7 @@ class Report(object):
         Run the report and save its results. Each test method will be executed in order.
         """
         self.logger.info(f"Running report")
-        job_result.status = JobResultStatusChoices.STATUS_RUNNING
+        job_result.status = JobStatusChoices.STATUS_RUNNING
         job_result.save()
 
         # Perform any post-run tasks
@@ -202,15 +204,15 @@ class Report(object):
                 test_method()
             if self.failed:
                 self.logger.warning("Report failed")
-                job_result.status = JobResultStatusChoices.STATUS_FAILED
+                job_result.status = JobStatusChoices.STATUS_FAILED
             else:
                 self.logger.info("Report completed successfully")
-                job_result.status = JobResultStatusChoices.STATUS_COMPLETED
+                job_result.status = JobStatusChoices.STATUS_COMPLETED
         except Exception as e:
             stacktrace = traceback.format_exc()
             self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
             logger.error(f"Exception raised during report execution: {e}")
-            job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
+            job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
         finally:
             job_result.terminate()
 

+ 6 - 4
netbox/extras/scripts.py

@@ -12,9 +12,11 @@ from django.core.validators import RegexValidator
 from django.db import transaction
 from django.utils.functional import classproperty
 
+from core.choices import JobStatusChoices
+from core.models import Job
 from extras.api.serializers import ScriptOutputSerializer
-from extras.choices import JobResultStatusChoices, LogLevelChoices
-from extras.models import JobResult, ScriptModule
+from extras.choices import LogLevelChoices
+from extras.models import ScriptModule
 from extras.signals import clear_webhooks
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@@ -482,7 +484,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
                 logger.error(f"Exception raised during script execution: {e}")
             script.log_info("Database changes have been reverted due to error.")
             job_result.data = ScriptOutputSerializer(script).data
-            job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
+            job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
             clear_webhooks.send(request)
 
         logger.info(f"Script completed in {job_result.duration}")
@@ -498,7 +500,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
     # Schedule the next job if an interval has been set
     if job_result.interval:
         new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
-        JobResult.enqueue_job(
+        Job.enqueue_job(
             run_script,
             name=job_result.name,
             obj_type=job_result.obj_type,

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

@@ -2,7 +2,6 @@ import json
 
 import django_tables2 as tables
 from django.conf import settings
-from django.utils.translation import gettext as _
 
 from extras.models import *
 from netbox.tables import NetBoxTable, columns
@@ -14,7 +13,6 @@ __all__ = (
     'CustomFieldTable',
     'CustomLinkTable',
     'ExportTemplateTable',
-    'JobResultTable',
     'JournalEntryTable',
     'ObjectChangeTable',
     'SavedFilterTable',
@@ -43,35 +41,6 @@ class CustomFieldTable(NetBoxTable):
         default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
 
 
-class JobResultTable(NetBoxTable):
-    name = tables.Column(
-        linkify=True
-    )
-    obj_type = columns.ContentTypeColumn(
-        verbose_name=_('Type')
-    )
-    status = columns.ChoiceFieldColumn()
-    created = columns.DateTimeColumn()
-    scheduled = columns.DateTimeColumn()
-    interval = columns.DurationColumn()
-    started = columns.DateTimeColumn()
-    completed = columns.DateTimeColumn()
-    actions = columns.ActionsColumn(
-        actions=('delete',)
-    )
-
-    class Meta(NetBoxTable.Meta):
-        model = JobResult
-        fields = (
-            'pk', 'id', 'obj_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
-            'user', 'job_id',
-        )
-        default_columns = (
-            'pk', 'id', 'obj_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
-            'user',
-        )
-
-
 class CustomLinkTable(NetBoxTable):
     name = tables.Column(
         linkify=True

+ 0 - 5
netbox/extras/urls.py

@@ -106,11 +106,6 @@ urlpatterns = [
     path('scripts/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
     path('scripts/<path:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
 
-    # 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'),
-
     # Markdown
     path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
 ]

+ 25 - 49
netbox/extras/views.py

@@ -2,13 +2,14 @@ from django.contrib import messages
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Count, Q
-from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
+from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.views.generic import View
 
-from core.choices import ManagedFileRootPathChoices
+from core.choices import JobStatusChoices, ManagedFileRootPathChoices
 from core.forms import ManagedFileForm
+from core.models import Job
 from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.utils import get_widget_class
 from netbox.views import generic
@@ -19,7 +20,6 @@ from utilities.templatetags.builtins.filters import render_markdown
 from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
 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, run_report
@@ -810,7 +810,7 @@ class ReportModuleDeleteView(generic.ObjectDeleteView):
 
 class ReportListView(ContentTypePermissionRequiredMixin, View):
     """
-    Retrieve all the available reports from disk and the recorded JobResult (if any) for each.
+    Retrieve all the available reports from disk and the recorded Job (if any) for each.
     """
     def get_required_permission(self):
         return 'extras.view_report'
@@ -821,9 +821,9 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
         job_results = {
             r.name: r
-            for r in JobResult.objects.filter(
-                obj_type=report_content_type,
-                status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            for r in Job.objects.filter(
+                object_type=report_content_type,
+                status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
             ).order_by('name', '-created').distinct('name').defer('data')
         }
 
@@ -836,7 +836,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
 
 class ReportView(ContentTypePermissionRequiredMixin, View):
     """
-    Display a single Report and its associated JobResult (if any).
+    Display a single Report and its associated Job (if any).
     """
     def get_required_permission(self):
         return 'extras.view_report'
@@ -846,10 +846,10 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
         report = module.reports[name]()
 
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
-        report.result = JobResult.objects.filter(
-            obj_type=report_content_type,
+        report.result = Job.objects.filter(
+            object_type=report_content_type,
             name=report.full_name,
-            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
 
         return render(request, 'extras/report.html', {
@@ -875,8 +875,8 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
                     'report': report,
                 })
 
-            # Run the Report. A new JobResult is created.
-            job_result = JobResult.enqueue_job(
+            # Run the Report. A new Job is created.
+            job_result = Job.enqueue_job(
                 run_report,
                 name=report.full_name,
                 obj_type=ContentType.objects.get_for_model(Report),
@@ -897,16 +897,16 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
 
 class ReportResultView(ContentTypePermissionRequiredMixin, View):
     """
-    Display a JobResult pertaining to the execution of a Report.
+    Display a Job pertaining to the execution of a Report.
     """
     def get_required_permission(self):
         return 'extras.view_report'
 
     def get(self, request, job_result_pk):
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
-        result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
+        result = get_object_or_404(Job.objects.all(), pk=job_result_pk, object_type=report_content_type)
 
-        # Retrieve the Report and attach the JobResult to it
+        # Retrieve the Report and attach the Job to it
         module, report_name = result.name.split('.', maxsplit=1)
         report = get_report(module, report_name)
         report.result = result
@@ -958,9 +958,9 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
         job_results = {
             r.name: r
-            for r in JobResult.objects.filter(
-                obj_type=script_content_type,
-                status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            for r in Job.objects.filter(
+                object_type=script_content_type,
+                status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
             ).order_by('name', '-created').distinct('name').defer('data')
         }
 
@@ -981,12 +981,12 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
         script = module.scripts[name]()
         form = script.as_form(initial=normalize_querydict(request.GET))
 
-        # Look for a pending JobResult (use the latest one by creation timestamp)
-        script.result = JobResult.objects.filter(
-            obj_type=ContentType.objects.get_for_model(Script),
+        # Look for a pending Job (use the latest one by creation timestamp)
+        script.result = Job.objects.filter(
+            object_type=ContentType.objects.get_for_model(Script),
             name=script.full_name,
         ).exclude(
-            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
 
         return render(request, 'extras/script.html', {
@@ -1008,7 +1008,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
             messages.error(request, "Unable to run script: RQ worker process not running.")
 
         elif form.is_valid():
-            job_result = JobResult.enqueue_job(
+            job_result = Job.enqueue_job(
                 run_script,
                 name=script.full_name,
                 obj_type=ContentType.objects.get_for_model(Script),
@@ -1036,10 +1036,8 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
         return 'extras.view_script'
 
     def get(self, request, job_result_pk):
-        result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk)
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
-        if result.obj_type != script_content_type:
-            raise Http404
+        result = get_object_or_404(Job.objects.all(), pk=job_result_pk, object_type=script_content_type)
 
         module_name, script_name = result.name.split('.', 1)
         module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path=f'{module_name}.py')
@@ -1062,28 +1060,6 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
         })
 
 
-#
-# 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
-
-
 #
 # Markdown
 #

+ 3 - 3
netbox/netbox/models/features.py

@@ -26,7 +26,7 @@ __all__ = (
     'CustomLinksMixin',
     'CustomValidationMixin',
     'ExportTemplatesMixin',
-    'JobResultsMixin',
+    'JobsMixin',
     'JournalingMixin',
     'SyncedDataMixin',
     'TagsMixin',
@@ -290,7 +290,7 @@ class ExportTemplatesMixin(models.Model):
         abstract = True
 
 
-class JobResultsMixin(models.Model):
+class JobsMixin(models.Model):
     """
     Enables support for job results.
     """
@@ -418,7 +418,7 @@ FEATURES_MAP = {
     'custom_fields': CustomFieldsMixin,
     'custom_links': CustomLinksMixin,
     'export_templates': ExportTemplatesMixin,
-    'job_results': JobResultsMixin,
+    'jobs': JobsMixin,
     'journaling': JournalingMixin,
     'synced_data': SyncedDataMixin,
     'tags': TagsMixin,

+ 2 - 2
netbox/netbox/navigation/menu.py

@@ -326,9 +326,9 @@ OPERATIONS_MENU = Menu(
             label=_('Jobs'),
             items=(
                 MenuItem(
-                    link='extras:jobresult_list',
+                    link='core:job_list',
                     link_text=_('Jobs'),
-                    permissions=['extras.view_jobresult'],
+                    permissions=['core.view_job'],
                 ),
             ),
         ),