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
@@ -0,0 +1,25 @@
1
+ # Generated by Django 4.2.20 on 2025-03-11 19:10
2
+
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ dependencies = [
9
+ ("extras", "0122_add_graphqlquery_owner_content_type"),
10
+ ("dcim", "0068_alter_softwareimagefile_download_url"),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name="softwareimagefile",
16
+ name="external_integration",
17
+ field=models.ForeignKey(
18
+ blank=True,
19
+ null=True,
20
+ on_delete=django.db.models.deletion.PROTECT,
21
+ related_name="software_image_files",
22
+ to="extras.externalintegration",
23
+ ),
24
+ ),
25
+ ]
@@ -14,7 +14,7 @@ import yaml
14
14
 
15
15
  from nautobot.core.constants import CHARFIELD_MAX_LENGTH
16
16
  from nautobot.core.models import BaseManager, RestrictedQuerySet
17
- from nautobot.core.models.fields import JSONArrayField, NaturalOrderingField
17
+ from nautobot.core.models.fields import JSONArrayField, LaxURLField, NaturalOrderingField
18
18
  from nautobot.core.models.generics import BaseModel, OrganizationalModel, PrimaryModel
19
19
  from nautobot.core.models.tree_queries import TreeModel
20
20
  from nautobot.core.utils.config import get_settings_or_config
@@ -1239,7 +1239,14 @@ class SoftwareImageFile(PrimaryModel):
1239
1239
  verbose_name="Image File Size",
1240
1240
  help_text="Image file size in bytes",
1241
1241
  )
1242
- download_url = models.URLField(blank=True, verbose_name="Download URL")
1242
+ download_url = LaxURLField(blank=True, verbose_name="Download URL")
1243
+ external_integration = models.ForeignKey(
1244
+ to="extras.ExternalIntegration",
1245
+ on_delete=models.PROTECT,
1246
+ related_name="software_image_files",
1247
+ blank=True,
1248
+ null=True,
1249
+ )
1243
1250
  default_image = models.BooleanField(
1244
1251
  verbose_name="Default Image", help_text="Is the default image for this software version", default=False
1245
1252
  )
@@ -1275,6 +1275,7 @@ class SoftwareImageFileTable(StatusTableMixin, BaseTable):
1275
1275
  "image_file_checksum",
1276
1276
  "hashing_algorithm",
1277
1277
  "download_url",
1278
+ "external_integration",
1278
1279
  "device_type_count",
1279
1280
  "tags",
1280
1281
  "actions",
@@ -100,6 +100,10 @@
100
100
  {% endwith %}
101
101
  </td>
102
102
  </tr>
103
+ <tr>
104
+ <td>External Integration</td>
105
+ <td>{{ object.external_integration|hyperlinked_object }}</td>
106
+ </tr>
103
107
  <tr>
104
108
  <td>Devices overridden to use this file</td>
105
109
  <td>
@@ -68,7 +68,7 @@ from nautobot.dcim.models import (
68
68
  VirtualChassis,
69
69
  VirtualDeviceContext,
70
70
  )
71
- from nautobot.extras.models import ConfigContextSchema, Role, SecretsGroup, Status
71
+ from nautobot.extras.models import ConfigContextSchema, ExternalIntegration, Role, SecretsGroup, Status
72
72
  from nautobot.ipam.models import IPAddress, Namespace, Prefix, VLAN, VLANGroup
73
73
  from nautobot.tenancy.models import Tenant
74
74
  from nautobot.virtualization.models import Cluster, ClusterType
@@ -187,7 +187,12 @@ class Mixins:
187
187
  def test_module_device_validation(self):
188
188
  """Assert that a modular component can have a module or a device but not both."""
189
189
 
