Rename JobResult to Job and move to core

This commit is contained in:
jeremystretch 2023-03-27 11:43:12 -04:00 committed by Jeremy Stretch
parent 669cfe8952
commit 40572b543f
41 changed files with 650 additions and 361 deletions

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'],
},
),
]

View File

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

View File

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

219
netbox/core/models/jobs.py Normal file
View File

@ -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
)

View File

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

View File

@ -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',
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,7 +22,6 @@ __all__ = (
'CustomLinkFilterSet',
'ExportTemplateFilterSet',
'ImageAttachmentFilterSet',
'JobResultFilterSet',
'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
'ObjectChangeFilterSet',
@ -537,69 +536,6 @@ class ObjectChangeFilterSet(BaseFilterSet):
)
#
# Job Results
#
class JobResultFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
created = django_filters.DateTimeFilter()
created__before = django_filters.DateTimeFilter(
field_name='created',
lookup_expr='lte'
)
created__after = django_filters.DateTimeFilter(
field_name='created',
lookup_expr='gte'
)
scheduled = django_filters.DateTimeFilter()
scheduled__before = django_filters.DateTimeFilter(
field_name='scheduled',
lookup_expr='lte'
)
scheduled__after = django_filters.DateTimeFilter(
field_name='scheduled',
lookup_expr='gte'
)
started = django_filters.DateTimeFilter()
started__before = django_filters.DateTimeFilter(
field_name='started',
lookup_expr='lte'
)
started__after = django_filters.DateTimeFilter(
field_name='started',
lookup_expr='gte'
)
completed = django_filters.DateTimeFilter()
completed__before = django_filters.DateTimeFilter(
field_name='completed',
lookup_expr='lte'
)
completed__after = django_filters.DateTimeFilter(
field_name='completed',
lookup_expr='gte'
)
status = django_filters.MultipleChoiceFilter(
choices=JobResultStatusChoices,
null_value=None
)
class Meta:
model = JobResult
fields = ('id', 'interval', 'status', 'user', 'obj_type', 'name')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(user__username__icontains=value) |
Q(name__icontains=value)
)
#
# ContentTypes
#

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -106,11 +106,6 @@ urlpatterns = [
path('scripts/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
path('scripts/<path:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
# Job results
path('job-results/', views.JobResultListView.as_view(), name='jobresult_list'),
path('job-results/delete/', views.JobResultBulkDeleteView.as_view(), name='jobresult_bulk_delete'),
path('job-results/<int:pk>/delete/', views.JobResultDeleteView.as_view(), name='jobresult_delete'),
# Markdown
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
]

View File

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

View File

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

View File

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