nautobot 2.4.4__py3-none-any.whl → 2.4.6__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.
Files changed (139) hide show
  1. nautobot/__init__.py +19 -3
  2. nautobot/core/api/mixins.py +10 -0
  3. nautobot/core/celery/__init__.py +5 -3
  4. nautobot/core/celery/encoders.py +2 -2
  5. nautobot/core/forms/fields.py +21 -5
  6. nautobot/core/forms/utils.py +1 -0
  7. nautobot/core/jobs/__init__.py +3 -2
  8. nautobot/core/jobs/bulk_actions.py +1 -1
  9. nautobot/core/management/commands/generate_test_data.py +1 -1
  10. nautobot/core/models/name_color_content_types.py +9 -0
  11. nautobot/core/models/validators.py +7 -0
  12. nautobot/core/settings.py +0 -14
  13. nautobot/core/settings.yaml +0 -28
  14. nautobot/core/tables.py +6 -1
  15. nautobot/core/templates/generic/object_retrieve.html +1 -1
  16. nautobot/core/testing/__init__.py +2 -0
  17. nautobot/core/testing/api.py +18 -0
  18. nautobot/core/testing/mixins.py +9 -0
  19. nautobot/core/tests/nautobot_config.py +0 -2
  20. nautobot/core/tests/runner.py +17 -140
  21. nautobot/core/tests/test_api.py +4 -4
  22. nautobot/core/tests/test_authentication.py +83 -4
  23. nautobot/core/tests/test_forms.py +11 -8
  24. nautobot/core/tests/test_graphql.py +9 -0
  25. nautobot/core/tests/test_jobs.py +33 -27
  26. nautobot/core/ui/object_detail.py +31 -0
  27. nautobot/dcim/factory.py +2 -0
  28. nautobot/dcim/filters/__init__.py +5 -0
  29. nautobot/dcim/forms.py +17 -1
  30. nautobot/dcim/migrations/0068_alter_softwareimagefile_download_url.py +19 -0
  31. nautobot/dcim/migrations/0069_softwareimagefile_external_integration.py +25 -0
  32. nautobot/dcim/models/devices.py +9 -2
  33. nautobot/dcim/tables/devices.py +1 -0
  34. nautobot/dcim/templates/dcim/softwareimagefile_retrieve.html +4 -0
  35. nautobot/dcim/tests/test_api.py +74 -31
  36. nautobot/dcim/tests/test_filters.py +2 -0
  37. nautobot/dcim/tests/test_jobs.py +4 -6
  38. nautobot/dcim/tests/test_models.py +65 -0
  39. nautobot/dcim/tests/test_views.py +3 -0
  40. nautobot/extras/choices.py +8 -3
  41. nautobot/extras/forms/forms.py +7 -3
  42. nautobot/extras/jobs.py +181 -103
  43. nautobot/extras/management/utils.py +13 -2
  44. nautobot/extras/models/datasources.py +4 -1
  45. nautobot/extras/models/jobs.py +20 -17
  46. nautobot/extras/plugins/marketplace_manifest.yml +18 -0
  47. nautobot/extras/tables.py +29 -34
  48. nautobot/extras/templates/extras/inc/panel_changelog.html +1 -1
  49. nautobot/extras/templates/extras/inc/panel_jobhistory.html +1 -1
  50. nautobot/extras/templates/extras/status.html +1 -37
  51. nautobot/extras/test_jobs/atomic_transaction.py +6 -6
  52. nautobot/extras/test_jobs/fail.py +75 -1
  53. nautobot/extras/tests/integration/test_notes.py +1 -1
  54. nautobot/extras/tests/test_api.py +23 -8
  55. nautobot/extras/tests/test_changelog.py +4 -4
  56. nautobot/extras/tests/test_customfields.py +3 -0
  57. nautobot/extras/tests/test_datasources.py +64 -54
  58. nautobot/extras/tests/test_jobs.py +69 -62
  59. nautobot/extras/tests/test_models.py +1 -1
  60. nautobot/extras/tests/test_plugins.py +19 -13
  61. nautobot/extras/tests/test_relationships.py +14 -5
  62. nautobot/extras/tests/test_tags.py +2 -2
  63. nautobot/extras/tests/test_views.py +15 -6
  64. nautobot/extras/urls.py +1 -30
  65. nautobot/extras/views.py +17 -55
  66. nautobot/ipam/forms.py +15 -0
  67. nautobot/ipam/querysets.py +6 -0
  68. nautobot/ipam/tables.py +6 -2
  69. nautobot/ipam/templates/ipam/namespace_retrieve.html +0 -41
  70. nautobot/ipam/templates/ipam/rir.html +1 -43
  71. nautobot/ipam/templates/ipam/service.html +2 -46
  72. nautobot/ipam/templates/ipam/service_edit.html +1 -17
  73. nautobot/ipam/templates/ipam/service_retrieve.html +7 -0
  74. nautobot/ipam/tests/migration/__init__.py +0 -0
  75. nautobot/ipam/tests/migration/test_migrations.py +510 -0
  76. nautobot/ipam/tests/test_api.py +66 -36
  77. nautobot/ipam/tests/test_filters.py +0 -10
  78. nautobot/ipam/tests/test_models.py +16 -0
  79. nautobot/ipam/tests/test_views.py +44 -2
  80. nautobot/ipam/urls.py +2 -67
  81. nautobot/ipam/utils/migrations.py +185 -152
  82. nautobot/ipam/utils/testing.py +177 -0
  83. nautobot/ipam/views.py +119 -198
  84. nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +43 -5
  85. nautobot/project-static/docs/code-reference/nautobot/apps/models.html +47 -0
  86. nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +18 -0
  87. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +35 -0
  88. nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +63 -0
  89. nautobot/project-static/docs/development/apps/api/testing.html +0 -87
  90. nautobot/project-static/docs/development/apps/migration/dependency-updates.html +1 -1
  91. nautobot/project-static/docs/development/core/best-practices.html +3 -3
  92. nautobot/project-static/docs/development/core/getting-started.html +78 -107
  93. nautobot/project-static/docs/development/core/release-checklist.html +1 -1
  94. nautobot/project-static/docs/development/core/style-guide.html +1 -1
  95. nautobot/project-static/docs/development/core/testing.html +24 -198
  96. nautobot/project-static/docs/development/jobs/index.html +27 -14
  97. nautobot/project-static/docs/media/user-guide/administration/getting-started/nautobot-cloud.png +0 -0
  98. nautobot/project-static/docs/objects.inv +0 -0
  99. nautobot/project-static/docs/overview/application_stack.html +1 -1
  100. nautobot/project-static/docs/release-notes/version-2.4.html +409 -1
  101. nautobot/project-static/docs/requirements.txt +1 -1
  102. nautobot/project-static/docs/search/search_index.json +1 -1
  103. nautobot/project-static/docs/sitemap.xml +290 -290
  104. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  105. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +2 -48
  106. nautobot/project-static/docs/user-guide/administration/guides/permissions.html +71 -0
  107. nautobot/project-static/docs/user-guide/administration/installation/http-server.html +3 -1
  108. nautobot/project-static/docs/user-guide/administration/installation/index.html +257 -16
  109. nautobot/project-static/docs/user-guide/administration/tools/nautobot-server.html +1 -1
  110. nautobot/project-static/docs/user-guide/administration/upgrading/upgrading.html +2 -2
  111. nautobot/project-static/docs/user-guide/core-data-model/dcim/softwareimagefile.html +4 -0
  112. nautobot/project-static/docs/user-guide/feature-guides/contacts-and-teams.html +11 -11
  113. nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-devices.html +8 -8
  114. nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-location-types-and-locations.html +1 -0
  115. nautobot/project-static/docs/user-guide/feature-guides/getting-started/interfaces.html +40 -25
  116. nautobot/project-static/docs/user-guide/feature-guides/getting-started/ipam.html +4 -4
  117. nautobot/project-static/docs/user-guide/feature-guides/getting-started/platforms.html +1 -1
  118. nautobot/project-static/docs/user-guide/feature-guides/getting-started/search-bar.html +77 -5
  119. nautobot/project-static/docs/user-guide/feature-guides/getting-started/tenants.html +1 -1
  120. nautobot/project-static/docs/user-guide/feature-guides/getting-started/vlans-and-vlan-groups.html +0 -1
  121. nautobot/project-static/docs/user-guide/feature-guides/git-data-source.html +1 -1
  122. nautobot/project-static/docs/user-guide/index.html +89 -2
  123. nautobot/project-static/docs/user-guide/platform-functionality/webhook.html +207 -122
  124. nautobot/virtualization/forms.py +20 -0
  125. nautobot/virtualization/templates/virtualization/clustergroup.html +1 -39
  126. nautobot/virtualization/templates/virtualization/clustertype.html +1 -0
  127. nautobot/virtualization/tests/test_api.py +14 -3
  128. nautobot/virtualization/tests/test_views.py +10 -2
  129. nautobot/virtualization/urls.py +10 -93
  130. nautobot/virtualization/views.py +33 -72
  131. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/METADATA +8 -7
  132. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/RECORD +137 -132
  133. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/WHEEL +1 -1
  134. nautobot/core/tests/performance_baselines.yml +0 -8900
  135. nautobot/ipam/tests/test_migrations.py +0 -462
  136. /nautobot/ipam/templates/ipam/{namespace_ipaddresses.html → namespace_ip_addresses.html} +0 -0
  137. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/LICENSE.txt +0 -0
  138. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/NOTICE +0 -0
  139. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/entry_points.txt +0 -0
