Browse Source

Rename JobResult to Job and move to core

jeremystretch 2 years ago
parent
commit
40572b543f
41 changed files with 650 additions and 361 deletions
  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 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                            |
 | [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                      |
 | [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                          |
 | [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 |
 | [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                                |
 | [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
 * [Custom script](../customization/custom-scripts.md) execution
 * Synchronization of [remote data sources](../integrations/synchronized-data.md)
 * 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
 ## 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
 ## Fields
 
 

+ 1 - 1
mkdocs.yml

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

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

@@ -1,12 +1,16 @@
 from rest_framework import serializers
 from rest_framework import serializers
 
 
+from core.choices import JobStatusChoices
 from core.models import *
 from core.models import *
+from netbox.api.fields import ChoiceField
 from netbox.api.serializers import WritableNestedSerializer
 from netbox.api.serializers import WritableNestedSerializer
+from users.api.nested_serializers import NestedUserSerializer
 
 
-__all__ = [
+__all__ = (
     'NestedDataFileSerializer',
     'NestedDataFileSerializer',
     'NestedDataSourceSerializer',
     'NestedDataSourceSerializer',
-]
+    'NestedJobSerializer',
+)
 
 
 
 
 class NestedDataSourceSerializer(WritableNestedSerializer):
 class NestedDataSourceSerializer(WritableNestedSerializer):
@@ -23,3 +27,15 @@ class NestedDataFileSerializer(WritableNestedSerializer):
     class Meta:
     class Meta:
         model = DataFile
         model = DataFile
         fields = ['id', 'url', 'display', 'path']
         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.choices import *
 from core.models 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 *
 from .nested_serializers import *
 
 
 __all__ = (
 __all__ = (
+    'DataFileSerializer',
     'DataSourceSerializer',
     'DataSourceSerializer',
+    'JobSerializer',
 )
 )
 
 
 
 
@@ -49,3 +52,21 @@ class DataFileSerializer(NetBoxModelSerializer):
         fields = [
         fields = [
             'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
             '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-sources', views.DataSourceViewSet)
 router.register('data-files', views.DataFileViewSet)
 router.register('data-files', views.DataFileViewSet)
 
 
+# Jobs
+router.register('job-results', views.JobViewSet)
+
 app_name = 'core-api'
 app_name = 'core-api'
 urlpatterns = router.urls
 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.exceptions import PermissionDenied
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.routers import APIRootView
+from rest_framework.viewsets import ReadOnlyModelViewSet
 
 
 from core import filtersets
 from core import filtersets
 from core.models import *
 from core.models import *
@@ -20,10 +21,6 @@ class CoreRootView(APIRootView):
         return 'Core'
         return 'Core'
 
 
 
 
-#
-# Data sources
-#
-
 class DataSourceViewSet(NetBoxModelViewSet):
 class DataSourceViewSet(NetBoxModelViewSet):
     queryset = DataSource.objects.annotate(
     queryset = DataSource.objects.annotate(
         file_count=count_related(DataFile, 'source')
         file_count=count_related(DataFile, 'source')
@@ -50,3 +47,12 @@ class DataFileViewSet(NetBoxReadOnlyModelViewSet):
     queryset = DataFile.objects.defer('data').prefetch_related('source')
     queryset = DataFile.objects.defer('data').prefetch_related('source')
     serializer_class = serializers.DataFileSerializer
     serializer_class = serializers.DataFileSerializer
     filterset_class = filtersets.DataFileFilterSet
     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')),
         (SCRIPTS, _('Scripts')),
         (REPORTS, _('Reports')),
         (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
 import django_filters
 
 
-from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet
+from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from .choices import *
 from .choices import *
 from .models import *
 from .models import *
 
 
 __all__ = (
 __all__ = (
     'DataFileFilterSet',
     'DataFileFilterSet',
     'DataSourceFilterSet',
     'DataSourceFilterSet',
+    'JobFilterSet',
 )
 )
 
 
 
 
@@ -62,3 +63,62 @@ class DataFileFilterSet(ChangeLoggedModelFilterSet):
         return queryset.filter(
         return queryset.filter(
             Q(path__icontains=value)
             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 import forms
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from core.choices import *
 from core.choices import *
 from core.models import *
 from core.models import *
+from extras.forms.mixins import SavedFiltersMixin
+from extras.utils import FeatureQuery
 from netbox.forms import NetBoxModelFilterSetForm
 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__ = (
 __all__ = (
     'DataFileFilterForm',
     'DataFileFilterForm',
     'DataSourceFilterForm',
     'DataSourceFilterForm',
+    'JobFilterForm',
 )
 )
 
 
 
 
@@ -45,3 +53,63 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
         required=False,
         required=False,
         label=_('Data source')
         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
 import logging
 
 
-from extras.choices import JobResultStatusChoices
+from .choices import JobStatusChoices
 from netbox.search.backends import search_backend
 from netbox.search.backends import search_backend
 from .choices import *
 from .choices import *
 from .exceptions import SyncError
 from .exceptions import SyncError
@@ -25,6 +25,6 @@ def sync_datasource(job_result, *args, **kwargs):
         job_result.terminate()
         job_result.terminate()
 
 
     except SyncError as e:
     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)
         DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
         logging.error(e)
         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 .data import *
 from .files 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 ..choices import *
 from ..exceptions import SyncError
 from ..exceptions import SyncError
 from ..signals import post_sync, pre_sync
 from ..signals import post_sync, pre_sync
+from .jobs import Job
 
 
 __all__ = (
 __all__ = (
     'DataFile',
     'DataFile',
@@ -112,14 +113,12 @@ class DataSource(PrimaryModel):
         """
         """
         Enqueue a background job to synchronize the DataSource by calling sync().
         Enqueue a background job to synchronize the DataSource by calling sync().
         """
         """
-        from extras.models import JobResult
-
         # Set the status to "syncing"
         # Set the status to "syncing"
         self.status = DataSourceStatusChoices.QUEUED
         self.status = DataSourceStatusChoices.QUEUED
         DataSource.objects.filter(pk=self.pk).update(status=self.status)
         DataSource.objects.filter(pk=self.pk).update(status=self.status)
 
 
         # Enqueue a sync job
         # Enqueue a sync job
-        job_result = JobResult.enqueue_job(
+        job_result = Job.enqueue_job(
             import_string('core.jobs.sync_datasource'),
             import_string('core.jobs.sync_datasource'),
             name=self.name,
             name=self.name,
             obj_type=ContentType.objects.get_for_model(DataSource),
             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 .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/delete/', views.DataFileBulkDeleteView.as_view(), name='datafile_bulk_delete'),
     path('data-files/<int:pk>/', include(get_model_urls('core', 'datafile'))),
     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')
     queryset = DataFile.objects.defer('data')
     filterset = filtersets.DataFileFilterSet
     filterset = filtersets.DataFileFilterSet
     table = tables.DataFileTable
     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 netbox.config import get_config, PARAMS
 from .forms import ConfigRevisionForm
 from .forms import ConfigRevisionForm
-from .models import ConfigRevision, JobResult
+from .models import ConfigRevision
 
 
 
 
 @admin.register(ConfigRevision)
 @admin.register(ConfigRevision)

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

@@ -1,9 +1,7 @@
 from rest_framework import serializers
 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 netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
-from users.api.nested_serializers import NestedUserSerializer
 
 
 __all__ = [
 __all__ = [
     'NestedConfigContextSerializer',
     'NestedConfigContextSerializer',
@@ -12,7 +10,6 @@ __all__ = [
     'NestedCustomLinkSerializer',
     'NestedCustomLinkSerializer',
     'NestedExportTemplateSerializer',
     'NestedExportTemplateSerializer',
     'NestedImageAttachmentSerializer',
     'NestedImageAttachmentSerializer',
-    'NestedJobResultSerializer',
     'NestedJournalEntrySerializer',
     'NestedJournalEntrySerializer',
     'NestedSavedFilterSerializer',
     'NestedSavedFilterSerializer',
     'NestedTagSerializer',  # Defined in netbox.api.serializers
     'NestedTagSerializer',  # Defined in netbox.api.serializers
@@ -90,15 +87,3 @@ class NestedJournalEntrySerializer(WritableNestedSerializer):
     class Meta:
     class Meta:
         model = models.JournalEntry
         model = models.JournalEntry
         fields = ['id', 'url', 'display', 'created']
         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 drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 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 (
 from dcim.api.nested_serializers import (
     NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
     NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
     NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
     NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
@@ -37,7 +38,6 @@ __all__ = (
     'DashboardSerializer',
     'DashboardSerializer',
     'ExportTemplateSerializer',
     'ExportTemplateSerializer',
     'ImageAttachmentSerializer',
     'ImageAttachmentSerializer',
-    'JobResultSerializer',
     'JournalEntrySerializer',
     'JournalEntrySerializer',
     'ObjectChangeSerializer',
     'ObjectChangeSerializer',
     'ReportDetailSerializer',
     '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
 # Reports
 #
 #
@@ -446,11 +424,11 @@ class ReportSerializer(serializers.Serializer):
     name = serializers.CharField(max_length=255)
     name = serializers.CharField(max_length=255)
     description = serializers.CharField(max_length=255, required=False)
     description = serializers.CharField(max_length=255, required=False)
     test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
     test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
-    result = NestedJobResultSerializer()
+    result = NestedJobSerializer()
 
 
 
 
 class ReportDetailSerializer(ReportSerializer):
 class ReportDetailSerializer(ReportSerializer):
-    result = JobResultSerializer()
+    result = JobSerializer()
 
 
 
 
 class ReportInputSerializer(serializers.Serializer):
 class ReportInputSerializer(serializers.Serializer):
@@ -473,7 +451,7 @@ class ScriptSerializer(serializers.Serializer):
     name = serializers.CharField(read_only=True)
     name = serializers.CharField(read_only=True)
     description = serializers.CharField(read_only=True)
     description = serializers.CharField(read_only=True)
     vars = serializers.SerializerMethodField(read_only=True)
     vars = serializers.SerializerMethodField(read_only=True)
-    result = NestedJobResultSerializer()
+    result = NestedJobSerializer()
 
 
     @swagger_serializer_method(serializer_or_field=serializers.JSONField)
     @swagger_serializer_method(serializer_or_field=serializers.JSONField)
     def get_vars(self, instance):
     def get_vars(self, instance):
@@ -483,7 +461,7 @@ class ScriptSerializer(serializers.Serializer):
 
 
 
 
 class ScriptDetailSerializer(ScriptSerializer):
 class ScriptDetailSerializer(ScriptSerializer):
-    result = JobResultSerializer()
+    result = JobSerializer()
 
 
 
 
 class ScriptInputSerializer(serializers.Serializer):
 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('reports', views.ReportViewSet, basename='report')
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('object-changes', views.ObjectChangeViewSet)
 router.register('object-changes', views.ObjectChangeViewSet)
-router.register('job-results', views.JobResultViewSet)
 router.register('content-types', views.ContentTypeViewSet)
 router.register('content-types', views.ContentTypeViewSet)
 
 
 app_name = 'extras-api'
 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 rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
 from rq import Worker
 from rq import Worker
 
 
+from core.choices import JobStatusChoices
+from core.models import Job
 from extras import filtersets
 from extras import filtersets
-from extras.choices import JobResultStatusChoices
 from extras.models import *
 from extras.models import *
-from extras.models import CustomField
 from extras.reports import get_report, run_report
 from extras.reports import get_report, run_report
 from extras.scripts import get_script, run_script
 from extras.scripts import get_script, run_script
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -191,9 +191,9 @@ class ReportViewSet(ViewSet):
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
         results = {
         results = {
             r.name: r
             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')
             ).order_by('name', '-created').distinct('name').defer('data')
         }
         }
 
 
@@ -201,7 +201,7 @@ class ReportViewSet(ViewSet):
         for report_module in ReportModule.objects.restrict(request.user):
         for report_module in ReportModule.objects.restrict(request.user):
             report_list.extend([report() for report in report_module.reports.values()])
             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:
         for report in report_list:
             report.result = results.get(report.full_name, None)
             report.result = results.get(report.full_name, None)
 
 
@@ -216,13 +216,13 @@ class ReportViewSet(ViewSet):
         Retrieve a single Report identified as "<module>.<report>".
         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 = self._retrieve_report(pk)
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
         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,
             name=report.full_name,
-            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
         ).first()
 
 
         serializer = serializers.ReportDetailSerializer(report, context={
         serializer = serializers.ReportDetailSerializer(report, context={
@@ -234,7 +234,7 @@ class ReportViewSet(ViewSet):
     @action(detail=True, methods=['post'])
     @action(detail=True, methods=['post'])
     def run(self, request, pk):
     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.
         # Check that the user has permission to run reports.
         if not request.user.has_perm('extras.run_report'):
         if not request.user.has_perm('extras.run_report'):
@@ -244,12 +244,12 @@ class ReportViewSet(ViewSet):
         if not Worker.count(get_connection('default')):
         if not Worker.count(get_connection('default')):
             raise RQWorkerNotRunningException()
             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)
         report = self._retrieve_report(pk)
         input_serializer = serializers.ReportInputSerializer(data=request.data)
         input_serializer = serializers.ReportInputSerializer(data=request.data)
 
 
         if input_serializer.is_valid():
         if input_serializer.is_valid():
-            job_result = JobResult.enqueue_job(
+            report.result = Job.enqueue_job(
                 run_report,
                 run_report,
                 name=report.full_name,
                 name=report.full_name,
                 obj_type=ContentType.objects.get_for_model(Report),
                 obj_type=ContentType.objects.get_for_model(Report),
@@ -258,8 +258,6 @@ class ReportViewSet(ViewSet):
                 schedule_at=input_serializer.validated_data.get('schedule_at'),
                 schedule_at=input_serializer.validated_data.get('schedule_at'),
                 interval=input_serializer.validated_data.get('interval')
                 interval=input_serializer.validated_data.get('interval')
             )
             )
-            report.result = job_result
-
             serializer = serializers.ReportDetailSerializer(report, context={'request': request})
             serializer = serializers.ReportDetailSerializer(report, context={'request': request})
 
 
             return Response(serializer.data)
             return Response(serializer.data)
@@ -288,9 +286,9 @@ class ScriptViewSet(ViewSet):
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
         results = {
         results = {
             r.name: r
             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')
             ).order_by('name', '-created').distinct('name').defer('data')
         }
         }
 
 
@@ -298,7 +296,7 @@ class ScriptViewSet(ViewSet):
         for script_module in ScriptModule.objects.restrict(request.user):
         for script_module in ScriptModule.objects.restrict(request.user):
             script_list.extend(script_module.scripts.values())
             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:
         for script in script_list:
             script.result = results.get(script.full_name, None)
             script.result = results.get(script.full_name, None)
 
 
@@ -309,10 +307,10 @@ class ScriptViewSet(ViewSet):
     def retrieve(self, request, pk):
     def retrieve(self, request, pk):
         script = self._get_script(pk)
         script = self._get_script(pk)
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
         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,
             name=script.full_name,
-            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
         ).first()
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
 
 
@@ -320,7 +318,7 @@ class ScriptViewSet(ViewSet):
 
 
     def post(self, request, pk):
     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'):
         if not request.user.has_perm('extras.run_script'):
@@ -334,7 +332,7 @@ class ScriptViewSet(ViewSet):
             raise RQWorkerNotRunningException()
             raise RQWorkerNotRunningException()
 
 
         if input_serializer.is_valid():
         if input_serializer.is_valid():
-            job_result = JobResult.enqueue_job(
+            script.result = Job.enqueue_job(
                 run_script,
                 run_script,
                 name=script.full_name,
                 name=script.full_name,
                 obj_type=ContentType.objects.get_for_model(Script),
                 obj_type=ContentType.objects.get_for_model(Script),
@@ -346,7 +344,6 @@ class ScriptViewSet(ViewSet):
                 schedule_at=input_serializer.validated_data.get('schedule_at'),
                 schedule_at=input_serializer.validated_data.get('schedule_at'),
                 interval=input_serializer.validated_data.get('interval')
                 interval=input_serializer.validated_data.get('interval')
             )
             )
-            script.result = job_result
             serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
             serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
 
 
             return Response(serializer.data)
             return Response(serializer.data)
@@ -368,19 +365,6 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
     filterset_class = filtersets.ObjectChangeFilterSet
     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
 # ContentTypes
 #
 #

+ 0 - 64
netbox/extras/filtersets.py

@@ -22,7 +22,6 @@ __all__ = (
     'CustomLinkFilterSet',
     'CustomLinkFilterSet',
     'ExportTemplateFilterSet',
     'ExportTemplateFilterSet',
     'ImageAttachmentFilterSet',
     'ImageAttachmentFilterSet',
-    'JobResultFilterSet',
     'JournalEntryFilterSet',
     'JournalEntryFilterSet',
     'LocalConfigContextFilterSet',
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
     '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
 # ContentTypes
 #
 #

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

@@ -11,9 +11,8 @@ from extras.utils import FeatureQuery
 from netbox.forms.base import NetBoxModelFilterSetForm
 from netbox.forms.base import NetBoxModelFilterSetForm
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 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 virtualization.models import Cluster, ClusterGroup, ClusterType
 from .mixins import SavedFiltersMixin
 from .mixins import SavedFiltersMixin
@@ -24,7 +23,6 @@ __all__ = (
     'CustomFieldFilterForm',
     'CustomFieldFilterForm',
     'CustomLinkFilterForm',
     'CustomLinkFilterForm',
     'ExportTemplateFilterForm',
     'ExportTemplateFilterForm',
-    'JobResultFilterForm',
     'JournalEntryFilterForm',
     'JournalEntryFilterForm',
     'LocalConfigContextFilterForm',
     'LocalConfigContextFilterForm',
     'ObjectChangeFilterForm',
     '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):
 class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id')),
         (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 django.utils import timezone
 from packaging import version
 from packaging import version
 
 
-from extras.models import JobResult
+from core.models import Job
 from extras.models import ObjectChange
 from extras.models import ObjectChange
 from netbox.config import Config
 from netbox.config import Config
 
 
@@ -64,15 +64,15 @@ class Command(BaseCommand):
                 f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})"
                 f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})"
             )
             )
 
 
-        # Delete expired JobResults
+        # Delete expired Jobs
         if options['verbosity']:
         if options['verbosity']:
-            self.stdout.write("[*] Checking for expired jobresult records")
+            self.stdout.write("[*] Checking for expired jobs")
         if config.JOBRESULT_RETENTION:
         if config.JOBRESULT_RETENTION:
             cutoff = timezone.now() - timedelta(days=config.JOBRESULT_RETENTION)
             cutoff = timezone.now() - timedelta(days=config.JOBRESULT_RETENTION)
             if options['verbosity'] >= 2:
             if options['verbosity'] >= 2:
                 self.stdout.write(f"\tRetention period: {config.JOBRESULT_RETENTION} days")
                 self.stdout.write(f"\tRetention period: {config.JOBRESULT_RETENTION} days")
                 self.stdout.write(f"\tCut-off time: {cutoff}")
                 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 expired_records:
                 if options['verbosity']:
                 if options['verbosity']:
                     self.stdout.write(
                     self.stdout.write(
@@ -81,7 +81,7 @@ class Command(BaseCommand):
                         ending=""
                         ending=""
                     )
                     )
                     self.stdout.flush()
                     self.stdout.flush()
-                JobResult.objects.filter(created__lt=cutoff).delete()
+                Job.objects.filter(created__lt=cutoff).delete()
                 if options['verbosity']:
                 if options['verbosity']:
                     self.stdout.write("Done.", self.style.SUCCESS)
                     self.stdout.write("Done.", self.style.SUCCESS)
             elif options['verbosity']:
             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.core.management.base import BaseCommand
 from django.utils import timezone
 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
 from extras.reports import run_report
 
 
 
 
@@ -21,13 +22,13 @@ class Command(BaseCommand):
             for report in module.reports.values():
             for report in module.reports.values():
                 if module.name in options['reports'] or report.full_name in options['reports']:
                 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(
                     self.stdout.write(
                         "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
                         "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
                     )
                     )
 
 
                     report_content_type = ContentType.objects.get(app_label='extras', model='report')
                     report_content_type = ContentType.objects.get(app_label='extras', model='report')
-                    job_result = JobResult.enqueue_job(
+                    job = Job.enqueue_job(
                         run_report,
                         run_report,
                         report.full_name,
                         report.full_name,
                         report_content_type,
                         report_content_type,
@@ -36,19 +37,19 @@ class Command(BaseCommand):
                     )
                     )
 
 
                     # Wait on the job to finish
                     # 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)
                         time.sleep(1)
-                        job_result = JobResult.objects.get(pk=job_result.pk)
+                        job = Job.objects.get(pk=job.pk)
 
 
                     # Report on success/failure
                     # Report on success/failure
-                    if job_result.status == JobResultStatusChoices.STATUS_FAILED:
+                    if job.status == JobStatusChoices.STATUS_FAILED:
                         status = self.style.ERROR('FAILED')
                         status = self.style.ERROR('FAILED')
-                    elif job_result == JobResultStatusChoices.STATUS_ERRORED:
+                    elif job == JobStatusChoices.STATUS_ERRORED:
                         status = self.style.ERROR('ERRORED')
                         status = self.style.ERROR('ERRORED')
                     else:
                     else:
                         status = self.style.SUCCESS('SUCCESS')
                         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(
                         self.stdout.write(
                             "\t{}: {} success, {} info, {} warning, {} failure".format(
                             "\t{}: {} success, {} info, {} warning, {} failure".format(
                                 test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
                                 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)
                         "[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
                     )
                     )
                     self.stdout.write(
                     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
         # 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.core.management.base import BaseCommand, CommandError
 from django.db import transaction
 from django.db import transaction
 
 
+from core.choices import JobStatusChoices
+from core.models import Job
 from extras.api.serializers import ScriptOutputSerializer
 from extras.api.serializers import ScriptOutputSerializer
-from extras.choices import JobResultStatusChoices
 from extras.context_managers import change_logging
 from extras.context_managers import change_logging
-from extras.models import JobResult
 from extras.scripts import get_script
 from extras.scripts import get_script
 from extras.signals import clear_webhooks
 from extras.signals import clear_webhooks
 from utilities.exceptions import AbortTransaction
 from utilities.exceptions import AbortTransaction
@@ -60,7 +60,7 @@ class Command(BaseCommand):
                 logger.error(f"Exception raised during script execution: {e}")
                 logger.error(f"Exception raised during script execution: {e}")
                 clear_webhooks.send(request)
                 clear_webhooks.send(request)
                 job_result.data = ScriptOutputSerializer(script).data
                 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}")
             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')
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
 
 
         # Create the job result
         # Create the job result
-        job_result = JobResult.objects.create(
+        job_result = Job.objects.create(
             name=script.full_name,
             name=script.full_name,
             obj_type=script_content_type,
             obj_type=script_content_type,
             user=User.objects.filter(is_superuser=True).order_by('pk')[0],
             user=User.objects.filter(is_superuser=True).order_by('pk')[0],
@@ -131,7 +131,7 @@ class Command(BaseCommand):
         })
         })
 
 
         if form.is_valid():
         if form.is_valid():
-            job_result.status = JobResultStatusChoices.STATUS_RUNNING
+            job_result.status = JobStatusChoices.STATUS_RUNNING
             job_result.save()
             job_result.save()
 
 
             logger.info(f"Running script (commit={commit})")
             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 field, errors in form.errors.get_json_data().items():
                 for error in errors:
                 for error in errors:
                     logger.error(f'\t{field}: {error.get("message")}')
                     logger.error(f'\t{field}: {error.get("message")}')
-            job_result.status = JobResultStatusChoices.STATUS_ERRORED
+            job_result.status = JobStatusChoices.STATUS_ERRORED
             job_result.save()
             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)),
                 ('status', models.CharField(default='pending', max_length=30)),
                 ('data', models.JSONField(blank=True, null=True)),
                 ('data', models.JSONField(blank=True, null=True)),
                 ('job_id', models.UUIDField(unique=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)),
                 ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
             ],
             ],
             options={
             options={

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

@@ -594,7 +594,7 @@ class JobResult(models.Model):
         to=ContentType,
         to=ContentType,
         related_name='job_results',
         related_name='job_results',
         verbose_name='Object types',
         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"),
         help_text=_("The object type to which this job result applies"),
         on_delete=models.CASCADE,
         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.choices import ManagedFileRootPathChoices
 from core.models import ManagedFile
 from core.models import ManagedFile
 from extras.utils import is_report
 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 utilities.querysets import RestrictedQuerySet
 from .mixins import PythonModuleMixin
 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.
     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.choices import ManagedFileRootPathChoices
 from core.models import ManagedFile
 from core.models import ManagedFile
 from extras.utils import is_script
 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 utilities.querysets import RestrictedQuerySet
 from .mixins import PythonModuleMixin
 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.
     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.utils.functional import classproperty
 from django_rq import job
 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__)
 logger = logging.getLogger(__name__)
 
 
@@ -33,14 +35,14 @@ def run_report(job_result, *args, **kwargs):
         job_result.start()
         job_result.start()
         report.run(job_result)
         report.run(job_result)
     except Exception:
     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}")
         logging.error(f"Error during execution of report {job_result.name}")
     finally:
     finally:
         # Schedule the next job if an interval has been set
         # Schedule the next job if an interval has been set
         start_time = job_result.scheduled or job_result.started
         start_time = job_result.scheduled or job_result.started
         if start_time and job_result.interval:
         if start_time and job_result.interval:
             new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
             new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
-            JobResult.enqueue_job(
+            Job.enqueue_job(
                 run_report,
                 run_report,
                 name=job_result.name,
                 name=job_result.name,
                 obj_type=job_result.obj_type,
                 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.
         Run the report and save its results. Each test method will be executed in order.
         """
         """
         self.logger.info(f"Running report")
         self.logger.info(f"Running report")
-        job_result.status = JobResultStatusChoices.STATUS_RUNNING
+        job_result.status = JobStatusChoices.STATUS_RUNNING
         job_result.save()
         job_result.save()
 
 
         # Perform any post-run tasks
         # Perform any post-run tasks
@@ -202,15 +204,15 @@ class Report(object):
                 test_method()
                 test_method()
             if self.failed:
             if self.failed:
                 self.logger.warning("Report failed")
                 self.logger.warning("Report failed")
-                job_result.status = JobResultStatusChoices.STATUS_FAILED
+                job_result.status = JobStatusChoices.STATUS_FAILED
             else:
             else:
                 self.logger.info("Report completed successfully")
                 self.logger.info("Report completed successfully")
-                job_result.status = JobResultStatusChoices.STATUS_COMPLETED
+                job_result.status = JobStatusChoices.STATUS_COMPLETED
         except Exception as e:
         except Exception as e:
             stacktrace = traceback.format_exc()
             stacktrace = traceback.format_exc()
             self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
             self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
             logger.error(f"Exception raised during report execution: {e}")
             logger.error(f"Exception raised during report execution: {e}")
-            job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
+            job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
         finally:
         finally:
             job_result.terminate()
             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.db import transaction
 from django.utils.functional import classproperty
 from django.utils.functional import classproperty
 
 
+from core.choices import JobStatusChoices
+from core.models import Job
 from extras.api.serializers import ScriptOutputSerializer
 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 extras.signals import clear_webhooks
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@@ -482,7 +484,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
                 logger.error(f"Exception raised during script execution: {e}")
                 logger.error(f"Exception raised during script execution: {e}")
             script.log_info("Database changes have been reverted due to error.")
             script.log_info("Database changes have been reverted due to error.")
             job_result.data = ScriptOutputSerializer(script).data
             job_result.data = ScriptOutputSerializer(script).data
-            job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
+            job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
             clear_webhooks.send(request)
             clear_webhooks.send(request)
 
 
         logger.info(f"Script completed in {job_result.duration}")
         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
     # Schedule the next job if an interval has been set
     if job_result.interval:
     if job_result.interval:
         new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
         new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
-        JobResult.enqueue_job(
+        Job.enqueue_job(
             run_script,
             run_script,
             name=job_result.name,
             name=job_result.name,
             obj_type=job_result.obj_type,
             obj_type=job_result.obj_type,

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

@@ -2,7 +2,6 @@ import json
 
 
 import django_tables2 as tables
 import django_tables2 as tables
 from django.conf import settings
 from django.conf import settings
-from django.utils.translation import gettext as _
 
 
 from extras.models import *
 from extras.models import *
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
@@ -14,7 +13,6 @@ __all__ = (
     'CustomFieldTable',
     'CustomFieldTable',
     'CustomLinkTable',
     'CustomLinkTable',
     'ExportTemplateTable',
     'ExportTemplateTable',
-    'JobResultTable',
     'JournalEntryTable',
     'JournalEntryTable',
     'ObjectChangeTable',
     'ObjectChangeTable',
     'SavedFilterTable',
     'SavedFilterTable',
@@ -43,35 +41,6 @@ class CustomFieldTable(NetBoxTable):
         default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
         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):
 class CustomLinkTable(NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         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/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
     path('scripts/<path:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
     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
     # Markdown
     path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_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.auth.mixins import LoginRequiredMixin
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Count, Q
 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.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.views.generic import View
 from django.views.generic import View
 
 
-from core.choices import ManagedFileRootPathChoices
+from core.choices import JobStatusChoices, ManagedFileRootPathChoices
 from core.forms import ManagedFileForm
 from core.forms import ManagedFileForm
+from core.models import Job
 from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.utils import get_widget_class
 from extras.dashboard.utils import get_widget_class
 from netbox.views import generic
 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.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
 from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
 from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
-from .choices import JobResultStatusChoices
 from .forms.reports import ReportForm
 from .forms.reports import ReportForm
 from .models import *
 from .models import *
 from .reports import get_report, run_report
 from .reports import get_report, run_report
@@ -810,7 +810,7 @@ class ReportModuleDeleteView(generic.ObjectDeleteView):
 
 
 class ReportListView(ContentTypePermissionRequiredMixin, View):
 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):
     def get_required_permission(self):
         return 'extras.view_report'
         return 'extras.view_report'
@@ -821,9 +821,9 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
         job_results = {
         job_results = {
             r.name: r
             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')
             ).order_by('name', '-created').distinct('name').defer('data')
         }
         }
 
 
@@ -836,7 +836,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
 
 
 class ReportView(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):
     def get_required_permission(self):
         return 'extras.view_report'
         return 'extras.view_report'
@@ -846,10 +846,10 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
         report = module.reports[name]()
         report = module.reports[name]()
 
 
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
         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,
             name=report.full_name,
-            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
         ).first()
 
 
         return render(request, 'extras/report.html', {
         return render(request, 'extras/report.html', {
@@ -875,8 +875,8 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
                     'report': report,
                     '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,
                 run_report,
                 name=report.full_name,
                 name=report.full_name,
                 obj_type=ContentType.objects.get_for_model(Report),
                 obj_type=ContentType.objects.get_for_model(Report),
@@ -897,16 +897,16 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
 
 
 class ReportResultView(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):
     def get_required_permission(self):
         return 'extras.view_report'
         return 'extras.view_report'
 
 
     def get(self, request, job_result_pk):
     def get(self, request, job_result_pk):
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
         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)
         module, report_name = result.name.split('.', maxsplit=1)
         report = get_report(module, report_name)
         report = get_report(module, report_name)
         report.result = result
         report.result = result
@@ -958,9 +958,9 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
         job_results = {
         job_results = {
             r.name: r
             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')
             ).order_by('name', '-created').distinct('name').defer('data')
         }
         }
 
 
@@ -981,12 +981,12 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
         script = module.scripts[name]()
         script = module.scripts[name]()
         form = script.as_form(initial=normalize_querydict(request.GET))
         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,
             name=script.full_name,
         ).exclude(
         ).exclude(
-            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
         ).first()
         ).first()
 
 
         return render(request, 'extras/script.html', {
         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.")
             messages.error(request, "Unable to run script: RQ worker process not running.")
 
 
         elif form.is_valid():
         elif form.is_valid():
-            job_result = JobResult.enqueue_job(
+            job_result = Job.enqueue_job(
                 run_script,
                 run_script,
                 name=script.full_name,
                 name=script.full_name,
                 obj_type=ContentType.objects.get_for_model(Script),
                 obj_type=ContentType.objects.get_for_model(Script),
@@ -1036,10 +1036,8 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
         return 'extras.view_script'
         return 'extras.view_script'
 
 
     def get(self, request, job_result_pk):
     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')
         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_name, script_name = result.name.split('.', 1)
         module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path=f'{module_name}.py')
         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
 # Markdown
 #
 #

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

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

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

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