Merge pull request #10417 from kkthxbye-code/8366-job-scheduling

Fixes #8366 - Add job scheduling
This commit is contained in:
Jeremy Stretch 2022-10-21 11:22:05 -04:00 committed by GitHub
commit 5d56d95fda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 353 additions and 74 deletions

View File

@ -267,7 +267,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
### 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
@ -282,6 +282,8 @@ http://netbox/api/extras/scripts/example.MyReport/ \
--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
Scripts can be run on the CLI by invoking the management command:

View File

@ -136,7 +136,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
### 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
@ -152,6 +152,8 @@ Our example report above would be called as:
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
Reports can be run on the CLI by invoking the management command:

View File

@ -131,24 +131,3 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
})
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

View File

@ -38,6 +38,7 @@ __all__ = (
'ObjectChangeSerializer',
'ReportDetailSerializer',
'ReportSerializer',
'ReportInputSerializer',
'ScriptDetailSerializer',
'ScriptInputSerializer',
'ScriptLogMessageSerializer',
@ -362,7 +363,7 @@ class JobResultSerializer(BaseModelSerializer):
class Meta:
model = JobResult
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()
class ReportInputSerializer(serializers.Serializer):
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
#
# Scripts
#
@ -419,6 +424,7 @@ class ScriptDetailSerializer(ScriptSerializer):
class ScriptInputSerializer(serializers.Serializer):
data = serializers.JSONField()
commit = serializers.BooleanField()
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
class ScriptLogMessageSerializer(serializers.Serializer):

View File

@ -231,19 +231,26 @@ class ReportViewSet(ViewSet):
# Retrieve and run the Report. This will create a new JobResult.
report = self._retrieve_report(pk)
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
)
report.result = job_result
input_serializer = serializers.ReportInputSerializer(data=request.data)
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():
data = input_serializer.data['data']
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')
job_result = JobResult.enqueue_job(
@ -323,6 +331,7 @@ class ScriptViewSet(ViewSet):
request=copy_safe_request(request),
commit=commit,
job_timeout=script.job_timeout,
schedule_at=schedule_at,
)
script.result = job_result
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})

View File

@ -141,6 +141,7 @@ class LogLevelChoices(ChoiceSet):
class JobResultStatusChoices(ChoiceSet):
STATUS_PENDING = 'pending'
STATUS_SCHEDULED = 'scheduled'
STATUS_RUNNING = 'running'
STATUS_COMPLETED = 'completed'
STATUS_ERRORED = 'errored'
@ -148,6 +149,7 @@ class JobResultStatusChoices(ChoiceSet):
CHOICES = (
(STATUS_PENDING, 'Pending'),
(STATUS_SCHEDULED, 'Scheduled'),
(STATUS_RUNNING, 'Running'),
(STATUS_COMPLETED, 'Completed'),
(STATUS_ERRORED, 'Errored'),

View File

@ -16,6 +16,7 @@ __all__ = (
'ConfigContextFilterSet',
'ContentTypeFilterSet',
'CustomFieldFilterSet',
'JobResultFilterSet',
'CustomLinkFilterSet',
'ExportTemplateFilterSet',
'ImageAttachmentFilterSet',
@ -435,7 +436,32 @@ class JobResultFilterSet(BaseFilterSet):
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'
)
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(
choices=JobResultStatusChoices,
null_value=None
@ -444,14 +470,15 @@ class JobResultFilterSet(BaseFilterSet):
class Meta:
model = JobResult
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):
if not value.strip():
return queryset
return queryset.filter(
Q(user__username__icontains=value)
Q(user__username__icontains=value) |
Q(name__icontains=value)
)

View File

@ -19,6 +19,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
'ConfigContextFilterForm',
'CustomFieldFilterForm',
'JobResultFilterForm',
'CustomLinkFilterForm',
'ExportTemplateFilterForm',
'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):
fieldsets = (
(None, ('q',)),

View 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",
)

View File

@ -1,6 +1,6 @@
from django import forms
from utilities.forms import BootstrapMixin
from utilities.forms import BootstrapMixin, DateTimePicker
__all__ = (
'ScriptForm',
@ -14,17 +14,25 @@ class ScriptForm(BootstrapMixin, forms.Form):
label="Commit changes",
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):
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')
self.fields['_schedule_at'] = schedule_at
self.fields['_commit'] = commit
@property
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)

View File

@ -81,7 +81,7 @@ class Command(BaseCommand):
ending=""
)
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']:
self.stdout.write("Done.", self.style.SUCCESS)
elif options['verbosity']:

View File