@@ -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):
@@ -486,7 +486,7 @@ class TestUserContextCustomValidator(CustomValidator):
486
486
  """
487
487
  Used to validate that the correct user context is available in the custom validator.
488
488
  """
489
- self.validation_error(self.context["user"])
489
+ self.validation_error(f"TestUserContextCustomValidator: user is {self.context['user']}")
490
490
 
491
491
 
492
492
  class AppCustomValidationTest(TestCase):
@@ -527,22 +527,28 @@ class AppCustomValidationTest(TestCase):
527
527
 
528
528
  def test_custom_validator_non_web_request_uses_anonymous_user(self):
529
529
  location_type = LocationType.objects.get(name="Campus")
530
- registry["plugin_custom_validators"]["dcim.locationtype"] = [TestUserContextCustomValidator]
530
+ before = registry["plugin_custom_validators"]["dcim.locationtype"]
531
+ try:
532
+ registry["plugin_custom_validators"]["dcim.locationtype"] = [TestUserContextCustomValidator]
531
533
 
532
- from django.contrib.auth.models import AnonymousUser
533
-
534
- with self.assertRaises(ValidationError) as context:
535
- location_type.clean()
536
- self.assertEqual(context.exception.message, AnonymousUser())
534
+ with self.assertRaises(ValidationError) as context:
535
+ location_type.clean()
536
+ self.assertEqual(context.exception.message, "TestUserContextCustomValidator: user is AnonymousUser")
537
+ finally:
538
+ registry["plugin_custom_validators"]["dcim.locationtype"] = before
537
539
 
