diff --git a/docs/models/core/job.md b/docs/models/core/job.md index e462b5620..ad073f888 100644 --- a/docs/models/core/job.md +++ b/docs/models/core/job.md @@ -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. diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index ca7439128..82830d51c 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -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(): diff --git a/netbox/core/tests/test_models.py b/netbox/core/tests/test_models.py index ce275364b..2f8c21d6c 100644 --- a/netbox/core/tests/test_models.py +++ b/netbox/core/tests/test_models.py @@ -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)