nautobot 2.4.4__py3-none-any.whl → 2.4.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of nautobot might be problematic. Click here for more details.

Files changed (42) hide show
  1. nautobot/__init__.py +19 -3
  2. nautobot/core/celery/__init__.py +5 -3
  3. nautobot/core/jobs/__init__.py +3 -2
  4. nautobot/core/testing/__init__.py +2 -0
  5. nautobot/core/testing/mixins.py +9 -0
  6. nautobot/core/tests/test_jobs.py +26 -27
  7. nautobot/dcim/tests/test_jobs.py +4 -6
  8. nautobot/extras/choices.py +8 -3
  9. nautobot/extras/jobs.py +181 -103
  10. nautobot/extras/management/utils.py +13 -2
  11. nautobot/extras/models/datasources.py +4 -1
  12. nautobot/extras/models/jobs.py +20 -17
  13. nautobot/extras/tables.py +25 -29
  14. nautobot/extras/test_jobs/atomic_transaction.py +6 -6
  15. nautobot/extras/test_jobs/fail.py +75 -1
  16. nautobot/extras/tests/test_api.py +1 -1
  17. nautobot/extras/tests/test_datasources.py +64 -54
  18. nautobot/extras/tests/test_jobs.py +69 -62
  19. nautobot/extras/tests/test_models.py +1 -1
  20. nautobot/extras/tests/test_relationships.py +5 -5
  21. nautobot/extras/views.py +7 -1
  22. nautobot/ipam/forms.py +15 -0
  23. nautobot/ipam/querysets.py +6 -0
  24. nautobot/ipam/templates/ipam/rir.html +1 -43
  25. nautobot/ipam/tests/test_models.py +16 -0
  26. nautobot/ipam/urls.py +1 -21
  27. nautobot/ipam/views.py +24 -41
  28. nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +43 -5
  29. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +35 -0
  30. nautobot/project-static/docs/development/jobs/index.html +27 -14
  31. nautobot/project-static/docs/objects.inv +0 -0
  32. nautobot/project-static/docs/release-notes/version-2.4.html +183 -0
  33. nautobot/project-static/docs/requirements.txt +1 -1
  34. nautobot/project-static/docs/search/search_index.json +1 -1
  35. nautobot/project-static/docs/sitemap.xml +290 -290
  36. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  37. {nautobot-2.4.4.dist-info → nautobot-2.4.5.dist-info}/METADATA +3 -3
  38. {nautobot-2.4.4.dist-info → nautobot-2.4.5.dist-info}/RECORD +42 -42
  39. {nautobot-2.4.4.dist-info → nautobot-2.4.5.dist-info}/LICENSE.txt +0 -0
  40. {nautobot-2.4.4.dist-info → nautobot-2.4.5.dist-info}/NOTICE +0 -0
  41. {nautobot-2.4.4.dist-info → nautobot-2.4.5.dist-info}/WHEEL +0 -0
  42. {nautobot-2.4.4.dist-info → nautobot-2.4.5.dist-info}/entry_points.txt +0 -0
@@ -62,6 +62,20 @@ class TestFailJob(Job):
62
62
  logger.info("after_return() was called as expected")
63
63
 
64
64
 
65
+ class TestFailInBeforeStart(TestFailJob):
66
+ """
67
+ Job that raises an exception in before_start().
68
+ """
69
+
70
+ def before_start(self, task_id, args, kwargs):
71
+ super().before_start(task_id, args, kwargs)
72
+ logger.info("I'm a test job that fails!")
73
+ raise RunJobTaskFailed("Setup failure")
74
+
75
+ def run(self):
76
+ raise RuntimeError("run() was unexpectedly called after a failure in before_start()")
77
+
78
+
65
79
  class TestFailWithSanitization(Job):
