diff --git a/netbox/core/api/serializers.py b/netbox/core/api/serializers.py index aa0cc7050..4117a609c 100644 --- a/netbox/core/api/serializers.py +++ b/netbox/core/api/serializers.py @@ -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', ] diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index 6d3e82e15..62a58086a 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -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(): diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py index fd31347e3..0fcb27bb0 100644 --- a/netbox/core/jobs.py +++ b/netbox/core/jobs.py @@ -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) diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index a4422ac79..c0007c290 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -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 {} diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 0ae626a74..d823a941b 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -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 diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index 662119dbc..540d252b2 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -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', ) diff --git a/netbox/core/views.py b/netbox/core/views.py index d49ac0023..06e821700 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -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()) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index bd775c668..65f74e2e2 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -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 "." - 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 = 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), diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index 6ce1251ad..808e07cdf 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -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 ) diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index 597fe05a7..76ceeb239 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -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() diff --git a/netbox/extras/models/reports.py b/netbox/extras/models/reports.py index 68174ef1a..66006c90f 100644 --- a/netbox/extras/models/reports.py +++ b/netbox/extras/models/reports.py @@ -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. """ diff --git a/netbox/extras/models/scripts.py b/netbox/extras/models/scripts.py index 859aa41bc..f830f134c 100644 --- a/netbox/extras/models/scripts.py +++ b/netbox/extras/models/scripts.py @@ -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. """ diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index ec7836329..00579f4e8 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -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}
{stacktrace}
") 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() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 061384e6a..b5be917f3 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -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) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 81a607eec..aa293e318 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -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): diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index bc3e33b01..400596c40 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -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//', views.ReportResultView.as_view(), name='report_result'), + path('reports/results//', views.ReportResultView.as_view(), name='report_result'), path('reports//', include(get_model_urls('extras', 'reportmodule'))), path('reports/./', views.ReportView.as_view(), name='report'), + path('reports/./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//', views.ScriptResultView.as_view(), name='script_result'), + path('scripts/results//', views.ScriptResultView.as_view(), name='script_result'), path('scripts//', include(get_model_urls('extras', 'scriptmodule'))), path('scripts/./', views.ScriptView.as_view(), name='script'), + path('scripts/./source/', views.ScriptSourceView.as_view(), name='script_source'), + path('scripts/./jobs/', views.ScriptJobsView.as_view(), name='script_jobs'), # Markdown path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown") diff --git a/netbox/extras/views.py b/netbox/extras/views.py index fc5f0aeea..65ebbfaa5 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -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, }) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 861f82882..5573b1abc 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -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, diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 8181b6d20..d78c8f754 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -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//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 "/.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): diff --git a/netbox/templates/core/object_jobs.html b/netbox/templates/core/object_jobs.html new file mode 100644 index 000000000..7d8c0a3b7 --- /dev/null +++ b/netbox/templates/core/object_jobs.html @@ -0,0 +1,15 @@ +{% extends base_template %} +{% load render_table from django_tables2 %} + +{% block content %} +
+
+
+
+ {% render_table table 'inc/table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html index fcf8cae68..d15898c3d 100644 --- a/netbox/templates/extras/htmx/report_result.html +++ b/netbox/templates/extras/htmx/report_result.html @@ -2,24 +2,24 @@ {% load helpers %}

- {% if result.started %} - Started: {{ result.started|annotated_date }} - {% elif result.scheduled %} - Scheduled for: {{ result.scheduled|annotated_date }} ({{ result.scheduled|naturaltime }}) + {% if job.started %} + Started: {{ job.started|annotated_date }} + {% elif job.scheduled %} + Scheduled for: {{ job.scheduled|annotated_date }} ({{ job.scheduled|naturaltime }}) {% else %} - Created: {{ result.created|annotated_date }} + Created: {{ job.created|annotated_date }} {% endif %} - {% if result.completed %} - Duration: {{ result.duration }} + {% if job.completed %} + Duration: {{ job.duration }} {% endif %} - {% badge result.get_status_display result.get_status_color %} + {% badge job.get_status_display job.get_status_color %}

-{% if result.completed %} +{% if job.completed %}
Report Methods
- {% for method, data in result.data.items %} + {% for method, data in job.data.items %} - {% for method, data in result.data.items %} + {% for method, data in job.data.items %}
{{ method }} @@ -46,7 +46,7 @@
{{ method }} @@ -75,6 +75,6 @@
-{% elif result.started %} +{% elif job.started %} {% include 'extras/inc/result_pending.html' %} {% endif %} diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index 6037c3052..5b2ac8cf3 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -3,19 +3,19 @@ {% load log_levels %}

- {% if result.started %} - Started: {{ result.started|annotated_date }} - {% elif result.scheduled %} - Scheduled for: {{ result.scheduled|annotated_date }} ({{ result.scheduled|naturaltime }}) + {% if job.started %} + Started: {{ job.started|annotated_date }} + {% elif job.scheduled %} + Scheduled for: {{ job.scheduled|annotated_date }} ({{ job.scheduled|naturaltime }}) {% else %} - Created: {{ result.created|annotated_date }} + Created: {{ job.created|annotated_date }} {% endif %} - {% if result.completed %} - Duration: {{ result.duration }} + {% if job.completed %} + Duration: {{ job.duration }} {% endif %} - {% badge result.get_status_display result.get_status_color %} + {% badge job.get_status_display job.get_status_color %}

-{% if result.completed %} +{% if job.completed %}
Script Log
@@ -25,7 +25,7 @@ Level Message - {% for log in result.data.log %} + {% for log in job.data.log %} {{ forloop.counter }} {% log_level log.status %} @@ -47,11 +47,11 @@ {% endif %}

Output

- {% if result.data.output %} -
{{ result.data.output }}
+ {% if job.data.output %} +
{{ job.data.output }}
{% else %}

None

{% endif %} -{% elif result.started %} +{% elif job.started %} {% include 'extras/inc/result_pending.html' %} {% endif %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 382c0669f..fa6b9660f 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -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 %} - - -{% endblock breadcrumbs %} - -{% block subtitle %} - {% if report.description %} -
-
{{ report.description|markdown }}
-
- {% endif %} -{% endblock subtitle %} - -{% block controls %}{% endblock %} - -{% block tabs %} - -{% endblock tabs %} - {% block content %}
{% if perms.extras.run_report %} @@ -55,7 +26,7 @@
{% if report.result %} - Last run: + Last run: {{ report.result.created|annotated_date }} {% endif %} diff --git a/netbox/templates/extras/report/base.html b/netbox/templates/extras/report/base.html new file mode 100644 index 000000000..218384f55 --- /dev/null +++ b/netbox/templates/extras/report/base.html @@ -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 %} + + +{% endblock breadcrumbs %} + +{% block subtitle %} + {% if report.description %} +
+
{{ report.description|markdown }}
+
+ {% endif %} +{% endblock subtitle %} + +{% block controls %}{% endblock %} + +{% block tabs %} + +{% endblock tabs %} diff --git a/netbox/templates/extras/report/jobs.html b/netbox/templates/extras/report/jobs.html new file mode 100644 index 000000000..a42e290cc --- /dev/null +++ b/netbox/templates/extras/report/jobs.html @@ -0,0 +1,15 @@ +{% extends 'extras/report/base.html' %} +{% load render_table from django_tables2 %} + +{% block content %} +
+
+
+
+ {% render_table table 'inc/table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index f2c527013..08d26aa2d 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -50,7 +50,7 @@ {% 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 %} {{ report.name }} @@ -58,7 +58,7 @@ {{ report.description|markdown|placeholder }} {% if last_result %} - {{ last_result.created|annotated_date }} + {{ last_result.created|annotated_date }} {% badge last_result.get_status_display last_result.get_status_color %} diff --git a/netbox/templates/extras/report_result.html b/netbox/templates/extras/report_result.html index ffa52f9b7..9358af364 100644 --- a/netbox/templates/extras/report_result.html +++ b/netbox/templates/extras/report_result.html @@ -4,7 +4,7 @@ {% block content-wrapper %}
-
+
{% include 'extras/htmx/report_result.html' %}
@@ -13,8 +13,8 @@ {% block controls %}
- {% if request.user|can_delete:result %} - {% delete_button result %} + {% if request.user|can_delete:job %} + {% delete_button job %} {% endif %}
diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index cc12d5a7c..0f74d6091 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -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 %} - - -{% endblock breadcrumbs %} - -{% block subtitle %} -
-
{{ script.Meta.description|markdown }}
-
-{% endblock subtitle %} - -{% block controls %}{% endblock %} - -{% block tabs %} - -{% endblock tabs %} - {% block content %} -
-
-
- {% if not perms.extras.run_script %} -
- - You do not have permission to run scripts. -
- {% endif %} -
- {% csrf_token %} -
- {% if form.requires_input %} - {% if script.Meta.fieldsets %} - {# Render grouped fields according to declared fieldsets #} - {% for group, fields in script.Meta.fieldsets %} -
-
-
{{ group }}
-
- {% for name in fields %} - {% with field=form|getfield:name %} - {% render_field field %} - {% endwith %} - {% endfor %} +
+
+ {% if not perms.extras.run_script %} +
+ + You do not have permission to run scripts. +
+ {% endif %} + + {% csrf_token %} +
+ {% if form.requires_input %} + {% if script.Meta.fieldsets %} + {# Render grouped fields according to declared fieldsets #} + {% for group, fields in script.Meta.fieldsets %} +
+
+
{{ group }}
- {% endfor %} - {% else %} - {# Render all fields as a single group #} -
-
Script Data
+ {% for name in fields %} + {% with field=form|getfield:name %} + {% render_field field %} + {% endwith %} + {% endfor %}
- {% render_form form %} - {% endif %} + {% endfor %} {% else %} -
- - This script does not require any input to run. + {# Render all fields as a single group #} +
+
Script Data
{% render_form form %} {% endif %} -
-
- Cancel - -
- -
+ {% else %} +
+ + This script does not require any input to run. +
+ {% render_form form %} + {% endif %} +
+
+ Cancel + +
+
-
- {{ script.filename }} -
{{ script.source }}
-
{% endblock content %} diff --git a/netbox/templates/extras/script/base.html b/netbox/templates/extras/script/base.html new file mode 100644 index 000000000..b788bc270 --- /dev/null +++ b/netbox/templates/extras/script/base.html @@ -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 %} + + +{% endblock breadcrumbs %} + +{% block subtitle %} +
+
{{ script.Meta.description|markdown }}
+
+{% endblock subtitle %} + +{% block controls %}{% endblock %} + +{% block tabs %} + +{% endblock tabs %} diff --git a/netbox/templates/extras/script/jobs.html b/netbox/templates/extras/script/jobs.html new file mode 100644 index 000000000..c550e5de7 --- /dev/null +++ b/netbox/templates/extras/script/jobs.html @@ -0,0 +1,15 @@ +{% extends 'extras/script/base.html' %} +{% load render_table from django_tables2 %} + +{% block content %} +
+
+
+
+ {% render_table table 'inc/table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/extras/script/source.html b/netbox/templates/extras/script/source.html new file mode 100644 index 000000000..733f6a1f3 --- /dev/null +++ b/netbox/templates/extras/script/source.html @@ -0,0 +1,6 @@ +{% extends 'extras/script/base.html' %} + +{% block content %} + {{ script.filename }} +
{{ script.source }}
+{% endblock %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 7377d5e8a..010444a3c 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -48,7 +48,7 @@ {% 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 %} {{ script_class.name }} @@ -58,7 +58,7 @@ {% if last_result %} - {{ last_result.created|annotated_date }} + {{ last_result.created|annotated_date }} {% badge last_result.get_status_display last_result.get_status_color %} diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html index bff3fc61e..4dfd7482a 100644 --- a/netbox/templates/extras/script_result.html +++ b/netbox/templates/extras/script_result.html @@ -16,8 +16,8 @@
@@ -28,8 +28,8 @@ {% block controls %}
- {% if request.user|can_delete:result %} - {% delete_button result %} + {% if request.user|can_delete:job %} + {% delete_button job %} {% endif %}
@@ -47,7 +47,7 @@
-
+
{% include 'extras/htmx/script_result.html' %}