Skip to content

Commit 5d56d95

Browse files
Merge pull request #10417 from kkthxbye-code/8366-job-scheduling
Fixes #8366 - Add job scheduling
2 parents bd79a27 + 5e5228f commit 5d56d95

27 files changed

+353
-74
lines changed

docs/customization/custom-scripts.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
267267

268268
### Via the Web UI
269269

270-
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.
270+
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.
271271

272272
### Via the API
273273

@@ -282,6 +282,8 @@ http://netbox/api/extras/scripts/example.MyReport/ \
282282
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
283283
```
284284

285+
Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time.
286+
285287
### Via the CLI
286288

287289
Scripts can be run on the CLI by invoking the management command:

docs/customization/reports.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
136136

137137
### Via the Web UI
138138

139-
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.
139+
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.
140140

141141
### Via the API
142142

@@ -152,6 +152,8 @@ Our example report above would be called as:
152152
POST /api/extras/reports/devices.DeviceConnectionsReport/run/
153153
```
154154

155+
Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time.
156+
155157
### Via the CLI
156158

157159
Reports can be run on the CLI by invoking the management command:

netbox/extras/admin.py

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -131,24 +131,3 @@ def restore(self, request, pk):
131131
})
132132

133133
return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context)
134-
135-
136-
#
137-
# Reports & scripts
138-
#
139-
140-
@admin.register(JobResult)
141-
class JobResultAdmin(admin.ModelAdmin):
142-
list_display = [
143-
'obj_type', 'name', 'created', 'completed', 'user', 'status',
144-
]
145-
fields = [
146-
'obj_type', 'name', 'created', 'completed', 'user', 'status', 'data', 'job_id'
147-
]
148-
list_filter = [
149-
'status',
150-
]
151-
readonly_fields = fields
152-
153-
def has_add_permission(self, request):
154-
return False

netbox/extras/api/serializers.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
'ObjectChangeSerializer',
3939
'ReportDetailSerializer',
4040
'ReportSerializer',
41+
'ReportInputSerializer',
4142
'ScriptDetailSerializer',
4243
'ScriptInputSerializer',
4344
'ScriptLogMessageSerializer',
@@ -362,7 +363,7 @@ class JobResultSerializer(BaseModelSerializer):
362363
class Meta:
363364
model = JobResult
364365
fields = [
365-
'id', 'url', 'display', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
366+
'id', 'url', 'display', 'created', 'completed', 'scheduled_time', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
366367
]
367368

368369

@@ -388,6 +389,10 @@ class ReportDetailSerializer(ReportSerializer):
388389
result = JobResultSerializer()
389390

390391

392+
class ReportInputSerializer(serializers.Serializer):
393+
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
394+
395+
391396
#
392397
# Scripts
393398
#
@@ -419,6 +424,7 @@ class ScriptDetailSerializer(ScriptSerializer):
419424
class ScriptInputSerializer(serializers.Serializer):
420425
data = serializers.JSONField()
421426
commit = serializers.BooleanField()
427+
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
422428

423429

424430
class ScriptLogMessageSerializer(serializers.Serializer):

netbox/extras/api/views.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -231,19 +231,26 @@ def run(self, request, pk):
231231

232232
# Retrieve and run the Report. This will create a new JobResult.
233233
report = self._retrieve_report(pk)
234-
report_content_type = ContentType.objects.get(app_label='extras', model='report')
235-
job_result = JobResult.enqueue_job(
236-
run_report,
237-
report.full_name,
238-
report_content_type,
239-
request.user,
240-
job_timeout=report.job_timeout
241-
)
242-
report.result = job_result
234+
input_serializer = serializers.ReportInputSerializer(data=request.data)
243235

244-
serializer = serializers.ReportDetailSerializer(report, context={'request': request})
236+
if input_serializer.is_valid():
237+
schedule_at = input_serializer.validated_data.get('schedule_at')
245238