@ -14,6 +14,8 @@ class Command(_Command):
of only the 'default' queue).
"""
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 len(args) < 1:

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

View File

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

View File

@ -505,6 +505,10 @@ class JobResult(models.Model):
null=True,
blank=True
)
scheduled_time = models.DateTimeField(
null=True,
blank=True
)
user = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
@ -525,12 +529,26 @@ class JobResult(models.Model):
unique=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['obj_type', 'name', '-created']
ordering = ['-created']
def __str__(self):
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
def duration(self):
if not self.completed:
@ -551,7 +569,7 @@ class JobResult(models.Model):
self.completed = timezone.now()
@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
@ -559,10 +577,11 @@ class JobResult(models.Model):
name: Name for the JobResult instance
obj_type: ContentType to link to the JobResult instance obj_type
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
kwargs: additional kargs passed to the callable
"""
job_result = cls.objects.create(
job_result: JobResult = cls.objects.create(
name=name,
obj_type=obj_type,
user=user,
@ -570,7 +589,15 @@ class JobResult(models.Model):
)
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

View File

@ -85,7 +85,6 @@ def run_report(job_result, *args, **kwargs):
try:
report.run(job_result)
except Exception as e:
print(e)
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
job_result.save()
logging.error(f"Error during execution of report {job_result.name}")

View File

@ -8,6 +8,7 @@ from .template_code import *
__all__ = (
'ConfigContextTable',
'CustomFieldTable',
'JobResultTable',
'CustomLinkTable',
'ExportTemplateTable',
'JournalEntryTable',
@ -39,6 +40,27 @@ class CustomFieldTable(NetBoxTable):
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
#

View File

@ -74,6 +74,11 @@ urlpatterns = [
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'),
# 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
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),

View File

@ -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 . import filtersets, forms, tables
from .choices import JobResultStatusChoices
from .forms.reports import ReportForm
from .models import *
from .reports import get_report, get_reports, run_report
from .scripts import get_scripts, run_script
@ -592,7 +593,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
return render(request, 'extras/report.html', {
'report': report,
'run_form': ConfirmationForm(),
'form': ReportForm(),
})
def post(self, request, module, name):
@ -605,24 +606,36 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
if report is None:
raise Http404
# 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,
})
schedule_at = None
form = ReportForm(request.POST)
# 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
)
if form.is_valid():
schedule_at = form.cleaned_data.get("schedule_at")
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):
@ -737,6 +750,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
elif form.is_valid():
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')
@ -749,6 +763,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
request=copy_safe_request(request),
commit=commit,
job_timeout=script.job_timeout,
schedule_at=schedule_at,
)
return redirect('extras:script_result', job_result_pk=job_result.pk)
@ -788,3 +803,25 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View)
'result': result,
'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

View File

@ -294,6 +294,11 @@ OTHER_MENU = Menu(
link_text='Scripts',
permissions=['extras.view_script']
),
MenuItem(
link='extras:jobresult_list',
link_text='Job Results',
permissions=['extras.view_jobresult'],
),
),
),
MenuGroup(

View File

@ -2,6 +2,9 @@
<p>
Initiated: <strong>{{ result.created|annotated_date }}</strong>
{% if result.scheduled_time %}
Scheduled for: <strong>{{ result.scheduled_time|annotated_date }}</strong>
{% endif %}
{% if result.completed %}
Duration: <strong>{{ result.duration }}</strong>
{% endif %}

View File

@ -3,6 +3,9 @@
<p>
Initiated: <strong>{{ result.created|annotated_date }}</strong>
{% if result.scheduled_time %}
Scheduled for: <strong>{{ result.scheduled_time|annotated_date }}</strong>
{% endif %}
{% if result.completed %}
Duration: <strong>{{ result.duration }}</strong>
{% endif %}

View File

@ -4,6 +4,8 @@
<span class="badge bg-danger">Errored</span>
{% elif result.status == 'pending' %}
<span class="badge bg-info">Pending</span>
{% elif result.status == 'scheduled' %}
<span class="badge bg-info">Scheduled</span>
{% elif result.status == 'running' %}
<span class="badge bg-warning">Running</span>
{% elif result.status == 'completed' %}

View File

@ -1,5 +1,6 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load form_helpers %}
{% block title %}{{ report.name }}{% endblock %}
@ -33,18 +34,24 @@
{% block content %}
<div role="tabpanel" class="tab-pane active" id="report">
{% if perms.extras.run_report %}
<div class="float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
<div class="row">
<div class="col">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post" class="form-object-edit">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary">
{% if report.result %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
{% render_form form %}
<div class="float-end">
<button type="submit" name="_run" class="btn btn-primary">
{% if report.result %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
</div>
</form>
</div>
</div>
{% endif %}
<div class="row">
<div class="col col-md-12">

View File

@ -1,4 +1,6 @@
{% extends 'extras/report.html' %}
{% load buttons %}
{% load perms %}
{% block content-wrapper %}
<div class="row p-3">
@ -7,3 +9,13 @@
</div>
</div>
{% endblock %}
{% block controls %}
<div class="controls">
<div class="control-group">
{% if request.user|can_delete:result %}
{% delete_button result %}
{% endif %}
</div>
</div>
{% endblock controls %}

View File

@ -43,7 +43,7 @@
You do not have permission to run scripts.
</div>
{% 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 %}
<div class="field-group my-4">
{% if form.requires_input %}

View File

@ -1,5 +1,7 @@
{% extends 'base/layout.html' %}
{% load helpers %}
{% load buttons %}
{% load perms %}
{% block title %}{{ script }}{% endblock %}
@ -23,6 +25,16 @@
{{ block.super }}
{% 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 %}
<ul class="nav nav-tabs px-3" role="tablist">
<li class="nav-item" role="presentation">
@ -46,3 +58,7 @@
</div>
</div>
{% endblock content-wrapper %}
{% block modals %}
{% include 'inc/htmx_modal.html' %}
{% endblock modals %}