190
- self.add_permissions(f"{self.model._meta.app_label}.add_{self.model._meta.model_name}")
190
+ self.add_permissions(
191
+ f"{self.model._meta.app_label}.add_{self.model._meta.model_name}",
192
+ "dcim.view_device",
193
+ "dcim.view_module",
194
+ "extras.view_status",
195
+ )
191
196
  data = {
192
197
  self.module_field: self.module.pk,
193
198
  self.device_field: self.device.pk,
@@ -220,7 +225,12 @@ class Mixins:
220
225
  def test_module_device_name_unique_validation(self):
221
226
  """Assert uniqueness constraint is enforced for (device,name) and (module,name) fields."""
222
227
 
223
- self.add_permissions(f"{self.model._meta.app_label}.add_{self.model._meta.model_name}")
228
+ self.add_permissions(
229
+ f"{self.model._meta.app_label}.add_{self.model._meta.model_name}",
230
+ "dcim.view_device",
231
+ "dcim.view_module",
232
+ "extras.view_status",
233
+ )
224
234
  modules = Module.objects.all()[:2]
225
235
  data = {
226
236
  self.module_field: modules[0].pk,
@@ -266,7 +276,11 @@ class Mixins:
266
276
  def test_module_type_device_type_validation(self):
267
277
  """Assert that a modular component template can have a module_type or a device_type but not both."""
268
278
 
269
- self.add_permissions(f"{self.model._meta.app_label}.add_{self.model._meta.model_name}")
279
+ self.add_permissions(
280
+ f"{self.model._meta.app_label}.add_{self.model._meta.model_name}",
281
+ "dcim.view_devicetype",
282
+ "dcim.view_moduletype",
283
+ )
270
284
  data = {
271
285
  "module_type": self.module_type.pk,
272
286
  "device_type": self.device_type.pk,
@@ -299,7 +313,11 @@ class Mixins:
299
313
  def test_module_type_device_type_name_unique_validation(self):
300
314
  """Assert uniqueness constraint is enforced for (device_type,name) and (module_type,name) fields."""
301
315
 
302
- self.add_permissions(f"{self.model._meta.app_label}.add_{self.model._meta.model_name}")
316
+ self.add_permissions(
317
+ f"{self.model._meta.app_label}.add_{self.model._meta.model_name}",
318
+ "dcim.view_devicetype",
319
+ "dcim.view_moduletype",
320
+ )
303
321
  module_types = ModuleType.objects.all()[:2]
304
322
  data = {
305
323
  "module_type": module_types[0].pk,
@@ -442,7 +460,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase, APIViewTestCases.TreeModelA
442
460
  Test allow_null to time_zone field on locaton.
443
461
  """
444
462
 
445
- self.add_permissions("dcim.add_location")
463
+ self.add_permissions("dcim.add_location", "dcim.view_locationtype", "extras.view_status")
446
464
  url = reverse("dcim-api:location-list")
447
465
  location = {
448
466
  "name": "foo",
@@ -461,7 +479,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase, APIViewTestCases.TreeModelA
461
479
  Test disallowed blank time_zone field on location.
462
480
  """
463
481
 
464
- self.add_permissions("dcim.add_location")
482
+ self.add_permissions("dcim.add_location", "dcim.view_locationtype", "extras.view_status")
465
483
  url = reverse("dcim-api:location-list")
466
484
  location = {
467
485
  "name": "foo",
@@ -480,7 +498,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase, APIViewTestCases.TreeModelA
480
498
  Test valid time_zone field on location.
481
499
  """
482
500
 
483
- self.add_permissions("dcim.add_location")
501
+ self.add_permissions("dcim.add_location", "dcim.view_locationtype", "extras.view_status")
484
502
  url = reverse("dcim-api:location-list")
485
503
  time_zone = "UTC"
486
504
  location = {
@@ -500,7 +518,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase, APIViewTestCases.TreeModelA
500
518
  Test invalid time_zone field on location.
501
519
  """
502
520
 
503
- self.add_permissions("dcim.add_location")
521
+ self.add_permissions("dcim.add_location", "dcim.view_locationtype", "extras.view_status")
504
522
  url = reverse("dcim-api:location-list")
505
523
  time_zone = "IDONOTEXIST"
506
524
  location = {
@@ -593,7 +611,7 @@ class RackGroupTest(APIViewTestCases.APIViewTestCase, APIViewTestCases.TreeModel
593
611
 
594
612
  def test_child_group_location_valid(self):
595
613
  """A child group with a location may fall within the parent group's location."""
596
- self.add_permissions("dcim.add_rackgroup")
614
+ self.add_permissions("dcim.add_rackgroup", "dcim.view_rackgroup", "dcim.view_location")
597
615
  url = reverse("dcim-api:rackgroup-list")
598
616
 
599
617
  parent_group = RackGroup.objects.filter(location=self.locations[0]).first()
@@ -615,7 +633,7 @@ class RackGroupTest(APIViewTestCases.APIViewTestCase, APIViewTestCases.TreeModel
615
633
 
616
634
  def test_child_group_location_invalid(self):
617
635
  """A child group with a location must not fall outside its parent group's location."""
618
- self.add_permissions("dcim.add_rackgroup")
636
+ self.add_permissions("dcim.add_rackgroup", "dcim.view_location", "dcim.view_rackgroup")
619
637
  url = reverse("dcim-api:rackgroup-list")
620
638
 
621
639
  parent_group = RackGroup.objects.filter(location=self.locations[0]).first()
@@ -1225,7 +1243,9 @@ class FrontPortTemplateTest(Mixins.BasePortTemplateTestMixin):
1225
1243
  def test_module_type_device_type_validation(self):
1226
1244
  """Assert that a modular component template can have a module_type or a device_type but not both."""
1227
1245
 
1228
- self.add_permissions("dcim.add_frontporttemplate")
1246
+ self.add_permissions(
1247
+ "dcim.add_frontporttemplate", "dcim.view_rearporttemplate", "dcim.view_devicetype", "dcim.view_moduletype"
1248
+ )
1229
1249
  data = {
1230
1250
  "module_type": self.module_type.pk,
1231
1251
  "device_type": self.device_type.pk,
@@ -1253,7 +1273,9 @@ class FrontPortTemplateTest(Mixins.BasePortTemplateTestMixin):
1253
1273
  def test_module_type_device_type_name_unique_validation(self):
1254
1274
  """Assert uniqueness constraint is enforced for (device_type,name) and (module_type,name) fields."""
1255
1275
 
1256
- self.add_permissions("dcim.add_frontporttemplate")
1276
+ self.add_permissions(
1277
+ "dcim.add_frontporttemplate", "dcim.view_rearporttemplate", "dcim.view_moduletype", "dcim.view_devicetype"
1278
+ )
1257
1279
  data = {
1258
1280
  "module_type": self.module_type.pk,
1259
1281
  "name": "test modular device_type component parent validation",
@@ -1617,7 +1639,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
1617
1639
  schema = ConfigContextSchema.objects.create(
1618
1640
  name="Schema 1", data_schema={"type": "object", "properties": {"A": {"type": "integer"}}}
1619
1641
  )
1620
- self.add_permissions("dcim.change_device")
1642
+ self.add_permissions("dcim.change_device", "extras.view_configcontextschema")
1621
1643
 
1622
1644
  patch_data = {"local_config_context_schema": str(schema.pk)}
1623
1645
 
@@ -1651,7 +1673,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
1651
1673
  Validate we can set primary_ip4 on a device using a PATCH.
1652
1674
  """
1653
1675
  # Add object-level permission
1654
- self.add_permissions("dcim.change_device")
1676
+ self.add_permissions("dcim.change_device", "ipam.view_ipaddress")
1655
1677
 
1656
1678
  dev = Device.objects.get(name="Device 3")
1657
1679
  intf_status = Status.objects.get_for_model(Interface).first()
@@ -1677,7 +1699,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
1677
1699
  Validate we can set device redundancy group on a device using a PATCH.
1678
1700
  """
1679
1701
  # Add object-level permission
1680
- self.add_permissions("dcim.change_device")
1702
+ self.add_permissions("dcim.change_device", "dcim.view_deviceredundancygroup")
1681
1703
 
1682
1704
  device_redundancy_group = DeviceRedundancyGroup.objects.first()
1683
1705
 
@@ -1751,7 +1773,15 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
1751
1773
  # Create test data
1752
1774
  parent_device, device_bay_1, device_bay_2, device_type_child = self._parent_device_test_data()
1753
1775
 
1754
- self.add_permissions("dcim.add_device")
1776
+ self.add_permissions(
1777
+ "dcim.add_device",
1778
+ "dcim.view_device",
1779
+ "dcim.view_devicetype",
1780
+ "extras.view_role",
1781
+ "extras.view_status",
1782
+ "dcim.view_location",
1783
+ "dcim.view_devicebay",
1784
+ )
1755
1785
  url = reverse("dcim-api:device-list")
1756
1786
 
1757
1787
  # Test creating device with parent bay by device bay data
@@ -1808,7 +1838,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
1808
1838
  # Create test data
1809
1839
  parent_device, device_bay_1, device_bay_2, device_type_child = self._parent_device_test_data()
1810
1840
 
1811
- self.add_permissions("dcim.change_device")
1841
+ self.add_permissions("dcim.change_device", "dcim.view_devicebay")
1812
1842
 
1813
1843
  child_device = Device.objects.create(
1814
1844
  device_type=device_type_child,
@@ -1852,7 +1882,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
1852
1882
  device_bay_1.installed_device = child_device
1853
1883
  device_bay_1.save()
1854
1884
 
1855
- self.add_permissions("dcim.change_device", "dcim.view_device", "dcim.change_devicebay")
1885
+ self.add_permissions("dcim.change_device", "dcim.view_device", "dcim.change_devicebay", "dcim.view_devicebay")
1856
1886
  child_device_detail_url = self._get_detail_url(child_device)
1857
1887
 
1858
1888
  response = self.client.get(child_device_detail_url, **self.header)
@@ -1950,7 +1980,9 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
1950
1980
  def test_parent_module_bay_location_validation(self):
1951
1981
  """Assert that a module can have a parent_module_bay or a location but not both."""
1952
1982
 
1953
- self.add_permissions("dcim.add_module")
1983
+ self.add_permissions(
1984
+ "dcim.add_module", "dcim.view_moduletype", "dcim.view_location", "dcim.view_modulebay", "extras.view_status"
1985
+ )
1954
1986
  data = {
1955
1987
  "module_type": self.module_type.pk,
1956
1988
  "location": self.location.pk,
@@ -1981,7 +2013,7 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
1981
2013
  )
1982
2014
 
1983
2015
  def test_serial_module_type_unique_validation(self):
1984
- self.add_permissions("dcim.add_module")
2016
+ self.add_permissions("dcim.add_module", "dcim.view_location", "dcim.view_moduletype", "extras.view_status")
1985
2017
  data = {
1986
2018
  "module_type": self.module_type.pk,
1987
2019
  "location": self.location.pk,
@@ -2006,7 +2038,7 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
2006
2038
  )
2007
2039
 
2008
2040
  def test_asset_tag_unique_validation(self):
2009
- self.add_permissions("dcim.add_module")
2041
+ self.add_permissions("dcim.add_module", "dcim.view_location", "dcim.view_moduletype", "extras.view_status")
2010
2042
  data = {
2011
2043
  "module_type": self.module_type.pk,
2012
2044
  "location": self.location.pk,
@@ -2321,7 +2353,7 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
2321
2353
 
2322
2354
  def test_untagged_vlan_requires_mode(self):
2323
2355
  """Test that when an `untagged_vlan` is specified, `mode` is also required."""
2324
- self.add_permissions("dcim.add_interface")
2356
+ self.add_permissions("dcim.add_interface", "dcim.view_device", "extras.view_status", "ipam.view_vlan")
2325
2357
 
2326
2358
  # This will fail.
2327
2359
  url = self._get_list_url()
@@ -2336,7 +2368,9 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
2336
2368
  )
2337
2369
 
2338
2370
  def test_tagged_vlan_must_be_in_the_location_or_parent_locations_of_the_parent_device(self):
2339
- self.add_permissions("dcim.add_interface")
2371
+ self.add_permissions(
2372
+ "dcim.add_interface", "dcim.view_interface", "dcim.view_device", "extras.view_status", "ipam.view_vlan"
2373
+ )
2340
2374
 
2341
2375
  interface_status = Status.objects.get_for_model(Interface).first()
2342
2376
  location = self.devices[0].location
@@ -2365,7 +2399,7 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
2365
2399
 
2366
2400
  def test_interface_belonging_to_common_device_or_vc_allowed(self):
2367
2401
  """Test parent, bridge, and LAG interfaces belonging to common device or VC is valid"""
2368
- self.add_permissions("dcim.add_interface")
2402
+ self.add_permissions("dcim.add_interface", "dcim.view_device", "dcim.view_interface", "extras.view_status")
2369
2403
 
2370
2404
  response = self.client.post(
2371
2405
  self._get_list_url(), data=self.common_device_or_vc_data[0], format="json", **self.header
@@ -2388,7 +2422,9 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
2388
2422
  def test_interface_not_belonging_to_common_device_or_vc_not_allowed(self):
2389
2423
  """Test parent, bridge, and LAG interfaces not belonging to common device or VC is invalid"""
2390
2424
 
2391
- self.add_permissions("dcim.add_interface")
2425
+ self.add_permissions(
2426
+ "dcim.add_interface", "dcim.view_device", "dcim.view_interface", "extras.view_status", "extras.view_role"
2427
+ )
2392
2428
 
2393
2429
  for name, payload in self.interfaces_not_belonging_to_same_device_data:
2394
2430
  response = self.client.post(self._get_list_url(), data=payload, format="json", **self.header)
@@ -2405,7 +2441,9 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
2405
2441
  )
2406
2442
 
2407
2443
  def test_tagged_vlan_raise_error_if_mode_not_set_to_tagged(self):
2408
- self.add_permissions("dcim.add_interface", "dcim.change_interface")
2444
+ self.add_permissions(
2445
+ "dcim.add_interface", "dcim.change_interface", "dcim.view_device", "extras.view_status", "ipam.view_vlan"
2446
+ )
2409
2447
  with self.subTest("On create, assert 400 status."):
2410
2448
  payload = {
2411
2449
  "device": self.devices[0].pk,
@@ -2509,7 +2547,7 @@ class FrontPortTest(Mixins.BasePortTestMixin):
2509
2547
  def test_module_device_validation(self):
2510
2548
  """Assert that a modular component can have a module or a device but not both."""
2511
2549
 
2512
- self.add_permissions("dcim.add_frontport")
2550
+ self.add_permissions("dcim.add_frontport", "dcim.view_device", "dcim.view_module", "dcim.view_rearport")
2513
2551
  data = {
2514
2552
  "module": self.module.pk,
2515
2553
  "device": self.device.pk,
@@ -2546,7 +2584,7 @@ class FrontPortTest(Mixins.BasePortTestMixin):
2546
2584
  def test_module_device_name_unique_validation(self):
2547
2585
  """Assert uniqueness constraint is enforced for (device,name) and (module,name) fields."""
2548
2586
 
2549
- self.add_permissions("dcim.add_frontport")
2587
+ self.add_permissions("dcim.add_frontport", "dcim.view_module", "dcim.view_rearport", "dcim.view_device")
2550
2588
  data = {
2551
2589
  "module": self.module.pk,
2552
2590
  "name": "test modular device component parent validation",
@@ -3410,22 +3448,26 @@ class SoftwareImageFileTestCase(Mixins.SoftwareImageFileRelatedModelMixin, APIVi
3410
3448
  def setUpTestData(cls):
3411
3449
  statuses = Status.objects.get_for_model(SoftwareImageFile)
3412
3450
  software_versions = SoftwareVersion.objects.all()
3451
+ external_integrations = ExternalIntegration.objects.all()
3413
3452
 
3414
3453
  cls.create_data = [
3415
3454
  {
3416
3455
  "software_version": software_versions[0].pk,
3417
3456
  "status": statuses[0].pk,
3418
3457
  "image_file_name": "software_image_file_test_case_1.bin",
3458
+ "external_integration": external_integrations[0].pk,
3419
3459
  },
3420
3460
  {
3421
3461
  "software_version": software_versions[1].pk,
3422
3462
  "status": statuses[1].pk,
3423
3463
  "image_file_name": "software_image_file_test_case_2.bin",
3464
+ "external_integration": external_integrations[1].pk,
3424
3465
  },
3425
3466
  {
3426
3467
  "software_version": software_versions[2].pk,
3427
3468
  "status": statuses[2].pk,
3428
3469
  "image_file_name": "software_image_file_test_case_3.bin",
3470
+ "external_integration": None,
3429
3471
  },
3430
3472
  ]
3431
3473
  cls.bulk_update_data = {
@@ -3435,6 +3477,7 @@ class SoftwareImageFileTestCase(Mixins.SoftwareImageFileRelatedModelMixin, APIVi
3435
3477
  "hashing_algorithm": SoftwareImageFileHashingAlgorithmChoices.SHA512,
3436
3478
  "image_file_size": 1234567890,
3437
3479
  "download_url": "https://example.com/software_image_file_test_case.bin",
3480
+ "external_integration": external_integrations[0].pk,
3438
3481
  }
3439
3482
 
3440
3483
 
@@ -3642,7 +3685,7 @@ class VirtualDeviceContextTestCase(APIViewTestCases.APIViewTestCase):
3642
3685
  Validate we can set primary_ip on a Virtual Device Context using a PATCH.
3643
3686
  """
3644
3687
  # Add object-level permission
3645
- self.add_permissions("dcim.change_virtualdevicecontext")
3688
+ self.add_permissions("dcim.change_virtualdevicecontext", "ipam.view_ipaddress")
3646
3689
  vdc = VirtualDeviceContext.objects.first()
3647
3690
  device = vdc.device
3648
3691
  intf_status = Status.objects.get_for_model(Interface).first()
@@ -3679,7 +3722,7 @@ class VirtualDeviceContextTestCase(APIViewTestCases.APIViewTestCase):
3679
3722
  """
3680
3723
  Validate that changing device on the virutal device context is not allowed.
3681
3724
  """
3682
- self.add_permissions("dcim.change_virtualdevicecontext")
3725
+ self.add_permissions("dcim.change_virtualdevicecontext", "dcim.view_device")
3683
3726
  vdc = VirtualDeviceContext.objects.first()
3684
3727
  old_device = vdc.device
3685
3728
  new_device = Device.objects.exclude(pk=old_device.pk).first()
@@ -3861,6 +3861,8 @@ class SoftwareImageFileFilterSetTestCase(FilterTestCases.FilterTestCase):
3861
3861
  ["software_version", "software_version__version"],
3862
3862
  ["status", "status__id"],
3863
3863
  ["status", "status__name"],
3864
+ ["external_integration", "external_integration__id"],
3865
+ ["external_integration", "external_integration__name"],
3864
3866
  )
3865
3867
 
3866
3868
  @classmethod
@@ -71,7 +71,7 @@ def create_common_data_for_software_related_test_cases():
71
71
  class TestSoftwareImageFileTestCase(TransactionTestCase):
72
72
  def test_correct_handling_for_model_protected_error(self):
73
73
  create_common_data_for_software_related_test_cases()
74
- software_image_file = SoftwareImageFile.objects.first()
74
+ software_image_file = SoftwareImageFile.objects.get(image_file_name="software_image_file_qs_test_1.bin")
75
75
 
76
76
  self.add_permissions("dcim.delete_softwareimagefile")
77
77
  pk_list = [str(software_image_file.pk)]
@@ -86,9 +86,7 @@ class TestSoftwareImageFileTestCase(TransactionTestCase):
86
86
  pk_list=pk_list,
87
87
  username=self.user.username,
88
88
  )
89
- logs = JobLogEntry.objects.filter(job_result=job_result)
90
- print([(log.message, log.log_level) for log in logs])
91
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
89
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
92
90
  error_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
93
91
  self.assertIn("Caught ProtectedError while attempting to delete objects", error_log.message)
94
92
  self.assertEqual(initial_count, SoftwareImageFile.objects.all().count())
@@ -97,7 +95,7 @@ class TestSoftwareImageFileTestCase(TransactionTestCase):
97
95
  class TestSoftwareVersionTestCase(TransactionTestCase):
98
96
  def test_correct_handling_for_model_protected_error(self):
99
97
  create_common_data_for_software_related_test_cases()
100
- software_version = SoftwareVersion.objects.first()
98
+ software_version = SoftwareVersion.objects.get(version="Test version 1.0.0")
101
99
 
102
100
  initial_count = SoftwareVersion.objects.all().count()
103
101
  self.add_permissions("dcim.delete_softwareversion")
@@ -112,7 +110,7 @@ class TestSoftwareVersionTestCase(TransactionTestCase):
112
110
  pk_list=pk_list,
113
111
  username=self.user.username,
114
112
  )
115
- self.assertEqual(job_result.status, JobResultStatusChoices.STATUS_FAILURE)
113
+ self.assertJobResultStatus(job_result, JobResultStatusChoices.STATUS_FAILURE)
116
114
  error_log = JobLogEntry.objects.get(job_result=job_result, log_level=LogLevelChoices.LOG_ERROR)
117
115
  self.assertIn("Caught ProtectedError while attempting to delete objects", error_log.message)
118
116
  self.assertEqual(initial_count, SoftwareVersion.objects.all().count())
@@ -9,6 +9,7 @@ from django.test import TestCase
9
9
  from django.test.utils import override_settings
10
10
 
11
11
  from nautobot.circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
12
+ from nautobot.core import settings
12
13
  from nautobot.core.testing.models import ModelTestCases
13
14
  from nautobot.dcim.choices import (
14
15
  CableStatusChoices,
@@ -2610,6 +2611,70 @@ class InterfaceTestCase(ModularDeviceComponentTestCaseMixin, ModelTestCases.Base
2610
2611
  class SoftwareImageFileTestCase(ModelTestCases.BaseModelTestCase):
2611
2612
  model = SoftwareImageFile
2612
2613
 
2614
+ def test_download_url_validation_behaviour(self):
2615
+ """
2616
+ Test that the `download_url` property behaves as expected in relation to laxURLField validation and
2617
+ the ALLOWED_URL_SCHEMES setting.
2618
+ """
2619
+ # Prepare prerequisite objects
2620
+ platform = Platform.objects.first()
2621
+ software_version_status = Status.objects.get_for_model(SoftwareVersion).first()
2622
+ software_image_file_status = Status.objects.get_for_model(SoftwareImageFile).first()
2623
+ software_version = SoftwareVersion.objects.create(
2624
+ platform=platform, version="Test version 1.0.0", status=software_version_status
2625
+ )
2626
+
2627
+ # Test that the download_url field is correctly validated with the default ALLOWED_URL_SCHEMES setting
2628
+ for scheme in settings.ALLOWED_URL_SCHEMES:
2629
+ software_image = SoftwareImageFile(
2630
+ software_version=software_version,
2631
+ image_file_name=f"software_image_file_qs_test_{scheme}.bin",
2632
+ status=software_image_file_status,
2633
+ download_url=f"{scheme}://example.com/software_image_file_qs_test_1.bin",
2634
+ )
2635
+ try:
2636
+ software_image.validated_save()
2637
+ except ValidationError as e:
2638
+ self.fail(f"download_url Scheme {scheme} ValidationError: {e}")
2639
+
2640
+ INVALID_SCHEMES = ["httpx", "rdp", "cryptoboi"]
2641
+ OVERRIDE_VALID_SCHEMES = ["sftp", "tftp", "https", "http", "newfs", "customfs"]
2642
+ # Invalid schemes should raise a ValidationError
2643
+ for scheme in INVALID_SCHEMES:
2644
+ software_image = SoftwareImageFile(
2645
+ software_version=software_version,
2646
+ image_file_name=f"software_image_file_qs_test_{scheme}2.bin",
2647
+ status=software_image_file_status,
2648
+ download_url=f"{scheme}://example.com/software_image_file_qs_test_2.bin",
2649
+ )
2650
+ with self.assertRaises(ValidationError) as err:
2651
+ software_image.validated_save()
2652
+ self.assertEqual(err.exception.message_dict["download_url"][0], "Enter a valid URL.")
2653
+
2654
+ with override_settings(ALLOWED_URL_SCHEMES=OVERRIDE_VALID_SCHEMES):
2655
+ for scheme in OVERRIDE_VALID_SCHEMES:
2656
+ software_image = SoftwareImageFile(
2657
+ software_version=software_version,
2658
+ image_file_name=f"software_image_file_qs_test_{scheme}3.bin",
2659
+ status=software_image_file_status,
2660
+ download_url=f"{scheme}://example.com/software_image_file_qs_test_3.bin",
2661
+ )
2662
+ try:
2663
+ software_image.validated_save()
2664
+ except ValidationError as e:
2665
+ self.fail(f"download_url Scheme {scheme} ValidationError: {e}")
2666
+
2667
+ for scheme in INVALID_SCHEMES:
2668
+ software_image = SoftwareImageFile(
2669
+ software_version=software_version,
2670
+ image_file_name=f"software_image_file_qs_test_{scheme}4.bin",
2671
+ status=software_image_file_status,
2672
+ download_url=f"{scheme}://example.com/software_image_file_qs_test_4.bin",
2673
+ )
2674
+ with self.assertRaises(ValidationError) as err:
2675
+ software_image.validated_save()
2676
+ self.assertEqual(err.exception.message_dict["download_url"][0], "Enter a valid URL.")
2677
+
2613
2678
  def test_queryset_get_for_object(self):
2614
2679
  """
2615
2680
  Test that the queryset get_for_object method returns the expected results for Device, DeviceType, InventoryItem and VirtualMachine
@@ -4579,6 +4579,7 @@ class SoftwareImageFileTestCase(ViewTestCases.PrimaryObjectViewTestCase):
4579
4579
  device_types = DeviceType.objects.all()[:2]
4580
4580
  statuses = Status.objects.get_for_model(SoftwareImageFile)
4581
4581
  software_versions = SoftwareVersion.objects.all()
4582
+ external_integration = ExternalIntegration.objects.first()
4582
4583
 
4583
4584
  cls.form_data = {
4584
4585
  "software_version": software_versions[0].pk,
@@ -4588,6 +4589,7 @@ class SoftwareImageFileTestCase(ViewTestCases.PrimaryObjectViewTestCase):
4588
4589
  "image_file_size": 1234567890,
4589
4590
  "hashing_algorithm": SoftwareImageFileHashingAlgorithmChoices.SHA512,
4590
4591
  "download_url": "https://example.com/software_image_file_test_case.bin",
4592
+ "external_integration": external_integration.pk,
4591
4593
  "device_types": [device_types[0].pk, device_types[1].pk],
4592
4594
  }
4593
4595
 
@@ -4598,6 +4600,7 @@ class SoftwareImageFileTestCase(ViewTestCases.PrimaryObjectViewTestCase):
4598
4600
  "hashing_algorithm": SoftwareImageFileHashingAlgorithmChoices.SHA512,
4599
4601
  "image_file_size": 1234567890,
4600
4602
  "download_url": "https://example.com/software_image_file_test_case.bin",
4603
+ "external_integration": external_integration.pk,
4601
4604
  }
4602
4605
 
4603
4606
 
@@ -251,8 +251,10 @@ class JobResultStatusChoices(ChoiceSet):
251
251
  """
252
252
 
253
253
  STATUS_FAILURE = states.FAILURE
254
+ STATUS_IGNORED = states.IGNORED
254
255
  STATUS_PENDING = states.PENDING
255
256
  STATUS_RECEIVED = states.RECEIVED
257
+ STATUS_REJECTED = states.REJECTED
256
258
  STATUS_RETRY = states.RETRY
257
259
  STATUS_REVOKED = states.REVOKED
258
260
  STATUS_STARTED = states.STARTED
@@ -302,27 +304,30 @@ class JobResultStatusChoices(ChoiceSet):
302
304
  class LogLevelChoices(ChoiceSet):
303
305
  LOG_DEBUG = "debug"
304
306
  LOG_INFO = "info"
307
+ LOG_SUCCESS = "success"
305
308
  LOG_WARNING = "warning"
309
+ LOG_FAILURE = "failure"
306
310
  LOG_ERROR = "error"
307
311
  LOG_CRITICAL = "critical"
308
- LOG_SUCCESS = "success"
309
312
 
310
313
  CHOICES = (
311
314
  (LOG_DEBUG, "Debug"),
312
315
  (LOG_INFO, "Info"),
316
+ (LOG_SUCCESS, "Success"),
313
317
  (LOG_WARNING, "Warning"),
318
+ (LOG_FAILURE, "Failure"),
314
319
  (LOG_ERROR, "Error"),
315
320
  (LOG_CRITICAL, "Critical"),
316
- (LOG_SUCCESS, "Success"),
317
321
  )
318
322
 
319
323
  CSS_CLASSES = {
320
324
  LOG_DEBUG: "debug",
321
325
  LOG_INFO: "info",
326
+ LOG_SUCCESS: "success",
322
327
  LOG_WARNING: "warning",
328
+ LOG_FAILURE: "failure",
323
329
  LOG_ERROR: "error",
324
330
  LOG_CRITICAL: "critical",
325
- LOG_SUCCESS: "success",
326
331
  }
327
332
 
328
333
 
@@ -1891,8 +1891,11 @@ class RoleBulkEditForm(NautobotBulkEditForm):
1891
1891
  color = forms.CharField(max_length=6, required=False, widget=ColorSelect())
1892
1892
  description = forms.CharField(max_length=CHARFIELD_MAX_LENGTH, required=False)
1893
1893
  weight = forms.IntegerField(required=False)
1894
- content_types = MultipleContentTypeField(
1895
- queryset=RoleModelsQuery().as_queryset(), required=False, label="Content Type(s)"
1894
+ add_content_types = MultipleContentTypeField(
1895
+ queryset=RoleModelsQuery().as_queryset(), required=False, label="Add Content Type(s)"
1896
+ )
1897
+ remove_content_types = MultipleContentTypeField(
1898
+ queryset=RoleModelsQuery().as_queryset(), required=False, label="Remove Content Type(s)"
1896
1899
  )
1897
1900
 
1898
1901
  class Meta:
@@ -2012,7 +2015,8 @@ class StatusBulkEditForm(NautobotBulkEditForm):
2012
2015
 
2013
2016
  pk = forms.ModelMultipleChoiceField(queryset=Status.objects.all(), widget=forms.MultipleHiddenInput)
2014
2017
  color = forms.CharField(max_length=6, required=False, widget=ColorSelect())
2015
- content_types = MultipleContentTypeField(feature="statuses", required=False, label="Content Type(s)")
2018
+ add_content_types = MultipleContentTypeField(feature="statuses", required=False, label="Add Content Type(s)")
2019
+ remove_content_types = MultipleContentTypeField(feature="statuses", required=False, label="Remove Content Type(s)")
2016
2020
 
2017
2021
  class Meta:
2018
2022
  nullable_fields = []