538
540
  def test_custom_validator_web_request_uses_real_user(self):
539
541
  location_type = LocationType.objects.get(name="Campus")
540
- registry["plugin_custom_validators"]["dcim.locationtype"] = [TestUserContextCustomValidator]
541
-
542
- with self.assertRaises(ValidationError) as context:
543
- with web_request_context(user=self.user):
544
- location_type.clean()
545
- self.assertEqual(context.exception.message, self.user)
542
+ before = registry["plugin_custom_validators"]["dcim.locationtype"]
543
+ try:
544
+ registry["plugin_custom_validators"]["dcim.locationtype"] = [TestUserContextCustomValidator]
545
+
546
+ with self.assertRaises(ValidationError) as context:
547
+ with web_request_context(user=self.user):
548
+ location_type.clean()
549
+ self.assertEqual(context.exception.message, f"TestUserContextCustomValidator: user is {self.user}")
550
+ finally:
551
+ registry["plugin_custom_validators"]["dcim.locationtype"] = before
546
552
 
547
553
 
548
554
  class ExampleModelCustomActionViewTest(TestCase):
@@ -1,10 +1,13 @@
1
+ import contextlib
1
2
  import logging
2
3
  import uuid
3
4
 
4
5
  from django.contrib.contenttypes.models import ContentType
6
+ from django.core.cache import cache
5
7
  from django.core.exceptions import ValidationError
6
8
  from django.urls import reverse
7
9
  from django.utils.html import format_html
10
+ import redis.exceptions
8
11
 
9
12
  from nautobot.circuits.models import CircuitType
10
13
  from nautobot.core.forms import (
@@ -179,6 +182,12 @@ class RelationshipBaseTest:
179
182
  ),
180
183
  ]
181
184
 
185
+ def tearDown(self):
186
+ """Ensure that relationship caches are cleared to avoid leakage into other tests."""
187
+ with contextlib.suppress(redis.exceptions.ConnectionError):
188
+ cache.delete_pattern(f"{Relationship.objects.get_for_model_source.cache_key_prefix}.*")
189
+ cache.delete_pattern(f"{Relationship.objects.get_for_model_destination.cache_key_prefix}.*")
190
+
182
191
 