66
80
  """
67
81
  Job with fail result that should be sanitized.
@@ -91,4 +105,64 @@ class TestFailWithSanitization(Job):
91
105
  raise exc
92
106
 
93
107
 
94
- register_jobs(TestFailJob, TestFailWithSanitization)
108
+ class TestFailCleanly(TestFailJob):
109
+ """
110
+ Job that fails "cleanly" through self.fail() instead of raising an exception.
111
+ """
112
+
113
+ def run(self): # pylint: disable=arguments-differ
114
+ logger.info("I'm a test job that fails!")
115
+ self.fail("Failure")
116
+ return "We failed"
117
+
118
+ def on_failure(self, exc, task_id, args, kwargs, einfo):
119
+ if exc != "We failed":
120
+ raise RuntimeError(f"Expected exc to be the message returned from run(), but it was {exc!r}")
121
+ if task_id != self.request.id: # pylint: disable=no-member
122
+ raise RuntimeError(f"Expected task_id {task_id} to equal self.request.id {self.request.id}") # pylint: disable=no-member
123
+ if args:
124
+ raise RuntimeError(f"Expected args to be empty, but it was {args!r}")
125
+ if kwargs:
126
+ raise RuntimeError(f"Expected kwargs to be empty, but it was {kwargs!r}")
127
+ if einfo is not None:
128
+ raise RuntimeError(f"Expected einfo to be None, but it was {einfo!r}")
129
+ logger.info("on_failure() was called as expected")
130
+
131
+ def after_return(self, status, retval, task_id, args, kwargs, einfo):
132
+ if status is not JobResultStatusChoices.STATUS_FAILURE:
133
+ raise RuntimeError(f"Expected status to be {JobResultStatusChoices.STATUS_FAILURE}, but it was {status!r}")
134
+ if retval != "We failed":
135
+ raise RuntimeError(f"Expected retval to be the message returned from run(), but it was {retval!r}")
136
+ if task_id != self.request.id: # pylint: disable=no-member
137
+ raise RuntimeError(f"Expected task_id {task_id} to equal self.request.id {self.request.id}") # pylint: disable=no-member
138
+ if args:
139
+ raise RuntimeError(f"Expected args to be empty, but it was {args!r}")
140
+ if kwargs:
141
+ raise RuntimeError(f"Expected kwargs to be empty, but it was {kwargs!r}")
142
+ if einfo is not None:
143
+ raise RuntimeError(f"Expected einfo to be None, but it was {einfo!r}")
144
+ logger.info("after_return() was called as expected")
145
+
146
+
147
+ class TestFailCleanlyInBeforeStart(TestFailCleanly):
148
+ """
149
+ Job that fails "cleanly" during before_start() through self.fail() instead of raising an exception.
150
+ """
151
+
152
+ def before_start(self, task_id, args, kwargs):
153
+ super().before_start(task_id, args, kwargs)
154
+ logger.info("I'm a test job that fails!")
155
+ self.fail("We failed")
156
+ return "We failed"
157
+
158
+ def run(self):
159
+ raise RuntimeError("run() was unexpectedly called after a failure in before_start()")
160
+
161
+
162
+ register_jobs(
163
+ TestFailJob,
164
+ TestFailInBeforeStart,
165
+ TestFailWithSanitization,
166
+ TestFailCleanly,
167
+ TestFailCleanlyInBeforeStart,
168
+ )
@@ -1917,7 +1917,7 @@ class JobTest(
1917
1917
  mock_get_worker_count.return_value = 1
1918
1918
  self.add_permissions("extras.run_job")
1919
1919
 
1920
- job_model = Job.objects.get(job_class_name="ExampleJob")
1920
+ job_model = Job.objects.get(job_class_name="TestHasSensitiveVariables")
1921
1921
  job_model.enabled = True
1922
1922
  job_model.validated_save()
1923
1923
 
@@ -238,11 +238,7 @@ class GitTest(TransactionTestCase):
238
238
  repository=self.repo.pk,
239
239
  )
240
240
 
241
- self.assertEqual(
242
- job_result.status,
243
- JobResultStatusChoices.STATUS_FAILURE,
244
- (job_result.result, list(job_result.job_log_entries.values_list("message", "log_object"))),
245
- )
241
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
246
242
  self.repo.refresh_from_db()
247
243
 
248
244
  log_entries = JobLogEntry.objects.filter(job_result=job_result)
@@ -308,11 +304,7 @@ class GitTest(TransactionTestCase):
308
304
  repository=self.repo.pk,
309
305
  )
310
306
 
311
- self.assertEqual(
312
- job_result.status,
313
- JobResultStatusChoices.STATUS_SUCCESS,
314
- (job_result.traceback, list(job_result.job_log_entries.values_list("message", flat=True))),
315
- )
307
+ self.assertJobResultStatus(job_result)
316
308
  self.repo.refresh_from_db()
317
309
  MockGitRepo.assert_called_with(
318
310
  os.path.join(tempdir, self.repo.slug),
@@ -331,11 +323,7 @@ class GitTest(TransactionTestCase):
331
323
  job_model = GitRepositorySync().job_model
332
324
  job_result = run_job_for_testing(job=job_model, repository=self.repo.pk)
333
325
  job_result.refresh_from_db()
334
- self.assertEqual(
335
- job_result.status,
336
- JobResultStatusChoices.STATUS_SUCCESS,
337
- (job_result.traceback, list(job_result.job_log_entries.values_list("message", flat=True))),
338
- )
326
+ self.assertJobResultStatus(job_result)
339
327
 
340
328
  # Make sure explicit ConfigContext was successfully loaded from file
341
329
  self.assert_explicit_config_context_exists("Frobozz 1000 NTP servers")
@@ -383,11 +371,7 @@ class GitTest(TransactionTestCase):
383
371
  # Run the Git operation and refresh the object from the DB
384
372
  job_result = run_job_for_testing(job=job_model, repository=self.repo.pk)
385
373
  job_result.refresh_from_db()
386
- self.assertEqual(
387
- job_result.status,
388
- JobResultStatusChoices.STATUS_SUCCESS,
389
- (job_result.traceback, list(job_result.job_log_entries.values_list("message", flat=True))),
390
- )
374
+ self.assertJobResultStatus(job_result)
391
375
 
392
376
  # Verify that objects have been removed from the database
393
377
  self.assertEqual(
@@ -442,11 +426,7 @@ class GitTest(TransactionTestCase):
442
426
  )
443
427
  job_result.refresh_from_db()
444
428
 
445
- self.assertEqual(
446
- job_result.status,
447
- JobResultStatusChoices.STATUS_FAILURE,
448
- job_result.result,
449
- )
429
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
450
430
 
451
431
  # Due to transaction rollback on failure, the database should still/again match the pre-sync state, of
452
432
  # no records owned by the repository.
@@ -547,11 +527,7 @@ class GitTest(TransactionTestCase):
547
527
  )
548
528
  job_result.refresh_from_db()
549
529
 
550
- self.assertEqual(
551
- job_result.status,
552
- JobResultStatusChoices.STATUS_SUCCESS,
553
- (job_result.traceback, list(job_result.job_log_entries.values_list("message", flat=True))),
554
- )
530
+ self.assertJobResultStatus(job_result)
555
531
 
556
532
  # Make sure ConfigContext was successfully loaded from file
557
533
  config_context = ConfigContext.objects.get(
@@ -591,15 +567,7 @@ class GitTest(TransactionTestCase):
591
567
  delete_job_result = JobResult.objects.filter(name=repo_name).first()
592
568
  # Make sure we didn't get the wrong JobResult
593
569
  self.assertNotEqual(job_result, delete_job_result)
594
- self.assertEqual(
595
- delete_job_result.status,
596
- JobResultStatusChoices.STATUS_SUCCESS,
597
- (
598
- delete_job_result,
599
- delete_job_result.traceback,
600
- list(delete_job_result.job_log_entries.values_list("message", flat=True)),
601
- ),
602
- )
570
+ self.assertJobResultStatus(delete_job_result)
603
571
 
604
572
  with self.assertRaises(ConfigContext.DoesNotExist):
605
573
  ConfigContext.objects.get(
@@ -637,11 +605,7 @@ class GitTest(TransactionTestCase):
637
605
  job_model = GitRepositorySync().job_model
638
606
  job_result = run_job_for_testing(job=job_model, repository=self.repo.pk)
639
607
  job_result.refresh_from_db()
640
- self.assertEqual(
641
- job_result.status,
642
- JobResultStatusChoices.STATUS_SUCCESS,
643
- (job_result.traceback, list(job_result.job_log_entries.values_list("message", flat=True))),
644
- )
608
+ self.assertJobResultStatus(job_result)
645
609
 
646
610
  self.assert_explicit_config_context_exists("Frobozz 1000 NTP servers")
647
611
  self.assert_implicit_config_context_exists("Location context")
@@ -673,11 +637,7 @@ class GitTest(TransactionTestCase):
673
637
  # Resync, attempting and failing to update to the new commit
674
638
  job_result = run_job_for_testing(job=job_model, repository=self.repo.pk)
675
639
  job_result.refresh_from_db()
676
- self.assertEqual(
677
- job_result.status,
678
- JobResultStatusChoices.STATUS_FAILURE,
679
- job_result.result,
680
- )
640
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
681
641
  log_entries = JobLogEntry.objects.filter(job_result=job_result)
682
642
 
683
643
  # Assert database changes were rolled back
@@ -718,11 +678,7 @@ class GitTest(TransactionTestCase):
718
678
  )
719
679
  job_result.refresh_from_db()
720
680
 
721
- self.assertEqual(
722
- job_result.status,
723
- JobResultStatusChoices.STATUS_SUCCESS,
724
- (job_result.traceback, list(job_result.job_log_entries.values_list("message", flat=True))),
725
- )
681
+ self.assertJobResultStatus(job_result)
726
682
 
727
683
  log_entries = JobLogEntry.objects.filter(job_result=job_result)
728
684
 
@@ -798,3 +754,57 @@ class GitTest(TransactionTestCase):
798
754
  "provides contents overlapping with this repository.",
799
755
  str(cm.exception),
800
756
  )
757
+
758
+ @mock.patch("nautobot.extras.models.datasources.GitRepo")
759
+ def test_clone_to_directory_with_secrets(self, MockGitRepo):
760
+ """
761
+ The clone_to_directory method should correctly make use of secrets.
762
+ """
763
+ with tempfile.TemporaryDirectory() as tempdir:
764
+ # Prepare secrets values
765
+ with open(os.path.join(tempdir, "username.txt"), "wt") as handle:
766
+ handle.write("núñez")
767
+
768
+ with open(os.path.join(tempdir, "token.txt"), "wt") as handle:
769
+ handle.write("1:3@/?=ab@")
770
+
771
+ # Create secrets and assign
772
+ username_secret = Secret.objects.create(
773
+ name="Git Username",
774
+ provider="text-file",
775
+ parameters={"path": os.path.join(tempdir, "username.txt")},
776
+ )
777
+ token_secret = Secret.objects.create(
778
+ name="Git Token",
779
+ provider="text-file",
780
+ parameters={"path": os.path.join(tempdir, "token.txt")},
781
+ )
782
+ secrets_group = SecretsGroup.objects.create(name="Git Credentials")
783
+ SecretsGroupAssociation.objects.create(
784
+ secret=username_secret,
785
+ secrets_group=secrets_group,
786
+ access_type=SecretsGroupAccessTypeChoices.TYPE_HTTP,
787
+ secret_type=SecretsGroupSecretTypeChoices.TYPE_USERNAME,
788
+ )
789
+ SecretsGroupAssociation.objects.create(
790
+ secret=token_secret,
791
+ secrets_group=secrets_group,
792
+ access_type=SecretsGroupAccessTypeChoices.TYPE_HTTP,
793
+ secret_type=SecretsGroupSecretTypeChoices.TYPE_TOKEN,
794
+ )
795
+
796
+ # Configure GitRepository model
797
+ self.repo.secrets_group = secrets_group
798
+ self.repo.remote_url = "http://localhost/git.git"
799
+ self.repo.save()
800
+
801
+ # Try to clone it
802
+ self.repo.clone_to_directory(tempdir, "main")
803
+
804
+ # Assert that GitRepo was called with proper args
805
+ args, kwargs = MockGitRepo.call_args
806
+ path, from_url = args
807
+ self.assertTrue(path.startswith(os.path.join(tempdir, self.repo.slug)))
808
+ self.assertEqual(from_url, "http://n%C3%BA%C3%B1ez:1%3A3%40%2F%3F%3Dab%40@localhost/git.git")
809
+ self.assertEqual(kwargs["depth"], 0)
810
+ self.assertEqual(kwargs["branch"], "main")
@@ -317,7 +317,7 @@ class JobTransactionTest(TransactionTestCase):
317
317
  pk_list=pk_list,
318
318
  username=self.user.username,
319
319
  )
320
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
320
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
321
321
  error_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
322
322
  self.assertIn(
323
323
  f"Unable to delete Job {system_job_queryset.first()}. System Job cannot be deleted", error_log.message
@@ -346,7 +346,7 @@ class JobTransactionTest(TransactionTestCase):
346
346
  },
347
347
  username=self.user.username,
348
348
  )
349
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
349
+ self.assertJobResultStatus(job_result)
350
350
  self.assertEqual(Job.objects.filter(description=job_description).count(), queryset.count())
351
351
  self.assertFalse(
352
352
  JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
@@ -389,7 +389,7 @@ class JobTransactionTest(TransactionTestCase):
389
389
  },
390
390
  username=self.user.username,
391
391
  )
392
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
392
+ self.assertJobResultStatus(job_result)
393
393
  self.assertEqual(Job.objects.filter(description=job_description).count(), queryset.count())
394
394
  self.assertFalse(
395
395
  JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
@@ -426,7 +426,7 @@ class JobTransactionTest(TransactionTestCase):
426
426
  module = "pass"
427
427
  name = "TestPassJob"
428
428
  job_result = create_job_result_and_run_job(module, name)
429
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
429
+ self.assertJobResultStatus(job_result)
430
430
  self.assertEqual(job_result.result, True)
431
431
  logs = job_result.job_log_entries
432
432
  self.assertGreater(logs.count(), 0)
@@ -449,7 +449,7 @@ class JobTransactionTest(TransactionTestCase):
449
449
  name = "TestHasSensitiveVariables"
450
450
  # This function create_job_result_and_run_job and the subsequent functions' arguments are very messy
451
451
  job_result = create_job_result_and_run_job(module, name, "local", 1, 2, "3", kwarg_1=1, kwarg_2="2")
452
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
452
+ self.assertJobResultStatus(job_result)
453
453
  self.assertEqual(job_result.task_args, [])
454
454
  self.assertEqual(job_result.task_kwargs, {})
455
455
 
@@ -458,21 +458,22 @@ class JobTransactionTest(TransactionTestCase):
458
458
  Job test with fail result.
459
459
  """
