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 ### 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:

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 ### 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:

View File

@ -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

View File

@ -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):

View File

@ -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})

View File

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

View File

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

View File

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

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 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)

View File

@ -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']:

View File

@ -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:

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, 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

View File

@ -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}")

View File

@ -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
# #

View File

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

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 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

View File

@ -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(

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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' %}

View File

@ -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">

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}