mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-13 16:47:34 -06:00
Rename JobResult to Job and move to core
This commit is contained in:
parent
669cfe8952
commit
40572b543f
@ -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 |
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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']
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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)
|
||||
)
|
||||
|
@ -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/',
|
||||
)
|
||||
)
|
||||
|
@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
from extras.choices import JobResultStatusChoices
|
||||
from .choices import JobStatusChoices
|
||||
from netbox.search.backends import search_backend
|
||||
from .choices import *
|
||||
from .exceptions import SyncError
|
||||
@ -25,6 +25,6 @@ def sync_datasource(job_result, *args, **kwargs):
|
||||
job_result.terminate()
|
||||
|
||||
except SyncError as e:
|
||||
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
||||
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||
logging.error(e)
|
||||
|
40
netbox/core/migrations/0003_move_jobresult_to_core.py
Normal file
40
netbox/core/migrations/0003_move_jobresult_to_core.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
@ -1,2 +1,3 @@
|
||||
from .data import *
|
||||
from .files import *
|
||||
from .jobs import *
|
||||
|
@ -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
219
netbox/core/models/jobs.py
Normal 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
|
||||
)
|
@ -1 +1,2 @@
|
||||
from .data import *
|
||||
from .jobs import *
|
||||
|
34
netbox/core/tables/jobs.py
Normal file
34
netbox/core/tables/jobs.py
Normal 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',
|
||||
)
|
@ -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'),
|
||||
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -6,7 +6,7 @@ from django.utils.html import format_html
|
||||
|
||||
from netbox.config import get_config, PARAMS
|
||||
from .forms import ConfigRevisionForm
|
||||
from .models import ConfigRevision, JobResult
|
||||
from .models import ConfigRevision
|
||||
|
||||
|
||||
@admin.register(ConfigRevision)
|
||||
|
@ -1,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']
|
||||
|
@ -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):
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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')),
|
||||
|
@ -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']:
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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={
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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.
|
||||
"""
|
||||
|
@ -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.
|
||||
"""
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
]
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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,
|
||||
|
@ -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'],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
Loading…
Reference in New Issue
Block a user