Reference database object by GFK when running scripts & reports via UI

This commit is contained in:
jeremystretch 2023-03-27 16:18:45 -04:00
parent 15590f1f48
commit ccb09b0f7b
15 changed files with 129 additions and 88 deletions

View File

@ -118,11 +118,10 @@ class DataSource(PrimaryModel):
DataSource.objects.filter(pk=self.pk).update(status=self.status)
# Enqueue a sync job
job_result = Job.enqueue_job(
job_result = Job.enqueue(
import_string('core.jobs.sync_datasource'),
name=self.name,
obj_type=ContentType.objects.get_for_model(DataSource),
user=request.user,
instance=self,
user=request.user
)
return job_result

View File

@ -194,6 +194,41 @@ class Job(models.Model):
return job
@classmethod
def enqueue(cls, func, instance, name=None, user=None, schedule_at=None, interval=None, **kwargs):
"""
Create a Job instance and enqueue a job using the given callable
Args:
func: The callable object to be enqueued for execution
instance: The NetBox object to which this job pertains
name: Name for the job (optional)
user: The user responsible for running the job
schedule_at: Schedule the job to be executed at the passed date and time
interval: Recurrence interval (in minutes)
"""
object_type = ContentType.objects.get_for_model(instance, for_concrete_model=False)
rq_queue_name = get_queue_for_model(object_type.model)
queue = django_rq.get_queue(rq_queue_name)
status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING
job = Job.objects.create(
object_type=object_type,
object_id=instance.pk,
name=name,
status=status,
scheduled=schedule_at,
interval=interval,
user=user,
job_id=uuid.uuid4()
)
if schedule_at:
queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job_result=job, **kwargs)
else:
queue.enqueue(func, job_id=str(job.job_id), job_result=job, **kwargs)
return job
def trigger_webhooks(self, event):
from extras.models import Webhook

View File

@ -1,5 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from rest_framework import status
from rest_framework.decorators import action

View File

@ -1,6 +1,7 @@
import inspect
from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
@ -35,6 +36,12 @@ class ReportModule(PythonModuleMixin, ManagedFile):
"""
Proxy model for report module files.
"""
jobs = GenericRelation(
to='core.Job',
content_type_field='object_type',
object_id_field='object_id'
)
objects = ReportModuleManager()
class Meta:

View File

