diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index e5d5a1ef5..456bcf472 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -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: diff --git a/docs/customization/reports.md b/docs/customization/reports.md index 150c32f40..470868ea0 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -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: diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 01011b276..837a8f2d3 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -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 diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index fd774f8ff..b34f5fba3 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -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): diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 63003bdf2..62a011530 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -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}) diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 5afe9f33f..ee806f094 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -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'), diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index df0af3541..8c9c58a13 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -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) ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 526d47013..059f0d9f2 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -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',)), diff --git a/netbox/extras/forms/reports.py b/netbox/extras/forms/reports.py new file mode 100644 index 000000000..aa4f6223b --- /dev/null +++ b/netbox/extras/forms/reports.py @@ -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", + ) diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 380b4364c..de55a3ee6 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -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) diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py index 51d50d7e1..42690568d 100644 --- a/netbox/extras/management/commands/housekeeping.py +++ b/netbox/extras/management/commands/housekeeping.py @@ -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']: diff --git a/netbox/extras/management/commands/rqworker.py b/netbox/extras/management/commands/rqworker.py index e2ad5b15c..e1fb6fd11 100644 --- a/netbox/extras/management/commands/rqworker.py +++ b/netbox/extras/management/commands/rqworker.py @@ -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: diff --git a/netbox/extras/migrations/0079_change_jobresult_order.py b/netbox/extras/migrations/0079_change_jobresult_order.py new file mode 100644 index 000000000..12e35bf67 --- /dev/null +++ b/netbox/extras/migrations/0079_change_jobresult_order.py @@ -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']}, + ), + ] diff --git a/netbox/extras/migrations/0080_add_jobresult_scheduled_time.py b/netbox/extras/migrations/0080_add_jobresult_scheduled_time.py new file mode 100644 index 000000000..fddde4bc5 --- /dev/null +++ b/netbox/extras/migrations/0080_add_jobresult_scheduled_time.py @@ -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), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index aadb6f888..6d7d2ae04 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -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 diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 32e4efc2d..525608c86 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -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}") diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 1df5c9487..8f365a58b 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -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 # diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index ecc4c116c..0640904f2 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -74,6 +74,11 @@ urlpatterns = [ path('reports/results//', views.ReportResultView.as_view(), name='report_result'), re_path(r'^reports/(?P.([^.]+)).(?P.(.+))/', 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//delete/', views.JobResultDeleteView.as_view(), name='jobresult_delete'), + # Scripts path('scripts/', views.ScriptListView.as_view(), name='script_list'), path('scripts/results//', views.ScriptResultView.as_view(), name='script_result'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 337980e94..c042c248a 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -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 diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 400a7bf5a..65c2ec7fc 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -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( diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html index c20bf5fe2..a51b2663d 100644 --- a/netbox/templates/extras/htmx/report_result.html +++ b/netbox/templates/extras/htmx/report_result.html @@ -2,6 +2,9 @@

Initiated: {{ result.created|annotated_date }} + {% if result.scheduled_time %} + Scheduled for: {{ result.scheduled_time|annotated_date }} + {% endif %} {% if result.completed %} Duration: {{ result.duration }} {% endif %} diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index 425f35898..d2af99c9b 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -3,6 +3,9 @@

Initiated: {{ result.created|annotated_date }} + {% if result.scheduled_time %} + Scheduled for: {{ result.scheduled_time|annotated_date }} + {% endif %} {% if result.completed %} Duration: {{ result.duration }} {% endif %} diff --git a/netbox/templates/extras/inc/job_label.html b/netbox/templates/extras/inc/job_label.html index d74931111..7ff788ede 100644 --- a/netbox/templates/extras/inc/job_label.html +++ b/netbox/templates/extras/inc/job_label.html @@ -4,6 +4,8 @@ Errored {% elif result.status == 'pending' %} Pending +{% elif result.status == 'scheduled' %} + Scheduled {% elif result.status == 'running' %} Running {% elif result.status == 'completed' %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 391de6614..94f37571b 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -1,5 +1,6 @@ {% extends 'generic/object.html' %} {% load helpers %} +{% load form_helpers %} {% block title %}{{ report.name }}{% endblock %} @@ -33,18 +34,24 @@ {% block content %}

{% if perms.extras.run_report %} -
-
+
+
+ {% csrf_token %} - + {% render_form form %} +
+ +
+
+ {% endif %}
diff --git a/netbox/templates/extras/report_result.html b/netbox/templates/extras/report_result.html index b4a0c0f12..0c61c63f9 100644 --- a/netbox/templates/extras/report_result.html +++ b/netbox/templates/extras/report_result.html @@ -1,4 +1,6 @@ {% extends 'extras/report.html' %} +{% load buttons %} +{% load perms %} {% block content-wrapper %}
@@ -7,3 +9,13 @@
{% endblock %} + +{% block controls %} +
+
+ {% if request.user|can_delete:result %} + {% delete_button result %} + {% endif %} +
+
+{% endblock controls %} \ No newline at end of file diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 99eade0a0..6fbcde322 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -43,7 +43,7 @@ You do not have permission to run scripts.
{% endif %} -
+ {% csrf_token %}
{% if form.requires_input %} diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html index 1c311ff26..2fc01e9fa 100644 --- a/netbox/templates/extras/script_result.html +++ b/netbox/templates/extras/script_result.html @@ -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 %} +
+
+ {% if request.user|can_delete:result %} + {% delete_button result %} + {% endif %} +
+
+{% endblock controls %} + {% block content-wrapper %}
{% endblock content-wrapper %} + +{% block modals %} + {% include 'inc/htmx_modal.html' %} +{% endblock modals %} \ No newline at end of file