Retain Job.duration as a deprecated property

Job.duration has been public since 3.4 and is reachable from user-authored
export templates as well as plugins, so removing it outright was a silent
breaking change. Restore the original implementation verbatim — including the
fallback to `created` when a job never started, and the preformatted string —
so existing templates keep working, and warn on access. Planned for removal
in v5.0, matching the rack legacy fields.

Note that elapsed_time deliberately does not reproduce the `created`
fallback: measuring from creation conflates queue wait time with execution
time, which is what the new field is meant to record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jeremy Stretch 2026-08-05 19:37:15 -04:00
parent c9185e1eb7
commit 4d3871009c
3 changed files with 90 additions and 5 deletions

View File

@ -32,6 +32,9 @@ The date and time at which the job completed (if complete).
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; while a job is still running, NetBox displays the time elapsed since it started instead.
!!! warning "The duration property is deprecated"
The job model's `duration` property, which returned a preformatted string such as `5 minutes, 3.00 seconds`, has been **deprecated** and is planned for removal in NetBox v5.0. Export templates and plugins should reference `elapsed_time` instead, which returns a duration rather than a string and which also reports progress for a job that is still running.
### User
The user who created the job.

View File

@ -1,6 +1,8 @@
import logging
import uuid
import warnings
from dataclasses import asdict
from datetime import timedelta
from functools import partial
import django_rq
@ -184,17 +186,48 @@ class Job(models.Model):
_("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
)
@property
def duration(self):
"""
Deprecated: use `elapsed_time` instead, which reports a timedelta and also covers jobs which
are still running. Retained for the benefit of existing export templates and plugins.
"""
warnings.warn(
"Job.duration is deprecated and will be removed in NetBox v5.0; use Job.elapsed_time "
"instead.",
DeprecationWarning,
stacklevel=2,
)
if not self.completed:
return None
start_time = self.started or self.created
if not start_time:
return None
duration = self.completed - start_time
minutes, seconds = divmod(duration.total_seconds(), 60)
return f'{int(minutes)} minutes, {seconds:.2f} seconds'
@property
def elapsed_time(self):
"""
The job's recorded execution time, or the time elapsed so far if it is still running.
Returns None for a job which has not yet started.
Returns None for a job which has not yet started. As this is the value NetBox displays, an
anomalous negative duration (which can result from clock skew) is clamped to zero; the
stored `execution_time` is left as recorded.
"""
if self.execution_time is not None:
return self.execution_time
if self.started and not self.completed:
return timezone.now() - self.started
return None
elapsed = self.execution_time
elif self.started and not self.completed:
elapsed = timezone.now() - self.started
else:
return None
return max(elapsed, timedelta())
@staticmethod
def elapsed_time_expression():

View File

@ -418,6 +418,55 @@ class JobTestCase(TestCase):
self.assertIsNone(job.started)
self.assertIsNone(job.elapsed_time)
def test_elapsed_time_clamps_negative_execution_time(self):
"""
elapsed_time is the value NetBox displays, so an anomalous negative duration (e.g. resulting
from clock skew) is floored at zero. The stored execution_time is left as recorded, so that
the anomaly remains visible to the API and to exports.
"""
job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
job.started = timezone.now()
job.completed = timezone.now()
job.execution_time = timedelta(seconds=-5)
job.status = JobStatusChoices.STATUS_COMPLETED
job.save()
self.assertEqual(job.elapsed_time, timedelta())
self.assertEqual(job.execution_time, timedelta(seconds=-5))
def test_duration_is_deprecated(self):
"""
Job.duration is retained for existing export templates and plugins, but must warn.
"""
job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
job.started = timezone.now() - timedelta(seconds=90)
job.completed = job.started + timedelta(seconds=90)
job.status = JobStatusChoices.STATUS_COMPLETED
job.save()
with self.assertWarns(DeprecationWarning):
self.assertEqual(job.duration, '1 minutes, 30.00 seconds')
def test_duration_falls_back_to_created(self):
"""
The deprecated property's original behavior must be preserved: a job which completed without
ever starting reports its duration relative to creation.
"""
job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
job.started = None
job.completed = job.created + timedelta(seconds=30)
job.status = JobStatusChoices.STATUS_ERRORED
job.save()
with self.assertWarns(DeprecationWarning):
self.assertEqual(job.duration, '0 minutes, 30.00 seconds')
def test_duration_none_when_not_completed(self):
job = self._make_job(None, JobNotificationChoices.NOTIFICATION_NEVER)
with self.assertWarns(DeprecationWarning):
self.assertIsNone(job.duration)
def test_elapsed_time_none_when_completed_without_execution_time(self):
"""
A job which completed without recording an execution time (e.g. one predating the field)