Initial work on #19816

This commit is contained in:
Jeremy Stretch 2025-07-08 16:56:52 -04:00
parent a1cd81ff35
commit ae3de95dce
9 changed files with 163 additions and 5 deletions

View File

@ -23,6 +23,6 @@ class JobSerializer(BaseModelSerializer):
model = Job model = Job
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled',
'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
] ]
brief_fields = ('url', 'created', 'completed', 'user', 'status') brief_fields = ('url', 'created', 'completed', 'user', 'status')

View File

@ -0,0 +1,19 @@
import logging
from dataclasses import dataclass, field
from datetime import datetime
__all__ = (
'JobLogEntry',
)
@dataclass
class JobLogEntry:
level: str
message: str
timestamp: datetime = field(default_factory=datetime.now)
@classmethod
def from_logrecord(cls, record: logging.LogRecord):
return cls(record.levelname.lower(), record.msg)

View File

@ -0,0 +1,25 @@
import django.contrib.postgres.fields
import django.core.serializers.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0015_remove_redundant_indexes'),
]
operations = [
migrations.AddField(
model_name='job',
name='log_entries',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.JSONField(
encoder=django.core.serializers.json.DjangoJSONEncoder
),
blank=True,
default=list,
size=None
),
),
]

View File

@ -1,9 +1,12 @@
import logging
import uuid import uuid
from dataclasses import asdict
from functools import partial from functools import partial
import django_rq import django_rq
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -14,6 +17,7 @@ from django.utils.translation import gettext as _
from rq.exceptions import InvalidJobOperation from rq.exceptions import InvalidJobOperation
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.dataclasses import JobLogEntry
from core.models import ObjectType from core.models import ObjectType
from core.signals import job_end, job_start from core.signals import job_end, job_start
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
@ -104,6 +108,14 @@ class Job(models.Model):
verbose_name=_('job ID'), verbose_name=_('job ID'),
unique=True unique=True
) )
log_entries = ArrayField(
base_field=models.JSONField(
encoder=DjangoJSONEncoder,
# TODO: Specify a decoder to handle ISO 8601 timestamps
),
blank=True,
default=list,
)
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
@ -271,3 +283,10 @@ class Job(models.Model):
transaction.on_commit(callback) transaction.on_commit(callback)
return job return job
def log(self, record: logging.LogRecord):
"""
Record a Python LogRecord in the job's log.
"""
entry = JobLogEntry.from_logrecord(record)
self.log_entries.append(asdict(entry))

View File

@ -1,7 +1,7 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable, columns from netbox.tables import BaseTable, NetBoxTable, columns
from ..models import Job from ..models import Job
@ -40,6 +40,9 @@ class JobTable(NetBoxTable):
completed = columns.DateTimeColumn( completed = columns.DateTimeColumn(
verbose_name=_('Completed'), verbose_name=_('Completed'),
) )
log_entries = tables.Column(
verbose_name=_('Log Entries'),
)
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=('delete',) actions=('delete',)
) )
@ -53,3 +56,22 @@ class JobTable(NetBoxTable):
default_columns = ( default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user', 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
) )
def render_log_entries(self, value):
return len(value)
class JobLogEntryTable(BaseTable):
timestamp = tables.Column(
verbose_name=_('Time'),
)
level = tables.Column(
verbose_name=_('Level'),
)
message = tables.Column(
verbose_name=_('Message'),
)
class Meta(BaseTable.Meta):
empty_text = _('No log entries')
fields = ('timestamp', 'level', 'message')

View File

