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.
- nautobot/__init__.py +19 -3
- nautobot/core/api/mixins.py +10 -0
- nautobot/core/celery/__init__.py +5 -3
- nautobot/core/celery/encoders.py +2 -2
- nautobot/core/forms/fields.py +21 -5
- nautobot/core/forms/utils.py +1 -0
- nautobot/core/jobs/__init__.py +3 -2
- nautobot/core/jobs/bulk_actions.py +1 -1
- nautobot/core/management/commands/generate_test_data.py +1 -1
- nautobot/core/models/name_color_content_types.py +9 -0
- nautobot/core/models/validators.py +7 -0
- nautobot/core/settings.py +0 -14
- nautobot/core/settings.yaml +0 -28
- nautobot/core/tables.py +6 -1
- nautobot/core/templates/generic/object_retrieve.html +1 -1
- nautobot/core/testing/__init__.py +2 -0
- nautobot/core/testing/api.py +18 -0
- nautobot/core/testing/mixins.py +9 -0
- nautobot/core/tests/nautobot_config.py +0 -2
- nautobot/core/tests/runner.py +17 -140
- nautobot/core/tests/test_api.py +4 -4
- nautobot/core/tests/test_authentication.py +83 -4
- nautobot/core/tests/test_forms.py +11 -8
- nautobot/core/tests/test_graphql.py +9 -0
- nautobot/core/tests/test_jobs.py +33 -27
- nautobot/core/ui/object_detail.py +31 -0
- nautobot/dcim/factory.py +2 -0
- nautobot/dcim/filters/__init__.py +5 -0
- nautobot/dcim/forms.py +17 -1
- nautobot/dcim/migrations/0068_alter_softwareimagefile_download_url.py +19 -0
- nautobot/dcim/migrations/0069_softwareimagefile_external_integration.py +25 -0
- nautobot/dcim/models/devices.py +9 -2
- nautobot/dcim/tables/devices.py +1 -0
- nautobot/dcim/templates/dcim/softwareimagefile_retrieve.html +4 -0
- nautobot/dcim/tests/test_api.py +74 -31
- nautobot/dcim/tests/test_filters.py +2 -0
- nautobot/dcim/tests/test_jobs.py +4 -6
- nautobot/dcim/tests/test_models.py +65 -0
- nautobot/dcim/tests/test_views.py +3 -0
- nautobot/extras/choices.py +8 -3
- nautobot/extras/forms/forms.py +7 -3
- nautobot/extras/jobs.py +181 -103
- nautobot/extras/management/utils.py +13 -2
- nautobot/extras/models/datasources.py +4 -1
- nautobot/extras/models/jobs.py +20 -17
- nautobot/extras/plugins/marketplace_manifest.yml +18 -0
- nautobot/extras/tables.py +29 -34
- nautobot/extras/templates/extras/inc/panel_changelog.html +1 -1
- nautobot/extras/templates/extras/inc/panel_jobhistory.html +1 -1
- nautobot/extras/templates/extras/status.html +1 -37
- nautobot/extras/test_jobs/atomic_transaction.py +6 -6
- nautobot/extras/test_jobs/fail.py +75 -1
- nautobot/extras/tests/integration/test_notes.py +1 -1
- nautobot/extras/tests/test_api.py +23 -8
- nautobot/extras/tests/test_changelog.py +4 -4
- nautobot/extras/tests/test_customfields.py +3 -0
- nautobot/extras/tests/test_datasources.py +64 -54
- nautobot/extras/tests/test_jobs.py +69 -62
- nautobot/extras/tests/test_models.py +1 -1
- nautobot/extras/tests/test_plugins.py +19 -13
- nautobot/extras/tests/test_relationships.py +14 -5
- nautobot/extras/tests/test_tags.py +2 -2
- nautobot/extras/tests/test_views.py +15 -6
- nautobot/extras/urls.py +1 -30
- nautobot/extras/views.py +17 -55
- nautobot/ipam/forms.py +15 -0
- nautobot/ipam/querysets.py +6 -0
- nautobot/ipam/tables.py +6 -2
- nautobot/ipam/templates/ipam/namespace_retrieve.html +0 -41
- nautobot/ipam/templates/ipam/rir.html +1 -43
- nautobot/ipam/templates/ipam/service.html +2 -46
- nautobot/ipam/templates/ipam/service_edit.html +1 -17
- nautobot/ipam/templates/ipam/service_retrieve.html +7 -0
- nautobot/ipam/tests/migration/__init__.py +0 -0
- nautobot/ipam/tests/migration/test_migrations.py +510 -0
- nautobot/ipam/tests/test_api.py +66 -36
- nautobot/ipam/tests/test_filters.py +0 -10
- nautobot/ipam/tests/test_models.py +16 -0
- nautobot/ipam/tests/test_views.py +44 -2
- nautobot/ipam/urls.py +2 -67
- nautobot/ipam/utils/migrations.py +185 -152
- nautobot/ipam/utils/testing.py +177 -0
- nautobot/ipam/views.py +119 -198
- nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +43 -5
- nautobot/project-static/docs/code-reference/nautobot/apps/models.html +47 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +18 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +35 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +63 -0
- nautobot/project-static/docs/development/apps/api/testing.html +0 -87
- nautobot/project-static/docs/development/apps/migration/dependency-updates.html +1 -1
- nautobot/project-static/docs/development/core/best-practices.html +3 -3
- nautobot/project-static/docs/development/core/getting-started.html +78 -107
- nautobot/project-static/docs/development/core/release-checklist.html +1 -1
- nautobot/project-static/docs/development/core/style-guide.html +1 -1
- nautobot/project-static/docs/development/core/testing.html +24 -198
- nautobot/project-static/docs/development/jobs/index.html +27 -14
- nautobot/project-static/docs/media/user-guide/administration/getting-started/nautobot-cloud.png +0 -0
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/overview/application_stack.html +1 -1
- nautobot/project-static/docs/release-notes/version-2.4.html +409 -1
- nautobot/project-static/docs/requirements.txt +1 -1
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +290 -290
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/configuration/settings.html +2 -48
- nautobot/project-static/docs/user-guide/administration/guides/permissions.html +71 -0
- nautobot/project-static/docs/user-guide/administration/installation/http-server.html +3 -1
- nautobot/project-static/docs/user-guide/administration/installation/index.html +257 -16
- nautobot/project-static/docs/user-guide/administration/tools/nautobot-server.html +1 -1
- nautobot/project-static/docs/user-guide/administration/upgrading/upgrading.html +2 -2
- nautobot/project-static/docs/user-guide/core-data-model/dcim/softwareimagefile.html +4 -0
- nautobot/project-static/docs/user-guide/feature-guides/contacts-and-teams.html +11 -11
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-devices.html +8 -8
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-location-types-and-locations.html +1 -0
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/interfaces.html +40 -25
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/ipam.html +4 -4
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/platforms.html +1 -1
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/search-bar.html +77 -5
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/tenants.html +1 -1
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/vlans-and-vlan-groups.html +0 -1
- nautobot/project-static/docs/user-guide/feature-guides/git-data-source.html +1 -1
- nautobot/project-static/docs/user-guide/index.html +89 -2
- nautobot/project-static/docs/user-guide/platform-functionality/webhook.html +207 -122
- nautobot/virtualization/forms.py +20 -0
- nautobot/virtualization/templates/virtualization/clustergroup.html +1 -39
- nautobot/virtualization/templates/virtualization/clustertype.html +1 -0
- nautobot/virtualization/tests/test_api.py +14 -3
- nautobot/virtualization/tests/test_views.py +10 -2
- nautobot/virtualization/urls.py +10 -93
- nautobot/virtualization/views.py +33 -72
- {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/METADATA +8 -7
- {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/RECORD +137 -132
- {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/WHEEL +1 -1
- nautobot/core/tests/performance_baselines.yml +0 -8900
- nautobot/ipam/tests/test_migrations.py +0 -462
- /nautobot/ipam/templates/ipam/{namespace_ipaddresses.html → namespace_ip_addresses.html} +0 -0
- {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/NOTICE +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
534
|
-
self.
|
|
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,
|
|
551
|
-
self.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
699
|
+
self.assertJobResultStatus(job_result_1)
|
|
699
700
|
job_result_2 = create_job_result_and_run_job(module, name)
|
|
700
|
-
self.
|
|
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.
|
|
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
|
-
|
|
727
|
+
try:
|
|
728
|
+
failed_job_result = create_job_result_and_run_job(module, name)
|
|
731
729
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
745
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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="
|
|
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[
|
|
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"]
|
|
530
|
+
before = registry["plugin_custom_validators"]["dcim.locationtype"]
|
|
531
|
+
try:
|
|
532
|
+
registry["plugin_custom_validators"]["dcim.locationtype"] = [TestUserContextCustomValidator]
|
|
531
533
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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"]
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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": [
|
|
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
|
-
#
|
|
3799
|
-
|
|
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": [
|
|
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"),
|