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
@@ -639,7 +639,7 @@ class WritableNestedSerializerTest(testing.APITestCase):
639
639
  "vlan_group": self.vlan_group1.pk,
640
640
  }
641
641
  url = reverse("ipam-api:vlan-list")
642
- self.add_permissions("ipam.add_vlan")
642
+ self.add_permissions("ipam.add_vlan", "ipam.view_vlangroup", "extras.view_status")
643
643
 
644
644
  response = self.client.post(url, data, format="json", **self.header)
645
645
  self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -672,7 +672,7 @@ class WritableNestedSerializerTest(testing.APITestCase):
672
672
  "vlan_group": {"name": self.vlan_group1.name},
673
673
  }
674
674
  url = reverse("ipam-api:vlan-list")
675
- self.add_permissions("ipam.add_vlan")
675
+ self.add_permissions("ipam.add_vlan", "ipam.view_vlangroup", "extras.view_status")
676
676
 
677
677
  response = self.client.post(url, data, format="json", **self.header)
678
678
  self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -708,7 +708,7 @@ class WritableNestedSerializerTest(testing.APITestCase):
708
708
  },
709
709
  }
710
710
  url = reverse("ipam-api:vlan-list")
711
- self.add_permissions("ipam.add_vlan")
711
+ self.add_permissions("ipam.add_vlan", "ipam.view_vlangroup", "extras.view_status")
712
712
 
713
713
  with testing.disable_warnings("django.request"):
714
714
  response = self.client.post(url, data, format="json", **self.header)
@@ -775,7 +775,7 @@ class WritableNestedSerializerTest(testing.APITestCase):
775
775
  "vlan_group": self.vlan_group1.pk,
776
776
  }
777
777
  url = reverse("ipam-api:vlan-list")
778
- self.add_permissions("ipam.add_vlan")
778
+ self.add_permissions("ipam.add_vlan", "ipam.view_vlangroup", "extras.view_status")
779
779
 
780
780
  response = self.client.post(url, data, format="json", **self.header)
781
781
  self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -383,14 +383,31 @@ class ObjectPermissionAPIViewTestCase(TestCase):
383
383
  )
384
384
  obj_perm.users.add(self.user)
385
385
  obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
386
-
386
+ related_obj_perm = ObjectPermission.objects.create(
387
+ name="Related object permission",
388
+ actions=["view"],
389
+ )
390
+ related_obj_perm.users.add(self.user)
391
+ related_obj_perm.object_types.add(
392
+ ContentType.objects.get_for_model(Namespace),
393
+ ContentType.objects.get_for_model(Status),
394
+ ContentType.objects.get_for_model(Location),
395
+ )
387
396
  # Attempt to create a non-permitted object
388
397
  response = self.client.post(url, data, format="json", **self.header)
389
398
  self.assertEqual(response.status_code, 403)
390
399
  self.assertEqual(Prefix.objects.count(), initial_count)
391
400
 
392
- # Create a permitted object
401
+ # Attempt to create a permitted object without related object permissions
393
402
  data["location"] = self.locations[0].pk
403
+ related_obj_perm.users.remove(self.user)
404
+ response = self.client.post(url, data, format="json", **self.header)
405
+ self.assertEqual(response.status_code, 400)
406
+ self.assertIn(b"Related object not found using the provided attribute", response.content)
407
+ self.assertEqual(Prefix.objects.count(), initial_count)
408
+
409
+ # Create a permitted object with related object permissions
410
+ related_obj_perm.users.add(self.user)
394
411
  response = self.client.post(url, data, format="json", **self.header)
395
412
  self.assertEqual(response.status_code, 201)
396
413
  self.assertEqual(Prefix.objects.count(), initial_count + 1)
@@ -411,17 +428,33 @@ class ObjectPermissionAPIViewTestCase(TestCase):
411
428
  )
412
429
  obj_perm.users.add(self.user)
413
430
  obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