460
460
  module = "fail"
461
- name = "TestFailJob"
462
- job_result = create_job_result_and_run_job(module, name)
463
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
464
- logs = job_result.job_log_entries
465
- self.assertGreater(logs.count(), 0)
466
- try:
467
- logs.get(message="before_start() was called as expected")
468
- logs.get(message="I'm a test job that fails!")
469
- logs.get(message="on_failure() was called as expected")
470
- logs.get(message="after_return() was called as expected")
471
- except models.JobLogEntry.DoesNotExist:
472
- for log in logs.all():
473
- print(log.message)
474
- print(job_result.traceback)
475
- raise
461
+ for name in ["TestFailJob", "TestFailInBeforeStart", "TestFailCleanly", "TestFailCleanlyInBeforeStart"]:
462
+ with self.subTest(job=name):
463
+ job_result = create_job_result_and_run_job(module, name)
464
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
465
+ logs = job_result.job_log_entries
466
+ self.assertGreater(logs.count(), 0)
467
+ try:
468
+ logs.get(message="before_start() was called as expected")
469
+ logs.get(message="I'm a test job that fails!")
470
+ logs.get(message="on_failure() was called as expected")
471
+ logs.get(message="after_return() was called as expected")
472
+ except models.JobLogEntry.DoesNotExist:
473
+ for log in logs.all():
474
+ print(log.message)
475
+ print(job_result.traceback)
476
+ raise
476
477
 