183
192
  class RelationshipTest(RelationshipBaseTest, ModelTestCases.BaseModelTestCase):
184
193
  model = Relationship
@@ -1784,7 +1793,7 @@ class RelationshipJobTestCase(RequiredRelationshipTestMixin, TransactionTestCase
1784
1793
 
1785
1794
  pk_list = [str(vlan.id) for vlan in vlans]
1786
1795
  job_result = self.create_job(pk_list)
1787
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
1796
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
1788
1797
  error_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
1789
1798
  self.assertIn("VLANs require at least one device, but no devices exist yet.", error_log.message)
1790
1799
 
@@ -1793,7 +1802,7 @@ class RelationshipJobTestCase(RequiredRelationshipTestMixin, TransactionTestCase
1793
1802
 
1794
1803
  # Try editing all 6 VLANs without adding the required device(fails):
1795
1804
  job_result = self.create_job(pk_list)
1796
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
1805
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
1797
1806
  error_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
1798
1807
  self.assertIn(
1799
1808
  '6 VLANs require a device for the required relationship \\"VLANs require at least one Device',
@@ -1802,7 +1811,7 @@ class RelationshipJobTestCase(RequiredRelationshipTestMixin, TransactionTestCase
1802
1811
 
1803
1812
  # Try editing 3 VLANs without adding the required device(fails):
1804
1813
  job_result = self.create_job(pk_list[:3])
1805
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
1814
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
1806
1815
  error_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
1807
1816
  self.assertIn(
1808
1817
  'These VLANs require a device for the required relationship \\"VLANs require at least one Device',
@@ -1813,11 +1822,11 @@ class RelationshipJobTestCase(RequiredRelationshipTestMixin, TransactionTestCase
1813
1822
 
1814
1823
  # Try editing 6 VLANs and adding the required device (succeeds):
1815
1824
  job_result = self.create_job(pk_list, add_cr_vlans_devices_m2m__source=[str(device_for_association.id)])
1816
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
1825
+ self.assertJobResultStatus(job_result)
1817
1826
 
1818
1827
  # Try editing 6 VLANs and removing the required device (fails):
1819
1828
  job_result = self.create_job(pk_list, remove_cr_vlans_devices_m2m__source=[str(device_for_association.id)])
1820
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
1829
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
1821
1830
  error_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
1822
1831
  self.assertIn(
1823
1832
  '6 VLANs require a device for the required relationship \\"VLANs require at least one Device',
@@ -46,7 +46,7 @@ class TaggedItemTest(APITestCase):
46
46
  "location_type": self.location_type.pk,
47
47
  }
48
48
  url = reverse("dcim-api:location-list")
49
- self.add_permissions("dcim.add_location")
49
+ self.add_permissions("dcim.add_location", "dcim.view_locationtype", "extras.view_tag", "extras.view_status")
50
50
 
51
51
  response = self.client.post(url, data, format="json", **self.header)
52
52
  self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -67,7 +67,7 @@ class TaggedItemTest(APITestCase):
67
67
  {"name": self.tags[3].name},
68
68
  ]
69
69
  }
70
- self.add_permissions("dcim.change_location")
70
+ self.add_permissions("dcim.change_location", "extras.view_tag")
71
71
  url = reverse("dcim-api:location-detail", kwargs={"pk": location.pk})
72
72
 
73
73
  response = self.client.patch(url, data, format="json", **self.header)
@@ -3584,23 +3584,28 @@ class StatusTestCase(
3584
3584
  ViewTestCases.GetObjectViewTestCase,
3585
3585
  ViewTestCases.GetObjectChangelogViewTestCase,
3586
3586
  ViewTestCases.ListObjectsViewTestCase,
3587
+ ViewTestCases.BulkEditObjectsViewTestCase,
3587
3588
  ):
3588
3589
  model = Status
3589
3590
 
3590
3591
  @classmethod
3591
3592
  def setUpTestData(cls):
3592
3593
  # Status objects to test.
3593
- content_type = ContentType.objects.get_for_model(Device)
3594
+ device_ct = ContentType.objects.get_for_model(Device)
3595
+ circuit_ct = ContentType.objects.get_for_model(Circuit)
3596
+ interface_ct = ContentType.objects.get_for_model(Interface)
3594
3597
 
3595
3598
  cls.form_data = {
3596
3599
  "name": "new_status",
3597
3600
  "description": "I am a new status object.",
3598
3601
  "color": "ffcc00",
3599
- "content_types": [content_type.pk],
3602
+ "content_types": [device_ct.pk],
3600
3603
  }
3601
3604
 
3602
3605
  cls.bulk_edit_data = {
3603
3606
  "color": "000000",
3607
+ "add_content_types": [interface_ct.pk, circuit_ct.pk],
3608
+ "remove_content_types": [device_ct.pk],
3604
3609
  }
3605
3610
 
3606
3611
 
@@ -3790,25 +3795,29 @@ class WebhookTestCase(
3790
3795
  }
3791
3796
 
3792
3797
 
3793
- class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
3798
+ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase, ViewTestCases.BulkEditObjectsViewTestCase):
3794
3799
  model = Role
3795
3800
 
3796
3801
  @classmethod
3797
3802
  def setUpTestData(cls):
3798
- # Status objects to test.
3799
- content_type = ContentType.objects.get_for_model(Device)
3803
+ # Role objects to test.
3804
+ device_ct = ContentType.objects.get_for_model(Device)
3805
+ ipaddress_ct = ContentType.objects.get_for_model(IPAddress)
3806
+ prefix_ct = ContentType.objects.get_for_model(Prefix)
3800
3807
 
3801
3808
  cls.form_data = {
3802
3809
  "name": "New Role",
3803
3810
  "description": "I am a new role object.",
3804
3811
  "color": ColorChoices.COLOR_GREY,
3805
- "content_types": [content_type.pk],
3812
+ "content_types": [device_ct.pk],
3806
3813
  }
3807
3814
 
3808
3815
  cls.bulk_edit_data = {
3809
3816
  "color": "000000",
3810
3817
  "description": "I used to be a new role object.",
3811
3818
  "weight": 255,
3819
+ "add_content_types": [ipaddress_ct.pk, prefix_ct.pk],
3820
+ "remove_content_types": [device_ct.pk],
3812
3821
  }
3813
3822
 
3814
3823
  @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
nautobot/extras/urls.py CHANGED
@@ -17,7 +17,6 @@ from nautobot.extras.models import (
17
17
  Note,
18
18
  Relationship,
19
19
  SecretsGroup,
20
- Status,
21
20
  Tag,
22
21
  Webhook,
23
22
  )
@@ -36,6 +35,7 @@ router.register("roles", views.RoleUIViewSet)
36
35
  router.register("saved-views", views.SavedViewUIViewSet)
37
36
  router.register("secrets", views.SecretUIViewSet)
38
37
  router.register("static-group-associations", views.StaticGroupAssociationUIViewSet)
38
+ router.register("statuses", views.StatusUIViewSet)
39
39
  router.register("teams", views.TeamUIViewSet)
40
40
 
41
41
  urlpatterns = [
@@ -589,35 +589,6 @@ urlpatterns = [
589
589
  name="secretsgroup_notes",
590
590
  kwargs={"model": SecretsGroup},
591
591
  ),
592
- # Custom statuses
593
- path("statuses/", views.StatusListView.as_view(), name="status_list"),
594
- path("statuses/add/", views.StatusEditView.as_view(), name="status_add"),
595
- path("statuses/edit/", views.StatusBulkEditView.as_view(), name="status_bulk_edit"),
596
- path(
597
- "statuses/delete/",
598
- views.StatusBulkDeleteView.as_view(),
599
- name="status_bulk_delete",
600
- ),
601
- path("statuses/import/", views.StatusBulkImportView.as_view(), name="status_import"), # 3.0 TODO: remove, unused
602
- path("statuses/<uuid:pk>/", views.StatusView.as_view(), name="status"),
603
- path("statuses/<uuid:pk>/edit/", views.StatusEditView.as_view(), name="status_edit"),
604
- path(
605
- "statuses/<uuid:pk>/delete/",
606
- views.StatusDeleteView.as_view(),
607
- name="status_delete",
608
- ),
609
- path(
610
- "statuses/<uuid:pk>/changelog/",
611
- views.ObjectChangeLogView.as_view(),
612
- name="status_changelog",
613
- kwargs={"model": Status},
614
- ),
615
- path(
616
- "statuses/<uuid:pk>/notes/",
617
- views.ObjectNotesView.as_view(),
618
- name="status_notes",
619
- kwargs={"model": Status},
620
- ),
621
592
  # Tags
622
593
  path("tags/", views.TagListView.as_view(), name="tag_list"),
623
594
  path("tags/add/", views.TagEditView.as_view(), name="tag_add"),