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) DataSource.objects.filter(pk=self.pk).update(status=self.status)
# Enqueue a sync job # Enqueue a sync job
job_result = Job.enqueue_job( job_result = Job.enqueue(
import_string('core.jobs.sync_datasource'), import_string('core.jobs.sync_datasource'),
name=self.name, instance=self,
obj_type=ContentType.objects.get_for_model(DataSource), user=request.user
user=request.user,
) )
return job_result return job_result

View File

@ -194,6 +194,41 @@ class Job(models.Model):
return job 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): def trigger_webhooks(self, event):
from extras.models import Webhook from extras.models import Webhook

View File

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

View File

@ -1,6 +1,7 @@
import inspect import inspect
from functools import cached_property from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -35,6 +36,12 @@ class ReportModule(PythonModuleMixin, ManagedFile):
""" """
Proxy model for report module files. Proxy model for report module files.
""" """
jobs = GenericRelation(
to='core.Job',
content_type_field='object_type',
object_id_field='object_id'
)
objects = ReportModuleManager() objects = ReportModuleManager()
class Meta: 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 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. method for queueing into the background processor.
""" """
module_name, report_name = job_result.name.split('.', 1) job_result.start()
report = get_report(module_name, report_name)()
module = ReportModule.objects.get(pk=job_result.object_id)
report = module.reports.get(job_result.name)()
try: try:
job_result.start()
report.run(job_result) report.run(job_result)
except Exception: except Exception:
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED) job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
logging.error(f"Error during execution of report {job_result.name}") logging.error(f"Error during execution of report {job_result.name}")
finally: finally:
# Schedule the next job if an interval has been set # Schedule the next job if an interval has been set
start_time = job_result.scheduled or job_result.started if job_result.interval:
if start_time and job_result.interval: new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
new_scheduled_time = start_time + timedelta(minutes=job_result.interval) Job.enqueue(
Job.enqueue_job(
run_report, run_report,
instance=job_result.object,
name=job_result.name, name=job_result.name,
obj_type=job_result.obj_type,
user=job_result.user, user=job_result.user,
job_timeout=report.job_timeout, job_timeout=report.job_timeout,
schedule_at=new_scheduled_time, 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 = kwargs.pop('job_result')
job_result.start() job_result.start()
module_name, script_name = job_result.name.split('.', 1) module = ScriptModule.objects.get(pk=job_result.object_id)
script = get_script(module_name, script_name)() 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})") logger.info(f"Running script (commit={commit})")
# Add files to form data # 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 # Schedule the next job if an interval has been set
if job_result.interval: if job_result.interval:
new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval) new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
Job.enqueue_job( Job.enqueue(
run_script, run_script,
instance=job_result.object,
name=job_result.name, name=job_result.name,
obj_type=job_result.obj_type,
user=job_result.user, user=job_result.user,
schedule_at=new_scheduled_time, schedule_at=new_scheduled_time,
interval=job_result.interval, interval=job_result.interval,

View File

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

View File

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

View File

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

View File

@ -55,7 +55,7 @@
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% if report.result %} {% 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> <strong>{{ report.result.created|annotated_date }}</strong>
</a> </a>
{% endif %} {% endif %}

View File

@ -58,7 +58,7 @@
<td>{{ report.description|markdown|placeholder }}</td> <td>{{ report.description|markdown|placeholder }}</td>
{% if last_result %} {% if last_result %}
<td> <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>
<td> <td>
{% badge last_result.get_status_display last_result.get_status_color %} {% badge last_result.get_status_display last_result.get_status_color %}

View File

@ -4,7 +4,7 @@
{% block content-wrapper %} {% block content-wrapper %}
<div class="row p-3"> <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' %} {% include 'extras/htmx/report_result.html' %}
</div> </div>
</div> </div>
@ -13,8 +13,8 @@
{% block controls %} {% block controls %}
<div class="controls"> <div class="controls">
<div class="control-group"> <div class="control-group">
{% if request.user|can_delete:result %} {% if request.user|can_delete:job %}
{% delete_button result %} {% delete_button job %}
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@ -58,7 +58,7 @@
</td> </td>
{% if last_result %} {% if last_result %}
<td> <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>
<td class="text-end"> <td class="text-end">
{% badge last_result.get_status_display last_result.get_status_color %} {% badge last_result.get_status_display last_result.get_status_color %}

View File

@ -16,8 +16,8 @@
<ol class="breadcrumb"> <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' %}">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_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"><a href="{% url 'extras:script' module=script.module name=script.class_name %}">{{ script }}</a></li>
<li class="breadcrumb-item">{{ result.created|annotated_date }}</li> <li class="breadcrumb-item">{{ job.created|annotated_date }}</li>
</ol> </ol>
</nav> </nav>
</div> </div>
@ -28,8 +28,8 @@
{% block controls %} {% block controls %}
<div class="controls"> <div class="controls">
<div class="control-group"> <div class="control-group">
{% if request.user|can_delete:result %} {% if request.user|can_delete:job %}
{% delete_button result %} {% delete_button job %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -47,7 +47,7 @@
<div class="tab-content mb-3"> <div class="tab-content mb-3">
<div role="tabpanel" class="tab-pane active" id="log"> <div role="tabpanel" class="tab-pane active" id="log">
<div class="row"> <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' %} {% include 'extras/htmx/script_result.html' %}
</div> </div>
</div> </div>