mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
Merge pull request #10417 from kkthxbye-code/8366-job-scheduling
Fixes #8366 - Add job scheduling
This commit is contained in:
commit
5d56d95fda
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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})
|
||||
|
@ -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'),
|
||||
|
@ -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)
|
||||
)
|
||||
|
||||
|
||||
|
@ -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',)),
|
||||
|
16
netbox/extras/forms/reports.py
Normal file
16
netbox/extras/forms/reports.py
Normal 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",
|
||||
)
|
@ -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)
|
||||
|
@ -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']:
|
||||
|
@ -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:
|
||||
|
17
netbox/extras/migrations/0079_change_jobresult_order.py
Normal file
17
netbox/extras/migrations/0079_change_jobresult_order.py
Normal 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']},
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
||||
|
@ -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}")
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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' %}
|
||||
|
@ -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">
|
||||
|
@ -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 %}
|
@ -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 %}
|
||||
|
@ -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 %}
|
Loading…
Reference in New Issue
Block a user