Просмотр исходного кода

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>
Jeremy Stretch 2 недель назад
Родитель
Сommit
4d3871009c
3 измененных файлов с 90 добавлено и 5 удалено
  1. 3 0
      docs/models/core/job.md
  2. 38 5
      netbox/core/models/jobs.py
  3. 49 0
      netbox/core/tests/test_models.py

+ 3 - 0
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.

+ 38 - 5
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():

+ 49 - 0
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)