nautobot 2.4.5__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 (115) hide show
  1. nautobot/core/api/mixins.py +10 -0
  2. nautobot/core/celery/encoders.py +2 -2
  3. nautobot/core/forms/fields.py +21 -5
  4. nautobot/core/forms/utils.py +1 -0
  5. nautobot/core/jobs/bulk_actions.py +1 -1
  6. nautobot/core/management/commands/generate_test_data.py +1 -1
  7. nautobot/core/models/name_color_content_types.py +9 -0
  8. nautobot/core/models/validators.py +7 -0
  9. nautobot/core/settings.py +0 -14
  10. nautobot/core/settings.yaml +0 -28
  11. nautobot/core/tables.py +6 -1
  12. nautobot/core/templates/generic/object_retrieve.html +1 -1
  13. nautobot/core/testing/api.py +18 -0
  14. nautobot/core/tests/nautobot_config.py +0 -2
  15. nautobot/core/tests/runner.py +17 -140
  16. nautobot/core/tests/test_api.py +4 -4
  17. nautobot/core/tests/test_authentication.py +83 -4
  18. nautobot/core/tests/test_forms.py +11 -8
  19. nautobot/core/tests/test_graphql.py +9 -0
  20. nautobot/core/tests/test_jobs.py +7 -0
  21. nautobot/core/ui/object_detail.py +31 -0
  22. nautobot/dcim/factory.py +2 -0
  23. nautobot/dcim/filters/__init__.py +5 -0
  24. nautobot/dcim/forms.py +17 -1
  25. nautobot/dcim/migrations/0068_alter_softwareimagefile_download_url.py +19 -0
  26. nautobot/dcim/migrations/0069_softwareimagefile_external_integration.py +25 -0
  27. nautobot/dcim/models/devices.py +9 -2
  28. nautobot/dcim/tables/devices.py +1 -0
  29. nautobot/dcim/templates/dcim/softwareimagefile_retrieve.html +4 -0
  30. nautobot/dcim/tests/test_api.py +74 -31
  31. nautobot/dcim/tests/test_filters.py +2 -0
  32. nautobot/dcim/tests/test_models.py +65 -0
  33. nautobot/dcim/tests/test_views.py +3 -0
  34. nautobot/extras/forms/forms.py +7 -3
  35. nautobot/extras/plugins/marketplace_manifest.yml +18 -0
  36. nautobot/extras/tables.py +4 -5
  37. nautobot/extras/templates/extras/inc/panel_changelog.html +1 -1
  38. nautobot/extras/templates/extras/inc/panel_jobhistory.html +1 -1
  39. nautobot/extras/templates/extras/status.html +1 -37
  40. nautobot/extras/tests/integration/test_notes.py +1 -1
  41. nautobot/extras/tests/test_api.py +22 -7
  42. nautobot/extras/tests/test_changelog.py +4 -4
  43. nautobot/extras/tests/test_customfields.py +3 -0
  44. nautobot/extras/tests/test_plugins.py +19 -13
  45. nautobot/extras/tests/test_relationships.py +9 -0
  46. nautobot/extras/tests/test_tags.py +2 -2
  47. nautobot/extras/tests/test_views.py +15 -6
  48. nautobot/extras/urls.py +1 -30
  49. nautobot/extras/views.py +10 -54
  50. nautobot/ipam/tables.py +6 -2
  51. nautobot/ipam/templates/ipam/namespace_retrieve.html +0 -41
  52. nautobot/ipam/templates/ipam/service.html +2 -46
  53. nautobot/ipam/templates/ipam/service_edit.html +1 -17
  54. nautobot/ipam/templates/ipam/service_retrieve.html +7 -0
  55. nautobot/ipam/tests/migration/__init__.py +0 -0
  56. nautobot/ipam/tests/migration/test_migrations.py +510 -0
  57. nautobot/ipam/tests/test_api.py +66 -36
  58. nautobot/ipam/tests/test_filters.py +0 -10
  59. nautobot/ipam/tests/test_views.py +44 -2
  60. nautobot/ipam/urls.py +2 -47
  61. nautobot/ipam/utils/migrations.py +185 -152
  62. nautobot/ipam/utils/testing.py +177 -0
  63. nautobot/ipam/views.py +95 -157
  64. nautobot/project-static/docs/code-reference/nautobot/apps/models.html +47 -0
  65. nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +18 -0
  66. nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +63 -0
  67. nautobot/project-static/docs/development/apps/api/testing.html +0 -87
  68. nautobot/project-static/docs/development/apps/migration/dependency-updates.html +1 -1
  69. nautobot/project-static/docs/development/core/best-practices.html +3 -3
  70. nautobot/project-static/docs/development/core/getting-started.html +78 -107
  71. nautobot/project-static/docs/development/core/release-checklist.html +1 -1
  72. nautobot/project-static/docs/development/core/style-guide.html +1 -1
  73. nautobot/project-static/docs/development/core/testing.html +24 -198
  74. nautobot/project-static/docs/media/user-guide/administration/getting-started/nautobot-cloud.png +0 -0
  75. nautobot/project-static/docs/objects.inv +0 -0
  76. nautobot/project-static/docs/overview/application_stack.html +1 -1
  77. nautobot/project-static/docs/release-notes/version-2.4.html +226 -1
  78. nautobot/project-static/docs/search/search_index.json +1 -1
  79. nautobot/project-static/docs/sitemap.xml +290 -290
  80. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  81. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +2 -48
  82. nautobot/project-static/docs/user-guide/administration/guides/permissions.html +71 -0
  83. nautobot/project-static/docs/user-guide/administration/installation/http-server.html +3 -1
  84. nautobot/project-static/docs/user-guide/administration/installation/index.html +257 -16
  85. nautobot/project-static/docs/user-guide/administration/tools/nautobot-server.html +1 -1
  86. nautobot/project-static/docs/user-guide/administration/upgrading/upgrading.html +2 -2
  87. nautobot/project-static/docs/user-guide/core-data-model/dcim/softwareimagefile.html +4 -0
  88. nautobot/project-static/docs/user-guide/feature-guides/contacts-and-teams.html +11 -11
  89. nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-devices.html +8 -8
  90. nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-location-types-and-locations.html +1 -0
  91. nautobot/project-static/docs/user-guide/feature-guides/getting-started/interfaces.html +40 -25
  92. nautobot/project-static/docs/user-guide/feature-guides/getting-started/ipam.html +4 -4
  93. nautobot/project-static/docs/user-guide/feature-guides/getting-started/platforms.html +1 -1
  94. nautobot/project-static/docs/user-guide/feature-guides/getting-started/search-bar.html +77 -5
  95. nautobot/project-static/docs/user-guide/feature-guides/getting-started/tenants.html +1 -1
  96. nautobot/project-static/docs/user-guide/feature-guides/getting-started/vlans-and-vlan-groups.html +0 -1
  97. nautobot/project-static/docs/user-guide/feature-guides/git-data-source.html +1 -1
  98. nautobot/project-static/docs/user-guide/index.html +89 -2
  99. nautobot/project-static/docs/user-guide/platform-functionality/webhook.html +207 -122
  100. nautobot/virtualization/forms.py +20 -0
  101. nautobot/virtualization/templates/virtualization/clustergroup.html +1 -39
  102. nautobot/virtualization/templates/virtualization/clustertype.html +1 -0
  103. nautobot/virtualization/tests/test_api.py +14 -3
  104. nautobot/virtualization/tests/test_views.py +10 -2
  105. nautobot/virtualization/urls.py +10 -93
  106. nautobot/virtualization/views.py +33 -72
  107. {nautobot-2.4.5.dist-info → nautobot-2.4.6.dist-info}/METADATA +6 -5
  108. {nautobot-2.4.5.dist-info → nautobot-2.4.6.dist-info}/RECORD +113 -108
  109. {nautobot-2.4.5.dist-info → nautobot-2.4.6.dist-info}/WHEEL +1 -1
  110. nautobot/core/tests/performance_baselines.yml +0 -8900
  111. nautobot/ipam/tests/test_migrations.py +0 -462
  112. /nautobot/ipam/templates/ipam/{namespace_ipaddresses.html → namespace_ip_addresses.html} +0 -0
  113. {nautobot-2.4.5.dist-info → nautobot-2.4.6.dist-info}/LICENSE.txt +0 -0
  114. {nautobot-2.4.5.dist-info → nautobot-2.4.6.dist-info}/NOTICE +0 -0
  115. {nautobot-2.4.5.dist-info → nautobot-2.4.6.dist-info}/entry_points.txt +0 -0
@@ -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)
@@ -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,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
+ ]
@@ -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>