mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 12:12:53 -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 links](../customization/custom-links.md) | `CustomLinksMixin` | `custom_links` | These models support the assignment of custom links |
|
||||||
| [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | - | Supports the enforcement of custom validation rules |
|
| [Custom validation](../customization/custom-validation.md) | `CustomValidationMixin` | - | Supports the enforcement of custom validation rules |
|
||||||
| [Export templates](../customization/export-templates.md) | `ExportTemplatesMixin` | `export_templates` | Users can create custom export templates for these models |
|
| [Export templates](../customization/export-templates.md) | `ExportTemplatesMixin` | `export_templates` | Users can create custom export templates for these models |
|
||||||
| [Job results](../features/background-jobs.md) | `JobResultsMixin` | `job_results` | Users can create custom export templates for these models |
|
| [Job results](../features/background-jobs.md) | `JobsMixin` | `jobs` | Users can create custom export templates for these models |
|
||||||
| [Journaling](../features/journaling.md) | `JournalingMixin` | `journaling` | These models support persistent historical commentary |
|
| [Journaling](../features/journaling.md) | `JournalingMixin` | `journaling` | These models support persistent historical commentary |
|
||||||
| [Synchronized data](../integrations/synchronized-data.md) | `SyncedDataMixin` | `synced_data` | Certain model data can be automatically synchronized from a remote data source |
|
| [Synchronized data](../integrations/synchronized-data.md) | `SyncedDataMixin` | `synced_data` | Certain model data can be automatically synchronized from a remote data source |
|
||||||
| [Tagging](../models/extras/tag.md) | `TagsMixin` | `tags` | The models can be tagged with user-defined tags |
|
| [Tagging](../models/extras/tag.md) | `TagsMixin` | `tags` | The models can be tagged with user-defined tags |
|
||||||
|
@ -6,7 +6,7 @@ NetBox includes the ability to execute certain functions as background tasks. Th
|
|||||||
* [Custom script](../customization/custom-scripts.md) execution
|
* [Custom script](../customization/custom-scripts.md) execution
|
||||||
* Synchronization of [remote data sources](../integrations/synchronized-data.md)
|
* Synchronization of [remote data sources](../integrations/synchronized-data.md)
|
||||||
|
|
||||||
Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [JobResult model](../models/extras/jobresult.md). Background tasks are executed by the `rqworker` process(es).
|
Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [Job model](../models/core/job.md). Background tasks are executed by the `rqworker` process(es).
|
||||||
|
|
||||||
## Scheduled Jobs
|
## Scheduled Jobs
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# Job Results
|
# Jobs
|
||||||
|
|
||||||
The JobResult model is used to schedule and record the execution of [background tasks](../../features/background-jobs.md).
|
The Job model is used to schedule and record the execution of [background tasks](../../features/background-jobs.md).
|
||||||
|
|
||||||
## Fields
|
## Fields
|
||||||
|
|
@ -159,6 +159,7 @@ nav:
|
|||||||
- Core:
|
- Core:
|
||||||
- DataFile: 'models/core/datafile.md'
|
- DataFile: 'models/core/datafile.md'
|
||||||
- DataSource: 'models/core/datasource.md'
|
- DataSource: 'models/core/datasource.md'
|
||||||
|
- Job: 'models/core/job.md'
|
||||||
- DCIM:
|
- DCIM:
|
||||||
- Cable: 'models/dcim/cable.md'
|
- Cable: 'models/dcim/cable.md'
|
||||||
- ConsolePort: 'models/dcim/consoleport.md'
|
- ConsolePort: 'models/dcim/consoleport.md'
|
||||||
@ -208,7 +209,6 @@ nav:
|
|||||||
- CustomLink: 'models/extras/customlink.md'
|
- CustomLink: 'models/extras/customlink.md'
|
||||||
- ExportTemplate: 'models/extras/exporttemplate.md'
|
- ExportTemplate: 'models/extras/exporttemplate.md'
|
||||||
- ImageAttachment: 'models/extras/imageattachment.md'
|
- ImageAttachment: 'models/extras/imageattachment.md'
|
||||||
- JobResult: 'models/extras/jobresult.md'
|
|
||||||
- JournalEntry: 'models/extras/journalentry.md'
|
- JournalEntry: 'models/extras/journalentry.md'
|
||||||
- SavedFilter: 'models/extras/savedfilter.md'
|
- SavedFilter: 'models/extras/savedfilter.md'
|
||||||
- StagedChange: 'models/extras/stagedchange.md'
|
- StagedChange: 'models/extras/stagedchange.md'
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from core.choices import JobStatusChoices
|
||||||
from core.models import *
|
from core.models import *
|
||||||
|
from netbox.api.fields import ChoiceField
|
||||||
from netbox.api.serializers import WritableNestedSerializer
|
from netbox.api.serializers import WritableNestedSerializer
|
||||||
|
from users.api.nested_serializers import NestedUserSerializer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = (
|
||||||
'NestedDataFileSerializer',
|
'NestedDataFileSerializer',
|
||||||
'NestedDataSourceSerializer',
|
'NestedDataSourceSerializer',
|
||||||
]
|
'NestedJobSerializer',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NestedDataSourceSerializer(WritableNestedSerializer):
|
class NestedDataSourceSerializer(WritableNestedSerializer):
|
||||||
@ -23,3 +27,15 @@ class NestedDataFileSerializer(WritableNestedSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DataFile
|
model = DataFile
|
||||||
fields = ['id', 'url', 'display', 'path']
|
fields = ['id', 'url', 'display', 'path']
|
||||||
|
|
||||||
|
|
||||||
|
class NestedJobSerializer(serializers.ModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail')
|
||||||
|
status = ChoiceField(choices=JobStatusChoices)
|
||||||
|
user = NestedUserSerializer(
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Job
|
||||||
|
fields = ['url', 'created', 'completed', 'user', 'status']
|
||||||
|
@ -2,12 +2,15 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
from core.choices import *
|
from core.choices import *
|
||||||
from core.models import *
|
from core.models import *
|
||||||
from netbox.api.fields import ChoiceField
|
from netbox.api.fields import ChoiceField, ContentTypeField
|
||||||
from netbox.api.serializers import NetBoxModelSerializer
|
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
|
||||||
|
from users.api.nested_serializers import NestedUserSerializer
|
||||||
from .nested_serializers import *
|
from .nested_serializers import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'DataFileSerializer',
|
||||||
'DataSourceSerializer',
|
'DataSourceSerializer',
|
||||||
|
'JobSerializer',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -49,3 +52,21 @@ class DataFileSerializer(NetBoxModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
|
'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class JobSerializer(BaseModelSerializer):
|
||||||
|
url = serializers.HyperlinkedIdentityField(view_name='core-api:job-detail')
|
||||||
|
user = NestedUserSerializer(
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
|
status = ChoiceField(choices=JobStatusChoices, read_only=True)
|
||||||
|
object_type = ContentTypeField(
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Job
|
||||||
|
fields = [
|
||||||
|
'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name',
|
||||||
|
'object_type', 'user', 'data', 'job_id',
|
||||||
|
]
|
||||||
|
@ -9,5 +9,8 @@ router.APIRootView = views.CoreRootView
|
|||||||
router.register('data-sources', views.DataSourceViewSet)
|
router.register('data-sources', views.DataSourceViewSet)
|
||||||
router.register('data-files', views.DataFileViewSet)
|
router.register('data-files', views.DataFileViewSet)
|
||||||
|
|
||||||
|
# Jobs
|
||||||
|
router.register('job-results', views.JobViewSet)
|
||||||
|
|
||||||
app_name = 'core-api'
|
app_name = 'core-api'
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
|
@ -4,6 +4,7 @@ from rest_framework.decorators import action
|
|||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.routers import APIRootView
|
from rest_framework.routers import APIRootView
|
||||||
|
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||||
|
|
||||||
from core import filtersets
|
from core import filtersets
|
||||||
from core.models import *
|
from core.models import *
|
||||||
@ -20,10 +21,6 @@ class CoreRootView(APIRootView):
|
|||||||
return 'Core'
|
return 'Core'
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Data sources
|
|
||||||
#
|
|
||||||
|
|
||||||
class DataSourceViewSet(NetBoxModelViewSet):
|
class DataSourceViewSet(NetBoxModelViewSet):
|
||||||
queryset = DataSource.objects.annotate(
|
queryset = DataSource.objects.annotate(
|
||||||
file_count=count_related(DataFile, 'source')
|
file_count=count_related(DataFile, 'source')
|
||||||
@ -50,3 +47,12 @@ class DataFileViewSet(NetBoxReadOnlyModelViewSet):
|
|||||||
queryset = DataFile.objects.defer('data').prefetch_related('source')
|
queryset = DataFile.objects.defer('data').prefetch_related('source')
|
||||||
serializer_class = serializers.DataFileSerializer
|
serializer_class = serializers.DataFileSerializer
|
||||||
filterset_class = filtersets.DataFileFilterSet
|
filterset_class = filtersets.DataFileFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
class JobViewSet(ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
Retrieve a list of job results
|
||||||
|
"""
|
||||||
|
queryset = Job.objects.prefetch_related('user')
|
||||||
|
serializer_class = serializers.JobSerializer
|
||||||
|
filterset_class = filtersets.JobFilterSet
|
||||||
|
@ -47,3 +47,32 @@ class ManagedFileRootPathChoices(ChoiceSet):
|
|||||||
(SCRIPTS, _('Scripts')),
|
(SCRIPTS, _('Scripts')),
|
||||||
(REPORTS, _('Reports')),
|
(REPORTS, _('Reports')),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Jobs
|
||||||
|
#
|
||||||
|
|
||||||
|
class JobStatusChoices(ChoiceSet):
|
||||||
|
|
||||||
|
STATUS_PENDING = 'pending'
|
||||||
|
STATUS_SCHEDULED = 'scheduled'
|
||||||
|
STATUS_RUNNING = 'running'
|
||||||
|
STATUS_COMPLETED = 'completed'
|
||||||
|
STATUS_ERRORED = 'errored'
|
||||||
|
STATUS_FAILED = 'failed'
|
||||||
|
|
||||||
|
CHOICES = (
|
||||||
|
(STATUS_PENDING, 'Pending', 'cyan'),
|
||||||
|
(STATUS_SCHEDULED, 'Scheduled', 'gray'),
|
||||||
|
(STATUS_RUNNING, 'Running', 'blue'),
|
||||||
|
(STATUS_COMPLETED, 'Completed', 'green'),
|
||||||
|
(STATUS_ERRORED, 'Errored', 'red'),
|
||||||
|
(STATUS_FAILED, 'Failed', 'red'),
|
||||||
|
)
|
||||||
|
|
||||||
|
TERMINAL_STATE_CHOICES = (
|
||||||
|
STATUS_COMPLETED,
|
||||||
|
STATUS_ERRORED,
|
||||||
|
STATUS_FAILED,
|
||||||
|
)
|
||||||
|
@ -3,13 +3,14 @@ from django.utils.translation import gettext as _
|
|||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
|
|
||||||
from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||||
from .choices import *
|
from .choices import *
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'DataFileFilterSet',
|
'DataFileFilterSet',
|
||||||
'DataSourceFilterSet',
|
'DataSourceFilterSet',
|
||||||
|
'JobFilterSet',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -62,3 +63,62 @@ class DataFileFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
Q(path__icontains=value)
|
Q(path__icontains=value)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobFilterSet(BaseFilterSet):
|
||||||
|
q = django_filters.CharFilter(
|
||||||
|
method='search',
|
||||||
|
label=_('Search'),
|
||||||
|
)
|
||||||
|
created = django_filters.DateTimeFilter()
|
||||||
|
created__before = django_filters.DateTimeFilter(
|
||||||
|
field_name='created',
|
||||||
|
lookup_expr='lte'
|
||||||
|
)
|
||||||
|
created__after = django_filters.DateTimeFilter(
|
||||||
|
field_name='created',
|
||||||
|
lookup_expr='gte'
|
||||||
|
)
|
||||||
|
scheduled = django_filters.DateTimeFilter()
|
||||||
|
scheduled__before = django_filters.DateTimeFilter(
|
||||||
|
field_name='scheduled',
|
||||||
|
lookup_expr='lte'
|
||||||
|
)
|
||||||
|
scheduled__after = django_filters.DateTimeFilter(
|
||||||
|
field_name='scheduled',
|
||||||
|
lookup_expr='gte'
|
||||||
|
)
|
||||||
|
started = django_filters.DateTimeFilter()
|
||||||
|
started__before = django_filters.DateTimeFilter(
|
||||||
|
field_name='started',
|
||||||
|
lookup_expr='lte'
|
||||||
|
)
|
||||||
|
started__after = django_filters.DateTimeFilter(
|
||||||
|
field_name='started',
|
||||||
|
lookup_expr='gte'
|
||||||
|
)
|
||||||
|
completed = django_filters.DateTimeFilter()
|
||||||
|
completed__before = django_filters.DateTimeFilter(
|
||||||
|
field_name='completed',
|
||||||
|
lookup_expr='lte'
|
||||||
|
)
|
||||||
|
completed__after = django_filters.DateTimeFilter(
|
||||||
|
field_name='completed',
|
||||||
|
lookup_expr='gte'
|
||||||
|
)
|
||||||
|
status = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=JobStatusChoices,
|
||||||
|
null_value=None
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Job
|
||||||
|
fields = ('id', 'interval', 'status', 'user', 'object_type', 'name')
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(user__username__icontains=value) |
|
||||||
|
Q(name__icontains=value)
|
||||||
|
)
|
||||||
|
@ -1,14 +1,22 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from core.choices import *
|
from core.choices import *
|
||||||
from core.models import *
|
from core.models import *
|
||||||
|
from extras.forms.mixins import SavedFiltersMixin
|
||||||
|
from extras.utils import FeatureQuery
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, DynamicModelMultipleChoiceField
|
from utilities.forms import (
|
||||||
|
APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeChoiceField, DateTimePicker,
|
||||||
|
DynamicModelMultipleChoiceField, FilterForm,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'DataFileFilterForm',
|
'DataFileFilterForm',
|
||||||
'DataSourceFilterForm',
|
'DataSourceFilterForm',
|
||||||
|
'JobFilterForm',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -45,3 +53,63 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Data source')
|
label=_('Data source')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('q', 'filter_id')),
|
||||||
|
('Attributes', ('object_type', 'status')),
|
||||||
|
('Creation', (
|
||||||
|
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
|
||||||
|
'started__after', 'completed__before', 'completed__after', 'user',
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
object_type = ContentTypeChoiceField(
|
||||||
|
label=_('Object Type'),
|
||||||
|
queryset=ContentType.objects.filter(FeatureQuery('jobs').get_query()),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
status = forms.MultipleChoiceField(
|
||||||
|
choices=JobStatusChoices,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
created__after = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
created__before = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
scheduled__after = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
scheduled__before = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
started__after = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
started__before = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
completed__after = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
completed__before = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
user = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=User.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('User'),
|
||||||
|
widget=APISelectMultiple(
|
||||||
|
api_url='/api/users/users/',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from extras.choices import JobResultStatusChoices
|
from .choices import JobStatusChoices
|
||||||
from netbox.search.backends import search_backend
|
from netbox.search.backends import search_backend
|
||||||
from .choices import *
|
from .choices import *
|
||||||
from .exceptions import SyncError
|
from .exceptions import SyncError
|
||||||
@ -25,6 +25,6 @@ def sync_datasource(job_result, *args, **kwargs):
|
|||||||
job_result.terminate()
|
job_result.terminate()
|
||||||
|
|
||||||
except SyncError as e:
|
except SyncError as e:
|
||||||
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||||
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||||
logging.error(e)
|
logging.error(e)
|
||||||
|
40
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 .data import *
|
||||||
from .files import *
|
from .files import *
|
||||||
|
from .jobs import *
|
||||||
|
@ -21,6 +21,7 @@ from utilities.querysets import RestrictedQuerySet
|
|||||||
from ..choices import *
|
from ..choices import *
|
||||||
from ..exceptions import SyncError
|
from ..exceptions import SyncError
|
||||||
from ..signals import post_sync, pre_sync
|
from ..signals import post_sync, pre_sync
|
||||||
|
from .jobs import Job
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'DataFile',
|
'DataFile',
|
||||||
@ -112,14 +113,12 @@ class DataSource(PrimaryModel):
|
|||||||
"""
|
"""
|
||||||
Enqueue a background job to synchronize the DataSource by calling sync().
|
Enqueue a background job to synchronize the DataSource by calling sync().
|
||||||
"""
|
"""
|
||||||
from extras.models import JobResult
|
|
||||||
|
|
||||||
# Set the status to "syncing"
|
# Set the status to "syncing"
|
||||||
self.status = DataSourceStatusChoices.QUEUED
|
self.status = DataSourceStatusChoices.QUEUED
|
||||||
DataSource.objects.filter(pk=self.pk).update(status=self.status)
|
DataSource.objects.filter(pk=self.pk).update(status=self.status)
|
||||||
|
|
||||||
# Enqueue a sync job
|
# Enqueue a sync job
|
||||||
job_result = JobResult.enqueue_job(
|
job_result = Job.enqueue_job(
|
||||||
import_string('core.jobs.sync_datasource'),
|
import_string('core.jobs.sync_datasource'),
|
||||||
name=self.name,
|
name=self.name,
|
||||||
obj_type=ContentType.objects.get_for_model(DataSource),
|
obj_type=ContentType.objects.get_for_model(DataSource),
|
||||||
|
219
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 .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/delete/', views.DataFileBulkDeleteView.as_view(), name='datafile_bulk_delete'),
|
||||||
path('data-files/<int:pk>/', include(get_model_urls('core', 'datafile'))),
|
path('data-files/<int:pk>/', include(get_model_urls('core', 'datafile'))),
|
||||||
|
|
||||||
|
# Job results
|
||||||
|
path('jobs/', views.JobListView.as_view(), name='job_list'),
|
||||||
|
path('jobs/delete/', views.JobBulkDeleteView.as_view(), name='job_bulk_delete'),
|
||||||
|
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
|
||||||
|
|
||||||
)
|
)
|
||||||
|
@ -120,3 +120,25 @@ class DataFileBulkDeleteView(generic.BulkDeleteView):
|
|||||||
queryset = DataFile.objects.defer('data')
|
queryset = DataFile.objects.defer('data')
|
||||||
filterset = filtersets.DataFileFilterSet
|
filterset = filtersets.DataFileFilterSet
|
||||||
table = tables.DataFileTable
|
table = tables.DataFileTable
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Jobs
|
||||||
|
#
|
||||||
|
|
||||||
|
class JobListView(generic.ObjectListView):
|
||||||
|
queryset = Job.objects.all()
|
||||||
|
filterset = filtersets.JobFilterSet
|
||||||
|
filterset_form = forms.JobFilterForm
|
||||||
|
table = tables.JobTable
|
||||||
|
actions = ('export', 'delete', 'bulk_delete', )
|
||||||
|
|
||||||
|
|
||||||
|
class JobDeleteView(generic.ObjectDeleteView):
|
||||||
|
queryset = Job.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class JobBulkDeleteView(generic.BulkDeleteView):
|
||||||
|
queryset = Job.objects.all()
|
||||||
|
filterset = filtersets.JobFilterSet
|
||||||
|
table = tables.JobTable
|
||||||
|
@ -6,7 +6,7 @@ from django.utils.html import format_html
|
|||||||
|
|
||||||
from netbox.config import get_config, PARAMS
|
from netbox.config import get_config, PARAMS
|
||||||
from .forms import ConfigRevisionForm
|
from .forms import ConfigRevisionForm
|
||||||
from .models import ConfigRevision, JobResult
|
from .models import ConfigRevision
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ConfigRevision)
|
@admin.register(ConfigRevision)
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from extras import choices, models
|
from extras import models
|
||||||
from netbox.api.fields import ChoiceField
|
|
||||||
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
|
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
|
||||||
from users.api.nested_serializers import NestedUserSerializer
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'NestedConfigContextSerializer',
|
'NestedConfigContextSerializer',
|
||||||
@ -12,7 +10,6 @@ __all__ = [
|
|||||||
'NestedCustomLinkSerializer',
|
'NestedCustomLinkSerializer',
|
||||||
'NestedExportTemplateSerializer',
|
'NestedExportTemplateSerializer',
|
||||||
'NestedImageAttachmentSerializer',
|
'NestedImageAttachmentSerializer',
|
||||||
'NestedJobResultSerializer',
|
|
||||||
'NestedJournalEntrySerializer',
|
'NestedJournalEntrySerializer',
|
||||||
'NestedSavedFilterSerializer',
|
'NestedSavedFilterSerializer',
|
||||||
'NestedTagSerializer', # Defined in netbox.api.serializers
|
'NestedTagSerializer', # Defined in netbox.api.serializers
|
||||||
@ -90,15 +87,3 @@ class NestedJournalEntrySerializer(WritableNestedSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = models.JournalEntry
|
model = models.JournalEntry
|
||||||
fields = ['id', 'url', 'display', 'created']
|
fields = ['id', 'url', 'display', 'created']
|
||||||
|
|
||||||
|
|
||||||
class NestedJobResultSerializer(serializers.ModelSerializer):
|
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
|
|
||||||
status = ChoiceField(choices=choices.JobResultStatusChoices)
|
|
||||||
user = NestedUserSerializer(
|
|
||||||
read_only=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.JobResult
|
|
||||||
fields = ['url', 'created', 'completed', 'user', 'status']
|
|
||||||
|
@ -4,7 +4,8 @@ from django.core.exceptions import ObjectDoesNotExist
|
|||||||
from drf_yasg.utils import swagger_serializer_method
|
from drf_yasg.utils import swagger_serializer_method
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer
|
from core.api.serializers import JobSerializer
|
||||||
|
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
|
||||||
from dcim.api.nested_serializers import (
|
from dcim.api.nested_serializers import (
|
||||||
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
|
NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
|
||||||
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
|
||||||
@ -37,7 +38,6 @@ __all__ = (
|
|||||||
'DashboardSerializer',
|
'DashboardSerializer',
|
||||||
'ExportTemplateSerializer',
|
'ExportTemplateSerializer',
|
||||||
'ImageAttachmentSerializer',
|
'ImageAttachmentSerializer',
|
||||||
'JobResultSerializer',
|
|
||||||
'JournalEntrySerializer',
|
'JournalEntrySerializer',
|
||||||
'ObjectChangeSerializer',
|
'ObjectChangeSerializer',
|
||||||
'ReportDetailSerializer',
|
'ReportDetailSerializer',
|
||||||
@ -409,28 +409,6 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Job Results
|
|
||||||
#
|
|
||||||
|
|
||||||
class JobResultSerializer(BaseModelSerializer):
|
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
|
|
||||||
user = NestedUserSerializer(
|
|
||||||
read_only=True
|
|
||||||
)
|
|
||||||
status = ChoiceField(choices=JobResultStatusChoices, read_only=True)
|
|
||||||
obj_type = ContentTypeField(
|
|
||||||
read_only=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = JobResult
|
|
||||||
fields = [
|
|
||||||
'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name',
|
|
||||||
'obj_type', 'user', 'data', 'job_id',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Reports
|
# Reports
|
||||||
#
|
#
|
||||||
@ -446,11 +424,11 @@ class ReportSerializer(serializers.Serializer):
|
|||||||
name = serializers.CharField(max_length=255)
|
name = serializers.CharField(max_length=255)
|
||||||
description = serializers.CharField(max_length=255, required=False)
|
description = serializers.CharField(max_length=255, required=False)
|
||||||
test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
|
test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
|
||||||
result = NestedJobResultSerializer()
|
result = NestedJobSerializer()
|
||||||
|
|
||||||
|
|
||||||
class ReportDetailSerializer(ReportSerializer):
|
class ReportDetailSerializer(ReportSerializer):
|
||||||
result = JobResultSerializer()
|
result = JobSerializer()
|
||||||
|
|
||||||
|
|
||||||
class ReportInputSerializer(serializers.Serializer):
|
class ReportInputSerializer(serializers.Serializer):
|
||||||
@ -473,7 +451,7 @@ class ScriptSerializer(serializers.Serializer):
|
|||||||
name = serializers.CharField(read_only=True)
|
name = serializers.CharField(read_only=True)
|
||||||
description = serializers.CharField(read_only=True)
|
description = serializers.CharField(read_only=True)
|
||||||
vars = serializers.SerializerMethodField(read_only=True)
|
vars = serializers.SerializerMethodField(read_only=True)
|
||||||
result = NestedJobResultSerializer()
|
result = NestedJobSerializer()
|
||||||
|
|
||||||
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
|
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
|
||||||
def get_vars(self, instance):
|
def get_vars(self, instance):
|
||||||
@ -483,7 +461,7 @@ class ScriptSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class ScriptDetailSerializer(ScriptSerializer):
|
class ScriptDetailSerializer(ScriptSerializer):
|
||||||
result = JobResultSerializer()
|
result = JobSerializer()
|
||||||
|
|
||||||
|
|
||||||
class ScriptInputSerializer(serializers.Serializer):
|
class ScriptInputSerializer(serializers.Serializer):
|
||||||
|
@ -20,7 +20,6 @@ router.register('config-templates', views.ConfigTemplateViewSet)
|
|||||||
router.register('reports', views.ReportViewSet, basename='report')
|
router.register('reports', views.ReportViewSet, basename='report')
|
||||||
router.register('scripts', views.ScriptViewSet, basename='script')
|
router.register('scripts', views.ScriptViewSet, basename='script')
|
||||||
router.register('object-changes', views.ObjectChangeViewSet)
|
router.register('object-changes', views.ObjectChangeViewSet)
|
||||||
router.register('job-results', views.JobResultViewSet)
|
|
||||||
router.register('content-types', views.ContentTypeViewSet)
|
router.register('content-types', views.ContentTypeViewSet)
|
||||||
|
|
||||||
app_name = 'extras-api'
|
app_name = 'extras-api'
|
||||||
|
@ -12,10 +12,10 @@ from rest_framework.routers import APIRootView
|
|||||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
||||||
from rq import Worker
|
from rq import Worker
|
||||||
|
|
||||||
|
from core.choices import JobStatusChoices
|
||||||
|
from core.models import Job
|
||||||
from extras import filtersets
|
from extras import filtersets
|
||||||
from extras.choices import JobResultStatusChoices
|
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.models import CustomField
|
|
||||||
from extras.reports import get_report, run_report
|
from extras.reports import get_report, run_report
|
||||||
from extras.scripts import get_script, run_script
|
from extras.scripts import get_script, run_script
|
||||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||||
@ -191,9 +191,9 @@ class ReportViewSet(ViewSet):
|
|||||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||||
results = {
|
results = {
|
||||||
r.name: r
|
r.name: r
|
||||||
for r in JobResult.objects.filter(
|
for r in Job.objects.filter(
|
||||||
obj_type=report_content_type,
|
object_type=report_content_type,
|
||||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||||
).order_by('name', '-created').distinct('name').defer('data')
|
).order_by('name', '-created').distinct('name').defer('data')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,7 +201,7 @@ class ReportViewSet(ViewSet):
|
|||||||
for report_module in ReportModule.objects.restrict(request.user):
|
for report_module in ReportModule.objects.restrict(request.user):
|
||||||
report_list.extend([report() for report in report_module.reports.values()])
|
report_list.extend([report() for report in report_module.reports.values()])
|
||||||
|
|
||||||
# Attach JobResult objects to each report (if any)
|
# Attach Job objects to each report (if any)
|
||||||
for report in report_list:
|
for report in report_list:
|
||||||
report.result = results.get(report.full_name, None)
|
report.result = results.get(report.full_name, None)
|
||||||
|
|
||||||
@ -216,13 +216,13 @@ class ReportViewSet(ViewSet):
|
|||||||
Retrieve a single Report identified as "<module>.<report>".
|
Retrieve a single Report identified as "<module>.<report>".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Retrieve the Report and JobResult, if any.
|
# Retrieve the Report and Job, if any.
|
||||||
report = self._retrieve_report(pk)
|
report = self._retrieve_report(pk)
|
||||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||||
report.result = JobResult.objects.filter(
|
report.result = Job.objects.filter(
|
||||||
obj_type=report_content_type,
|
object_type=report_content_type,
|
||||||
name=report.full_name,
|
name=report.full_name,
|
||||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
serializer = serializers.ReportDetailSerializer(report, context={
|
serializer = serializers.ReportDetailSerializer(report, context={
|
||||||
@ -234,7 +234,7 @@ class ReportViewSet(ViewSet):
|
|||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def run(self, request, pk):
|
def run(self, request, pk):
|
||||||
"""
|
"""
|
||||||
Run a Report identified as "<module>.<script>" and return the pending JobResult as the result
|
Run a Report identified as "<module>.<script>" and return the pending Job as the result
|
||||||
"""
|
"""
|
||||||
# Check that the user has permission to run reports.
|
# Check that the user has permission to run reports.
|
||||||
if not request.user.has_perm('extras.run_report'):
|
if not request.user.has_perm('extras.run_report'):
|
||||||
@ -244,12 +244,12 @@ class ReportViewSet(ViewSet):
|
|||||||
if not Worker.count(get_connection('default')):
|
if not Worker.count(get_connection('default')):
|
||||||
raise RQWorkerNotRunningException()
|
raise RQWorkerNotRunningException()
|
||||||
|
|
||||||
# Retrieve and run the Report. This will create a new JobResult.
|
# Retrieve and run the Report. This will create a new Job.
|
||||||
report = self._retrieve_report(pk)
|
report = self._retrieve_report(pk)
|
||||||
input_serializer = serializers.ReportInputSerializer(data=request.data)
|
input_serializer = serializers.ReportInputSerializer(data=request.data)
|
||||||
|
|
||||||
if input_serializer.is_valid():
|
if input_serializer.is_valid():
|
||||||
job_result = JobResult.enqueue_job(
|
report.result = Job.enqueue_job(
|
||||||
run_report,
|
run_report,
|
||||||
name=report.full_name,
|
name=report.full_name,
|
||||||
obj_type=ContentType.objects.get_for_model(Report),
|
obj_type=ContentType.objects.get_for_model(Report),
|
||||||
@ -258,8 +258,6 @@ class ReportViewSet(ViewSet):
|
|||||||
schedule_at=input_serializer.validated_data.get('schedule_at'),
|
schedule_at=input_serializer.validated_data.get('schedule_at'),
|
||||||
interval=input_serializer.validated_data.get('interval')
|
interval=input_serializer.validated_data.get('interval')
|
||||||
)
|
)
|
||||||
report.result = job_result
|
|
||||||
|
|
||||||
serializer = serializers.ReportDetailSerializer(report, context={'request': request})
|
serializer = serializers.ReportDetailSerializer(report, context={'request': request})
|
||||||
|
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
@ -288,9 +286,9 @@ class ScriptViewSet(ViewSet):
|
|||||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||||
results = {
|
results = {
|
||||||
r.name: r
|
r.name: r
|
||||||
for r in JobResult.objects.filter(
|
for r in Job.objects.filter(
|
||||||
obj_type=script_content_type,
|
object_type=script_content_type,
|
||||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||||
).order_by('name', '-created').distinct('name').defer('data')
|
).order_by('name', '-created').distinct('name').defer('data')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -298,7 +296,7 @@ class ScriptViewSet(ViewSet):
|
|||||||
for script_module in ScriptModule.objects.restrict(request.user):
|
for script_module in ScriptModule.objects.restrict(request.user):
|
||||||
script_list.extend(script_module.scripts.values())
|
script_list.extend(script_module.scripts.values())
|
||||||
|
|
||||||
# Attach JobResult objects to each script (if any)
|
# Attach Job objects to each script (if any)
|
||||||
for script in script_list:
|
for script in script_list:
|
||||||
script.result = results.get(script.full_name, None)
|
script.result = results.get(script.full_name, None)
|
||||||
|
|
||||||
@ -309,10 +307,10 @@ class ScriptViewSet(ViewSet):
|
|||||||
def retrieve(self, request, pk):
|
def retrieve(self, request, pk):
|
||||||
script = self._get_script(pk)
|
script = self._get_script(pk)
|
||||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||||
script.result = JobResult.objects.filter(
|
script.result = Job.objects.filter(
|
||||||
obj_type=script_content_type,
|
object_type=script_content_type,
|
||||||
name=script.full_name,
|
name=script.full_name,
|
||||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||||
).first()
|
).first()
|
||||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||||
|
|
||||||
@ -320,7 +318,7 @@ class ScriptViewSet(ViewSet):
|
|||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
"""
|
"""
|
||||||
Run a Script identified as "<module>.<script>" and return the pending JobResult as the result
|
Run a Script identified as "<module>.<script>" and return the pending Job as the result
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not request.user.has_perm('extras.run_script'):
|
if not request.user.has_perm('extras.run_script'):
|
||||||
@ -334,7 +332,7 @@ class ScriptViewSet(ViewSet):
|
|||||||
raise RQWorkerNotRunningException()
|
raise RQWorkerNotRunningException()
|
||||||
|
|
||||||
if input_serializer.is_valid():
|
if input_serializer.is_valid():
|
||||||
job_result = JobResult.enqueue_job(
|
script.result = Job.enqueue_job(
|
||||||
run_script,
|
run_script,
|
||||||
name=script.full_name,
|
name=script.full_name,
|
||||||
obj_type=ContentType.objects.get_for_model(Script),
|
obj_type=ContentType.objects.get_for_model(Script),
|
||||||
@ -346,7 +344,6 @@ class ScriptViewSet(ViewSet):
|
|||||||
schedule_at=input_serializer.validated_data.get('schedule_at'),
|
schedule_at=input_serializer.validated_data.get('schedule_at'),
|
||||||
interval=input_serializer.validated_data.get('interval')
|
interval=input_serializer.validated_data.get('interval')
|
||||||
)
|
)
|
||||||
script.result = job_result
|
|
||||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||||
|
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
@ -368,19 +365,6 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
|||||||
filterset_class = filtersets.ObjectChangeFilterSet
|
filterset_class = filtersets.ObjectChangeFilterSet
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Job Results
|
|
||||||
#
|
|
||||||
|
|
||||||
class JobResultViewSet(ReadOnlyModelViewSet):
|
|
||||||
"""
|
|
||||||
Retrieve a list of job results
|
|
||||||
"""
|
|
||||||
queryset = JobResult.objects.prefetch_related('user')
|
|
||||||
serializer_class = serializers.JobResultSerializer
|
|
||||||
filterset_class = filtersets.JobResultFilterSet
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# ContentTypes
|
# ContentTypes
|
||||||
#
|
#
|
||||||
|
@ -22,7 +22,6 @@ __all__ = (
|
|||||||
'CustomLinkFilterSet',
|
'CustomLinkFilterSet',
|
||||||
'ExportTemplateFilterSet',
|
'ExportTemplateFilterSet',
|
||||||
'ImageAttachmentFilterSet',
|
'ImageAttachmentFilterSet',
|
||||||
'JobResultFilterSet',
|
|
||||||
'JournalEntryFilterSet',
|
'JournalEntryFilterSet',
|
||||||
'LocalConfigContextFilterSet',
|
'LocalConfigContextFilterSet',
|
||||||
'ObjectChangeFilterSet',
|
'ObjectChangeFilterSet',
|
||||||
@ -537,69 +536,6 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Job Results
|
|
||||||
#
|
|
||||||
|
|
||||||
class JobResultFilterSet(BaseFilterSet):
|
|
||||||
q = django_filters.CharFilter(
|
|
||||||
method='search',
|
|
||||||
label=_('Search'),
|
|
||||||
)
|
|
||||||
created = django_filters.DateTimeFilter()
|
|
||||||
created__before = django_filters.DateTimeFilter(
|
|
||||||
field_name='created',
|
|
||||||
lookup_expr='lte'
|
|
||||||
)
|
|
||||||
created__after = django_filters.DateTimeFilter(
|
|
||||||
field_name='created',
|
|
||||||
lookup_expr='gte'
|
|
||||||
)
|
|
||||||
scheduled = django_filters.DateTimeFilter()
|
|
||||||
scheduled__before = django_filters.DateTimeFilter(
|
|
||||||
field_name='scheduled',
|
|
||||||
lookup_expr='lte'
|
|
||||||
)
|
|
||||||
scheduled__after = django_filters.DateTimeFilter(
|
|
||||||
field_name='scheduled',
|
|
||||||
lookup_expr='gte'
|
|
||||||
)
|
|
||||||
started = django_filters.DateTimeFilter()
|
|
||||||
started__before = django_filters.DateTimeFilter(
|
|
||||||
field_name='started',
|
|
||||||
lookup_expr='lte'
|
|
||||||
)
|
|
||||||
started__after = django_filters.DateTimeFilter(
|
|
||||||
field_name='started',
|
|
||||||
lookup_expr='gte'
|
|
||||||
)
|
|
||||||
completed = django_filters.DateTimeFilter()
|
|
||||||
completed__before = django_filters.DateTimeFilter(
|
|
||||||
field_name='completed',
|
|
||||||
lookup_expr='lte'
|
|
||||||
)
|
|
||||||
completed__after = django_filters.DateTimeFilter(
|
|
||||||
field_name='completed',
|
|
||||||
lookup_expr='gte'
|
|
||||||
)
|
|
||||||
status = django_filters.MultipleChoiceFilter(
|
|
||||||
choices=JobResultStatusChoices,
|
|
||||||
null_value=None
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = JobResult
|
|
||||||
fields = ('id', 'interval', 'status', 'user', 'obj_type', 'name')
|
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
|
||||||
if not value.strip():
|
|
||||||
return queryset
|
|
||||||
return queryset.filter(
|
|
||||||
Q(user__username__icontains=value) |
|
|
||||||
Q(name__icontains=value)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# ContentTypes
|
# ContentTypes
|
||||||
#
|
#
|
||||||
|
@ -11,9 +11,8 @@ from extras.utils import FeatureQuery
|
|||||||
from netbox.forms.base import NetBoxModelFilterSetForm
|
from netbox.forms.base import NetBoxModelFilterSetForm
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
add_blank_choice, APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeChoiceField,
|
add_blank_choice, APISelectMultiple, BOOLEAN_WITH_BLANK_CHOICES, ContentTypeMultipleChoiceField, DateTimePicker,
|
||||||
ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, FilterForm,
|
DynamicModelMultipleChoiceField, FilterForm, TagFilterField,
|
||||||
TagFilterField,
|
|
||||||
)
|
)
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
from .mixins import SavedFiltersMixin
|
from .mixins import SavedFiltersMixin
|
||||||
@ -24,7 +23,6 @@ __all__ = (
|
|||||||
'CustomFieldFilterForm',
|
'CustomFieldFilterForm',
|
||||||
'CustomLinkFilterForm',
|
'CustomLinkFilterForm',
|
||||||
'ExportTemplateFilterForm',
|
'ExportTemplateFilterForm',
|
||||||
'JobResultFilterForm',
|
|
||||||
'JournalEntryFilterForm',
|
'JournalEntryFilterForm',
|
||||||
'LocalConfigContextFilterForm',
|
'LocalConfigContextFilterForm',
|
||||||
'ObjectChangeFilterForm',
|
'ObjectChangeFilterForm',
|
||||||
@ -76,66 +74,6 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class JobResultFilterForm(SavedFiltersMixin, FilterForm):
|
|
||||||
fieldsets = (
|
|
||||||
(None, ('q', 'filter_id')),
|
|
||||||
('Attributes', ('obj_type', 'status')),
|
|
||||||
('Creation', (
|
|
||||||
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
|
|
||||||
'started__after', 'completed__before', 'completed__after', 'user',
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
obj_type = ContentTypeChoiceField(
|
|
||||||
label=_('Object Type'),
|
|
||||||
queryset=ContentType.objects.filter(FeatureQuery('job_results').get_query()),
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
status = forms.MultipleChoiceField(
|
|
||||||
choices=JobResultStatusChoices,
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
created__after = forms.DateTimeField(
|
|
||||||
required=False,
|
|
||||||
widget=DateTimePicker()
|
|
||||||
)
|
|
||||||
created__before = forms.DateTimeField(
|
|
||||||
required=False,
|
|
||||||
widget=DateTimePicker()
|
|
||||||
)
|
|
||||||
scheduled__after = forms.DateTimeField(
|
|
||||||
required=False,
|
|
||||||
widget=DateTimePicker()
|
|
||||||
)
|
|
||||||
scheduled__before = forms.DateTimeField(
|
|
||||||
required=False,
|
|
||||||
widget=DateTimePicker()
|
|
||||||
)
|
|
||||||
started__after = forms.DateTimeField(
|
|
||||||
required=False,
|
|
||||||
widget=DateTimePicker()
|
|
||||||
)
|
|
||||||
started__before = forms.DateTimeField(
|
|
||||||
required=False,
|
|
||||||
widget=DateTimePicker()
|
|
||||||
)
|
|
||||||
completed__after = forms.DateTimeField(
|
|
||||||
required=False,
|
|
||||||
widget=DateTimePicker()
|
|
||||||
)
|
|
||||||
completed__before = forms.DateTimeField(
|
|
||||||
required=False,
|
|
||||||
widget=DateTimePicker()
|
|
||||||
)
|
|
||||||
user = DynamicModelMultipleChoiceField(
|
|
||||||
queryset=User.objects.all(),
|
|
||||||
required=False,
|
|
||||||
label=_('User'),
|
|
||||||
widget=APISelectMultiple(
|
|
||||||
api_url='/api/users/users/',
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id')),
|
(None, ('q', 'filter_id')),
|
||||||
|
@ -9,7 +9,7 @@ from django.db import DEFAULT_DB_ALIAS
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from packaging import version
|
from packaging import version
|
||||||
|
|
||||||
from extras.models import JobResult
|
from core.models import Job
|
||||||
from extras.models import ObjectChange
|
from extras.models import ObjectChange
|
||||||
from netbox.config import Config
|
from netbox.config import Config
|
||||||
|
|
||||||
@ -64,15 +64,15 @@ class Command(BaseCommand):
|
|||||||
f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})"
|
f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete expired JobResults
|
# Delete expired Jobs
|
||||||
if options['verbosity']:
|
if options['verbosity']:
|
||||||
self.stdout.write("[*] Checking for expired jobresult records")
|
self.stdout.write("[*] Checking for expired jobs")
|
||||||
if config.JOBRESULT_RETENTION:
|
if config.JOBRESULT_RETENTION:
|
||||||
cutoff = timezone.now() - timedelta(days=config.JOBRESULT_RETENTION)
|
cutoff = timezone.now() - timedelta(days=config.JOBRESULT_RETENTION)
|
||||||
if options['verbosity'] >= 2:
|
if options['verbosity'] >= 2:
|
||||||
self.stdout.write(f"\tRetention period: {config.JOBRESULT_RETENTION} days")
|
self.stdout.write(f"\tRetention period: {config.JOBRESULT_RETENTION} days")
|
||||||
self.stdout.write(f"\tCut-off time: {cutoff}")
|
self.stdout.write(f"\tCut-off time: {cutoff}")
|
||||||
expired_records = JobResult.objects.filter(created__lt=cutoff).count()
|
expired_records = Job.objects.filter(created__lt=cutoff).count()
|
||||||
if expired_records:
|
if expired_records:
|
||||||
if options['verbosity']:
|
if options['verbosity']:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
@ -81,7 +81,7 @@ class Command(BaseCommand):
|
|||||||
ending=""
|
ending=""
|
||||||
)
|
)
|
||||||
self.stdout.flush()
|
self.stdout.flush()
|
||||||
JobResult.objects.filter(created__lt=cutoff).delete()
|
Job.objects.filter(created__lt=cutoff).delete()
|
||||||
if options['verbosity']:
|
if options['verbosity']:
|
||||||
self.stdout.write("Done.", self.style.SUCCESS)
|
self.stdout.write("Done.", self.style.SUCCESS)
|
||||||
elif options['verbosity']:
|
elif options['verbosity']:
|
||||||
|
@ -4,8 +4,9 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from extras.choices import JobResultStatusChoices
|
from core.choices import JobStatusChoices
|
||||||
from extras.models import JobResult, ReportModule
|
from core.models import Job
|
||||||
|
from extras.models import ReportModule
|
||||||
from extras.reports import run_report
|
from extras.reports import run_report
|
||||||
|
|
||||||
|
|
||||||
@ -21,13 +22,13 @@ class Command(BaseCommand):
|
|||||||
for report in module.reports.values():
|
for report in module.reports.values():
|
||||||
if module.name in options['reports'] or report.full_name in options['reports']:
|
if module.name in options['reports'] or report.full_name in options['reports']:
|
||||||
|
|
||||||
# Run the report and create a new JobResult
|
# Run the report and create a new Job
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
|
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
|
||||||
)
|
)
|
||||||
|
|
||||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||||
job_result = JobResult.enqueue_job(
|
job = Job.enqueue_job(
|
||||||
run_report,
|
run_report,
|
||||||
report.full_name,
|
report.full_name,
|
||||||
report_content_type,
|
report_content_type,
|
||||||
@ -36,19 +37,19 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Wait on the job to finish
|
# Wait on the job to finish
|
||||||
while job_result.status not in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
|
while job.status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
job_result = JobResult.objects.get(pk=job_result.pk)
|
job = Job.objects.get(pk=job.pk)
|
||||||
|
|
||||||
# Report on success/failure
|
# Report on success/failure
|
||||||
if job_result.status == JobResultStatusChoices.STATUS_FAILED:
|
if job.status == JobStatusChoices.STATUS_FAILED:
|
||||||
status = self.style.ERROR('FAILED')
|
status = self.style.ERROR('FAILED')
|
||||||
elif job_result == JobResultStatusChoices.STATUS_ERRORED:
|
elif job == JobStatusChoices.STATUS_ERRORED:
|
||||||
status = self.style.ERROR('ERRORED')
|
status = self.style.ERROR('ERRORED')
|
||||||
else:
|
else:
|
||||||
status = self.style.SUCCESS('SUCCESS')
|
status = self.style.SUCCESS('SUCCESS')
|
||||||
|
|
||||||
for test_name, attrs in job_result.data.items():
|
for test_name, attrs in job.data.items():
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
"\t{}: {} success, {} info, {} warning, {} failure".format(
|
"\t{}: {} success, {} info, {} warning, {} failure".format(
|
||||||
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
|
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
|
||||||
@ -58,7 +59,7 @@ class Command(BaseCommand):
|
|||||||
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
|
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
|
||||||
)
|
)
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
"[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), report.full_name, job_result.duration)
|
"[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), report.full_name, job.duration)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wrap things up
|
# Wrap things up
|
||||||
|
@ -9,10 +9,10 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
|
from core.choices import JobStatusChoices
|
||||||
|
from core.models import Job
|
||||||
from extras.api.serializers import ScriptOutputSerializer
|
from extras.api.serializers import ScriptOutputSerializer
|
||||||
from extras.choices import JobResultStatusChoices
|
|
||||||
from extras.context_managers import change_logging
|
from extras.context_managers import change_logging
|
||||||
from extras.models import JobResult
|
|
||||||
from extras.scripts import get_script
|
from extras.scripts import get_script
|
||||||
from extras.signals import clear_webhooks
|
from extras.signals import clear_webhooks
|
||||||
from utilities.exceptions import AbortTransaction
|
from utilities.exceptions import AbortTransaction
|
||||||
@ -60,7 +60,7 @@ class Command(BaseCommand):
|
|||||||
logger.error(f"Exception raised during script execution: {e}")
|
logger.error(f"Exception raised during script execution: {e}")
|
||||||
clear_webhooks.send(request)
|
clear_webhooks.send(request)
|
||||||
job_result.data = ScriptOutputSerializer(script).data
|
job_result.data = ScriptOutputSerializer(script).data
|
||||||
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||||
|
|
||||||
logger.info(f"Script completed in {job_result.duration}")
|
logger.info(f"Script completed in {job_result.duration}")
|
||||||
|
|
||||||
@ -113,7 +113,7 @@ class Command(BaseCommand):
|
|||||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||||
|
|
||||||
# Create the job result
|
# Create the job result
|
||||||
job_result = JobResult.objects.create(
|
job_result = Job.objects.create(
|
||||||
name=script.full_name,
|
name=script.full_name,
|
||||||
obj_type=script_content_type,
|
obj_type=script_content_type,
|
||||||
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
|
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
|
||||||
@ -131,7 +131,7 @@ class Command(BaseCommand):
|
|||||||
})
|
})
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
job_result.status = JobResultStatusChoices.STATUS_RUNNING
|
job_result.status = JobStatusChoices.STATUS_RUNNING
|
||||||
job_result.save()
|
job_result.save()
|
||||||
|
|
||||||
logger.info(f"Running script (commit={commit})")
|
logger.info(f"Running script (commit={commit})")
|
||||||
@ -146,5 +146,5 @@ class Command(BaseCommand):
|
|||||||
for field, errors in form.errors.get_json_data().items():
|
for field, errors in form.errors.get_json_data().items():
|
||||||
for error in errors:
|
for error in errors:
|
||||||
logger.error(f'\t{field}: {error.get("message")}')
|
logger.error(f'\t{field}: {error.get("message")}')
|
||||||
job_result.status = JobResultStatusChoices.STATUS_ERRORED
|
job_result.status = JobStatusChoices.STATUS_ERRORED
|
||||||
job_result.save()
|
job_result.save()
|
||||||
|
@ -151,7 +151,7 @@ class Migration(migrations.Migration):
|
|||||||
('status', models.CharField(default='pending', max_length=30)),
|
('status', models.CharField(default='pending', max_length=30)),
|
||||||
('data', models.JSONField(blank=True, null=True)),
|
('data', models.JSONField(blank=True, null=True)),
|
||||||
('job_id', models.UUIDField(unique=True)),
|
('job_id', models.UUIDField(unique=True)),
|
||||||
('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('job_results'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')),
|
('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')),
|
||||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
@ -594,7 +594,7 @@ class JobResult(models.Model):
|
|||||||
to=ContentType,
|
to=ContentType,
|
||||||
related_name='job_results',
|
related_name='job_results',
|
||||||
verbose_name='Object types',
|
verbose_name='Object types',
|
||||||
limit_choices_to=FeatureQuery('job_results'),
|
limit_choices_to=FeatureQuery('jobs'),
|
||||||
help_text=_("The object type to which this job result applies"),
|
help_text=_("The object type to which this job result applies"),
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
|
@ -7,7 +7,7 @@ from django.urls import reverse
|
|||||||
from core.choices import ManagedFileRootPathChoices
|
from core.choices import ManagedFileRootPathChoices
|
||||||
from core.models import ManagedFile
|
from core.models import ManagedFile
|
||||||
from extras.utils import is_report
|
from extras.utils import is_report
|
||||||
from netbox.models.features import JobResultsMixin, WebhooksMixin
|
from netbox.models.features import JobsMixin, WebhooksMixin
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from .mixins import PythonModuleMixin
|
from .mixins import PythonModuleMixin
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Report(JobResultsMixin, WebhooksMixin, models.Model):
|
class Report(JobsMixin, WebhooksMixin, models.Model):
|
||||||
"""
|
"""
|
||||||
Dummy model used to generate permissions for reports. Does not exist in the database.
|
Dummy model used to generate permissions for reports. Does not exist in the database.
|
||||||
"""
|
"""
|
||||||
|
@ -7,7 +7,7 @@ from django.urls import reverse
|
|||||||
from core.choices import ManagedFileRootPathChoices
|
from core.choices import ManagedFileRootPathChoices
|
||||||
from core.models import ManagedFile
|
from core.models import ManagedFile
|
||||||
from extras.utils import is_script
|
from extras.utils import is_script
|
||||||
from netbox.models.features import JobResultsMixin, WebhooksMixin
|
from netbox.models.features import JobsMixin, WebhooksMixin
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from .mixins import PythonModuleMixin
|
from .mixins import PythonModuleMixin
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Script(JobResultsMixin, WebhooksMixin, models.Model):
|
class Script(JobsMixin, WebhooksMixin, models.Model):
|
||||||
"""
|
"""
|
||||||
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
|
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
|
||||||
"""
|
"""
|
||||||
|
@ -6,8 +6,10 @@ from django.utils import timezone
|
|||||||
from django.utils.functional import classproperty
|
from django.utils.functional import classproperty
|
||||||
from django_rq import job
|
from django_rq import job
|
||||||
|
|
||||||
from .choices import JobResultStatusChoices, LogLevelChoices
|
from core.choices import JobStatusChoices
|
||||||
from .models import JobResult, ReportModule
|
from core.models import Job
|
||||||
|
from .choices import LogLevelChoices
|
||||||
|
from .models import ReportModule
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -33,14 +35,14 @@ def run_report(job_result, *args, **kwargs):
|
|||||||
job_result.start()
|
job_result.start()
|
||||||
report.run(job_result)
|
report.run(job_result)
|
||||||
except Exception:
|
except Exception:
|
||||||
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||||
logging.error(f"Error during execution of report {job_result.name}")
|
logging.error(f"Error during execution of report {job_result.name}")
|
||||||
finally:
|
finally:
|
||||||
# Schedule the next job if an interval has been set
|
# Schedule the next job if an interval has been set
|
||||||
start_time = job_result.scheduled or job_result.started
|
start_time = job_result.scheduled or job_result.started
|
||||||
if start_time and job_result.interval:
|
if start_time and job_result.interval:
|
||||||
new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
|
new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
|
||||||
JobResult.enqueue_job(
|
Job.enqueue_job(
|
||||||
run_report,
|
run_report,
|
||||||
name=job_result.name,
|
name=job_result.name,
|
||||||
obj_type=job_result.obj_type,
|
obj_type=job_result.obj_type,
|
||||||
@ -189,7 +191,7 @@ class Report(object):
|
|||||||
Run the report and save its results. Each test method will be executed in order.
|
Run the report and save its results. Each test method will be executed in order.
|
||||||
"""
|
"""
|
||||||
self.logger.info(f"Running report")
|
self.logger.info(f"Running report")
|
||||||
job_result.status = JobResultStatusChoices.STATUS_RUNNING
|
job_result.status = JobStatusChoices.STATUS_RUNNING
|
||||||
job_result.save()
|
job_result.save()
|
||||||
|
|
||||||
# Perform any post-run tasks
|
# Perform any post-run tasks
|
||||||
@ -202,15 +204,15 @@ class Report(object):
|
|||||||
test_method()
|
test_method()
|
||||||
if self.failed:
|
if self.failed:
|
||||||
self.logger.warning("Report failed")
|
self.logger.warning("Report failed")
|
||||||
job_result.status = JobResultStatusChoices.STATUS_FAILED
|
job_result.status = JobStatusChoices.STATUS_FAILED
|
||||||
else:
|
else:
|
||||||
self.logger.info("Report completed successfully")
|
self.logger.info("Report completed successfully")
|
||||||
job_result.status = JobResultStatusChoices.STATUS_COMPLETED
|
job_result.status = JobStatusChoices.STATUS_COMPLETED
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
stacktrace = traceback.format_exc()
|
stacktrace = traceback.format_exc()
|
||||||
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
|
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
|
||||||
logger.error(f"Exception raised during report execution: {e}")
|
logger.error(f"Exception raised during report execution: {e}")
|
||||||
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||||
finally:
|
finally:
|
||||||
job_result.terminate()
|
job_result.terminate()
|
||||||
|
|
||||||
|
@ -12,9 +12,11 @@ from django.core.validators import RegexValidator
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils.functional import classproperty
|
from django.utils.functional import classproperty
|
||||||
|
|
||||||
|
from core.choices import JobStatusChoices
|
||||||
|
from core.models import Job
|
||||||
from extras.api.serializers import ScriptOutputSerializer
|
from extras.api.serializers import ScriptOutputSerializer
|
||||||
from extras.choices import JobResultStatusChoices, LogLevelChoices
|
from extras.choices import LogLevelChoices
|
||||||
from extras.models import JobResult, ScriptModule
|
from extras.models import ScriptModule
|
||||||
from extras.signals import clear_webhooks
|
from extras.signals import clear_webhooks
|
||||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||||
@ -482,7 +484,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
|||||||
logger.error(f"Exception raised during script execution: {e}")
|
logger.error(f"Exception raised during script execution: {e}")
|
||||||
script.log_info("Database changes have been reverted due to error.")
|
script.log_info("Database changes have been reverted due to error.")
|
||||||
job_result.data = ScriptOutputSerializer(script).data
|
job_result.data = ScriptOutputSerializer(script).data
|
||||||
job_result.terminate(status=JobResultStatusChoices.STATUS_ERRORED)
|
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||||
clear_webhooks.send(request)
|
clear_webhooks.send(request)
|
||||||
|
|
||||||
logger.info(f"Script completed in {job_result.duration}")
|
logger.info(f"Script completed in {job_result.duration}")
|
||||||
@ -498,7 +500,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
|||||||
# Schedule the next job if an interval has been set
|
# Schedule the next job if an interval has been set
|
||||||
if job_result.interval:
|
if job_result.interval:
|
||||||
new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
|
new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
|
||||||
JobResult.enqueue_job(
|
Job.enqueue_job(
|
||||||
run_script,
|
run_script,
|
||||||
name=job_result.name,
|
name=job_result.name,
|
||||||
obj_type=job_result.obj_type,
|
obj_type=job_result.obj_type,
|
||||||
|
@ -2,7 +2,6 @@ import json
|
|||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
|
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
@ -14,7 +13,6 @@ __all__ = (
|
|||||||
'CustomFieldTable',
|
'CustomFieldTable',
|
||||||
'CustomLinkTable',
|
'CustomLinkTable',
|
||||||
'ExportTemplateTable',
|
'ExportTemplateTable',
|
||||||
'JobResultTable',
|
|
||||||
'JournalEntryTable',
|
'JournalEntryTable',
|
||||||
'ObjectChangeTable',
|
'ObjectChangeTable',
|
||||||
'SavedFilterTable',
|
'SavedFilterTable',
|
||||||
@ -43,35 +41,6 @@ class CustomFieldTable(NetBoxTable):
|
|||||||
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
|
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
|
||||||
|
|
||||||
|
|
||||||
class JobResultTable(NetBoxTable):
|
|
||||||
name = tables.Column(
|
|
||||||
linkify=True
|
|
||||||
)
|
|
||||||
obj_type = columns.ContentTypeColumn(
|
|
||||||
verbose_name=_('Type')
|
|
||||||
)
|
|
||||||
status = columns.ChoiceFieldColumn()
|
|
||||||
created = columns.DateTimeColumn()
|
|
||||||
scheduled = columns.DateTimeColumn()
|
|
||||||
interval = columns.DurationColumn()
|
|
||||||
started = columns.DateTimeColumn()
|
|
||||||
completed = columns.DateTimeColumn()
|
|
||||||
actions = columns.ActionsColumn(
|
|
||||||
actions=('delete',)
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
|
||||||
model = JobResult
|
|
||||||
fields = (
|
|
||||||
'pk', 'id', 'obj_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
|
|
||||||
'user', 'job_id',
|
|
||||||
)
|
|
||||||
default_columns = (
|
|
||||||
'pk', 'id', 'obj_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
|
|
||||||
'user',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkTable(NetBoxTable):
|
class CustomLinkTable(NetBoxTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
|
@ -106,11 +106,6 @@ urlpatterns = [
|
|||||||
path('scripts/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
|
path('scripts/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
|
||||||
path('scripts/<path:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
|
path('scripts/<path:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
|
||||||
|
|
||||||
# Job results
|
|
||||||
path('job-results/', views.JobResultListView.as_view(), name='jobresult_list'),
|
|
||||||
path('job-results/delete/', views.JobResultBulkDeleteView.as_view(), name='jobresult_bulk_delete'),
|
|
||||||
path('job-results/<int:pk>/delete/', views.JobResultDeleteView.as_view(), name='jobresult_delete'),
|
|
||||||
|
|
||||||
# Markdown
|
# Markdown
|
||||||
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
|
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
|
||||||
]
|
]
|
||||||
|
@ -2,13 +2,14 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
|
from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
|
|
||||||
from core.choices import ManagedFileRootPathChoices
|
from core.choices import JobStatusChoices, ManagedFileRootPathChoices
|
||||||
from core.forms import ManagedFileForm
|
from core.forms import ManagedFileForm
|
||||||
|
from core.models import Job
|
||||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||||
from extras.dashboard.utils import get_widget_class
|
from extras.dashboard.utils import get_widget_class
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
@ -19,7 +20,6 @@ from utilities.templatetags.builtins.filters import render_markdown
|
|||||||
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
||||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .choices import JobResultStatusChoices
|
|
||||||
from .forms.reports import ReportForm
|
from .forms.reports import ReportForm
|
||||||
from .models import *
|
from .models import *
|
||||||
from .reports import get_report, run_report
|
from .reports import get_report, run_report
|
||||||
@ -810,7 +810,7 @@ class ReportModuleDeleteView(generic.ObjectDeleteView):
|
|||||||
|
|
||||||
class ReportListView(ContentTypePermissionRequiredMixin, View):
|
class ReportListView(ContentTypePermissionRequiredMixin, View):
|
||||||
"""
|
"""
|
||||||
Retrieve all the available reports from disk and the recorded JobResult (if any) for each.
|
Retrieve all the available reports from disk and the recorded Job (if any) for each.
|
||||||
"""
|
"""
|
||||||
def get_required_permission(self):
|
def get_required_permission(self):
|
||||||
return 'extras.view_report'
|
return 'extras.view_report'
|
||||||
@ -821,9 +821,9 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
|
|||||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||||
job_results = {
|
job_results = {
|
||||||
r.name: r
|
r.name: r
|
||||||
for r in JobResult.objects.filter(
|
for r in Job.objects.filter(
|
||||||
obj_type=report_content_type,
|
object_type=report_content_type,
|
||||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||||
).order_by('name', '-created').distinct('name').defer('data')
|
).order_by('name', '-created').distinct('name').defer('data')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -836,7 +836,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
|
|||||||
|
|
||||||
class ReportView(ContentTypePermissionRequiredMixin, View):
|
class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||||
"""
|
"""
|
||||||
Display a single Report and its associated JobResult (if any).
|
Display a single Report and its associated Job (if any).
|
||||||
"""
|
"""
|
||||||
def get_required_permission(self):
|
def get_required_permission(self):
|
||||||
return 'extras.view_report'
|
return 'extras.view_report'
|
||||||
@ -846,10 +846,10 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
|||||||
report = module.reports[name]()
|
report = module.reports[name]()
|
||||||
|
|
||||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||||
report.result = JobResult.objects.filter(
|
report.result = Job.objects.filter(
|
||||||
obj_type=report_content_type,
|
object_type=report_content_type,
|
||||||
name=report.full_name,
|
name=report.full_name,
|
||||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
return render(request, 'extras/report.html', {
|
return render(request, 'extras/report.html', {
|
||||||
@ -875,8 +875,8 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
|||||||
'report': report,
|
'report': report,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Run the Report. A new JobResult is created.
|
# Run the Report. A new Job is created.
|
||||||
job_result = JobResult.enqueue_job(
|
job_result = Job.enqueue_job(
|
||||||
run_report,
|
run_report,
|
||||||
name=report.full_name,
|
name=report.full_name,
|
||||||
obj_type=ContentType.objects.get_for_model(Report),
|
obj_type=ContentType.objects.get_for_model(Report),
|
||||||
@ -897,16 +897,16 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
|||||||
|
|
||||||
class ReportResultView(ContentTypePermissionRequiredMixin, View):
|
class ReportResultView(ContentTypePermissionRequiredMixin, View):
|
||||||
"""
|
"""
|
||||||
Display a JobResult pertaining to the execution of a Report.
|
Display a Job pertaining to the execution of a Report.
|
||||||
"""
|
"""
|
||||||
def get_required_permission(self):
|
def get_required_permission(self):
|
||||||
return 'extras.view_report'
|
return 'extras.view_report'
|
||||||
|
|
||||||
def get(self, request, job_result_pk):
|
def get(self, request, job_result_pk):
|
||||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||||
result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
|
result = get_object_or_404(Job.objects.all(), pk=job_result_pk, object_type=report_content_type)
|
||||||
|
|
||||||
# Retrieve the Report and attach the JobResult to it
|
# Retrieve the Report and attach the Job to it
|
||||||
module, report_name = result.name.split('.', maxsplit=1)
|
module, report_name = result.name.split('.', maxsplit=1)
|
||||||
report = get_report(module, report_name)
|
report = get_report(module, report_name)
|
||||||
report.result = result
|
report.result = result
|
||||||
@ -958,9 +958,9 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
|||||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||||
job_results = {
|
job_results = {
|
||||||
r.name: r
|
r.name: r
|
||||||
for r in JobResult.objects.filter(
|
for r in Job.objects.filter(
|
||||||
obj_type=script_content_type,
|
object_type=script_content_type,
|
||||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||||
).order_by('name', '-created').distinct('name').defer('data')
|
).order_by('name', '-created').distinct('name').defer('data')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -981,12 +981,12 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
|
|||||||
script = module.scripts[name]()
|
script = module.scripts[name]()
|
||||||
form = script.as_form(initial=normalize_querydict(request.GET))
|
form = script.as_form(initial=normalize_querydict(request.GET))
|
||||||
|
|
||||||
# Look for a pending JobResult (use the latest one by creation timestamp)
|
# Look for a pending Job (use the latest one by creation timestamp)
|
||||||
script.result = JobResult.objects.filter(
|
script.result = Job.objects.filter(
|
||||||
obj_type=ContentType.objects.get_for_model(Script),
|
object_type=ContentType.objects.get_for_model(Script),
|
||||||
name=script.full_name,
|
name=script.full_name,
|
||||||
).exclude(
|
).exclude(
|
||||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
return render(request, 'extras/script.html', {
|
return render(request, 'extras/script.html', {
|
||||||
@ -1008,7 +1008,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
|
|||||||
messages.error(request, "Unable to run script: RQ worker process not running.")
|
messages.error(request, "Unable to run script: RQ worker process not running.")
|
||||||
|
|
||||||
elif form.is_valid():
|
elif form.is_valid():
|
||||||
job_result = JobResult.enqueue_job(
|
job_result = Job.enqueue_job(
|
||||||
run_script,
|
run_script,
|
||||||
name=script.full_name,
|
name=script.full_name,
|
||||||
obj_type=ContentType.objects.get_for_model(Script),
|
obj_type=ContentType.objects.get_for_model(Script),
|
||||||
@ -1036,10 +1036,8 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
|
|||||||
return 'extras.view_script'
|
return 'extras.view_script'
|
||||||
|
|
||||||
def get(self, request, job_result_pk):
|
def get(self, request, job_result_pk):
|
||||||
result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk)
|
|
||||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||||
if result.obj_type != script_content_type:
|
result = get_object_or_404(Job.objects.all(), pk=job_result_pk, object_type=script_content_type)
|
||||||
raise Http404
|
|
||||||
|
|
||||||
module_name, script_name = result.name.split('.', 1)
|
module_name, script_name = result.name.split('.', 1)
|
||||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path=f'{module_name}.py')
|
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path=f'{module_name}.py')
|
||||||
@ -1062,28 +1060,6 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Job results
|
|
||||||
#
|
|
||||||
|
|
||||||
class JobResultListView(generic.ObjectListView):
|
|
||||||
queryset = JobResult.objects.all()
|
|
||||||
filterset = filtersets.JobResultFilterSet
|
|
||||||
filterset_form = forms.JobResultFilterForm
|
|
||||||
table = tables.JobResultTable
|
|
||||||
actions = ('export', 'delete', 'bulk_delete', )
|
|
||||||
|
|
||||||
|
|
||||||
class JobResultDeleteView(generic.ObjectDeleteView):
|
|
||||||
queryset = JobResult.objects.all()
|
|
||||||
|
|
||||||
|
|
||||||
class JobResultBulkDeleteView(generic.BulkDeleteView):
|
|
||||||
queryset = JobResult.objects.all()
|
|
||||||
filterset = filtersets.JobResultFilterSet
|
|
||||||
table = tables.JobResultTable
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Markdown
|
# Markdown
|
||||||
#
|
#
|
||||||
|
@ -26,7 +26,7 @@ __all__ = (
|
|||||||
'CustomLinksMixin',
|
'CustomLinksMixin',
|
||||||
'CustomValidationMixin',
|
'CustomValidationMixin',
|
||||||
'ExportTemplatesMixin',
|
'ExportTemplatesMixin',
|
||||||
'JobResultsMixin',
|
'JobsMixin',
|
||||||
'JournalingMixin',
|
'JournalingMixin',
|
||||||
'SyncedDataMixin',
|
'SyncedDataMixin',
|
||||||
'TagsMixin',
|
'TagsMixin',
|
||||||
@ -290,7 +290,7 @@ class ExportTemplatesMixin(models.Model):
|
|||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
class JobResultsMixin(models.Model):
|
class JobsMixin(models.Model):
|
||||||
"""
|
"""
|
||||||
Enables support for job results.
|
Enables support for job results.
|
||||||
"""
|
"""
|
||||||
@ -418,7 +418,7 @@ FEATURES_MAP = {
|
|||||||
'custom_fields': CustomFieldsMixin,
|
'custom_fields': CustomFieldsMixin,
|
||||||
'custom_links': CustomLinksMixin,
|
'custom_links': CustomLinksMixin,
|
||||||
'export_templates': ExportTemplatesMixin,
|
'export_templates': ExportTemplatesMixin,
|
||||||
'job_results': JobResultsMixin,
|
'jobs': JobsMixin,
|
||||||
'journaling': JournalingMixin,
|
'journaling': JournalingMixin,
|
||||||
'synced_data': SyncedDataMixin,
|
'synced_data': SyncedDataMixin,
|
||||||
'tags': TagsMixin,
|
'tags': TagsMixin,
|
||||||
|
@ -326,9 +326,9 @@ OPERATIONS_MENU = Menu(
|
|||||||
label=_('Jobs'),
|
label=_('Jobs'),
|
||||||
items=(
|
items=(
|
||||||
MenuItem(
|
MenuItem(
|
||||||
link='extras:jobresult_list',
|
link='core:job_list',
|
||||||
link_text=_('Jobs'),
|
link_text=_('Jobs'),
|
||||||
permissions=['extras.view_jobresult'],
|
permissions=['core.view_job'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Loading…
Reference in New Issue
Block a user