From e7e1362c35af555ed9ece821a21cb9fc29d024da Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Wed, 24 Jun 2026 16:39:59 +0200 Subject: [PATCH 1/2] Closes #22441: Add execution_time field to background jobs Adds a nullable DurationField computed as completed - started, set in Job.terminate(). Exposes it as an orderable table column, detail-panel attr, REST API field, and UI/API range filters (execution_time__gte / execution_time__lte). A plain stored field (not a GeneratedField) keeps the migration metadata-only, avoiding a full table rewrite on core_job. --- docs/models/core/job.md | 4 ++ netbox/core/api/serializers_/jobs.py | 4 +- netbox/core/filtersets.py | 13 ++++++- netbox/core/forms/filtersets.py | 11 ++++++ .../migrations/0025_add_job_execution_time.py | 16 ++++++++ netbox/core/models/jobs.py | 8 ++++ netbox/core/tables/jobs.py | 9 ++++- netbox/core/tests/test_api.py | 16 ++++++++ netbox/core/tests/test_filtersets.py | 14 ++++++- netbox/core/tests/test_models.py | 37 +++++++++++++++++++ netbox/core/ui/panels.py | 1 + netbox/netbox/ui/attrs.py | 11 ++++++ netbox/utilities/string.py | 27 ++++++++++++++ netbox/utilities/tests/test_string.py | 31 ++++++++++++++++ 14 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 netbox/core/migrations/0025_add_job_execution_time.py create mode 100644 netbox/utilities/tests/test_string.py diff --git a/docs/models/core/job.md b/docs/models/core/job.md index 05ed53024..43ba0d660 100644 --- a/docs/models/core/job.md +++ b/docs/models/core/job.md @@ -28,6 +28,10 @@ The interval (in minutes) at which a scheduled job should re-execute. The date and time at which the job completed (if complete). +### Execution Time + +The amount of time the job spent executing, calculated as the difference between its start and completion times. This is populated only once a started job has completed. + ### User The user who created the job. diff --git a/netbox/core/api/serializers_/jobs.py b/netbox/core/api/serializers_/jobs.py index 1d5959608..d5b2a7d9f 100644 --- a/netbox/core/api/serializers_/jobs.py +++ b/netbox/core/api/serializers_/jobs.py @@ -32,8 +32,8 @@ class JobSerializer(BaseModelSerializer): model = Job fields = [ 'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created', - 'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name', - 'notifications', 'log_entries', + 'scheduled', 'interval', 'started', 'completed', 'execution_time', 'user', 'data', 'error', 'job_id', + 'queue_name', 'notifications', 'log_entries', ] brief_fields = ('url', 'created', 'completed', 'user', 'status') diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index 7416dd7de..c3fc6c7f7 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -132,6 +132,17 @@ class JobFilterSet(BaseFilterSet): field_name='completed', lookup_expr='gte' ) + execution_time = django_filters.DurationFilter() + execution_time__gte = django_filters.DurationFilter( + field_name='execution_time', + lookup_expr='gte', + label=_('Execution time (minimum)') + ) + execution_time__lte = django_filters.DurationFilter( + field_name='execution_time', + lookup_expr='lte', + label=_('Execution time (maximum)') + ) status = django_filters.MultipleChoiceFilter( choices=JobStatusChoices, distinct=False, @@ -160,7 +171,7 @@ class JobFilterSet(BaseFilterSet): model = Job fields = ( 'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id', - 'queue_name', + 'queue_name', 'execution_time', ) def search(self, queryset, name, value): diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 7b931190e..56a0861ea 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -80,6 +80,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): 'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before', 'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation') ), + FieldSet('execution_time__gte', 'execution_time__lte', name=_('Execution')), ) object_type_id = ContentTypeChoiceField( label=_('Object Type'), @@ -140,6 +141,16 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('User') ) + execution_time__gte = forms.DurationField( + label=_('Execution time (minimum)'), + required=False, + help_text=_('Seconds, or HH:MM:SS') + ) + execution_time__lte = forms.DurationField( + label=_('Execution time (maximum)'), + required=False, + help_text=_('Seconds, or HH:MM:SS') + ) class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): diff --git a/netbox/core/migrations/0025_add_job_execution_time.py b/netbox/core/migrations/0025_add_job_execution_time.py new file mode 100644 index 000000000..f8c58d9ed --- /dev/null +++ b/netbox/core/migrations/0025_add_job_execution_time.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0024_job_notifications"), + ] + + operations = [ + migrations.AddField( + model_name="job", + name="execution_time", + field=models.DurationField(blank=True, editable=False, null=True), + ), + ] diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index be9b6f55c..36e412c54 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -84,6 +84,12 @@ class Job(models.Model): null=True, blank=True ) + execution_time = models.DurationField( + verbose_name=_('execution time'), + null=True, + blank=True, + editable=False + ) user = models.ForeignKey( to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, @@ -241,6 +247,8 @@ class Job(models.Model): if error: self.error = error self.completed = timezone.now() + if self.started: + self.execution_time = self.completed - self.started self.save() # Notify the user (if any) of completion diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index e97fb0ac4..caf4903e0 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -7,6 +7,7 @@ from core.constants import JOB_LOG_ENTRY_LEVELS from core.models import Job from core.tables.columns import BadgeColumn from netbox.tables import BaseTable, NetBoxTable, columns +from utilities.string import humanize_duration class JobTable(NetBoxTable): @@ -44,6 +45,9 @@ class JobTable(NetBoxTable): completed = columns.DateTimeColumn( verbose_name=_('Completed'), ) + execution_time = tables.Column( + verbose_name=_('Execution Time'), + ) queue_name = tables.Column( verbose_name=_('Queue'), ) @@ -58,7 +62,7 @@ class JobTable(NetBoxTable): model = Job fields = ( 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started', - 'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id', + 'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id', 'execution_time' ) default_columns = ( 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user', @@ -67,6 +71,9 @@ class JobTable(NetBoxTable): def render_log_entries(self, value): return len(value) + def render_execution_time(self, value): + return humanize_duration(value) + class JobLogEntryTable(BaseTable): timestamp = columns.DateTimeColumn( diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index 332169f2c..e2975e98d 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -206,10 +206,26 @@ class JobTestCase( status='completed', queue_name='default', job_id=uuid.uuid4(), + execution_time=timezone.timedelta(seconds=90), ), ] ) + def test_list_objects_by_execution_time(self): + """The Job list endpoint supports filtering and ordering by execution_time.""" + self.add_permissions('core.view_job') + url = reverse('core-api:job-list') + + # Filter: only the completed job has a (90s) execution_time + response = self.client.get(f'{url}?execution_time__gte=60', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + + # Ordering by execution_time should be accepted (NULLs sort to one end) + response = self.client.get(f'{url}?ordering=execution_time', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 3) + class BackgroundTaskTestCase(RQQueueTestMixin, TestCase): user_permissions = () diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index d8513d90a..15503b292 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -1,5 +1,5 @@ import uuid -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from django.contrib.contenttypes.models import ContentType from django.test import TestCase @@ -262,14 +262,17 @@ class JobTestCase(TestCase, BaseFilterSetTests): Job( name='Job 1', job_id=uuid.uuid4(), user=users[0], notifications=JobNotificationChoices.NOTIFICATION_ALWAYS, + execution_time=timedelta(seconds=30), ), Job( name='Job 2', job_id=uuid.uuid4(), user=users[0], notifications=JobNotificationChoices.NOTIFICATION_ALWAYS, + execution_time=timedelta(seconds=60), ), Job( name='Job 3', job_id=uuid.uuid4(), user=users[1], notifications=JobNotificationChoices.NOTIFICATION_ON_FAILURE, + execution_time=timedelta(seconds=120), ), Job( name='Job 4', job_id=uuid.uuid4(), user=users[2], @@ -293,6 +296,15 @@ class JobTestCase(TestCase, BaseFilterSetTests): ]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_execution_time(self): + """Filter Jobs by execution time (exact value and gte/lte range).""" + params = {'execution_time': timedelta(seconds=60)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'execution_time__gte': timedelta(seconds=60)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'execution_time__lte': timedelta(seconds=60)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ObjectTypeTestCase(TestCase, BaseFilterSetTests): queryset = ObjectType.objects.all() diff --git a/netbox/core/tests/test_models.py b/netbox/core/tests/test_models.py index 0b6de44c5..d4465c436 100644 --- a/netbox/core/tests/test_models.py +++ b/netbox/core/tests/test_models.py @@ -1,9 +1,11 @@ import uuid +from datetime import timedelta from unittest.mock import MagicMock, patch from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.test import TestCase +from django.utils import timezone from core.choices import JobNotificationChoices, JobStatusChoices, ObjectChangeActionChoices from core.models import DataSource, Job, ObjectType @@ -344,3 +346,38 @@ class JobTestCase(TestCase): 0, msg=f"Expected no notification for status={status} with notifications=never", ) + + @patch('core.models.jobs.job_end') + def test_execution_time_set_on_terminate(self, mock_job_end): + """ + terminate() should set execution_time to (completed - started) for a started job. + """ + job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + job.started = timezone.now() - timedelta(seconds=90) + job.save() + + job.terminate(status=JobStatusChoices.STATUS_COMPLETED) + + self.assertIsNotNone(job.execution_time) + self.assertEqual(job.execution_time, job.completed - job.started) + + @patch('core.models.jobs.job_end') + def test_execution_time_none_before_completion(self, mock_job_end): + """ + A job which has not completed should have a null execution_time. + """ + job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + self.assertIsNone(job.execution_time) + + @patch('core.models.jobs.job_end') + def test_execution_time_none_when_never_started(self, mock_job_end): + """ + Terminating a job which was never started (no started timestamp) should leave + execution_time null rather than computing a value from a missing start time. + """ + job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER) + self.assertIsNone(job.started) + + job.terminate(status=JobStatusChoices.STATUS_COMPLETED) + + self.assertIsNone(job.execution_time) diff --git a/netbox/core/ui/panels.py b/netbox/core/ui/panels.py index a2a0781bd..13e21ef79 100644 --- a/netbox/core/ui/panels.py +++ b/netbox/core/ui/panels.py @@ -62,6 +62,7 @@ class JobSchedulingPanel(panels.ObjectAttributesPanel): scheduled = attrs.TemplatedAttr('scheduled', template_name='core/job/attrs/scheduled.html') started = attrs.DateTimeAttr('started') completed = attrs.DateTimeAttr('completed') + execution_time = attrs.DurationAttr('execution_time') queue = attrs.TextAttr('queue_name', label=_('Queue')) diff --git a/netbox/netbox/ui/attrs.py b/netbox/netbox/ui/attrs.py index 60a14d33a..a352b10b8 100644 --- a/netbox/netbox/ui/attrs.py +++ b/netbox/netbox/ui/attrs.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.config import get_config from netbox.ui.utils import build_coords_url, is_coordinate_map_url from utilities.data import resolve_attr_path +from utilities.string import humanize_duration __all__ = ( 'AddressAttr', @@ -14,6 +15,7 @@ __all__ = ( 'ColorAttr', 'DateTimeAttr', 'DistanceAttr', + 'DurationAttr', 'GPSCoordinatesAttr', 'GenericForeignKeyAttr', 'ImageAttr', @@ -562,6 +564,15 @@ class TimezoneAttr(ObjectAttribute): template_name = 'ui/attrs/timezone.html' +class DurationAttr(TextAttr): + """ + A duration (timedelta) value, rendered in a human-friendly format (e.g. 1h 5m 23s). + """ + def get_value(self, obj): + value = resolve_attr_path(obj, self.accessor) + return humanize_duration(value) or None + + class TemplatedAttr(ObjectAttribute): """ Renders an attribute using a custom template. diff --git a/netbox/utilities/string.py b/netbox/utilities/string.py index fae9bc5be..70cf94819 100644 --- a/netbox/utilities/string.py +++ b/netbox/utilities/string.py @@ -2,12 +2,39 @@ import re __all__ = ( 'enum_key', + 'humanize_duration', 'remove_linebreaks', 'title', 'trailing_slash', ) +def humanize_duration(value): + """ + Express a timedelta in a human-friendly format. Example: 1h 5m 23s. Returns an empty string + for a null or zero-length duration. + """ + if not value: + return '' + + # Round to whole seconds and decompose + total_seconds = int(value.total_seconds()) + days, remainder = divmod(total_seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + + ret = '' + if days: + ret += f'{days}d ' + if hours: + ret += f'{hours}h ' + if minutes: + ret += f'{minutes}m ' + if seconds or not ret: + ret += f'{seconds}s' + return ret.strip() + + def enum_key(value): """ Convert the given value to a string suitable for use as an Enum key. diff --git a/netbox/utilities/tests/test_string.py b/netbox/utilities/tests/test_string.py new file mode 100644 index 000000000..0a9c98854 --- /dev/null +++ b/netbox/utilities/tests/test_string.py @@ -0,0 +1,31 @@ +from datetime import timedelta + +from django.test import TestCase + +from utilities.string import humanize_duration + + +class HumanizeDurationTest(TestCase): + + def test_none_and_zero(self): + self.assertEqual(humanize_duration(None), '') + self.assertEqual(humanize_duration(timedelta(0)), '') + + def test_seconds_only(self): + self.assertEqual(humanize_duration(timedelta(seconds=45)), '45s') + + def test_minutes_and_seconds(self): + self.assertEqual(humanize_duration(timedelta(minutes=5, seconds=23)), '5m 23s') + + def test_hours_minutes_seconds(self): + self.assertEqual(humanize_duration(timedelta(hours=1, minutes=5, seconds=23)), '1h 5m 23s') + + def test_days(self): + self.assertEqual(humanize_duration(timedelta(days=2, hours=3, minutes=17)), '2d 3h 17m') + + def test_whole_minute_omits_seconds(self): + self.assertEqual(humanize_duration(timedelta(minutes=2)), '2m') + + def test_sub_second_rounds_down_to_zero(self): + # Fractional seconds are truncated; a sub-second duration reads as 0s. + self.assertEqual(humanize_duration(timedelta(milliseconds=500)), '0s') From f1f84faa7f2e9722185368b5e85b1f03812d046a Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Wed, 24 Jun 2026 17:31:13 +0200 Subject: [PATCH 2/2] Fixes #22441: Address claudebot review feedback - humanize_duration: guard on 'is None' instead of falsiness so timedelta(0) renders as '0s' rather than blank - execution_time filter: add label= for consistency with sibling filters --- netbox/core/filtersets.py | 4 +++- netbox/utilities/string.py | 4 ++-- netbox/utilities/tests/test_string.py | 6 ++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index c3fc6c7f7..c1d3cadbf 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -132,7 +132,9 @@ class JobFilterSet(BaseFilterSet): field_name='completed', lookup_expr='gte' ) - execution_time = django_filters.DurationFilter() + execution_time = django_filters.DurationFilter( + label=_('Execution time') + ) execution_time__gte = django_filters.DurationFilter( field_name='execution_time', lookup_expr='gte', diff --git a/netbox/utilities/string.py b/netbox/utilities/string.py index 70cf94819..f19e1d313 100644 --- a/netbox/utilities/string.py +++ b/netbox/utilities/string.py @@ -12,9 +12,9 @@ __all__ = ( def humanize_duration(value): """ Express a timedelta in a human-friendly format. Example: 1h 5m 23s. Returns an empty string - for a null or zero-length duration. + for None; zero-duration timedeltas render as "0s". """ - if not value: + if value is None: return '' # Round to whole seconds and decompose diff --git a/netbox/utilities/tests/test_string.py b/netbox/utilities/tests/test_string.py index 0a9c98854..620ee730d 100644 --- a/netbox/utilities/tests/test_string.py +++ b/netbox/utilities/tests/test_string.py @@ -7,9 +7,11 @@ from utilities.string import humanize_duration class HumanizeDurationTest(TestCase): - def test_none_and_zero(self): + def test_none(self): self.assertEqual(humanize_duration(None), '') - self.assertEqual(humanize_duration(timedelta(0)), '') + + def test_zero_duration(self): + self.assertEqual(humanize_duration(timedelta(0)), '0s') def test_seconds_only(self): self.assertEqual(humanize_duration(timedelta(seconds=45)), '45s')