414
-
431
+ related_obj_perm = ObjectPermission.objects.create(
432
+ name="Related object permission",
433
+ actions=["view"],
434
+ )
435
+ related_obj_perm.users.add(self.user)
436
+ related_obj_perm.object_types.add(
437
+ ContentType.objects.get_for_model(Namespace),
438
+ ContentType.objects.get_for_model(Status),
439
+ ContentType.objects.get_for_model(Location),
440
+ )
415
441
  # Attempt to edit a non-permitted object
416
442
  data = {"location": self.locations[0].pk}
417
443
  url = reverse("ipam-api:prefix-detail", kwargs={"pk": self.prefixes[3].pk})
418
444
  response = self.client.patch(url, data, format="json", **self.header)
419
445
  self.assertEqual(response.status_code, 404)
420
446
 
421
- # Edit a permitted object
447
+ # Attempt to edit a permitted object without related object permissions
448
+ related_obj_perm.users.remove(self.user)
422
449
  data["status"] = self.statuses[1].pk
423
450
  url = reverse("ipam-api:prefix-detail", kwargs={"pk": self.prefixes[0].pk})
424
451
  response = self.client.patch(url, data, format="json", **self.header)
452
+ self.assertEqual(response.status_code, 400)
453
+ self.assertIn(b"Related object not found using the provided attribute", response.content)
454
+
455
+ # Edit a permitted object with related object permissions
456
+ related_obj_perm.users.add(self.user)
457
+ response = self.client.patch(url, data, format="json", **self.header)
425
458
  self.assertEqual(response.status_code, 200)
426
459
 
427
460
  # Attempt to modify a permitted object to a non-permitted object
@@ -456,6 +489,32 @@ class ObjectPermissionAPIViewTestCase(TestCase):
456
489
  response = self.client.delete(url, format="json", **self.header)
457
490
  self.assertEqual(response.status_code, 204)
458
491
 
492
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
493
+ def test_related_object_permission_constraints_on_get_requests(self):
494
+ """
495
+ Users who have permission to view Location objects, but not LocationType and Status objects
496
+ should still be able to view Location objects from the API.
497
+ """
498
+ self.add_permissions("dcim.view_location")
499
+ response = self.client.get(reverse("dcim-api:location-list"), **self.header)
500
+ self.assertEqual(response.status_code, 200)
501
+ # we should be able to get all the locations
502
+ self.assertEqual(len(response.data["results"]), Location.objects.count())
503
+
504
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
505
+ def test_related_object_permission_constraints_on_patch_requests(self):
506
+ """
507
+ Users who have permission to view and change Location objects, but not LocationType and Status objects
508
+ should still be able to change a Location object's name from the API.
509
+ """
510
+ self.add_permissions("dcim.view_location", "dcim.change_location")
511
+ location = Location.objects.first()
512
+ data = {"name": "New Location Name"}
513
+ url = reverse("dcim-api:location-detail", kwargs={"pk": location.pk})
514
+ response = self.client.patch(url, data, format="json", **self.header)
515
+ self.assertEqual(response.status_code, 200)
516
+ self.assertEqual(response.data["name"], "New Location Name")
517
+
459
518
  @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
460
519
  def test_user_token_constraints(self):