246-
return Response(serializer.data)
239+
report_content_type = ContentType.objects.get(app_label='extras', model='report')
240+
job_result = JobResult.enqueue_job(
241+
run_report,
242+
report.full_name,
243+
report_content_type,
244+
request.user,
245+
job_timeout=report.job_timeout,
246+
schedule_at=schedule_at,
247+
)
248+
report.result = job_result
249+
250+
serializer = serializers.ReportDetailSerializer(report, context={'request': request})
251+
252+
return Response(serializer.data)
253+
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
247254

248255

249256
#
@@ -312,6 +319,7 @@ def post(self, request, pk):
312319
if input_serializer.is_valid():
313320
data = input_serializer.data['data']
314321
commit = input_serializer.data['commit']
322+
schedule_at = input_serializer.validated_data.get('schedule_at')
315323

316324
script_content_type = ContentType.objects.get(app_label='extras', model='script')
317325
job_result = JobResult.enqueue_job(
@@ -323,6 +331,7 @@ def post(self, request, pk):
323331
request=copy_safe_request(request),
324332
commit=commit,
325333
job_timeout=script.job_timeout,
334+
schedule_at=schedule_at,
326335
)
327336
script.result = job_result
328337
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})

netbox/extras/choices.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,13 +141,15 @@ class LogLevelChoices(ChoiceSet):
141141
class JobResultStatusChoices(ChoiceSet):
142142

143143
STATUS_PENDING = 'pending'
144+
STATUS_SCHEDULED = 'scheduled'
144145
STATUS_RUNNING = 'running'
145146
STATUS_COMPLETED = 'completed'
146147
STATUS_ERRORED = 'errored'
147148
STATUS_FAILED = 'failed'
148149