@ -28,24 +28,24 @@ def run_report(job_result, *args, **kwargs):
Helper function to call the run method on a report. This is needed to get around the inability to pickle an instance
method for queueing into the background processor.
"""
module_name, report_name = job_result.name.split('.', 1)
report = get_report(module_name, report_name)()
job_result.start()
module = ReportModule.objects.get(pk=job_result.object_id)
report = module.reports.get(job_result.name)()
try:
job_result.start()
report.run(job_result)
except Exception:
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
logging.error(f"Error during execution of report {job_result.name}")
finally:
# Schedule the next job if an interval has been set
start_time = job_result.scheduled or job_result.started
if start_time and job_result.interval:
new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
Job.enqueue_job(
if job_result.interval:
new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
Job.enqueue(
run_report,
instance=job_result.object,
name=job_result.name,
obj_type=job_result.obj_type,
user=job_result.user,
job_timeout=report.job_timeout,
schedule_at=new_scheduled_time,

View File

@ -444,10 +444,10 @@ def run_script(data, request, commit=True, *args, **kwargs):
job_result = kwargs.pop('job_result')
job_result.start()
module_name, script_name = job_result.name.split('.', 1)
script = get_script(module_name, script_name)()
module = ScriptModule.objects.get(pk=job_result.object_id)
script = module.scripts.get(job_result.name)()
logger = logging.getLogger(f"netbox.scripts.{module_name}.{script_name}")
logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
logger.info(f"Running script (commit={commit})")
# Add files to form data
@ -500,10 +500,10 @@ def run_script(data, request, commit=True, *args, **kwargs):
# 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)
Job.enqueue_job(
Job.enqueue(
run_script,
instance=job_result.object,
name=job_result.name,
obj_type=job_result.obj_type,
user=job_result.user,
schedule_at=new_scheduled_time,
interval=job_result.interval,

View File

@ -95,14 +95,14 @@ urlpatterns = [
# Reports
path('reports/', views.ReportListView.as_view(), name='report_list'),
path('reports/add/', views.ReportModuleCreateView.as_view(), name='reportmodule_add'),
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
path('reports/results/<int:job_pk>/', views.ReportResultView.as_view(), name='report_result'),
path('reports/<int:pk>/', include(get_model_urls('extras', 'reportmodule'))),
path('reports/<path:module>.<str:name>/', views.ReportView.as_view(), name='report'),
# Scripts
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'),
path('scripts/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
path('scripts/<path:module>.<str:name>/', views.ScriptView.as_view(), name='script'),

View File

@ -845,10 +845,11 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path=f'{module}.py')
report = module.reports[name]()
report_content_type = ContentType.objects.get(app_label='extras', model='report')
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
report.result = Job.objects.filter(
object_type=report_content_type,
name=report.full_name,
object_type=object_type,
object_id=module.pk,
name=report.name,
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).first()
@ -876,17 +877,17 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
})
# Run the Report. A new Job is created.
job_result = Job.enqueue_job(
job = Job.enqueue(
run_report,
name=report.full_name,
obj_type=ContentType.objects.get_for_model(Report),
instance=module,
name=report.class_name,
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)
return redirect('extras:report_result', job_pk=job.pk)
return render(request, 'extras/report.html', {
'module': module,
@ -902,28 +903,26 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View):
def get_required_permission(self):
return 'extras.view_report'
def get(self, request, job_result_pk):
report_content_type = ContentType.objects.get(app_label='extras', model='report')
result = get_object_or_404(Job.objects.all(), pk=job_result_pk, object_type=report_content_type)
def get(self, request, job_pk):
object_type = ContentType.objects.get_by_natural_key(app_label='extras', model='reportmodule')
job = get_object_or_404(Job.objects.all(), pk=job_pk, object_type=object_type)
# Retrieve the Report and attach the Job to it
module, report_name = result.name.split('.', maxsplit=1)
report = get_report(module, report_name)
report.result = result
module = job.object
report = module.reports[job.name]
# If this is an HTMX request, return only the result HTML
if is_htmx(request):
response = render(request, 'extras/htmx/report_result.html', {
'report': report,
'result': result,
'job': job,
})
if result.completed or not result.started:
if job.completed or not job.started:
response.status_code = 286
return response
return render(request, 'extras/report_result.html', {
'report': report,
'result': result,
'job': job,
})
@ -982,9 +981,11 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
form = script.as_form(initial=normalize_querydict(request.GET))
# Look for a pending Job (use the latest one by creation timestamp)
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
script.result = Job.objects.filter(
object_type=ContentType.objects.get_for_model(Script),
name=script.full_name,
object_type=object_type,
object_id=module.pk,
name=script.name,
).exclude(
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).first()
@ -1008,10 +1009,10 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
messages.error(request, "Unable to run script: RQ worker process not running.")
elif form.is_valid():
job_result = Job.enqueue_job(
job = Job.enqueue(
run_script,
name=script.full_name,
obj_type=ContentType.objects.get_for_model(Script),
instance=module,
name=script.class_name,
user=request.user,
schedule_at=form.cleaned_data.pop('_schedule_at'),
interval=form.cleaned_data.pop('_interval'),
@ -1021,7 +1022,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
commit=form.cleaned_data.pop('_commit')
)
return redirect('extras:script_result', job_result_pk=job_result.pk)
return redirect('extras:script_result', job_pk=job.pk)
return render(request, 'extras/script.html', {
'module': module,
@ -1035,28 +1036,26 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
def get_required_permission(self):
return 'extras.view_script'
def get(self, request, job_result_pk):
script_content_type = ContentType.objects.get(app_label='extras', model='script')
result = get_object_or_404(Job.objects.all(), pk=job_result_pk, object_type=script_content_type)
def get(self, request, job_pk):
object_type = ContentType.objects.get_by_natural_key(app_label='extras', model='scriptmodule')
job = get_object_or_404(Job.objects.all(), pk=job_pk, object_type=object_type)
module_name, script_name = result.name.split('.', 1)
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path=f'{module_name}.py')
script = module.scripts[script_name]()
module = job.object
script = module.scripts[job.name]()
# If this is an HTMX request, return only the result HTML
if is_htmx(request):
response = render(request, 'extras/htmx/script_result.html', {
'script': script,
'result': result,
'job': job,
})
if result.completed or not result.started:
if job.completed or not job.started:
response.status_code = 286
return response
return render(request, 'extras/script_result.html', {
'script': script,
'result': result,
'class_name': script.__class__.__name__
'job': job,
})

View File

@ -2,24 +2,24 @@
{% load helpers %}
<p>
{% if result.started %}
Started: <strong>{{ result.started|annotated_date }}</strong>
{% elif result.scheduled %}
Scheduled for: <strong>{{ result.scheduled|annotated_date }}</strong> ({{ result.scheduled|naturaltime }})
{% if job.started %}
Started: <strong>{{ job.started|annotated_date }}</strong>
{% elif job.scheduled %}
Scheduled for: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }})
{% else %}
Created: <strong>{{ result.created|annotated_date }}</strong>
Created: <strong>{{ job.created|annotated_date }}</strong>
{% endif %}
{% if result.completed %}
Duration: <strong>{{ result.duration }}</strong>
{% if job.completed %}
Duration: <strong>{{ job.duration }}</strong>
{% endif %}
<span id="pending-result-label">{% badge result.get_status_display result.get_status_color %}</span>
<span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
</p>
{% if result.completed %}
{% if job.completed %}
<div class="card">
<h5 class="card-header">Report Methods</h5>
<div class="card-body">
<table class="table table-hover">
{% for method, data in result.data.items %}
{% for method, data in job.data.items %}
<tr>
<td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
<td class="text-end report-stats">
@ -46,7 +46,7 @@
</tr>
</thead>
<tbody>
{% for method, data in result.data.items %}
{% for method, data in job.data.items %}
<tr>
<th colspan="4" style="font-family: monospace">
<a name="{{ method }}"></a>{{ method }}
@ -75,6 +75,6 @@
</table>
</div>
</div>
{% elif result.started %}
{% elif job.started %}
{% include 'extras/inc/result_pending.html' %}
{% endif %}

View File

@ -3,19 +3,19 @@
{% load log_levels %}
<p>
{% if result.started %}
Started: <strong>{{ result.started|annotated_date }}</strong>
{% elif result.scheduled %}
Scheduled for: <strong>{{ result.scheduled|annotated_date }}</strong> ({{ result.scheduled|naturaltime }})
{% if job.started %}
Started: <strong>{{ job.started|annotated_date }}</strong>
{% elif job.scheduled %}
Scheduled for: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }})
{% else %}
Created: <strong>{{ result.created|annotated_date }}</strong>
Created: <strong>{{ job.created|annotated_date }}</strong>
{% endif %}
{% if result.completed %}
Duration: <strong>{{ result.duration }}</strong>
{% if job.completed %}
Duration: <strong>{{ job.duration }}</strong>
{% endif %}
<span id="pending-result-label">{% badge result.get_status_display result.get_status_color %}</span>
<span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
</p>
{% if result.completed %}
{% if job.completed %}
<div class="card mb-3">
<h5 class="card-header">Script Log</h5>
<div class="card-body">
@ -25,7 +25,7 @@
<th>Level</th>
<th>Message</th>
</tr>
{% for log in result.data.log %}
{% for log in job.data.log %}
<tr>
<td>{{ forloop.counter }}</td>
<td>{% log_level log.status %}</td>
@ -47,11 +47,11 @@
{% endif %}
</div>
<h4>Output</h4>
{% if result.data.output %}
<pre class="block">{{ result.data.output }}</pre>
{% if job.data.output %}
<pre class="block">{{ job.data.output }}</pre>
{% else %}
<p class="text-muted">None</p>
{% endif %}
{% elif result.started %}
{% elif job.started %}
{% include 'extras/inc/result_pending.html' %}
{% endif %}

View File

@ -55,7 +55,7 @@
<div class="row">
<div class="col col-md-12">
{% if report.result %}
Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
Last run: <a href="{% url 'extras:report_result' job_pk=report.result.pk %}">
<strong>{{ report.result.created|annotated_date }}</strong>
</a>
{% endif %}

View File

@ -58,7 +58,7 @@
<td>{{ report.description|markdown|placeholder }}</td>
{% if last_result %}
<td>
<a href="{% url 'extras:report_result' job_result_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
<a href="{% url 'extras:report_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
</td>
<td>
{% badge last_result.get_status_display last_result.get_status_color %}

View File

@ -4,7 +4,7 @@
{% block content-wrapper %}
<div class="row p-3">
<div class="col col-md-12"{% if not result.completed %} hx-get="{% url 'extras:report_result' job_result_pk=result.pk %}" hx-trigger="every 5s"{% endif %}>
<div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:report_result' job_pk=job.pk %}" hx-trigger="every 5s"{% endif %}>
{% include 'extras/htmx/report_result.html' %}
</div>
</div>
@ -13,8 +13,8 @@
{% block controls %}
<div class="controls">
<div class="control-group">
{% if request.user|can_delete:result %}
{% delete_button result %}
{% if request.user|can_delete:job %}
{% delete_button job %}
{% endif %}
</div>
</div>

View File

@ -58,7 +58,7 @@
</td>
{% if last_result %}
<td>
<a href="{% url 'extras:script_result' job_result_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
<a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
</td>
<td class="text-end">
{% badge last_result.get_status_display last_result.get_status_color %}

View File

@ -16,8 +16,8 @@
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</a></li>
<li class="breadcrumb-item"><a href="{% url 'extras:script' module=script.module name=class_name %}">{{ script }}</a></li>
<li class="breadcrumb-item">{{ result.created|annotated_date }}</li>
<li class="breadcrumb-item"><a href="{% url 'extras:script' module=script.module name=script.class_name %}">{{ script }}</a></li>
<li class="breadcrumb-item">{{ job.created|annotated_date }}</li>
</ol>
</nav>
</div>
@ -28,8 +28,8 @@
{% block controls %}
<div class="controls">
<div class="control-group">
{% if request.user|can_delete:result %}
{% delete_button result %}
{% if request.user|can_delete:job %}
{% delete_button job %}
{% endif %}
</div>
</div>
@ -47,7 +47,7 @@
<div class="tab-content mb-3">
<div role="tabpanel" class="tab-pane active" id="log">
<div class="row">
<div class="col col-md-12"{% if not result.completed %} hx-get="{% url 'extras:script_result' job_result_pk=result.pk %}" hx-trigger="every 5s"{% endif %}>
<div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:script_result' job_pk=job.pk %}" hx-trigger="every 5s"{% endif %}>
{% include 'extras/htmx/script_result.html' %}
</div>
</div>