461
520
  """
@@ -487,6 +546,16 @@ class ObjectPermissionAPIViewTestCase(TestCase):
487
546
  )
488
547
  obj_perm.users.add(self.user, obj_user2)
489
548
  obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
549
+ related_obj_perm = ObjectPermission.objects.create(
550
+ name="Related object permission",
551
+ actions=["view"],
552
+ )
553
+ related_obj_perm.users.add(self.user, obj_user2)
554
+ related_obj_perm.object_types.add(
555
+ ContentType.objects.get_for_model(Namespace),
556
+ ContentType.objects.get_for_model(Status),
557
+ ContentType.objects.get_for_model(Location),
558
+ )
490
559
  # Create one Prefix object per user
491
560
  self.client.post(url, data[0], format="json", **self.header)
492
561
  self.client.post(url, data[1], format="json", **header_user2)
@@ -550,6 +619,16 @@ class ObjectPermissionAPIViewTestCase(TestCase):
550
619
  )
551
620
  obj_perm.users.add(self.user, obj_user2)
552
621
  obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
622
+ related_obj_perm = ObjectPermission.objects.create(
623
+ name="Related object permission",
624
+ actions=["view"],
625
+ )
626
+ related_obj_perm.users.add(self.user, obj_user2)
627
+ related_obj_perm.object_types.add(
628
+ ContentType.objects.get_for_model(Namespace),
629
+ ContentType.objects.get_for_model(Status),
630
+ ContentType.objects.get_for_model(Location),
631
+ )
553
632
  # Create one Prefix object per user
554
633
  self.client.post(url, data[0], format="json", **self.header)
555
634
  self.client.post(url, data[1], format="json", **header_user2)
@@ -486,20 +486,23 @@ class NumericArrayFieldTest(TestCase):
486
486
  self.field = dcim_forms.DeviceFilterForm().fields["device_redundancy_group_priority"]
487
487
 
488
488
  def test_valid_input(self):
489
- # Mapping of input => expected
490
- tests = {
491
- None: [],
492
- "": [],
493
- "80,443-444": [80, 443, 444],
494
- "1024-1028,31337": [1024, 1025, 1026, 1027, 1028, 31337],
495
- }
496
- for test, expected in tests.items():
489
+ # List of (input, expected output) tuples
490
+ tests = [
491
+ (None, []),
492
+ ("", []),
493
+ ("80,443-444", [80, 443, 444]),
494
+ ("1024-1028,31337", [1024, 1025, 1026, 1027, 1028, 31337]),
495
+ (["47-49", "103"], [47, 48, 49, 103]),
496
+ ([231, 432, 313], [231, 313, 432]),
497
+ ]
498
+ for test, expected in tests:
497
499
  self.assertEqual(self.field.clean(test), expected)
498
500
 
499
501
  def test_invalid_input(self):
500
502
  tests = [
501
503
  "pizza",
502
504
  "-41",
505
+ "[84,52,33]",
503
506
  ]
504
507
  for test in tests:
505
508
  with self.assertRaises(django_forms.ValidationError):
@@ -1,3 +1,4 @@
1
+ import contextlib
1
2
  import datetime
2
3
  import random
3
4
  import types
@@ -8,6 +9,7 @@ from django.apps import apps
8
9
  from django.contrib.auth import get_user_model
9
10
  from django.contrib.auth.models import Group
10
11
  from django.contrib.contenttypes.models import ContentType
12
+ from django.core.cache import cache
11
13
  from django.db.models import Count, Q
12
14
  from django.test import override_settings, TestCase
13
15
  from django.test.client import RequestFactory
@@ -17,6 +19,7 @@ from graphene_django.registry import get_global_registry
17
19
  from graphene_django.settings import graphene_settings
18
20
  from graphql import get_default_backend, GraphQLError
19
21
  from graphql.error.located_error import GraphQLLocatedError
22
+ import redis.exceptions
20
23
  from rest_framework import status
21
24
 
22
25
  from nautobot.circuits.models import CircuitTermination, Provider
@@ -396,6 +399,12 @@ class GraphQLExtendSchemaRelationship(GraphQLTestCaseBase):
396
399
  self.location_schema = generate_schema_type(app_name="dcim", model=Location)
397
400
  self.vlan_schema = generate_schema_type(app_name="ipam", model=VLAN)
398
401
 
402
+ def tearDown(self):
403
+ """Ensure that relationship caches are cleared to avoid leakage into other tests."""
404
+ with contextlib.suppress(redis.exceptions.ConnectionError):
405
+ cache.delete_pattern(f"{Relationship.objects.get_for_model_source.cache_key_prefix}.*")
406
+ cache.delete_pattern(f"{Relationship.objects.get_for_model_destination.cache_key_prefix}.*")
407
+
399
408
  def test_extend_relationship_default_prefix(self):
400
409
  """Verify that relationships are correctly added to the schema."""
401
410
  schema = extend_schema_type_relationships(self.vlan_schema, VLAN)
@@ -48,7 +48,7 @@ class ExportObjectListTest(TransactionTestCase):
48
48
  username=self.user.username, # otherwise run_job_for_testing defaults to a superuser account
49
49
  content_type=ContentType.objects.get_for_model(Status).pk,
50
50
  )
51
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
51
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
52
52
  log_error = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
53
53
  self.assertEqual(log_error.message, f'User "{self.user}" does not have permission to view status objects')
54
54
  self.assertFalse(job_result.files.exists())
@@ -70,7 +70,7 @@ class ExportObjectListTest(TransactionTestCase):
70
70
  username=self.user.username, # otherwise run_job_for_testing defaults to a superuser account
71
71
  content_type=ContentType.objects.get_for_model(Status).pk,
72
72
  )
73
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
73
+ self.assertJobResultStatus(job_result)
74
74
  self.assertTrue(job_result.files.exists())
75
75
  self.assertEqual(Path(job_result.files.first().file.name).name, "nautobot_statuses.csv")
76
76
  csv_bytes = job_result.files.first().file.read()
@@ -86,7 +86,7 @@ class ExportObjectListTest(TransactionTestCase):
86
86
  "ExportObjectList",
87
87
  content_type=ContentType.objects.get_for_model(Status).pk,
88
88
  )
89
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
89
+ self.assertJobResultStatus(job_result)
90
90
  self.assertTrue(job_result.files.exists())
91
91
  self.assertEqual(Path(job_result.files.first().file.name).name, "nautobot_statuses.csv")
92
92
  csv_data = job_result.files.first().file.read().decode("utf-8")
@@ -107,7 +107,7 @@ class ExportObjectListTest(TransactionTestCase):
107
107
  content_type=ContentType.objects.get_for_model(Status).pk,
108
108
  export_template=et.pk,
109
109
  )
110
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
110
+ self.assertJobResultStatus(job_result)
111
111
  self.assertTrue(job_result.files.exists())
112
112
  self.assertEqual(Path(job_result.files.first().file.name).name, "nautobot_statuses.txt")
113
113
  text_data = job_result.files.first().file.read().decode("utf-8")
@@ -129,7 +129,7 @@ class ExportObjectListTest(TransactionTestCase):
129
129
  content_type=ContentType.objects.get_for_model(DeviceType).pk,
130
130
  export_format="yaml",
131
131
  )
132
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
132
+ self.assertJobResultStatus(job_result)
133
133
  self.assertTrue(job_result.files.exists())
134
134
  self.assertEqual(Path(job_result.files.first().file.name).name, "nautobot_device_types.yaml")
135
135
  yaml_data = job_result.files.first().file.read().decode("utf-8")
@@ -159,7 +159,7 @@ class ImportObjectsTestCase(TransactionTestCase):
159
159
  content_type=ContentType.objects.get_for_model(Status).pk,
160
160
  csv_data=self.csv_data,
161
161
  )
162
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
162
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
163
163
  log_error = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
164
164
  self.assertEqual(log_error.message, f'User "{self.user}" does not have permission to create status objects')
165
165
  self.assertFalse(Status.objects.filter(name__startswith="test_status").exists())
@@ -172,7 +172,7 @@ class ImportObjectsTestCase(TransactionTestCase):
172
172
  username=self.user.username, # otherwise run_job_for_testing defaults to a superuser account
173
173
  content_type=ContentType.objects.get_for_model(Status).pk,
174
174
  )
175
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
175
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
176
176
 
177
177
  def test_csv_import_with_constrained_permission(self):
178
178
  """Job should only allow the user to import objects they have permission to add."""
@@ -191,7 +191,7 @@ class ImportObjectsTestCase(TransactionTestCase):
191
191
  content_type=ContentType.objects.get_for_model(Status).pk,
192
192
  csv_data=self.csv_data,
193
193
  )
194
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
194
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
195
195
  log_successes = JobLogEntry.objects.filter(
196
196
  job_result=job_result, log_level=LogLevelChoices.LOG_INFO, message__icontains="created"
197
197
  )
@@ -220,7 +220,7 @@ class ImportObjectsTestCase(TransactionTestCase):
220
220
  content_type=ContentType.objects.get_for_model(Status).pk,
221
221
  csv_data=self.csv_data,
222
222
  )
223
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
223
+ self.assertJobResultStatus(job_result)
224
224
  self.assertFalse(
225
225
  JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
226
226
  )
@@ -246,7 +246,7 @@ class ImportObjectsTestCase(TransactionTestCase):
246
246
  content_type=ContentType.objects.get_for_model(Prefix).pk,
247
247
  csv_file=csv_file.id,
248
248
  )
249
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
249
+ self.assertJobResultStatus(job_result)
250
250
  self.assertFalse(
251
251
  JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
252
252
  )
@@ -287,7 +287,7 @@ class ImportObjectsTestCase(TransactionTestCase):
287
287
  content_type=ContentType.objects.get_for_model(Device).pk,
288
288
  csv_file=csv_file.id,
289
289
  )
290
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
290
+ self.assertJobResultStatus(job_result)
291
291
  self.assertFalse(
292
292
  JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
293
293
  )
@@ -313,7 +313,7 @@ class ImportObjectsTestCase(TransactionTestCase):
313
313
  csv_data=csv_data,
314
314
  roll_back_if_error=True,
315
315
  )
316
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
316
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
317
317
  log_info = JobLogEntry.objects.filter(
318
318
  job_result=job_result, log_level=LogLevelChoices.LOG_INFO, message__icontains="created"
319
319
  )
@@ -335,7 +335,7 @@ class ImportObjectsTestCase(TransactionTestCase):
335
335
  content_type=ContentType.objects.get_for_model(Status).pk,
336
336
  csv_data=csv_data,
337
337
  )
338
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
338
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
339
339
  log_errors = JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
340
340
  self.assertEqual(log_errors[0].message, "Row 1: `color`: `Enter a valid hexadecimal RGB color code.`")
341
341
  self.assertFalse(Status.objects.filter(name="test_status0").exists())
@@ -353,6 +353,13 @@ class ImportObjectsTestCase(TransactionTestCase):
353
353
  self.assertEqual(log_successes[4].message, "Created 4 status object(s) from 5 row(s) of data")
354
354
 
355
355
  def test_csv_import_contact_assignment(self):
356
+ self.add_permissions(
357
+ "dcim.view_locationtype",
358
+ "extras.view_status",
359
+ "dcim.view_location",
360
+ "extras.add_role",
361
+ "extras.add_contact",
362
+ )
356
363
  location_types_csv = "\n".join(["name", "ContactAssignmentImportTestLocationType"])
357
364
  locations_csv = "\n".join(
358
365
  [
@@ -375,7 +382,7 @@ class ImportObjectsTestCase(TransactionTestCase):
375
382
  content_type=ContentType.objects.get_for_model(LocationType).pk,
376
383
  csv_data=location_types_csv,
377
384
  )
378
- self.assertEqual(location_types_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
385
+ self.assertJobResultStatus(location_types_job_result)
379
386
 
380
387
  location_type_count = LocationType.objects.filter(name="ContactAssignmentImportTestLocationType").count()
381
388
  self.assertEqual(location_type_count, 1, f"Unexpected count of LocationTypes {location_type_count}")
@@ -386,7 +393,7 @@ class ImportObjectsTestCase(TransactionTestCase):
386
393
  content_type=ContentType.objects.get_for_model(Location).pk,
387
394
  csv_data=locations_csv,
388
395
  )
389
- self.assertEqual(locations_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
396
+ self.assertJobResultStatus(locations_job_result)
390
397
 
391
398
  location_count = Location.objects.filter(location_type__name="ContactAssignmentImportTestLocationType").count()
392
399
  self.assertEqual(location_count, 2, f"Unexpected count of Locations {location_count}")
@@ -397,7 +404,7 @@ class ImportObjectsTestCase(TransactionTestCase):
397
404
  content_type=ContentType.objects.get_for_model(Contact).pk,
398
405
  csv_data=contacts_csv,
399
406
  )
400
- self.assertEqual(contacts_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
407
+ self.assertJobResultStatus(contacts_job_result)
401
408
 
402
409
  contact_count = Contact.objects.filter(name="Bob-ContactAssignmentImportTestLocation").count()
403
410
  self.assertEqual(contact_count, 1, f"Unexpected number of contacts {contact_count}")
@@ -408,7 +415,7 @@ class ImportObjectsTestCase(TransactionTestCase):
408
415
  content_type=ContentType.objects.get_for_model(Role).pk,
409
416
  csv_data=roles_csv,
410
417
  )
411
- self.assertEqual(roles_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
418
+ self.assertJobResultStatus(roles_job_result)
412
419
 
413
420
  role_count = Role.objects.filter(name="ContactAssignmentImportTestLocation-On Site").count()
414
421
  self.assertEqual(role_count, 1, f"Unexpected number of role values {role_count}")
@@ -426,8 +433,7 @@ class ImportObjectsTestCase(TransactionTestCase):
426
433
  content_type=ContentType.objects.get_for_model(ContactAssociation).pk,
427
434
  csv_data=associations_csv,
428
435
  )
429
-
430
- self.assertEqual(associations_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
436
+ self.assertJobResultStatus(associations_job_result)
431
437
 
432
438
 
433
439
  class LogsCleanupTestCase(TransactionTestCase):
@@ -469,7 +475,7 @@ class LogsCleanupTestCase(TransactionTestCase):
469
475
  cleanup_types=[CleanupTypes.JOB_RESULT],
470
476
  max_age=0,
471
477
  )
472
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
478
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
473
479
  log_error = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
474
480
  self.assertEqual(log_error.message, f'User "{self.user}" does not have permission to delete JobResult records')
475
481
  self.assertEqual(JobResult.objects.count(), job_result_count + 1)
@@ -484,7 +490,7 @@ class LogsCleanupTestCase(TransactionTestCase):
484
490
  cleanup_types=[CleanupTypes.OBJECT_CHANGE],
485
491
  max_age=0,
486
492
  )
487
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
493
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
488
494
  log_error = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
489
495
  self.assertEqual(
490
496
  log_error.message, f'User "{self.user}" does not have permission to delete ObjectChange records'
@@ -532,7 +538,7 @@ class LogsCleanupTestCase(TransactionTestCase):
532
538
  cleanup_types=[CleanupTypes.JOB_RESULT, CleanupTypes.OBJECT_CHANGE],
533
539
  max_age=0,
534
540
  )
535
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
541
+ self.assertJobResultStatus(job_result)
536
542
  self.assertEqual(job_result.result["extras.JobResult"], 1)
537
543
  self.assertEqual(job_result.result["extras.ObjectChange"], 1)
538
544
  with self.assertRaises(JobResult.DoesNotExist):
@@ -629,7 +635,7 @@ class BulkEditTestCase(TransactionTestCase):
629
635
  tag.content_types.add(self.namespace_ct)
630
636
 
631
637
  def _common_no_error_test_assertion(self, model, job_result, expected_count, **filter_params):
632
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
638
+ self.assertJobResultStatus(job_result)
633
639
  self.assertEqual(model.objects.filter(**filter_params).count(), expected_count)
634
640
  self.assertFalse(
635
641
  JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
@@ -650,7 +656,7 @@ class BulkEditTestCase(TransactionTestCase):
650
656
  form_data={"pk": pk_list, "color": "aa1409"},
651
657
  username=self.user.username,
652
658
  )
653
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
659
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
654
660
  job_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
655
661
  self.assertEqual(job_log.message, f'User "{self.user}" does not have permission to update status objects')
656
662
 
@@ -961,7 +967,7 @@ class BulkDeleteTestCase(TransactionTestCase):
961
967
  )
962
968
 
963
969
  def _common_no_error_test_assertion(self, model, job_result, **filter_params):
964
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
970
+ self.assertJobResultStatus(job_result)
965
971
  self.assertEqual(model.objects.filter(**filter_params).count(), 0)
966
972
  self.assertFalse(
967
973
  JobLogEntry.objects.filter(job_result=job_result, log_level=LogLevelChoices.LOG_WARNING).exists()
@@ -979,7 +985,7 @@ class BulkDeleteTestCase(TransactionTestCase):
979
985
  pk_list=statuses_to_delete,
980
986
  username=self.user.username,
981
987
  )
982
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
988
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
983
989
  job_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
984
990
  self.assertEqual(job_log.message, f'User "{self.user}" does not have permission to delete status objects')
985
991
  self.assertEqual(Status.objects.filter(pk__in=statuses_to_delete).count(), len(statuses_to_delete))
@@ -1002,7 +1008,7 @@ class BulkDeleteTestCase(TransactionTestCase):
1002
1008
  pk_list=statuses_to_delete,
1003
1009
  username=self.user.username,
1004
1010
  )
1005
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
1011
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
1006
1012
  error_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
1007
1013
  self.assertEqual(
1008
1014
  error_log.message, "You do not have permissions to delete some of the objects provided in `pk_list`."
@@ -375,6 +375,11 @@ class Tab(Component):
375
375
  class DistinctViewTab(Tab):
376
376
  """