149150
CHOICES = (
150151
(STATUS_PENDING, 'Pending'),
152+
(STATUS_SCHEDULED, 'Scheduled'),
151153
(STATUS_RUNNING, 'Running'),
152154
(STATUS_COMPLETED, 'Completed'),
153155
(STATUS_ERRORED, 'Errored'),

netbox/extras/filtersets.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
'ConfigContextFilterSet',
1717
'ContentTypeFilterSet',
1818
'CustomFieldFilterSet',
19+
'JobResultFilterSet',
1920
'CustomLinkFilterSet',
2021
'ExportTemplateFilterSet',
2122
'ImageAttachmentFilterSet',
@@ -435,7 +436,32 @@ class JobResultFilterSet(BaseFilterSet):
435436
label='Search',
436437
)
437438
created = django_filters.DateTimeFilter()
439+
created__before = django_filters.DateTimeFilter(
440+
field_name='created',
441+
lookup_expr='lte'
442+
)
443+
created__after = django_filters.DateTimeFilter(
444+
field_name='created',
445+
lookup_expr='gte'
446+
)
438447
completed = django_filters.DateTimeFilter()
448+
completed__before = django_filters.DateTimeFilter(
449+
field_name='completed',
450+
lookup_expr='lte'
451+
)
452+
completed__after = django_filters.DateTimeFilter(
453+
field_name='completed',
454+
lookup_expr='gte'
455+
)
456+
scheduled_time = django_filters.DateTimeFilter()
457+
scheduled_time__before = django_filters.DateTimeFilter(
458+
field_name='scheduled_time',
459+
lookup_expr='lte'
460+
)
461+
scheduled_time__after = django_filters.DateTimeFilter(
462+
field_name='scheduled_time',
463+
lookup_expr='gte'
464+
)
439465
status = django_filters.MultipleChoiceFilter(
440466
choices=JobResultStatusChoices,
441467
null_value=None
@@ -444,14 +470,15 @@ class JobResultFilterSet(BaseFilterSet):
444470
class Meta:
445471
model = JobResult
446472
fields = [
447-
'id', 'created', 'completed', 'status', 'user', 'obj_type', 'name'
473+
'id', 'created', 'completed', 'scheduled_time', 'status', 'user', 'obj_type', 'name'
448474
]
449475

450476
def search(self, queryset, name, value):
451477
if not value.strip():
452478
return queryset
453479
return queryset.filter(
454-
Q(user__username__icontains=value)
480+
Q(user__username__icontains=value) |
481+
Q(name__icontains=value)
455482
)
456483

457484

netbox/extras/forms/filtersets.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
__all__ = (
2020
'ConfigContextFilterForm',
2121
'CustomFieldFilterForm',
22+
'JobResultFilterForm',
2223
'CustomLinkFilterForm',
2324
'ExportTemplateFilterForm',
2425
'JournalEntryFilterForm',
@@ -65,6 +66,58 @@ class CustomFieldFilterForm(FilterForm):
6566
)
6667

6768

69+
class JobResultFilterForm(FilterForm):
70+
fieldsets = (
71+
(None, ('q',)),
72+
('Attributes', ('obj_type', 'status')),
73+
('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after',
74+
'scheduled_time__before', 'scheduled_time__after', 'user')),
75+
)
76+
77+
obj_type = ContentTypeChoiceField(
78+
label=_('Object Type'),
79+
queryset=ContentType.objects.all(),
80+
limit_choices_to=FeatureQuery('job_results'), # TODO: This doesn't actually work
81+
required=False,
82+
)
83+
status = MultipleChoiceField(
84+
choices=JobResultStatusChoices,
85+
required=False
86+
)
87+
created__after = forms.DateTimeField(
88+
required=False,
89+
widget=DateTimePicker()
90+
)
91+
created__before = forms.DateTimeField(
92+
required=False,
93+
widget=DateTimePicker()
94+
)
95+
completed__after = forms.DateTimeField(
96+
required=False,
97+
widget=DateTimePicker()
98+
)
99+
completed__before = forms.DateTimeField(
100+
required=False,
101+
widget=DateTimePicker()
102+
)
103+
scheduled_time__after = forms.DateTimeField(
104+
required=False,
105+
widget=DateTimePicker()
106+
)
107+
scheduled_time__before = forms.DateTimeField(
108+
required=False,
109+
widget=DateTimePicker()
110+
)
111+
user = DynamicModelMultipleChoiceField(
112+
queryset=User.objects.all(),
113+
required=False,
114+
label=_('User'),
115+
widget=APISelectMultiple(
116+
api_url='/api/users/users/',
117+
)
118+
)
119+
120+
68121
class CustomLinkFilterForm(FilterForm):
69122
fieldsets = (
70123
(None, ('q',)),

netbox/extras/forms/reports.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django import forms
2+
3+
from utilities.forms import BootstrapMixin, DateTimePicker
4+
5+
__all__ = (
6+
'ReportForm',
7+
)
8+
9+
10+
class ReportForm(BootstrapMixin, forms.Form):
11+
schedule_at = forms.DateTimeField(
12+
required=False,
13+
widget=DateTimePicker(),
14+
label="Schedule at",
15+
help_text="Schedule execution of report to a set time",
16+
)

netbox/extras/forms/scripts.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from django import forms
22

3-
from utilities.forms import BootstrapMixin
3+
from utilities.forms import BootstrapMixin, DateTimePicker
44

55
__all__ = (
66
'ScriptForm',
@@ -14,17 +14,25 @@ class ScriptForm(BootstrapMixin, forms.Form):
1414
label="Commit changes",
1515
help_text="Commit changes to the database (uncheck for a dry-run)"
1616
)
17+
_schedule_at = forms.DateTimeField(
18+
required=False,
19+
widget=DateTimePicker(),
20+
label="Schedule at",
21+
help_text="Schedule execution of script to a set time",
22+
)
1723

1824
def __init__(self, *args, **kwargs):
1925
super().__init__(*args, **kwargs)
2026

21-
# Move _commit to the end of the form
27+
# Move _commit and _schedule_at to the end of the form
28+
schedule_at = self.fields.pop('_schedule_at')
2229
commit = self.fields.pop('_commit')
30+
self.fields['_schedule_at'] = schedule_at
2331
self.fields['_commit'] = commit
2432

2533
@property
2634
def requires_input(self):
2735
"""
28-
A boolean indicating whether the form requires user input (ignore the _commit field).
36+
A boolean indicating whether the form requires user input (ignore the _commit and _schedule_at fields).
2937
"""
30-
return bool(len(self.fields) > 1)
38+
return bool(len(self.fields) > 2)

0 commit comments

Comments
 (0)