mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-13 16:47:34 -06:00
* Reference database object by GFK when running scripts & reports via UI * Reference database object by GFK when running scripts & reports via API * Remove old enqueue_job() method * Enable filtering jobs by object * Introduce ObjectJobsView * Add tabbed views for report & script jobs * Add object_id to JobSerializer * Move generic relation to JobsMixin * Clean up old naming
This commit is contained in:
parent
15590f1f48
commit
d2a694a878
@ -67,6 +67,6 @@ class JobSerializer(BaseModelSerializer):
|
||||
class Meta:
|
||||
model = Job
|
||||
fields = [
|
||||
'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name',
|
||||
'object_type', 'user', 'data', 'job_id',
|
||||
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
|
||||
'started', 'completed', 'user', 'data', 'job_id',
|
||||
]
|
||||
|
@ -113,7 +113,7 @@ class JobFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Job
|
||||
fields = ('id', 'interval', 'status', 'user', 'object_type', 'name')
|
||||
fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user')
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
@ -1,6 +1,5 @@
|
||||
import logging
|
||||
|
||||
from .choices import JobStatusChoices
|
||||
from netbox.search.backends import search_backend
|
||||
from .choices import *
|
||||
from .exceptions import SyncError
|
||||
@ -9,22 +8,22 @@ from .models import DataSource
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sync_datasource(job_result, *args, **kwargs):
|
||||
def sync_datasource(job, *args, **kwargs):
|
||||
"""
|
||||
Call sync() on a DataSource.
|
||||
"""
|
||||
datasource = DataSource.objects.get(name=job_result.name)
|
||||
datasource = DataSource.objects.get(pk=job.object_id)
|
||||
|
||||
try:
|
||||
job_result.start()
|
||||
job.start()
|
||||
datasource.sync()
|
||||
|
||||
# Update the search cache for DataFiles belonging to this source
|
||||
search_backend.cache(datasource.datafiles.iterator())
|
||||
|
||||
job_result.terminate()
|
||||
job.terminate()
|
||||
|
||||
except SyncError as e:
|
||||
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||
logging.error(e)
|
||||
|
@ -5,7 +5,7 @@ from fnmatch import fnmatchcase
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
@ -15,6 +15,7 @@ from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.models import PrimaryModel
|
||||
from netbox.models.features import JobsMixin
|
||||
from netbox.registry import registry
|
||||
from utilities.files import sha256_hash
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
@ -31,7 +32,7 @@ __all__ = (
|
||||
logger = logging.getLogger('netbox.core.data')
|
||||
|
||||
|
||||
class DataSource(PrimaryModel):
|
||||
class DataSource(JobsMixin, PrimaryModel):
|
||||
"""
|
||||
A remote source, such as a git repository, from which DataFiles are synchronized.
|
||||
"""
|
||||
@ -118,15 +119,12 @@ class DataSource(PrimaryModel):
|
||||
DataSource.objects.filter(pk=self.pk).update(status=self.status)
|
||||
|
||||
# Enqueue a sync job
|
||||
job_result = Job.enqueue_job(
|
||||
return 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
|
||||
|
||||
def get_backend(self):
|
||||
backend_cls = registry['data_backends'].get(self.type)
|
||||
backend_params = self.parameters or {}
|
||||
|
@ -7,7 +7,6 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@ -96,21 +95,12 @@ class Job(models.Model):
|
||||
def __str__(self):
|
||||
return str(self.job_id)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.object_type.model, RQ_QUEUE_DEFAULT)
|
||||
queue = django_rq.get_queue(rq_queue_name)
|
||||
job = queue.fetch_job(str(self.job_id))
|
||||
|
||||
if job:
|
||||
job.cancel()
|
||||
|
||||
def get_absolute_url(self):
|
||||
try:
|
||||
return reverse(f'extras:{self.object_type.model}_result', args=[self.pk])
|
||||
except NoReverseMatch:
|
||||
return None
|
||||
# TODO: Employ dynamic registration
|
||||
if self.object_type.model == 'reportmodule':
|
||||
return reverse(f'extras:report_result', kwargs={'job_pk': self.pk})
|
||||
if self.object_type.model == 'scriptmodule':
|
||||
return reverse(f'extras:script_result', kwargs={'job_pk': self.pk})
|
||||
|
||||
def get_status_color(self):
|
||||
return JobStatusChoices.colors.get(self.status)
|
||||
@ -130,6 +120,16 @@ class Job(models.Model):
|
||||
|
||||
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.object_type.model, RQ_QUEUE_DEFAULT)
|
||||
queue = django_rq.get_queue(rq_queue_name)
|
||||
job = queue.fetch_job(str(self.job_id))
|
||||
|
||||
if job:
|
||||
job.cancel()
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Record the job's start time and update its status to "running."
|
||||
@ -162,25 +162,27 @@ class Job(models.Model):
|
||||
self.trigger_webhooks(event=EVENT_JOB_END)
|
||||
|
||||
@classmethod
|
||||
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs):
|
||||
def enqueue(cls, func, instance, name='', 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)
|
||||
obj_type: ContentType to link to the Job instance object_type
|
||||
user: User object to link to the Job instance
|
||||
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)
|
||||
"""
|
||||
rq_queue_name = get_queue_for_model(obj_type.model)
|
||||
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,
|
||||
object_type=obj_type,
|
||||
scheduled=schedule_at,
|
||||
interval=interval,
|
||||
user=user,
|
||||
@ -188,9 +190,9 @@ class Job(models.Model):
|
||||
)
|
||||
|
||||
if schedule_at:
|
||||
queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job_result=job, **kwargs)
|
||||
queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
else:
|
||||
queue.enqueue(func, job_id=str(job.job_id), job_result=job, **kwargs)
|
||||
queue.enqueue(func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
|
||||
return job
|
||||
|
||||
|
@ -6,12 +6,18 @@ from ..models import Job
|
||||
|
||||
|
||||
class JobTable(NetBoxTable):
|
||||
id = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
object_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Type')
|
||||
)
|
||||
object = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
created = columns.DateTimeColumn()
|
||||
scheduled = columns.DateTimeColumn()
|
||||
@ -25,10 +31,9 @@ class JobTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Job
|
||||
fields = (
|
||||
'pk', 'id', 'object_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
|
||||
'user', 'job_id',
|
||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
|
||||
'completed', 'user', 'job_id',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'object_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
|
||||
'user',
|
||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
||||
)
|
||||
|
@ -55,9 +55,9 @@ class DataSourceSyncView(BaseObjectView):
|
||||
|
||||
def post(self, request, pk):
|
||||
datasource = get_object_or_404(self.queryset, pk=pk)
|
||||
job_result = datasource.enqueue_sync_job(request)
|
||||
job = datasource.enqueue_sync_job(request)
|
||||
|
||||
messages.success(request, f"Queued job #{job_result.pk} to sync {datasource}")
|
||||
messages.success(request, f"Queued job #{job.pk} to sync {datasource}")
|
||||
return redirect(datasource.get_absolute_url())
|
||||
|
||||
|
||||
|
@ -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
|
||||
@ -16,8 +17,8 @@ from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
from extras import filtersets
|
||||
from extras.models import *
|
||||
from extras.reports import get_report, run_report
|
||||
from extras.scripts import get_script, run_script
|
||||
from extras.reports import get_module_and_report, run_report
|
||||
from extras.scripts import get_module_and_script, run_script
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.features import SyncedDataMixin
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
@ -170,19 +171,17 @@ class ReportViewSet(ViewSet):
|
||||
exclude_from_schema = True
|
||||
lookup_value_regex = '[^/]+' # Allow dots
|
||||
|
||||
def _retrieve_report(self, pk):
|
||||
|
||||
# Read the PK as "<module>.<report>"
|
||||
if '.' not in pk:
|
||||
def _get_report(self, pk):
|
||||
try:
|
||||
module_name, report_name = pk.split('.', maxsplit=1)
|
||||
except ValueError:
|
||||
raise Http404
|
||||
module_name, report_name = pk.split('.', maxsplit=1)
|
||||
|
||||
# Raise a 404 on an invalid Report module/name
|
||||
report = get_report(module_name, report_name)
|
||||
module, report = get_module_and_report(module_name, report_name)
|
||||
if report is None:
|
||||
raise Http404
|
||||
|
||||
return report
|
||||
return module, report
|
||||
|
||||
def list(self, request):
|
||||
"""
|
||||
@ -215,13 +214,13 @@ class ReportViewSet(ViewSet):
|
||||
"""
|
||||
Retrieve a single Report identified as "<module>.<report>".
|
||||
"""
|
||||
module, report = self._get_report(pk)
|
||||
|
||||
# Retrieve the Report and Job, if any.
|
||||
report = self._retrieve_report(pk)
|
||||
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,
|
||||
name=report.name,
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
|
||||
@ -245,14 +244,14 @@ class ReportViewSet(ViewSet):
|
||||
raise RQWorkerNotRunningException()
|
||||
|
||||
# Retrieve and run the Report. This will create a new Job.
|
||||
report = self._retrieve_report(pk)
|
||||
module, report = self._get_report(pk)
|
||||
input_serializer = serializers.ReportInputSerializer(data=request.data)
|
||||
|
||||
if input_serializer.is_valid():
|
||||
report.result = Job.enqueue_job(
|
||||
report.result = 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,
|
||||
job_timeout=report.job_timeout,
|
||||
schedule_at=input_serializer.validated_data.get('schedule_at'),
|
||||
@ -275,11 +274,16 @@ class ScriptViewSet(ViewSet):
|
||||
lookup_value_regex = '[^/]+' # Allow dots
|
||||
|
||||
def _get_script(self, pk):
|
||||
module_name, script_name = pk.split('.', maxsplit=1)
|
||||
script = get_script(module_name, script_name)
|
||||
try:
|
||||
module_name, script_name = pk.split('.', maxsplit=1)
|
||||
except ValueError:
|
||||
raise Http404
|
||||
|
||||
module, script = get_module_and_script(module_name, script_name)
|
||||
if script is None:
|
||||
raise Http404
|
||||
return script
|
||||
|
||||
return module, script
|
||||
|
||||
def list(self, request):
|
||||
|
||||
@ -305,11 +309,11 @@ class ScriptViewSet(ViewSet):
|
||||
return Response(serializer.data)
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
script = self._get_script(pk)
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
module, script = self._get_script(pk)
|
||||
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
|
||||
script.result = Job.objects.filter(
|
||||
object_type=script_content_type,
|
||||
name=script.full_name,
|
||||
object_type=object_type,
|
||||
name=script.name,
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||
@ -324,7 +328,7 @@ class ScriptViewSet(ViewSet):
|
||||
if not request.user.has_perm('extras.run_script'):
|
||||
raise PermissionDenied("This user does not have permission to run scripts.")
|
||||
|
||||
script = self._get_script(pk)()
|
||||
module, script = self._get_script(pk)
|
||||
input_serializer = serializers.ScriptInputSerializer(data=request.data)
|
||||
|
||||
# Check that at least one RQ worker is running
|
||||
@ -332,10 +336,10 @@ class ScriptViewSet(ViewSet):
|
||||
raise RQWorkerNotRunningException()
|
||||
|
||||
if input_serializer.is_valid():
|
||||
script.result = Job.enqueue_job(
|
||||
script.result = 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,
|
||||
data=input_serializer.data['data'],
|
||||
request=copy_safe_request(request),
|
||||
|
@ -1,6 +1,5 @@
|
||||
import time
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
@ -27,12 +26,10 @@ class Command(BaseCommand):
|
||||
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
|
||||
)
|
||||
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job = Job.enqueue_job(
|
||||
job = Job.enqueue(
|
||||
run_report,
|
||||
report.full_name,
|
||||
report_content_type,
|
||||
None,
|
||||
instance=module,
|
||||
name=report.class_name,
|
||||
job_timeout=report.job_timeout
|
||||
)
|
||||
|
||||
|
@ -5,7 +5,6 @@ import traceback
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
@ -13,7 +12,7 @@ from core.choices import JobStatusChoices
|
||||
from core.models import Job
|
||||
from extras.api.serializers import ScriptOutputSerializer
|
||||
from extras.context_managers import change_logging
|
||||
from extras.scripts import get_script
|
||||
from extras.scripts import get_module_and_script
|
||||
from extras.signals import clear_webhooks
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.utils import NetBoxFakeRequest
|
||||
@ -49,8 +48,8 @@ class Command(BaseCommand):
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
clear_webhooks.send(request)
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.terminate()
|
||||
job.data = ScriptOutputSerializer(script).data
|
||||
job.terminate()
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
@ -59,10 +58,10 @@ class Command(BaseCommand):
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
clear_webhooks.send(request)
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
job.data = ScriptOutputSerializer(script).data
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
|
||||
logger.info(f"Script completed in {job_result.duration}")
|
||||
logger.info(f"Script completed in {job.duration}")
|
||||
|
||||
# Params
|
||||
script = options['script']
|
||||
@ -73,7 +72,8 @@ class Command(BaseCommand):
|
||||
except TypeError:
|
||||
data = {}
|
||||
|
||||
module, name = script.split('.', 1)
|
||||
module_name, script_name = script.split('.', 1)
|
||||
module, script = get_module_and_script(module_name, script_name)
|
||||
|
||||
# Take user from command line if provided and exists, other
|
||||
if options['user']:
|
||||
@ -90,7 +90,7 @@ class Command(BaseCommand):
|
||||
stdouthandler.setLevel(logging.DEBUG)
|
||||
stdouthandler.setFormatter(formatter)
|
||||
|
||||
logger = logging.getLogger(f"netbox.scripts.{module}.{name}")
|
||||
logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
|
||||
logger.addHandler(stdouthandler)
|
||||
|
||||
try:
|
||||
@ -105,17 +105,14 @@ class Command(BaseCommand):
|
||||
except KeyError:
|
||||
raise CommandError(f"Invalid log level: {loglevel}")
|
||||
|
||||
# Get the script
|
||||
script = get_script(module, name)()
|
||||
# Parse the parameters
|
||||
# Initialize the script form
|
||||
script = script()
|
||||
form = script.as_form(data, None)
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
|
||||
# Create the job result
|
||||
job_result = Job.objects.create(
|
||||
name=script.full_name,
|
||||
obj_type=script_content_type,
|
||||
# Create the job
|
||||
job = Job.objects.create(
|
||||
instance=module,
|
||||
name=script.name,
|
||||
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
|
||||
job_id=uuid.uuid4()
|
||||
)
|
||||
@ -127,12 +124,12 @@ class Command(BaseCommand):
|
||||
'FILES': {},
|
||||
'user': user,
|
||||
'path': '',
|
||||
'id': job_result.job_id
|
||||
'id': job.job_id
|
||||
})
|
||||
|
||||
if form.is_valid():
|
||||
job_result.status = JobStatusChoices.STATUS_RUNNING
|
||||
job_result.save()
|
||||
job.status = JobStatusChoices.STATUS_RUNNING
|
||||
job.save()
|
||||
|
||||
logger.info(f"Running script (commit={commit})")
|
||||
script.request = request
|
||||
@ -146,5 +143,5 @@ class Command(BaseCommand):
|
||||
for field, errors in form.errors.get_json_data().items():
|
||||
for error in errors:
|
||||
logger.error(f'\t{field}: {error.get("message")}')
|
||||
job_result.status = JobStatusChoices.STATUS_ERRORED
|
||||
job_result.save()
|
||||
job.status = JobStatusChoices.STATUS_ERRORED
|
||||
job.save()
|
||||
|
@ -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
|
||||
|
||||
@ -17,7 +18,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class Report(JobsMixin, WebhooksMixin, models.Model):
|
||||
class Report(WebhooksMixin, models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for reports. Does not exist in the database.
|
||||
"""
|
||||
@ -31,7 +32,7 @@ class ReportModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.REPORTS)
|
||||
|
||||
|
||||
class ReportModule(PythonModuleMixin, ManagedFile):
|
||||
class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
"""
|
||||
Proxy model for report module files.
|
||||
"""
|
||||
|
@ -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
|
||||
|
||||
@ -17,7 +18,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class Script(JobsMixin, WebhooksMixin, models.Model):
|
||||
class Script(WebhooksMixin, models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
|
||||
"""
|
||||
@ -31,7 +32,7 @@ class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.SCRIPTS)
|
||||
|
||||
|
||||
class ScriptModule(PythonModuleMixin, ManagedFile):
|
||||
class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
"""
|
||||
Proxy model for script module files.
|
||||
"""
|
||||
|
@ -11,45 +11,49 @@ from core.models import Job
|
||||
from .choices import LogLevelChoices
|
||||
from .models import ReportModule
|
||||
|
||||
__all__ = (
|
||||
'Report',
|
||||
'get_module_and_report',
|
||||
'run_report',
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_report(module_name, report_name):
|
||||
"""
|
||||
Return a specific report from within a module.
|
||||
"""
|
||||
def get_module_and_report(module_name, report_name):
|
||||
module = ReportModule.objects.get(file_path=f'{module_name}.py')
|
||||
return module.reports.get(report_name)
|
||||
report = module.reports.get(report_name)
|
||||
return module, report
|
||||
|
||||
|
||||
@job('default')
|
||||
def run_report(job_result, *args, **kwargs):
|
||||
def run_report(job, *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.start()
|
||||
|
||||
module = ReportModule.objects.get(pk=job.object_id)
|
||||
report = module.reports.get(job.name)()
|
||||
|
||||
try:
|
||||
job_result.start()
|
||||
report.run(job_result)
|
||||
report.run(job)
|
||||
except Exception:
|
||||
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
logging.error(f"Error during execution of report {job_result.name}")
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
logging.error(f"Error during execution of report {job.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.interval:
|
||||
new_scheduled_time = job.scheduled + timedelta(minutes=job.interval)
|
||||
Job.enqueue(
|
||||
run_report,
|
||||
name=job_result.name,
|
||||
obj_type=job_result.obj_type,
|
||||
user=job_result.user,
|
||||
instance=job.object,
|
||||
name=job.name,
|
||||
user=job.user,
|
||||
job_timeout=report.job_timeout,
|
||||
schedule_at=new_scheduled_time,
|
||||
interval=job_result.interval
|
||||
interval=job.interval
|
||||
)
|
||||
|
||||
|
||||
@ -186,13 +190,13 @@ class Report(object):
|
||||
# Run methods
|
||||
#
|
||||
|
||||
def run(self, job_result):
|
||||
def run(self, job):
|
||||
"""
|
||||
Run the report and save its results. Each test method will be executed in order.
|
||||
"""
|
||||
self.logger.info(f"Running report")
|
||||
job_result.status = JobStatusChoices.STATUS_RUNNING
|
||||
job_result.save()
|
||||
job.status = JobStatusChoices.STATUS_RUNNING
|
||||
job.save()
|
||||
|
||||
# Perform any post-run tasks
|
||||
self.pre_run()
|
||||
@ -204,17 +208,17 @@ class Report(object):
|
||||
test_method()
|
||||
if self.failed:
|
||||
self.logger.warning("Report failed")
|
||||
job_result.status = JobStatusChoices.STATUS_FAILED
|
||||
job.status = JobStatusChoices.STATUS_FAILED
|
||||
else:
|
||||
self.logger.info("Report completed successfully")
|
||||
job_result.status = JobStatusChoices.STATUS_COMPLETED
|
||||
job.status = JobStatusChoices.STATUS_COMPLETED
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
|
||||
logger.error(f"Exception raised during report execution: {e}")
|
||||
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
finally:
|
||||
job_result.terminate()
|
||||
job.terminate()
|
||||
|
||||
# Perform any post-run tasks
|
||||
self.post_run()
|
||||
|
@ -25,7 +25,7 @@ from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicMo
|
||||
from .context_managers import change_logging
|
||||
from .forms import ScriptForm
|
||||
|
||||
__all__ = [
|
||||
__all__ = (
|
||||
'BaseScript',
|
||||
'BooleanVar',
|
||||
'ChoiceVar',
|
||||
@ -40,7 +40,9 @@ __all__ = [
|
||||
'Script',
|
||||
'StringVar',
|
||||
'TextVar',
|
||||
]
|
||||
'get_module_and_script',
|
||||
'run_script',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@ -436,18 +438,23 @@ def is_variable(obj):
|
||||
return isinstance(obj, ScriptVariable)
|
||||
|
||||
|
||||
def run_script(data, request, commit=True, *args, **kwargs):
|
||||
def get_module_and_script(module_name, script_name):
|
||||
module = ScriptModule.objects.get(file_path=f'{module_name}.py')
|
||||
script = module.scripts.get(script_name)
|
||||
return module, script
|
||||
|
||||
|
||||
def run_script(data, request, job, commit=True, **kwargs):
|
||||
"""
|
||||
A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
|
||||
exists outside the Script class to ensure it cannot be overridden by a script author.
|
||||
"""
|
||||
job_result = kwargs.pop('job_result')
|
||||
job_result.start()
|
||||
job.start()
|
||||
|
||||
module_name, script_name = job_result.name.split('.', 1)
|
||||
script = get_script(module_name, script_name)()
|
||||
module = ScriptModule.objects.get(pk=job.object_id)
|
||||
script = module.scripts.get(job.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
|
||||
@ -472,8 +479,8 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
clear_webhooks.send(request)
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.terminate()
|
||||
job.data = ScriptOutputSerializer(script).data
|
||||
job.terminate()
|
||||
except Exception as e:
|
||||
if type(e) is AbortScript:
|
||||
script.log_failure(f"Script aborted with error: {e}")
|
||||
@ -483,11 +490,11 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
script.log_failure(f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
job.data = ScriptOutputSerializer(script).data
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
clear_webhooks.send(request)
|
||||
|
||||
logger.info(f"Script completed in {job_result.duration}")
|
||||
logger.info(f"Script completed in {job.duration}")
|
||||
|
||||
# Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
|
||||
# change logging, webhooks, etc.
|
||||
@ -498,25 +505,17 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
_run_script()
|
||||
|
||||
# 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(
|
||||
if job.interval:
|
||||
new_scheduled_time = job.scheduled + timedelta(minutes=job.interval)
|
||||
Job.enqueue(
|
||||
run_script,
|
||||
name=job_result.name,
|
||||
obj_type=job_result.obj_type,
|
||||
user=job_result.user,
|
||||
instance=job.object,
|
||||
name=job.name,
|
||||
user=job.user,
|
||||
schedule_at=new_scheduled_time,
|
||||
interval=job_result.interval,
|
||||
interval=job.interval,
|
||||
job_timeout=script.job_timeout,
|
||||
data=data,
|
||||
request=request,
|
||||
commit=commit
|
||||
)
|
||||
|
||||
|
||||
def get_script(module_name, script_name):
|
||||
"""
|
||||
Retrieve a script class by module and name. Returns None if the script does not exist.
|
||||
"""
|
||||
module = ScriptModule.objects.get(file_path=f'{module_name}.py')
|
||||
return module.scripts.get(script_name)
|
||||
|
@ -9,6 +9,7 @@ from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
from rq import Worker
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
||||
from extras.api.views import ReportViewSet, ScriptViewSet
|
||||
from extras.models import *
|
||||
@ -524,14 +525,21 @@ class ReportTest(APITestCase):
|
||||
def test_foo(self):
|
||||
self.log_success(None, "Report completed")
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
ReportModule.objects.create(
|
||||
file_root=ManagedFileRootPathChoices.REPORTS,
|
||||
file_path='/var/tmp/report.py'
|
||||
)
|
||||
|
||||
def get_test_report(self, *args):
|
||||
return self.TestReport()
|
||||
return ReportModule.objects.first(), self.TestReport()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Monkey-patch the API viewset's _get_script method to return our test script above
|
||||
ReportViewSet._retrieve_report = self.get_test_report
|
||||
# Monkey-patch the API viewset's _get_report() method to return our test Report above
|
||||
ReportViewSet._get_report = self.get_test_report
|
||||
|
||||
def test_get_report(self):
|
||||
url = reverse('extras-api:report-detail', kwargs={'pk': None})
|
||||
@ -569,14 +577,20 @@ class ScriptTest(APITestCase):
|
||||
|
||||
return 'Script complete'
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
ScriptModule.objects.create(
|
||||
file_root=ManagedFileRootPathChoices.SCRIPTS,
|
||||
file_path='/var/tmp/script.py'
|
||||
)
|
||||
|
||||
def get_test_script(self, *args):
|
||||
return self.TestScript
|
||||
return ScriptModule.objects.first(), self.TestScript
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
# Monkey-patch the API viewset's _get_script method to return our test script above
|
||||
# Monkey-patch the API viewset's _get_script() method to return our test Script above
|
||||
ScriptViewSet._get_script = self.get_test_script
|
||||
|
||||
def test_get_script(self):
|
||||
|
@ -95,16 +95,19 @@ 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'),
|
||||
path('reports/<path:module>.<str:name>/jobs/', views.ReportJobsView.as_view(), name='report_jobs'),
|
||||
|
||||
# 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'),
|
||||
path('scripts/<path:module>.<str:name>/source/', views.ScriptSourceView.as_view(), name='script_source'),
|
||||
path('scripts/<path:module>.<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
|
||||
|
||||
# Markdown
|
||||
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
|
||||
|
@ -10,6 +10,7 @@ from django.views.generic import View
|
||||
from core.choices import JobStatusChoices, ManagedFileRootPathChoices
|
||||
from core.forms import ManagedFileForm
|
||||
from core.models import Job
|
||||
from core.tables import JobTable
|
||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
from netbox.views import generic
|
||||
@ -22,7 +23,7 @@ from utilities.views import ContentTypePermissionRequiredMixin, register_model_v
|
||||
from . import filtersets, forms, tables
|
||||
from .forms.reports import ReportForm
|
||||
from .models import *
|
||||
from .reports import get_report, run_report
|
||||
from .reports import run_report
|
||||
from .scripts import run_script
|
||||
|
||||
|
||||
@ -819,7 +820,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
|
||||
report_modules = ReportModule.objects.restrict(request.user)
|
||||
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job_results = {
|
||||
jobs = {
|
||||
r.name: r
|
||||
for r in Job.objects.filter(
|
||||
object_type=report_content_type,
|
||||
@ -830,7 +831,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
|
||||
return render(request, 'extras/report_list.html', {
|
||||
'model': ReportModule,
|
||||
'report_modules': report_modules,
|
||||
'job_results': job_results,
|
||||
'jobs': jobs,
|
||||
})
|
||||
|
||||
|
||||
@ -845,10 +846,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 +878,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,
|
||||
@ -895,6 +897,38 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
class ReportJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path=f'{module}.py')
|
||||
report = module.reports[name]()
|
||||
|
||||
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
|
||||
jobs = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=module.pk,
|
||||
name=report.name,
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
)
|
||||
|
||||
jobs_table = JobTable(
|
||||
data=jobs,
|
||||
orderable=False,
|
||||
user=request.user
|
||||
)
|
||||
jobs_table.configure(request)
|
||||
|
||||
return render(request, 'extras/report/jobs.html', {
|
||||
'module': module,
|
||||
'report': report,
|
||||
'table': jobs_table,
|
||||
'tab': 'jobs',
|
||||
})
|
||||
|
||||
|
||||
class ReportResultView(ContentTypePermissionRequiredMixin, View):
|
||||
"""
|
||||
Display a Job pertaining to the execution of a Report.
|
||||
@ -902,28 +936,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,
|
||||
})
|
||||
|
||||
|
||||
@ -956,7 +988,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
||||
script_modules = ScriptModule.objects.restrict(request.user)
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
job_results = {
|
||||
jobs = {
|
||||
r.name: r
|
||||
for r in Job.objects.filter(
|
||||
object_type=script_content_type,
|
||||
@ -967,7 +999,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
||||
return render(request, 'extras/script_list.html', {
|
||||
'model': ScriptModule,
|
||||
'script_modules': script_modules,
|
||||
'job_results': job_results,
|
||||
'jobs': jobs,
|
||||
})
|
||||
|
||||
|
||||
@ -982,9 +1014,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 +1042,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 +1055,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,
|
||||
@ -1030,33 +1064,79 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path=f'{module}.py')
|
||||
script = module.scripts[name]()
|
||||
|
||||
return render(request, 'extras/script/source.html', {
|
||||
'module': module,
|
||||
'script': script,
|
||||
'tab': 'source',
|
||||
})
|
||||
|
||||
|
||||
class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path=f'{module}.py')
|
||||
script = module.scripts[name]()
|
||||
|
||||
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
|
||||
jobs = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=module.pk,
|
||||
name=script.class_name,
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
)
|
||||
|
||||
jobs_table = JobTable(
|
||||
data=jobs,
|
||||
orderable=False,
|
||||
user=request.user
|
||||
)
|
||||
jobs_table.configure(request)
|
||||
|
||||
return render(request, 'extras/script/jobs.html', {
|
||||
'module': module,
|
||||
'script': script,
|
||||
'table': jobs_table,
|
||||
'tab': 'jobs',
|
||||
})
|
||||
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
|
@ -299,6 +299,12 @@ class JobsMixin(models.Model):
|
||||
"""
|
||||
Enables support for job results.
|
||||
"""
|
||||
jobs = GenericRelation(
|
||||
to='core.Job',
|
||||
content_type_field='object_type',
|
||||
object_id_field='object_id'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@ -455,6 +461,12 @@ def _register_features(sender, **kwargs):
|
||||
'changelog',
|
||||
kwargs={'model': sender}
|
||||
)('netbox.views.generic.ObjectChangeLogView')
|
||||
if issubclass(sender, JobsMixin):
|
||||
register_model_view(
|
||||
sender,
|
||||
'jobs',
|
||||
kwargs={'model': sender}
|
||||
)('netbox.views.generic.ObjectJobsView')
|
||||
if issubclass(sender, SyncedDataMixin):
|
||||
register_model_view(
|
||||
sender,
|
||||
|
@ -6,6 +6,8 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
|
||||
from core.models import Job
|
||||
from core.tables import JobTable
|
||||
from extras import forms, tables
|
||||
from extras.models import *
|
||||
from utilities.permissions import get_permission_for_model
|
||||
@ -15,6 +17,7 @@ from .base import BaseMultiObjectView
|
||||
__all__ = (
|
||||
'BulkSyncDataView',
|
||||
'ObjectChangeLogView',
|
||||
'ObjectJobsView',
|
||||
'ObjectJournalView',
|
||||
'ObjectSyncDataView',
|
||||
)
|
||||
@ -134,6 +137,59 @@ class ObjectJournalView(View):
|
||||
})
|
||||
|
||||
|
||||
class ObjectJobsView(View):
|
||||
"""
|
||||
Render a list of all Job assigned to an object. For example:
|
||||
|
||||
path('data-sources/<int:pk>/jobs/', ObjectJobsView.as_view(), name='datasource_jobs', kwargs={'model': DataSource}),
|
||||
|
||||
Attributes:
|
||||
base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used.
|
||||
"""
|
||||
base_template = None
|
||||
tab = ViewTab(
|
||||
label=_('Jobs'),
|
||||
badge=lambda obj: obj.jobs.count(),
|
||||
permission='core.view_job',
|
||||
weight=11000
|
||||
)
|
||||
|
||||
def get_object(self, request, **kwargs):
|
||||
return get_object_or_404(self.model.objects.restrict(request.user, 'view'), **kwargs)
|
||||
|
||||
def get_jobs(self, instance):
|
||||
object_type = ContentType.objects.get_for_model(instance)
|
||||
return Job.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=instance.id
|
||||
)
|
||||
|
||||
def get(self, request, model, **kwargs):
|
||||
self.model = model
|
||||
obj = self.get_object(request, **kwargs)
|
||||
|
||||
# Gather all Jobs for this object
|
||||
jobs = self.get_jobs(obj)
|
||||
jobs_table = JobTable(
|
||||
data=jobs,
|
||||
orderable=False,
|
||||
user=request.user
|
||||
)
|
||||
jobs_table.configure(request)
|
||||
|
||||
# Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
|
||||
# fall back to using base.html.
|
||||
if self.base_template is None:
|
||||
self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
|
||||
|
||||
return render(request, 'core/object_jobs.html', {
|
||||
'object': obj,
|
||||
'table': jobs_table,
|
||||
'base_template': self.base_template,
|
||||
'tab': self.tab,
|
||||
})
|
||||
|
||||
|
||||
class ObjectSyncDataView(View):
|
||||
|
||||
def post(self, request, model, **kwargs):
|
||||
|
15
netbox/templates/core/object_jobs.html
Normal file
15
netbox/templates/core/object_jobs.html
Normal file
@ -0,0 +1,15 @@
|
||||
{% extends base_template %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-body table-responsive">
|
||||
{% render_table table 'inc/table.html' %}
|
||||
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -1,36 +1,7 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% extends 'extras/report/base.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}{{ report.name }}{% endblock %}
|
||||
|
||||
{% block object_identifier %}
|
||||
{{ report.full_name }}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}">Reports</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module{{ module.pk }}">{{ report.module|bettertitle }}</a></li>
|
||||
{% endblock breadcrumbs %}
|
||||
|
||||
{% block subtitle %}
|
||||
{% if report.description %}
|
||||
<div class="object-subtitle">
|
||||
<div class="text-muted">{{ report.description|markdown }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock subtitle %}
|
||||
|
||||
{% block controls %}{% endblock %}
|
||||
|
||||
{% block tabs %}
|
||||
<ul class="nav nav-tabs px-3">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="#report" role="tab" data-bs-toggle="tab" class="nav-link active">Report</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock tabs %}
|
||||
|
||||
{% block content %}
|
||||
<div role="tabpanel" class="tab-pane active" id="report">
|
||||
{% if perms.extras.run_report %}
|
||||
@ -55,7 +26,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 %}
|
||||
|
35
netbox/templates/extras/report/base.html
Normal file
35
netbox/templates/extras/report/base.html
Normal file
@ -0,0 +1,35 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}{{ report.name }}{% endblock %}
|
||||
|
||||
{% block object_identifier %}
|
||||
{{ report.full_name }}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}">Reports</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module{{ module.pk }}">{{ report.module|bettertitle }}</a></li>
|
||||
{% endblock breadcrumbs %}
|
||||
|
||||
{% block subtitle %}
|
||||
{% if report.description %}
|
||||
<div class="object-subtitle">
|
||||
<div class="text-muted">{{ report.description|markdown }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock subtitle %}
|
||||
|
||||
{% block controls %}{% endblock %}
|
||||
|
||||
{% block tabs %}
|
||||
<ul class="nav nav-tabs px-3">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link{% if not tab %} active{% endif %}" href="{% url 'extras:report' module=report.module name=report.class_name %}">Report</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:report_jobs' module=report.module name=report.class_name %}">Jobs</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock tabs %}
|
15
netbox/templates/extras/report/jobs.html
Normal file
15
netbox/templates/extras/report/jobs.html
Normal file
@ -0,0 +1,15 @@
|
||||
{% extends 'extras/report/base.html' %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-body table-responsive">
|
||||
{% render_table table 'inc/table.html' %}
|
||||
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -50,7 +50,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for report_name, report in module.reports.items %}
|
||||
{% with last_result=job_results|get_key:report.full_name %}
|
||||
{% with last_result=jobs|get_key:report.full_name %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'extras:report' module=module.path name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
|
||||
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -1,91 +1,55 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% extends 'extras/script/base.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
{% load log_levels %}
|
||||
|
||||
{% block title %}{{ script }}{% endblock %}
|
||||
|
||||
{% block object_identifier %}
|
||||
{{ script.full_name }}
|
||||
{% endblock object_identifier %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module{{ module.pk }}">{{ module|bettertitle }}</a></li>
|
||||
{% endblock breadcrumbs %}
|
||||
|
||||
{% block subtitle %}
|
||||
<div class="object-subtitle">
|
||||
<div class="text-muted">{{ script.Meta.description|markdown }}</div>
|
||||
</div>
|
||||
{% endblock subtitle %}
|
||||
|
||||
{% block controls %}{% endblock %}
|
||||
|
||||
{% block tabs %}
|
||||
<ul class="nav nav-tabs px-3">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="#run" role="tab" data-bs-toggle="tab" class="nav-link active">Run</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="#source" role="tab" data-bs-toggle="tab" class="nav-link">Source</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock tabs %}
|
||||
|
||||
{% block content %}
|
||||
<div role="tabpanel" class="tab-pane active" id="run">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% if not perms.extras.run_script %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="mdi mdi-alert"></i>
|
||||
You do not have permission to run scripts.
|
||||
</div>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
{% if script.Meta.fieldsets %}
|
||||
{# Render grouped fields according to declared fieldsets #}
|
||||
{% for group, fields in script.Meta.fieldsets %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">{{ group }}</h5>
|
||||
</div>
|
||||
{% for name in fields %}
|
||||
{% with field=form|getfield:name %}
|
||||
{% render_field field %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% if not perms.extras.run_script %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="mdi mdi-alert"></i>
|
||||
You do not have permission to run scripts.
|
||||
</div>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
{% if script.Meta.fieldsets %}
|
||||
{# Render grouped fields according to declared fieldsets #}
|
||||
{% for group, fields in script.Meta.fieldsets %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">{{ group }}</h5>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# Render all fields as a single group #}
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Script Data</h5>
|
||||
{% for name in fields %}
|
||||
{% with field=form|getfield:name %}
|
||||
{% render_field field %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% render_form form %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<i class="mdi mdi-information"></i>
|
||||
This script does not require any input to run.
|
||||
{# Render all fields as a single group #}
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Script Data</h5>
|
||||
</div>
|
||||
{% render_form form %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="float-end">
|
||||
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>
|
||||
<button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="mdi mdi-play"></i> Run Script</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<i class="mdi mdi-information"></i>
|
||||
This script does not require any input to run.
|
||||
</div>
|
||||
{% render_form form %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="float-end">
|
||||
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>
|
||||
<button type="submit" name="_run" class="btn btn-primary"{% if not perms.extras.run_script %} disabled="disabled"{% endif %}><i class="mdi mdi-play"></i> Run Script</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="source">
|
||||
<code class="h6 my-3 d-block">{{ script.filename }}</code>
|
||||
<pre class="block">{{ script.source }}</pre>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
37
netbox/templates/extras/script/base.html
Normal file
37
netbox/templates/extras/script/base.html
Normal file
@ -0,0 +1,37 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
{% load log_levels %}
|
||||
|
||||
{% block title %}{{ script }}{% endblock %}
|
||||
|
||||
{% block object_identifier %}
|
||||
{{ script.full_name }}
|
||||
{% endblock object_identifier %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module{{ module.pk }}">{{ module|bettertitle }}</a></li>
|
||||
{% endblock breadcrumbs %}
|
||||
|
||||
{% block subtitle %}
|
||||
<div class="object-subtitle">
|
||||
<div class="text-muted">{{ script.Meta.description|markdown }}</div>
|
||||
</div>
|
||||
{% endblock subtitle %}
|
||||
|
||||
{% block controls %}{% endblock %}
|
||||
|
||||
{% block tabs %}
|
||||
<ul class="nav nav-tabs px-3">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link{% if not tab %} active{% endif %}" href="{% url 'extras:script' module=script.module name=script.class_name %}">Script</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link{% if tab == 'source' %} active{% endif %}" href="{% url 'extras:script_source' module=script.module name=script.class_name %}">Source</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:script_jobs' module=script.module name=script.class_name %}">Jobs</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock tabs %}
|
15
netbox/templates/extras/script/jobs.html
Normal file
15
netbox/templates/extras/script/jobs.html
Normal file
@ -0,0 +1,15 @@
|
||||
{% extends 'extras/script/base.html' %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-body table-responsive">
|
||||
{% render_table table 'inc/table.html' %}
|
||||
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
6
netbox/templates/extras/script/source.html
Normal file
6
netbox/templates/extras/script/source.html
Normal file
@ -0,0 +1,6 @@
|
||||
{% extends 'extras/script/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<code class="h6 my-3 d-block">{{ script.filename }}</code>
|
||||
<pre class="block">{{ script.source }}</pre>
|
||||
{% endblock %}
|
@ -48,7 +48,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for script_name, script_class in module.scripts.items %}
|
||||
{% with last_result=job_results|get_key:script_class.full_name %}
|
||||
{% with last_result=jobs|get_key:script_class.full_name %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'extras:script' module=module.path name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
|
||||
@ -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 %}
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user