mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
Merge pull request #10417 from kkthxbye-code/8366-job-scheduling
Fixes #8366 - Add job scheduling
This commit is contained in:
commit
5d56d95fda
@ -267,7 +267,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
|||||||
|
|
||||||
### Via the Web UI
|
### Via the Web UI
|
||||||
|
|
||||||
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button.
|
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button. It is possible to schedule a script to be executed at specified time in the future. A scheduled script can be canceled by deleting the associated job result object.
|
||||||
|
|
||||||
### Via the API
|
### Via the API
|
||||||
|
|
||||||
@ -282,6 +282,8 @@ http://netbox/api/extras/scripts/example.MyReport/ \
|
|||||||
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
|
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time.
|
||||||
|
|
||||||
### Via the CLI
|
### Via the CLI
|
||||||
|
|
||||||
Scripts can be run on the CLI by invoking the management command:
|
Scripts can be run on the CLI by invoking the management command:
|
||||||
|
@ -136,7 +136,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
|||||||
|
|
||||||
### Via the Web UI
|
### Via the Web UI
|
||||||
|
|
||||||
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view.
|
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view. It is possible to schedule a report to be executed at specified time in the future. A scheduled report can be canceled by deleting the associated job result object.
|
||||||
|
|
||||||
### Via the API
|
### Via the API
|
||||||
|
|
||||||
@ -152,6 +152,8 @@ Our example report above would be called as:
|
|||||||
POST /api/extras/reports/devices.DeviceConnectionsReport/run/
|
POST /api/extras/reports/devices.DeviceConnectionsReport/run/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time.
|
||||||
|
|
||||||
### Via the CLI
|
### Via the CLI
|
||||||
|
|
||||||
Reports can be run on the CLI by invoking the management command:
|
Reports can be run on the CLI by invoking the management command:
|
||||||
|
@ -131,24 +131,3 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
|
|||||||
})
|
})
|
||||||
|
|
||||||
return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context)
|
return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context)
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Reports & scripts
|
|
||||||
#
|
|
||||||
|
|
||||||
@admin.register(JobResult)
|
|
||||||
class JobResultAdmin(admin.ModelAdmin):
|
|
||||||
list_display = [
|
|
||||||
'obj_type', 'name', 'created', 'completed', 'user', 'status',
|
|
||||||
]
|
|
||||||
fields = [
|
|
||||||
'obj_type', 'name', 'created', 'completed', 'user', 'status', 'data', 'job_id'
|
|
||||||
]
|
|
||||||
list_filter = [
|
|
||||||
'status',
|
|
||||||
]
|
|
||||||
readonly_fields = fields
|
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
|
||||||
return False
|
|
||||||
|
@ -38,6 +38,7 @@ __all__ = (
|
|||||||
'ObjectChangeSerializer',
|
'ObjectChangeSerializer',
|
||||||
'ReportDetailSerializer',
|
'ReportDetailSerializer',
|
||||||
'ReportSerializer',
|
'ReportSerializer',
|
||||||
|
'ReportInputSerializer',
|
||||||
'ScriptDetailSerializer',
|
'ScriptDetailSerializer',
|
||||||
'ScriptInputSerializer',
|
'ScriptInputSerializer',
|
||||||
'ScriptLogMessageSerializer',
|
'ScriptLogMessageSerializer',
|
||||||
@ -362,7 +363,7 @@ class JobResultSerializer(BaseModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = JobResult
|
model = JobResult
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
|
'id', 'url', 'display', 'created', 'completed', 'scheduled_time', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -388,6 +389,10 @@ class ReportDetailSerializer(ReportSerializer):
|
|||||||
result = JobResultSerializer()
|
result = JobResultSerializer()
|
||||||
|
|
||||||
|
|
||||||
|
class ReportInputSerializer(serializers.Serializer):
|
||||||
|
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Scripts
|
# Scripts
|
||||||
#
|
#
|
||||||
@ -419,6 +424,7 @@ class ScriptDetailSerializer(ScriptSerializer):
|
|||||||
class ScriptInputSerializer(serializers.Serializer):
|
class ScriptInputSerializer(serializers.Serializer):
|
||||||
data = serializers.JSONField()
|
data = serializers.JSONField()
|
||||||
commit = serializers.BooleanField()
|
commit = serializers.BooleanField()
|
||||||
|
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
class ScriptLogMessageSerializer(serializers.Serializer):
|
class ScriptLogMessageSerializer(serializers.Serializer):
|
||||||
|
@ -231,19 +231,26 @@ class ReportViewSet(ViewSet):
|
|||||||
|
|
||||||
# Retrieve and run the Report. This will create a new JobResult.
|
# Retrieve and run the Report. This will create a new JobResult.
|
||||||
report = self._retrieve_report(pk)
|
report = self._retrieve_report(pk)
|
||||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
input_serializer = serializers.ReportInputSerializer(data=request.data)
|
||||||
job_result = JobResult.enqueue_job(
|
|
||||||
run_report,
|
|
||||||
report.full_name,
|
|
||||||
report_content_type,
|
|
||||||
request.user,
|
|
||||||
job_timeout=report.job_timeout
|
|
||||||
)
|
|
||||||
report.result = job_result
|
|
||||||
|
|
||||||
serializer = serializers.ReportDetailSerializer(report, context={'request': request})
|
if input_serializer.is_valid():
|
||||||
|
schedule_at = input_serializer.validated_data.get('schedule_at')
|
||||||
|
|
||||||
return Response(serializer.data)
|
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||||
|
job_result = JobResult.enqueue_job(
|
||||||
|
run_report,
|
||||||
|
report.full_name,
|
||||||
|
report_content_type,
|
||||||
|
request.user,
|
||||||
|
job_timeout=report.job_timeout,
|
||||||
|
schedule_at=schedule_at,
|
||||||
|
)
|
||||||
|
report.result = job_result
|
||||||
|
|
||||||
|
serializer = serializers.ReportDetailSerializer(report, context={'request': request})
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -312,6 +319,7 @@ class ScriptViewSet(ViewSet):
|
|||||||
if input_serializer.is_valid():
|
if input_serializer.is_valid():
|
||||||
data = input_serializer.data['data']
|
data = input_serializer.data['data']
|
||||||
commit = input_serializer.data['commit']
|
commit = input_serializer.data['commit']
|
||||||
|
schedule_at = input_serializer.validated_data.get('schedule_at')
|
||||||
|
|
||||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||||
job_result = JobResult.enqueue_job(
|
job_result = JobResult.enqueue_job(
|
||||||
@ -323,6 +331,7 @@ class ScriptViewSet(ViewSet):
|
|||||||
request=copy_safe_request(request),
|
request=copy_safe_request(request),
|
||||||
commit=commit,
|
commit=commit,
|
||||||
job_timeout=script.job_timeout,
|
job_timeout=script.job_timeout,
|
||||||
|
schedule_at=schedule_at,
|
||||||
)
|
)
|
||||||
script.result = job_result
|
script.result = job_result
|
||||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||||
|
@ -141,6 +141,7 @@ class LogLevelChoices(ChoiceSet):
|
|||||||
class JobResultStatusChoices(ChoiceSet):
|
class JobResultStatusChoices(ChoiceSet):
|
||||||
|
|
||||||
STATUS_PENDING = 'pending'
|
STATUS_PENDING = 'pending'
|
||||||
|
STATUS_SCHEDULED = 'scheduled'
|
||||||
STATUS_RUNNING = 'running'
|
STATUS_RUNNING = 'running'
|
||||||
STATUS_COMPLETED = 'completed'
|
STATUS_COMPLETED = 'completed'
|
||||||
STATUS_ERRORED = 'errored'
|
STATUS_ERRORED = 'errored'
|
||||||
@ -148,6 +149,7 @@ class JobResultStatusChoices(ChoiceSet):
|
|||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
(STATUS_PENDING, 'Pending'),
|
(STATUS_PENDING, 'Pending'),
|
||||||
|
(STATUS_SCHEDULED, 'Scheduled'),
|
||||||
(STATUS_RUNNING, 'Running'),
|
(STATUS_RUNNING, 'Running'),
|
||||||
(STATUS_COMPLETED, 'Completed'),
|
(STATUS_COMPLETED, 'Completed'),
|
||||||
(STATUS_ERRORED, 'Errored'),
|
(STATUS_ERRORED, 'Errored'),
|
||||||
|
@ -16,6 +16,7 @@ __all__ = (
|
|||||||
'ConfigContextFilterSet',
|
'ConfigContextFilterSet',
|
||||||
'ContentTypeFilterSet',
|
'ContentTypeFilterSet',
|
||||||
'CustomFieldFilterSet',
|
'CustomFieldFilterSet',
|
||||||
|
'JobResultFilterSet',
|
||||||
'CustomLinkFilterSet',
|
'CustomLinkFilterSet',
|
||||||
'ExportTemplateFilterSet',
|
'ExportTemplateFilterSet',
|
||||||
'ImageAttachmentFilterSet',
|
'ImageAttachmentFilterSet',
|
||||||
@ -435,7 +436,32 @@ class JobResultFilterSet(BaseFilterSet):
|
|||||||
label='Search',
|
label='Search',
|
||||||
)
|
)
|
||||||
created = django_filters.DateTimeFilter()
|
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'
|
||||||
|
)
|
||||||
completed = django_filters.DateTimeFilter()
|
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'
|
||||||
|
)
|
||||||
|
scheduled_time = django_filters.DateTimeFilter()
|
||||||
|
scheduled_time__before = django_filters.DateTimeFilter(
|
||||||
|
field_name='scheduled_time',
|
||||||
|
lookup_expr='lte'
|
||||||
|
)
|
||||||
|
scheduled_time__after = django_filters.DateTimeFilter(
|
||||||
|
field_name='scheduled_time',
|
||||||
|
lookup_expr='gte'
|
||||||
|
)
|
||||||
status = django_filters.MultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
choices=JobResultStatusChoices,
|
choices=JobResultStatusChoices,
|
||||||
null_value=None
|
null_value=None
|
||||||
@ -444,14 +470,15 @@ class JobResultFilterSet(BaseFilterSet):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = JobResult
|
model = JobResult
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'created', 'completed', 'status', 'user', 'obj_type', 'name'
|
'id', 'created', 'completed', 'scheduled_time', 'status', 'user', 'obj_type', 'name'
|
||||||
]
|
]
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
return queryset
|
return queryset
|
||||||
return queryset.filter(
|
return queryset.filter(
|
||||||
Q(user__username__icontains=value)
|
Q(user__username__icontains=value) |
|
||||||
|
Q(name__icontains=value)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigContextFilterForm',
|
'ConfigContextFilterForm',
|
||||||
'CustomFieldFilterForm',
|
'CustomFieldFilterForm',
|
||||||
|
'JobResultFilterForm',
|
||||||
'CustomLinkFilterForm',
|
'CustomLinkFilterForm',
|
||||||
'ExportTemplateFilterForm',
|
'ExportTemplateFilterForm',
|
||||||
'JournalEntryFilterForm',
|
'JournalEntryFilterForm',
|
||||||
@ -65,6 +66,58 @@ class CustomFieldFilterForm(FilterForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobResultFilterForm(FilterForm):
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('q',)),
|
||||||
|
('Attributes', ('obj_type', 'status')),
|
||||||
|
('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after',
|
||||||
|
'scheduled_time__before', 'scheduled_time__after', 'user')),
|
||||||
|
)
|
||||||
|
|
||||||
|
obj_type = ContentTypeChoiceField(
|
||||||
|
label=_('Object Type'),
|
||||||
|
queryset=ContentType.objects.all(),
|
||||||
|
limit_choices_to=FeatureQuery('job_results'), # TODO: This doesn't actually work
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
status = MultipleChoiceField(
|
||||||
|
choices=JobResultStatusChoices,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
created__after = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
created__before = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
completed__after = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
completed__before = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
scheduled_time__after = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker()
|
||||||
|
)
|
||||||
|
scheduled_time__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(FilterForm):
|
class CustomLinkFilterForm(FilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q',)),
|
(None, ('q',)),
|
||||||
|
16
netbox/extras/forms/reports.py
Normal file
16
netbox/extras/forms/reports.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from django import forms
|
||||||
|
|
||||||
|
from utilities.forms import BootstrapMixin, DateTimePicker
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'ReportForm',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportForm(BootstrapMixin, forms.Form):
|
||||||
|
schedule_at = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker(),
|
||||||
|
label="Schedule at",
|
||||||
|
help_text="Schedule execution of report to a set time",
|
||||||
|
)
|
@ -1,6 +1,6 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from utilities.forms import BootstrapMixin
|
from utilities.forms import BootstrapMixin, DateTimePicker
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ScriptForm',
|
'ScriptForm',
|
||||||
@ -14,17 +14,25 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
|||||||
label="Commit changes",
|
label="Commit changes",
|
||||||
help_text="Commit changes to the database (uncheck for a dry-run)"
|
help_text="Commit changes to the database (uncheck for a dry-run)"
|
||||||
)
|
)
|
||||||
|
_schedule_at = forms.DateTimeField(
|
||||||
|
required=False,
|
||||||
|
widget=DateTimePicker(),
|
||||||
|
label="Schedule at",
|
||||||
|
help_text="Schedule execution of script to a set time",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Move _commit to the end of the form
|
# Move _commit and _schedule_at to the end of the form
|
||||||
|
schedule_at = self.fields.pop('_schedule_at')
|
||||||
commit = self.fields.pop('_commit')
|
commit = self.fields.pop('_commit')
|
||||||
|
self.fields['_schedule_at'] = schedule_at
|
||||||
self.fields['_commit'] = commit
|
self.fields['_commit'] = commit
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def requires_input(self):
|
def requires_input(self):
|
||||||
"""
|
"""
|
||||||
A boolean indicating whether the form requires user input (ignore the _commit field).
|
A boolean indicating whether the form requires user input (ignore the _commit and _schedule_at fields).
|
||||||
"""
|
"""
|
||||||
return bool(len(self.fields) > 1)
|
return bool(len(self.fields) > 2)
|
||||||
|
@ -81,7 +81,7 @@ class Command(BaseCommand):
|
|||||||
ending=""
|
ending=""
|
||||||
)
|
)
|
||||||
self.stdout.flush()
|
self.stdout.flush()
|
||||||
JobResult.objects.filter(created__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
|
JobResult.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']:
|
||||||
|
@ -14,6 +14,8 @@ class Command(_Command):
|
|||||||
of only the 'default' queue).
|
of only the 'default' queue).
|
||||||
"""
|
"""
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
|
# Run the worker with scheduler functionality
|
||||||
|
options['with_scheduler'] = True
|
||||||
|
|
||||||
# If no queues have been specified on the command line, listen on all configured queues.
|
# If no queues have been specified on the command line, listen on all configured queues.
|
||||||
if len(args) < 1:
|
if len(args) < 1:
|
||||||
|
17
netbox/extras/migrations/0079_change_jobresult_order.py
Normal file
17
netbox/extras/migrations/0079_change_jobresult_order.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.1.1 on 2022-10-09 18:37
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0078_unique_constraints'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='jobresult',
|
||||||
|
options={'ordering': ['-created']},
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1.1 on 2022-10-16 09:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0079_change_jobresult_order'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='jobresult',
|
||||||
|
name='scheduled_time',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
@ -505,6 +505,10 @@ class JobResult(models.Model):
|
|||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
|
scheduled_time = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
to=User,
|
to=User,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@ -525,12 +529,26 @@ class JobResult(models.Model):
|
|||||||
unique=True
|
unique=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['obj_type', 'name', '-created']
|
ordering = ['-created']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.job_id)
|
return str(self.job_id)
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
queue = django_rq.get_queue("default")
|
||||||
|
job = queue.fetch_job(str(self.job_id))
|
||||||
|
|
||||||
|
if job:
|
||||||
|
job.cancel()
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def duration(self):
|
def duration(self):
|
||||||
if not self.completed:
|
if not self.completed:
|
||||||
@ -551,7 +569,7 @@ class JobResult(models.Model):
|
|||||||
self.completed = timezone.now()
|
self.completed = timezone.now()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs):
|
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Create a JobResult instance and enqueue a job using the given callable
|
Create a JobResult instance and enqueue a job using the given callable
|
||||||
|
|
||||||
@ -559,10 +577,11 @@ class JobResult(models.Model):
|
|||||||
name: Name for the JobResult instance
|
name: Name for the JobResult instance
|
||||||
obj_type: ContentType to link to the JobResult instance obj_type
|
obj_type: ContentType to link to the JobResult instance obj_type
|
||||||
user: User object to link to the JobResult instance
|
user: User object to link to the JobResult instance
|
||||||
|
schedule_at: Schedule the job to be executed at the passed date and time
|
||||||
args: additional args passed to the callable
|
args: additional args passed to the callable
|
||||||
kwargs: additional kargs passed to the callable
|
kwargs: additional kargs passed to the callable
|
||||||
"""
|
"""
|
||||||
job_result = cls.objects.create(
|
job_result: JobResult = cls.objects.create(
|
||||||
name=name,
|
name=name,
|
||||||
obj_type=obj_type,
|
obj_type=obj_type,
|
||||||
user=user,
|
user=user,
|
||||||
@ -570,7 +589,15 @@ class JobResult(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
queue = django_rq.get_queue("default")
|
queue = django_rq.get_queue("default")
|
||||||
queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
|
|
||||||
|
if schedule_at:
|
||||||
|
job_result.status = JobResultStatusChoices.STATUS_SCHEDULED
|
||||||
|
job_result.scheduled_time = schedule_at
|
||||||
|
job_result.save()
|
||||||
|
|
||||||
|
queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
|
||||||
|
else:
|
||||||
|
queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
|
||||||
|
|
||||||
return job_result
|
return job_result
|
||||||
|
|
||||||
|
@ -85,7 +85,6 @@ def run_report(job_result, *args, **kwargs):
|
|||||||
try:
|
try:
|
||||||
report.run(job_result)
|
report.run(job_result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||||
job_result.save()
|
job_result.save()
|
||||||
logging.error(f"Error during execution of report {job_result.name}")
|
logging.error(f"Error during execution of report {job_result.name}")
|
||||||
|
@ -8,6 +8,7 @@ from .template_code import *
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigContextTable',
|
'ConfigContextTable',
|
||||||
'CustomFieldTable',
|
'CustomFieldTable',
|
||||||
|
'JobResultTable',
|
||||||
'CustomLinkTable',
|
'CustomLinkTable',
|
||||||
'ExportTemplateTable',
|
'ExportTemplateTable',
|
||||||
'JournalEntryTable',
|
'JournalEntryTable',
|
||||||
@ -39,6 +40,27 @@ 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')
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Custom fields
|
||||||
|
#
|
||||||
|
|
||||||
|
class JobResultTable(NetBoxTable):
|
||||||
|
name = tables.Column(
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = columns.ActionsColumn(
|
||||||
|
actions=('delete',)
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = JobResult
|
||||||
|
fields = (
|
||||||
|
'pk', 'id', 'name', 'obj_type', 'job_id', 'created', 'completed', 'scheduled_time', 'user', 'status',
|
||||||
|
)
|
||||||
|
default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'completed', 'user',)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Custom links
|
# Custom links
|
||||||
#
|
#
|
||||||
|
@ -74,6 +74,11 @@ urlpatterns = [
|
|||||||
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
|
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
|
||||||
re_path(r'^reports/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ReportView.as_view(), name='report'),
|
re_path(r'^reports/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ReportView.as_view(), name='report'),
|
||||||
|
|
||||||
|
# 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'),
|
||||||
|
|
||||||
# Scripts
|
# Scripts
|
||||||
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
|
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
|
||||||
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
||||||
|
@ -15,6 +15,7 @@ from utilities.utils import copy_safe_request, count_related, get_viewname, norm
|
|||||||
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 .choices import JobResultStatusChoices
|
||||||
|
from .forms.reports import ReportForm
|
||||||
from .models import *
|
from .models import *
|
||||||
from .reports import get_report, get_reports, run_report
|
from .reports import get_report, get_reports, run_report
|
||||||
from .scripts import get_scripts, run_script
|
from .scripts import get_scripts, run_script
|
||||||
@ -592,7 +593,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
|||||||
|
|
||||||
return render(request, 'extras/report.html', {
|
return render(request, 'extras/report.html', {
|
||||||
'report': report,
|
'report': report,
|
||||||
'run_form': ConfirmationForm(),
|
'form': ReportForm(),
|
||||||
})
|
})
|
||||||
|
|
||||||
def post(self, request, module, name):
|
def post(self, request, module, name):
|
||||||
@ -605,24 +606,36 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
|||||||
if report is None:
|
if report is None:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
# Allow execution only if RQ worker process is running
|
schedule_at = None
|
||||||
if not Worker.count(get_connection('default')):
|
form = ReportForm(request.POST)
|
||||||
messages.error(request, "Unable to run report: RQ worker process not running.")
|
|
||||||
return render(request, 'extras/report.html', {
|
|
||||||
'report': report,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Run the Report. A new JobResult is created.
|
if form.is_valid():
|
||||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
schedule_at = form.cleaned_data.get("schedule_at")
|
||||||
job_result = JobResult.enqueue_job(
|
|
||||||
run_report,
|
|
||||||
report.full_name,
|
|
||||||
report_content_type,
|
|
||||||
request.user,
|
|
||||||
job_timeout=report.job_timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
return redirect('extras:report_result', job_result_pk=job_result.pk)
|
# Allow execution only if RQ worker process is running
|
||||||
|
if not Worker.count(get_connection('default')):
|
||||||
|
messages.error(request, "Unable to run report: RQ worker process not running.")
|
||||||
|
return render(request, 'extras/report.html', {
|
||||||
|
'report': report,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Run the Report. A new JobResult is created.
|
||||||
|
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||||
|
job_result = JobResult.enqueue_job(
|
||||||
|
run_report,
|
||||||
|
report.full_name,
|
||||||
|
report_content_type,
|
||||||
|
request.user,
|
||||||
|
job_timeout=report.job_timeout,
|
||||||
|
schedule_at=schedule_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect('extras:report_result', job_result_pk=job_result.pk)
|
||||||
|
|
||||||
|
return render(request, 'extras/report.html', {
|
||||||
|
'report': report,
|
||||||
|
'form': form,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class ReportResultView(ContentTypePermissionRequiredMixin, View):
|
class ReportResultView(ContentTypePermissionRequiredMixin, View):
|
||||||
@ -737,6 +750,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
|
|||||||
|
|
||||||
elif form.is_valid():
|
elif form.is_valid():
|
||||||
commit = form.cleaned_data.pop('_commit')
|
commit = form.cleaned_data.pop('_commit')
|
||||||
|
schedule_at = form.cleaned_data.pop("_schedule_at")
|
||||||
|
|
||||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||||
|
|
||||||
@ -749,6 +763,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
|
|||||||
request=copy_safe_request(request),
|
request=copy_safe_request(request),
|
||||||
commit=commit,
|
commit=commit,
|
||||||
job_timeout=script.job_timeout,
|
job_timeout=script.job_timeout,
|
||||||
|
schedule_at=schedule_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
return redirect('extras:script_result', job_result_pk=job_result.pk)
|
return redirect('extras:script_result', job_result_pk=job_result.pk)
|
||||||
@ -788,3 +803,25 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View)
|
|||||||
'result': result,
|
'result': result,
|
||||||
'class_name': script.__class__.__name__
|
'class_name': script.__class__.__name__
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
@ -294,6 +294,11 @@ OTHER_MENU = Menu(
|
|||||||
link_text='Scripts',
|
link_text='Scripts',
|
||||||
permissions=['extras.view_script']
|
permissions=['extras.view_script']
|
||||||
),
|
),
|
||||||
|
MenuItem(
|
||||||
|
link='extras:jobresult_list',
|
||||||
|
link_text='Job Results',
|
||||||
|
permissions=['extras.view_jobresult'],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MenuGroup(
|
MenuGroup(
|
||||||
|
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
<p>
|
<p>
|
||||||
Initiated: <strong>{{ result.created|annotated_date }}</strong>
|
Initiated: <strong>{{ result.created|annotated_date }}</strong>
|
||||||
|
{% if result.scheduled_time %}
|
||||||
|
Scheduled for: <strong>{{ result.scheduled_time|annotated_date }}</strong>
|
||||||
|
{% endif %}
|
||||||
{% if result.completed %}
|
{% if result.completed %}
|
||||||
Duration: <strong>{{ result.duration }}</strong>
|
Duration: <strong>{{ result.duration }}</strong>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
<p>
|
<p>
|
||||||
Initiated: <strong>{{ result.created|annotated_date }}</strong>
|
Initiated: <strong>{{ result.created|annotated_date }}</strong>
|
||||||
|
{% if result.scheduled_time %}
|
||||||
|
Scheduled for: <strong>{{ result.scheduled_time|annotated_date }}</strong>
|
||||||
|
{% endif %}
|
||||||
{% if result.completed %}
|
{% if result.completed %}
|
||||||
Duration: <strong>{{ result.duration }}</strong>
|
Duration: <strong>{{ result.duration }}</strong>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -4,6 +4,8 @@
|
|||||||
<span class="badge bg-danger">Errored</span>
|
<span class="badge bg-danger">Errored</span>
|
||||||
{% elif result.status == 'pending' %}
|
{% elif result.status == 'pending' %}
|
||||||
<span class="badge bg-info">Pending</span>
|
<span class="badge bg-info">Pending</span>
|
||||||
|
{% elif result.status == 'scheduled' %}
|
||||||
|
<span class="badge bg-info">Scheduled</span>
|
||||||
{% elif result.status == 'running' %}
|
{% elif result.status == 'running' %}
|
||||||
<span class="badge bg-warning">Running</span>
|
<span class="badge bg-warning">Running</span>
|
||||||
{% elif result.status == 'completed' %}
|
{% elif result.status == 'completed' %}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{% extends 'generic/object.html' %}
|
{% extends 'generic/object.html' %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
{% load form_helpers %}
|
||||||
|
|
||||||
{% block title %}{{ report.name }}{% endblock %}
|
{% block title %}{{ report.name }}{% endblock %}
|
||||||
|
|
||||||
@ -33,18 +34,24 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div role="tabpanel" class="tab-pane active" id="report">
|
<div role="tabpanel" class="tab-pane active" id="report">
|
||||||
{% if perms.extras.run_report %}
|
{% if perms.extras.run_report %}
|
||||||
<div class="float-end noprint">
|
<div class="row">
|
||||||
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
|
<div class="col">
|
||||||
|
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post" class="form-object-edit">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" name="_run" class="btn btn-primary">
|
{% render_form form %}
|
||||||
{% if report.result %}
|
<div class="float-end">
|
||||||
<i class="mdi mdi-replay"></i> Run Again
|
<button type="submit" name="_run" class="btn btn-primary">
|
||||||
{% else %}
|
{% if report.result %}
|
||||||
<i class="mdi mdi-play"></i> Run Report
|
<i class="mdi mdi-replay"></i> Run Again
|
||||||
{% endif %}
|
{% else %}
|
||||||
</button>
|
<i class="mdi mdi-play"></i> Run Report
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
{% extends 'extras/report.html' %}
|
{% extends 'extras/report.html' %}
|
||||||
|
{% load buttons %}
|
||||||
|
{% load perms %}
|
||||||
|
|
||||||
{% block content-wrapper %}
|
{% block content-wrapper %}
|
||||||
<div class="row p-3">
|
<div class="row p-3">
|
||||||
@ -7,3 +9,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block controls %}
|
||||||
|
<div class="controls">
|
||||||
|
<div class="control-group">
|
||||||
|
{% if request.user|can_delete:result %}
|
||||||
|
{% delete_button result %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock controls %}
|
@ -43,7 +43,7 @@
|
|||||||
You do not have permission to run scripts.
|
You do not have permission to run scripts.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
|
<form action="" method="post" enctype="multipart/form-data" class="form form-object-edit">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="field-group my-4">
|
<div class="field-group my-4">
|
||||||
{% if form.requires_input %}
|
{% if form.requires_input %}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
{% extends 'base/layout.html' %}
|
{% extends 'base/layout.html' %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
{% load buttons %}
|
||||||
|
{% load perms %}
|
||||||
|
|
||||||
{% block title %}{{ script }}{% endblock %}
|
{% block title %}{{ script }}{% endblock %}
|
||||||
|
|
||||||
@ -23,6 +25,16 @@
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
{% endblock header %}
|
{% endblock header %}
|
||||||
|
|
||||||
|
{% block controls %}
|
||||||
|
<div class="controls">
|
||||||
|
<div class="control-group">
|
||||||
|
{% if request.user|can_delete:result %}
|
||||||
|
{% delete_button result %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock controls %}
|
||||||
|
|
||||||
{% block content-wrapper %}
|
{% block content-wrapper %}
|
||||||
<ul class="nav nav-tabs px-3" role="tablist">
|
<ul class="nav nav-tabs px-3" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
@ -46,3 +58,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content-wrapper %}
|
{% endblock content-wrapper %}
|
||||||
|
|
||||||
|
{% block modals %}
|
||||||
|
{% include 'inc/htmx_modal.html' %}
|
||||||
|
{% endblock modals %}
|
Loading…
Reference in New Issue
Block a user