@ -32,13 +32,13 @@ from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
from utilities.json import ConfigJSONEncoder from utilities.json import ConfigJSONEncoder
from utilities.query import count_related from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, ViewTab, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import DataSourceStatusChoices from .choices import DataSourceStatusChoices
from .jobs import SyncDataSourceJob from .jobs import SyncDataSourceJob
from .models import * from .models import *
from .plugins import get_catalog_plugins, get_local_plugins from .plugins import get_catalog_plugins, get_local_plugins
from .tables import CatalogPluginTable, PluginVersionTable from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
# #
@ -184,6 +184,25 @@ class JobView(generic.ObjectView):
actions = (DeleteObject,) actions = (DeleteObject,)
@register_model_view(Job, 'log')
class JobLogView(generic.ObjectView):
queryset = Job.objects.all()
actions = (DeleteObject,)
template_name = 'core/job_log.html'
tab = ViewTab(
label=_('Log'),
badge=lambda obj: len(obj.log_entries),
weight=500,
)
def get_extra_context(self, request, instance):
table = JobLogEntryTable(instance.log_entries)
table.configure(request)
return {
'table': table,
}
@register_model_view(Job, 'delete') @register_model_view(Job, 'delete')
class JobDeleteView(generic.ObjectDeleteView): class JobDeleteView(generic.ObjectDeleteView):
queryset = Job.objects.defer('data') queryset = Job.objects.defer('data')

View File

@ -90,7 +90,10 @@ class ScriptJob(JobRunner):
request: The WSGI request associated with this execution (if any) request: The WSGI request associated with this execution (if any)
commit: Passed through to Script.run() commit: Passed through to Script.run()
""" """
script = ScriptModel.objects.get(pk=self.job.object_id).python_class() script_model = ScriptModel.objects.get(pk=self.job.object_id)
self.logger.debug(f"Found ScriptModel ID {script_model.pk}")
script = script_model.python_class()
self.logger.debug(f"Loaded script {script.full_name}")
# Add files to form data # Add files to form data
if request: if request:
@ -100,6 +103,7 @@ class ScriptJob(JobRunner):
# Add the current request as a property of the script # Add the current request as a property of the script
script.request = request script.request = request
self.logger.debug(f"Request ID: {request.id}")
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process # Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
# change logging, event rules, etc. # change logging, event rules, etc.

View File

@ -34,6 +34,17 @@ def system_job(interval):
return _wrapper return _wrapper
class JobLogHandler(logging.Handler):
def __init__(self, job, *args, **kwargs):
super().__init__(*args, **kwargs)
self.job = job
def emit(self, record):
# Enter the record in the log of the associated Job
self.job.log(record)
class JobRunner(ABC): class JobRunner(ABC):
""" """
Background Job helper class. Background Job helper class.
@ -52,6 +63,11 @@ class JobRunner(ABC):
""" """
self.job = job self.job = job
# Initiate the system logger
self.logger = logging.getLogger(f"netbox.jobs.{self.__class__.__name__}")
self.logger.setLevel(logging.DEBUG)
self.logger.addHandler(JobLogHandler(job))
@classproperty @classproperty
def name(cls): def name(cls):
return getattr(cls.Meta, 'name', cls.__name__) return getattr(cls.Meta, 'name', cls.__name__)

View File

@ -0,0 +1,34 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load helpers %}
{% load perms %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% block breadcrumbs %}
{{ block.super }}
{% if object.object %}
<li class="breadcrumb-item">
<a href="{% url 'core:job_list' %}?object_type={{ object.object_type_id }}">{{ object.object|meta:"verbose_name_plural"|bettertitle }}</a>
</li>
{% with parent_jobs_viewname=object.object|viewname:"jobs" %}
<li class="breadcrumb-item">
<a href="{% url parent_jobs_viewname pk=object.object.pk %}">{{ object.object }}</a>
</li>
{% endwith %}
{% else %}
<li class="breadcrumb-item">
<a href="{% url 'core:job_list' %}?name={{ object.name|urlencode }}">{{ object.name }}</a>
</li>
{% endif %}
{% endblock breadcrumbs %}
{% block content %}
<div class="row mb-3">
<div class="col">
<div class="card">
{% render_table table %}
</div>
</div>
</div>
{% endblock %}