477
478
  def test_job_fail_with_sanitization(self):
478
479
  """
@@ -482,7 +483,7 @@ class JobTransactionTest(TransactionTestCase):
482
483
  name = "TestFailWithSanitization"
483
484
  job_result = create_job_result_and_run_job(module, name)
484
485
  json_result = json.dumps(job_result.result)
485
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
486
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
486
487
  self.assertIn("(redacted)@github.com", json_result)
487
488
  self.assertNotIn("abc123@github.com", json_result)
488
489
  self.assertIn("(redacted)@github.com", job_result.traceback)
@@ -495,7 +496,7 @@ class JobTransactionTest(TransactionTestCase):
495
496
  module = "atomic_transaction"
496
497
  name = "TestAtomicDecorator"
497
498
  job_result = create_job_result_and_run_job(module, name)
498
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
499
+ self.assertJobResultStatus(job_result)
499
500
  # Ensure DB transaction was not aborted
500
501
  self.assertTrue(models.Status.objects.filter(name="Test database atomic rollback 1").exists())
501
502
  # Ensure the correct job log messages were saved
@@ -513,7 +514,7 @@ class JobTransactionTest(TransactionTestCase):
513
514
  module = "atomic_transaction"
514
515
  name = "TestAtomicContextManager"
515
516
  job_result = create_job_result_and_run_job(module, name)
516
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
517
+ self.assertJobResultStatus(job_result)
517
518
  # Ensure DB transaction was not aborted
518
519
  self.assertTrue(models.Status.objects.filter(name="Test database atomic rollback 2").exists())
519
520
  # Ensure the correct job log messages were saved
@@ -530,8 +531,8 @@ class JobTransactionTest(TransactionTestCase):
530
531
  """