377
377
  A Tab that doesn't render inline on the same page, but instead links to a distinct view of its own when clicked.
378
+
379
+ Args:
380
+ url_name (str): The name of the URL pattern to link to, which will be reversed to generate the URL.
381
+ label_wrapper_template_path (str, optional): Template path to render the tab label to HTML.
382
+ related_object_attribute (str, optional): The name of the related object attribute to count for the tab label.
378
383
  """
379
384
 
380
385
  def __init__(
@@ -382,9 +387,11 @@ class DistinctViewTab(Tab):
382
387
  *,
383
388
  url_name,
384
389
  label_wrapper_template_path="components/tab/label_wrapper_distinct_view.html",
390
+ related_object_attribute="",
385
391
  **kwargs,
386
392
  ):
387
393
  self.url_name = url_name
394
+ self.related_object_attribute = related_object_attribute
388
395
  super().__init__(label_wrapper_template_path=label_wrapper_template_path, **kwargs)
389
396
 
390
397
  def get_extra_context(self, context: Context):
@@ -393,6 +400,30 @@ class DistinctViewTab(Tab):
393
400
  def render(self, context: Context):
394
401
  return ""
395
402
 
403
+ def render_label(self, context: Context):
404
+ if not self.related_object_attribute:
405
+ return super().render_label(context)
406
+
407
+ obj = get_obj_from_context(context)
408
+ if not hasattr(obj, self.related_object_attribute):
409
+ logger.warning(
410
+ f"{obj} does not have a related attribute {self.related_object_attribute} to count for tab label."
411
+ )
412
+ return super().render_label(context)
413
+
414
+ try:
415
+ related_obj_count = getattr(obj, self.related_object_attribute).count()
416
+ return format_html(
417
+ "{} {}",
418
+ self.label,
419
+ render_to_string("utilities/templatetags/badge.html", badge(related_obj_count)),
420
+ )
421
+ except AttributeError:
422
+ logger.warning(
423
+ f"{obj}'s attribute {self.related_object_attribute} is not a related manager to count for tab label."
424
+ )
425
+ return super().render_label(context)
426
+
396
427
 
397
428
  class Panel(Component):
398
429
  """Base class for defining an individual display panel within a Layout within a Tab."""
nautobot/dcim/factory.py CHANGED
@@ -680,6 +680,7 @@ class SoftwareImageFileFactory(PrimaryModelFactory):
680
680
  has_hashing_algorithm = NautobotBoolIterator()
681
681
  has_image_file_size = NautobotBoolIterator()
682
682
  has_download_url = NautobotBoolIterator()
683
+ has_external_integration = NautobotBoolIterator()
683
684
 
684
685
  status = random_instance(
685
686
  lambda: Status.objects.get_for_model(SoftwareImageFile),
@@ -698,6 +699,7 @@ class SoftwareImageFileFactory(PrimaryModelFactory):
698
699
  default_image = factory.LazyAttribute(
699
700
  lambda o: not o.software_version.software_image_files.filter(default_image=True).exists()
700
701
  )
702
+ external_integration = factory.Maybe("has_external_integration", random_instance(ExternalIntegration))
701
703
 
702
704
 
703
705
  class SoftwareVersionFactory(PrimaryModelFactory):
@@ -1791,6 +1791,11 @@ class SoftwareImageFileFilterSet(NautobotFilterSet, StatusModelFilterSetMixin):
1791
1791
  default_image = django_filters.BooleanFilter(
1792
1792
  label="Is default image for associated software version",
1793
1793
  )
1794
+ external_integration = NaturalKeyOrPKMultipleChoiceFilter(
1795
+ queryset=ExternalIntegration.objects.all(),
1796
+ to_field_name="name",
1797
+ label="External integration (name or ID)",
1798
+ )
1794
1799
 
1795
1800
  class Meta:
1796
1801
  model = SoftwareImageFile
nautobot/dcim/forms.py CHANGED
@@ -36,6 +36,7 @@ from nautobot.core.forms import (
36
36
  TagFilterField,
37
37
  )
38
38
  from nautobot.core.forms.constants import BOOLEAN_WITH_BLANK_CHOICES
39
+ from nautobot.core.forms.fields import LaxURLField
39
40
  from nautobot.dcim.form_mixins import (
40
41
  LocatableModelBulkEditFormMixin,
41
42
  LocatableModelFilterFormMixin,
@@ -4773,7 +4774,11 @@ class SoftwareImageFileBulkEditForm(TagsBulkEditFormMixin, StatusModelBulkEditFo
4773
4774
  )
4774
4775
  image_file_size = forms.IntegerField(required=False)
4775
4776
  default_image = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label="Is default image")
4776
- download_url = forms.URLField(required=False)
4777
+ download_url = LaxURLField(required=False)
4778
+ external_integration = DynamicModelChoiceField(
4779
+ queryset=ExternalIntegration.objects.all(),
4780
+ required=False,
4781
+ )
4777
4782
 
4778
4783
  class Meta:
4779
4784
  model = SoftwareImageFile
@@ -4782,6 +4787,7 @@ class SoftwareImageFileBulkEditForm(TagsBulkEditFormMixin, StatusModelBulkEditFo
4782
4787
  "hashing_algorithm",
4783
4788
  "image_file_size",
4784
4789
  "download_url",
4790
+ "external_integration",
4785
4791
  ]
4786
4792
 
4787
4793
 
@@ -4816,6 +4822,10 @@ class SoftwareImageFileFilterForm(NautobotFilterForm, StatusModelFilterFormMixin
4816
4822
  label="Has device types",
4817
4823
  widget=StaticSelect2(choices=BOOLEAN_WITH_BLANK_CHOICES),
4818
4824
  )
4825
+ external_integration = DynamicModelChoiceField(
4826
+ queryset=ExternalIntegration.objects.all(),
4827
+ required=False,
4828
+ )
4819
4829
 
4820
4830
  tags = TagFilterField(model)
4821
4831
 
@@ -4829,6 +4839,7 @@ class SoftwareImageFileFilterForm(NautobotFilterForm, StatusModelFilterFormMixin
4829
4839
  "download_url",
4830
4840
  "device_types",
4831
4841
  "has_device_types",
4842
+ "external_integration",
4832
4843
  "status",
4833
4844
  "tags",
4834
4845
  ]
@@ -4843,6 +4854,11 @@ class SoftwareImageFileForm(NautobotModelForm):
4843
4854
  label="Device Types",
4844
4855
  )
4845
4856
 
4857
+ external_integration = DynamicModelChoiceField(
4858
+ queryset=ExternalIntegration.objects.all(),
4859
+ required=False,
4860
+ )
4861
+
4846
4862
  field_order = [
4847
4863
  "software_version",
4848
4864
  "image_file_name",
@@ -0,0 +1,19 @@
1
+ # Generated by Django 4.2.19 on 2025-03-06 19:34
2
+
3
+ from django.db import migrations
4
+
5
+ import nautobot.core.models.fields
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+ dependencies = [
10
+ ("dcim", "0067_controllermanageddevicegroup_tenant"),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AlterField(
15
+ model_name="softwareimagefile",
16
+ name="download_url",
17
+ field=nautobot.core.models.fields.LaxURLField(blank=True),
18
+ ),
19
+ ]