diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 2a9d3ec28..8b9c6dcb1 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -414,6 +414,7 @@ class ReportDetailSerializer(ReportSerializer): class ReportInputSerializer(serializers.Serializer): schedule_at = serializers.DateTimeField(required=False, allow_null=True) + interval = serializers.IntegerField(required=False, allow_null=True) # @@ -448,6 +449,7 @@ class ScriptInputSerializer(serializers.Serializer): data = serializers.JSONField() commit = serializers.BooleanField() schedule_at = serializers.DateTimeField(required=False, allow_null=True) + interval = serializers.IntegerField(required=False, allow_null=True) class ScriptLogMessageSerializer(serializers.Serializer): diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index ab111b0ec..56bc8567d 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,5 +1,4 @@ from django.contrib.contenttypes.models import ContentType -from django.db.models import Q from django.http import Http404 from django_rq.queues import get_connection from rest_framework import status @@ -246,16 +245,14 @@ class ReportViewSet(ViewSet): input_serializer = serializers.ReportInputSerializer(data=request.data) if input_serializer.is_valid(): - schedule_at = input_serializer.validated_data.get('schedule_at') - - 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, + name=report.full_name, + obj_type=ContentType.objects.get_for_model(Report), + user=request.user, job_timeout=report.job_timeout, - schedule_at=schedule_at, + schedule_at=input_serializer.validated_data.get('schedule_at'), + interval=input_serializer.validated_data.get('interval') ) report.result = job_result @@ -329,21 +326,17 @@ class ScriptViewSet(ViewSet): raise RQWorkerNotRunningException() 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( run_script, - script.full_name, - script_content_type, - request.user, - data=data, + name=script.full_name, + obj_type=ContentType.objects.get_for_model(Script), + user=request.user, + data=input_serializer.data['data'], request=copy_safe_request(request), - commit=commit, + commit=input_serializer.data['commit'], job_timeout=script.job_timeout, - schedule_at=schedule_at, + schedule_at=input_serializer.validated_data.get('schedule_at'), + interval=input_serializer.validated_data.get('interval') ) script.result = job_result serializer = serializers.ScriptDetailSerializer(script, context={'request': request}) diff --git a/netbox/extras/forms/reports.py b/netbox/extras/forms/reports.py index 863cf29c1..33c51245c 100644 --- a/netbox/extras/forms/reports.py +++ b/netbox/extras/forms/reports.py @@ -15,3 +15,8 @@ class ReportForm(BootstrapMixin, forms.Form): label=_("Schedule at"), help_text=_("Schedule execution of report to a set time"), ) + interval = forms.IntegerField( + required=False, + label=_("Recurs every"), + help_text=_("Interval at which this report is re-run (in minutes)") + ) diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 74c865c8d..754a3faec 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -21,19 +21,26 @@ class ScriptForm(BootstrapMixin, forms.Form): label=_("Schedule at"), help_text=_("Schedule execution of script to a set time"), ) + _interval = forms.IntegerField( + required=False, + label=_("Recurs every"), + help_text=_("Interval at which this script is re-run (in minutes)") + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Move _commit and _schedule_at to the end of the form schedule_at = self.fields.pop('_schedule_at') + interval = self.fields.pop('_interval') commit = self.fields.pop('_commit') self.fields['_schedule_at'] = schedule_at + self.fields['_interval'] = interval self.fields['_commit'] = commit @property def requires_input(self): """ - A boolean indicating whether the form requires user input (ignore the _commit and _schedule_at fields). + A boolean indicating whether the form requires user input (ignore the built-in fields). """ - return bool(len(self.fields) > 2) + return bool(len(self.fields) > 3) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 647f17149..e717001a7 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -1,8 +1,8 @@ -import importlib import inspect import logging import pkgutil import traceback +from datetime import timedelta from django.conf import settings from django.utils import timezone @@ -11,7 +11,6 @@ from django_rq import job from .choices import JobResultStatusChoices, LogLevelChoices from .models import JobResult - logger = logging.getLogger(__name__) @@ -85,10 +84,23 @@ def run_report(job_result, *args, **kwargs): try: job_result.start() report.run(job_result) - except Exception as e: + except Exception: job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) job_result.save() logging.error(f"Error during execution of report {job_result.name}") + finally: + # Schedule the next job if an interval has been set + if job_result.interval: + new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval) + JobResult.enqueue_job( + run_report, + name=job_result.name, + obj_type=job_result.obj_type, + user=job_result.user, + job_timeout=report.job_timeout, + schedule_at=new_scheduled_time, + interval=job_result.interval + ) class Report(object): diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index a4bcd0748..998d727a4 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -4,8 +4,9 @@ import logging import os import pkgutil import sys -import traceback import threading +import traceback +from datetime import timedelta import yaml from django import forms @@ -16,6 +17,7 @@ from django.utils.functional import classproperty from extras.api.serializers import ScriptOutputSerializer from extras.choices import JobResultStatusChoices, LogLevelChoices +from extras.models import JobResult from extras.signals import clear_webhooks from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator @@ -491,6 +493,22 @@ def run_script(data, request, commit=True, *args, **kwargs): else: _run_script() + # Schedule the next job if an interval has been set + if job_result.interval: + new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval) + JobResult.enqueue_job( + run_script, + name=job_result.name, + obj_type=job_result.obj_type, + user=job_result.user, + schedule_at=new_scheduled_time, + interval=job_result.interval, + job_timeout=script.job_timeout, + data=data, + request=request, + commit=commit + ) + def get_scripts(use_names=False): """ diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9ce643ca5..2d2608ae8 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -676,7 +676,6 @@ class ReportView(ContentTypePermissionRequiredMixin, View): form = ReportForm(request.POST) if form.is_valid(): - schedule_at = form.cleaned_data.get("schedule_at") # Allow execution only if RQ worker process is running if not Worker.count(get_connection('default')): @@ -686,14 +685,14 @@ class ReportView(ContentTypePermissionRequiredMixin, View): }) # 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, + name=report.full_name, + obj_type=ContentType.objects.get_for_model(Report), + user=request.user, + schedule_at=form.cleaned_data.get('schedule_at'), + interval=form.cleaned_data.get('interval'), + job_timeout=report.job_timeout ) return redirect('extras:report_result', job_result_pk=job_result.pk) @@ -787,9 +786,8 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View): form = script.as_form(initial=normalize_querydict(request.GET)) # Look for a pending JobResult (use the latest one by creation timestamp) - script_content_type = ContentType.objects.get(app_label='extras', model='script') script.result = JobResult.objects.filter( - obj_type=script_content_type, + obj_type=ContentType.objects.get_for_model(Script), name=script.full_name, ).exclude( status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES @@ -815,21 +813,17 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View): messages.error(request, "Unable to run script: RQ worker process not running.") 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') - job_result = JobResult.enqueue_job( run_script, - script.full_name, - script_content_type, - request.user, + name=script.full_name, + obj_type=ContentType.objects.get_for_model(Script), + user=request.user, + schedule_at=form.cleaned_data.pop('_schedule_at'), + interval=form.cleaned_data.pop('_interval'), data=form.cleaned_data, request=copy_safe_request(request), - commit=commit, job_timeout=script.job_timeout, - schedule_at=schedule_at, + commit=form.cleaned_data.pop('_commit') ) return redirect('extras:script_result', job_result_pk=job_result.pk)