531
532
  module = "atomic_transaction"
532
533
  name = "TestAtomicDecorator"
533
- job_result = create_job_result_and_run_job(module, name, fail=True)
534
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
534
+ job_result = create_job_result_and_run_job(module, name, should_fail=True)
535
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
535
536
  # Ensure DB transaction was aborted
536
537
  self.assertFalse(models.Status.objects.filter(name="Test database atomic rollback 1").exists())
537
538
  # Ensure the correct job log messages were saved
@@ -547,8 +548,8 @@ class JobTransactionTest(TransactionTestCase):
547
548
  """
548
549
  module = "atomic_transaction"
549
550
  name = "TestAtomicContextManager"
550
- job_result = create_job_result_and_run_job(module, name, fail=True)
551
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
551
+ job_result = create_job_result_and_run_job(module, name, should_fail=True)
552
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
552
553
  # Ensure DB transaction was aborted
553
554
  self.assertFalse(models.Status.objects.filter(name="Test database atomic rollback 2").exists())
554
555
  # Ensure the correct job log messages were saved
@@ -596,7 +597,7 @@ class JobTransactionTest(TransactionTestCase):
596
597
  job_result_data = json.loads(log_info.log_object) if log_info.log_object else None
597
598
 
598
599
  # Assert stuff
599
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
600
+ self.assertJobResultStatus(job_result)
600
601
  self.assertEqual(form_data, job_result_data)
601
602
 
602
603
  @override_settings(
@@ -651,7 +652,7 @@ class JobTransactionTest(TransactionTestCase):
651
652
  ).first()
652
653
 
653
654
  # Assert stuff
654
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
655
+ self.assertJobResultStatus(job_result)
655
656
  self.assertEqual(info_log.log_object, "")
656
657
  self.assertEqual(info_log.message, f"Role: {role.name}")
657
658
  self.assertEqual(job_result.result, "Nice Roles!")
@@ -670,7 +671,7 @@ class JobTransactionTest(TransactionTestCase):
670
671
  ).first()
671
672
 
672
673
  # Assert stuff
673
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
674
+ self.assertJobResultStatus(job_result)
674
675
  self.assertEqual(info_log.log_object, "")
675
676
  self.assertEqual(info_log.message, "The Location if any that the user provided.")
676
677
  self.assertEqual(job_result.result, "Nice Location (or not)!")
@@ -685,7 +686,7 @@ class JobTransactionTest(TransactionTestCase):
685
686
  job_result = create_job_result_and_run_job(module, name, **data)
686
687
 
687
688
  # Assert stuff
688
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
689
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
689
690
  self.assertIn("location is a required field", job_result.traceback)
690
691
 
691
692
  def test_job_latest_result_property(self):
@@ -695,9 +696,9 @@ class JobTransactionTest(TransactionTestCase):
695
696
  module = "pass"
696
697
  name = "TestPassJob"
697
698
  job_result_1 = create_job_result_and_run_job(module, name)
698
- self.assertEqual(job_result_1.status, JobResultStatusChoices.STATUS_SUCCESS)
699
+ self.assertJobResultStatus(job_result_1)
699
700
  job_result_2 = create_job_result_and_run_job(module, name)
700
- self.assertEqual(job_result_2.status, JobResultStatusChoices.STATUS_SUCCESS)
701
+ self.assertJobResultStatus(job_result_2)
701
702
  _job_class, job_model = get_job_class_and_model(module, name)
702
703
  self.assertGreaterEqual(job_model.job_results.count(), 2)
703
704
  latest_job_result = job_model.latest_result
@@ -710,11 +711,7 @@ class JobTransactionTest(TransactionTestCase):
710
711
  # The job itself contains the 'assert' by loading the resulting profiling file from the workers filesystem
711
712
  job_result = create_job_result_and_run_job(module, name, profile=True)
712
713
 
713
- self.assertEqual(
714
- job_result.status,
715
- JobResultStatusChoices.STATUS_SUCCESS,
716
- msg="Profiling test job errored, this indicates that either no profiling file was created or it is malformed.",
717
- )
714
+ self.assertJobResultStatus(job_result)
718
715
 
719
716
  profiling_result = Path(f"{tempfile.gettempdir()}/nautobot-jobresult-{job_result.id}.pstats")
720
717
  self.assertTrue(profiling_result.exists())
@@ -727,12 +724,17 @@ class JobTransactionTest(TransactionTestCase):
727
724
  job_class, _ = get_job_class_and_model(module, name, "local")
728
725
  self.assertTrue(job_class.is_singleton)
729
726
  cache.set(job_class.singleton_cache_key, 1)
730
- failed_job_result = create_job_result_and_run_job(module, name)
727
+ try:
728
+ failed_job_result = create_job_result_and_run_job(module, name)
731
729
 
732
- self.assertEqual(
733
- failed_job_result.status, JobResultStatusChoices.STATUS_FAILURE, msg="Duplicate singleton job didn't error."
734
- )
735
- self.assertIsNone(cache.get(job_class.singleton_cache_key, None))
730
+ self.assertEqual(
731
+ failed_job_result.status,
732
+ JobResultStatusChoices.STATUS_FAILURE,
733
+ msg="Duplicate singleton job didn't error.",
734
+ )
735
+ finally:
736
+ # Clean up after ourselves
737
+ cache.delete(job_class.singleton_cache_key)
736
738
 
737
739
  def test_job_ignore_singleton(self):
738
740
  module = "singleton"
@@ -741,16 +743,21 @@ class JobTransactionTest(TransactionTestCase):
741
743
  job_class, _ = get_job_class_and_model(module, name, "local")
742
744
  self.assertTrue(job_class.is_singleton)
743
745
  cache.set(job_class.singleton_cache_key, 1)
744
- passed_job_result = create_job_result_and_run_job(
745
- module, name, celery_kwargs={"nautobot_job_ignore_singleton_lock": True}
746
- )
746
+ try:
747
+ passed_job_result = create_job_result_and_run_job(
748
+ module, name, celery_kwargs={"nautobot_job_ignore_singleton_lock": True}
749
+ )
747
750
 
748
- self.assertEqual(
749
- passed_job_result.status,
750
- JobResultStatusChoices.STATUS_SUCCESS,
751
- msg="Duplicate singleton job didn't succeed with nautobot_job_ignore_singleton_lock=True.",
752
- )
753
- self.assertIsNone(cache.get(job_class.singleton_cache_key, None))
751
+ self.assertEqual(
752
+ passed_job_result.status,
753
+ JobResultStatusChoices.STATUS_SUCCESS,
754
+ msg="Duplicate singleton job didn't succeed with nautobot_job_ignore_singleton_lock=True.",
755
+ )
756
+ # Singleton cache key should be cleared when the job completes
757
+ self.assertIsNone(cache.get(job_class.singleton_cache_key, None))
758
+ finally:
759
+ # Clean up after ourselves, just in case
760
+ cache.delete(job_class.singleton_cache_key)
754
761
 
755
762
  @mock.patch("nautobot.extras.context_managers.enqueue_webhooks")
756
763
  def test_job_fires_webhooks(self, mock_enqueue_webhooks):
@@ -762,7 +769,7 @@ class JobTransactionTest(TransactionTestCase):
762
769
  webhook.content_types.set([status_ct])
763
770
 
764
771
  job_result = create_job_result_and_run_job(module, name)
765
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
772
+ self.assertJobResultStatus(job_result)
766
773
 
767
774
  mock_enqueue_webhooks.assert_called_once()
768
775
 
@@ -865,7 +872,7 @@ class JobFileOutputTest(TransactionTestCase):
865
872
  data = {"lines": 3}
866
873
  job_result = create_job_result_and_run_job(module, name, **data)
867
874
 
868
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS, job_result.traceback)
875
+ self.assertJobResultStatus(job_result)
869
876
  # JobResult should have one attached file
870
877
  self.assertEqual(1, job_result.files.count())
871
878
  self.assertEqual(job_result.files.first().name, "output.txt")
@@ -896,7 +903,7 @@ class JobFileOutputTest(TransactionTestCase):
896
903
  # Exactly JOB_CREATE_FILE_MAX_SIZE bytes should be okay:
897
904
  with override_config(JOB_CREATE_FILE_MAX_SIZE=len("Hello world!\n")):
898
905
  job_result = create_job_result_and_run_job(module, name, **data)
899
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS, job_result.traceback)
906
+ self.assertJobResultStatus(job_result)
900
907
  self.assertEqual(1, job_result.files.count())
901
908
  self.assertEqual(job_result.files.first().name, "output.txt")
902
909
  self.assertEqual(job_result.files.first().file.read().decode("utf-8"), "Hello World!\n")
@@ -904,7 +911,7 @@ class JobFileOutputTest(TransactionTestCase):
904
911
  # Even one byte over is too much:
905
912
  with override_config(JOB_CREATE_FILE_MAX_SIZE=len("Hello world!\n") - 1):
906
913
  job_result = create_job_result_and_run_job(module, name, **data)
907
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
914
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
908
915
  self.assertIn("ValueError", job_result.traceback)
909
916
  self.assertEqual(0, job_result.files.count())
910
917
 
@@ -912,7 +919,7 @@ class JobFileOutputTest(TransactionTestCase):
912
919
  with override_config(JOB_CREATE_FILE_MAX_SIZE=10 << 20):
913
920
  with override_settings(JOB_CREATE_FILE_MAX_SIZE=len("Hello world!\n") - 1):
914
921
  job_result = create_job_result_and_run_job(module, name, **data)
915
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
922
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
916
923
  self.assertIn("ValueError", job_result.traceback)
917
924
  self.assertEqual(0, job_result.files.count())
918
925
 
@@ -945,7 +952,7 @@ class RunJobManagementCommandTest(TransactionTestCase):
945
952
 
946
953
  out, err = self.run_command("--local", "--no-color", "--username", self.user.username, job_model.class_path)
947
954
  self.assertIn(f"Running {job_model.class_path}...", out)
948
- self.assertIn("run: 0 debug, 1 info, 0 warning, 0 error, 0 critical", out)
955
+ self.assertIn("run: 0 debug, 1 info, 0 success, 0 warning, 0 failure, 0 error, 0 critical", out)
949
956
  self.assertIn("info: Success", out)
950
957
  self.assertIn(f"{job_model.class_path}: SUCCESS", out)
951
958
  self.assertEqual("", err)
@@ -967,7 +974,7 @@ class RunJobManagementCommandTest(TransactionTestCase):
967
974
  out, err = self.run_command("--local", "--no-color", "--username", self.user.username, job_model.class_path)
968
975
  self.assertIn(f"Running {job_model.class_path}...", out)
969
976
  # Changed job to actually log data. Can't display empty results if no logs were created.
970
- self.assertIn("run: 0 debug, 1 info, 0 warning, 0 error, 0 critical", out)
977
+ self.assertIn("run: 0 debug, 1 info, 0 success, 0 warning, 0 failure, 0 error, 0 critical", out)
971
978
  self.assertIn(f"{job_model.class_path}: SUCCESS", out)
972
979
  self.assertEqual("", err)
973
980
 
@@ -999,7 +1006,7 @@ class JobLocationCustomFieldTest(TransactionTestCase):
999
1006
  job_result = create_job_result_and_run_job(module, name)
1000
1007
  job_result.refresh_from_db()
1001
1008
 
1002
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
1009
+ self.assertJobResultStatus(job_result)
1003
1010
 
1004
1011
  # Test location with a value for custom_field
1005
1012
  location_1 = Location.objects.filter(name="Test Location One")
@@ -1077,7 +1084,7 @@ class JobButtonReceiverTransactionTest(TransactionTestCase):
1077
1084
  module = "job_button_receiver"
1078
1085
  name = "TestJobButtonReceiverFail"
1079
1086
  job_result = create_job_result_and_run_job(module, name, **self.data)
1080
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
1087
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
1081
1088
 
1082
1089
 
1083
1090
  class JobHookReceiverTest(TestCase):
@@ -1149,7 +1156,7 @@ class JobHookReceiverTransactionTest(TransactionTestCase):
1149
1156
  module = "job_hook_receiver"
1150
1157
  name = "TestJobHookReceiverFail"
1151
1158
  job_result = create_job_result_and_run_job(module, name, **self.data)
1152
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
1159
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
1153
1160
 
1154
1161
 
1155
1162
  class JobHookTest(TestCase):
@@ -1367,7 +1367,7 @@ class JobModelTest(ModelTestCases.BaseModelTestCase):
1367
1367
  def setUpTestData(cls):
1368
1368
  # JobModel instances are automatically instantiated at startup, so we just need to look them up.
1369
1369
  cls.local_job = JobModel.objects.get(job_class_name="TestPassJob")
1370
- cls.job_containing_sensitive_variables = JobModel.objects.get(job_class_name="ExampleLoggingJob")
1370
+ cls.job_containing_sensitive_variables = JobModel.objects.get(job_class_name="TestHasSensitiveVariables")
1371
1371
  cls.app_job = JobModel.objects.get(job_class_name="ExampleJob")
1372
1372
 
1373
1373
  def test_job_class(self):