Closes #12068: Establish a direct relationship from jobs to objects (#12075)

* 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:
Jeremy Stretch 2023-03-28 15:47:09 -04:00 committed by GitHub
parent 15590f1f48
commit d2a694a878
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 583 additions and 353 deletions

View File

@ -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',
]

View File

@ -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():

View File

@ -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)

View File

@ -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 {}

View File

@ -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

View File

@ -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',
)

View File

@ -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())

View File

@ -1,5 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from rest_framework import status
from rest_framework.decorators import action
@ -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),

View File

@ -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
)

View File

@ -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()

View File

@ -1,6 +1,7 @@
import inspect
from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
@ -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.
"""

View File

@ -1,6 +1,7 @@
import inspect
from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
@ -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.
"""

View File

@ -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()

View File

@ -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)

View File

@ -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):

View File

@ -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")

View File

@ -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,
})

View File

@ -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,

View File

@ -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):

View 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 %}

View File

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

View File

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

View File

@ -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 %}

View 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 %}

View 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 %}

View File

@ -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 %}

View File

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

View File

@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@ -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 %}

View File

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