nautobot 2.4.4__py3-none-any.whl → 2.4.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nautobot/__init__.py +19 -3
- nautobot/core/api/mixins.py +10 -0
- nautobot/core/celery/__init__.py +5 -3
- nautobot/core/celery/encoders.py +2 -2
- nautobot/core/forms/fields.py +21 -5
- nautobot/core/forms/utils.py +1 -0
- nautobot/core/jobs/__init__.py +3 -2
- nautobot/core/jobs/bulk_actions.py +1 -1
- nautobot/core/management/commands/generate_test_data.py +1 -1
- nautobot/core/models/name_color_content_types.py +9 -0
- nautobot/core/models/validators.py +7 -0
- nautobot/core/settings.py +0 -14
- nautobot/core/settings.yaml +0 -28
- nautobot/core/tables.py +6 -1
- nautobot/core/templates/generic/object_retrieve.html +1 -1
- nautobot/core/testing/__init__.py +2 -0
- nautobot/core/testing/api.py +18 -0
- nautobot/core/testing/mixins.py +9 -0
- nautobot/core/tests/nautobot_config.py +0 -2
- nautobot/core/tests/runner.py +17 -140
- nautobot/core/tests/test_api.py +4 -4
- nautobot/core/tests/test_authentication.py +83 -4
- nautobot/core/tests/test_forms.py +11 -8
- nautobot/core/tests/test_graphql.py +9 -0
- nautobot/core/tests/test_jobs.py +33 -27
- nautobot/core/ui/object_detail.py +31 -0
- nautobot/dcim/factory.py +2 -0
- nautobot/dcim/filters/__init__.py +5 -0
- nautobot/dcim/forms.py +17 -1
- nautobot/dcim/migrations/0068_alter_softwareimagefile_download_url.py +19 -0
- nautobot/dcim/migrations/0069_softwareimagefile_external_integration.py +25 -0
- nautobot/dcim/models/devices.py +9 -2
- nautobot/dcim/tables/devices.py +1 -0
- nautobot/dcim/templates/dcim/softwareimagefile_retrieve.html +4 -0
- nautobot/dcim/tests/test_api.py +74 -31
- nautobot/dcim/tests/test_filters.py +2 -0
- nautobot/dcim/tests/test_jobs.py +4 -6
- nautobot/dcim/tests/test_models.py +65 -0
- nautobot/dcim/tests/test_views.py +3 -0
- nautobot/extras/choices.py +8 -3
- nautobot/extras/forms/forms.py +7 -3
- nautobot/extras/jobs.py +181 -103
- nautobot/extras/management/utils.py +13 -2
- nautobot/extras/models/datasources.py +4 -1
- nautobot/extras/models/jobs.py +20 -17
- nautobot/extras/plugins/marketplace_manifest.yml +18 -0
- nautobot/extras/tables.py +29 -34
- nautobot/extras/templates/extras/inc/panel_changelog.html +1 -1
- nautobot/extras/templates/extras/inc/panel_jobhistory.html +1 -1
- nautobot/extras/templates/extras/status.html +1 -37
- nautobot/extras/test_jobs/atomic_transaction.py +6 -6
- nautobot/extras/test_jobs/fail.py +75 -1
- nautobot/extras/tests/integration/test_notes.py +1 -1
- nautobot/extras/tests/test_api.py +23 -8
- nautobot/extras/tests/test_changelog.py +4 -4
- nautobot/extras/tests/test_customfields.py +3 -0
- nautobot/extras/tests/test_datasources.py +64 -54
- nautobot/extras/tests/test_jobs.py +69 -62
- nautobot/extras/tests/test_models.py +1 -1
- nautobot/extras/tests/test_plugins.py +19 -13
- nautobot/extras/tests/test_relationships.py +14 -5
- nautobot/extras/tests/test_tags.py +2 -2
- nautobot/extras/tests/test_views.py +15 -6
- nautobot/extras/urls.py +1 -30
- nautobot/extras/views.py +17 -55
- nautobot/ipam/forms.py +15 -0
- nautobot/ipam/querysets.py +6 -0
- nautobot/ipam/tables.py +6 -2
- nautobot/ipam/templates/ipam/namespace_retrieve.html +0 -41
- nautobot/ipam/templates/ipam/rir.html +1 -43
- nautobot/ipam/templates/ipam/service.html +2 -46
- nautobot/ipam/templates/ipam/service_edit.html +1 -17
- nautobot/ipam/templates/ipam/service_retrieve.html +7 -0
- nautobot/ipam/tests/migration/__init__.py +0 -0
- nautobot/ipam/tests/migration/test_migrations.py +510 -0
- nautobot/ipam/tests/test_api.py +66 -36
- nautobot/ipam/tests/test_filters.py +0 -10
- nautobot/ipam/tests/test_models.py +16 -0
- nautobot/ipam/tests/test_views.py +44 -2
- nautobot/ipam/urls.py +2 -67
- nautobot/ipam/utils/migrations.py +185 -152
- nautobot/ipam/utils/testing.py +177 -0
- nautobot/ipam/views.py +119 -198
- nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +43 -5
- nautobot/project-static/docs/code-reference/nautobot/apps/models.html +47 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +18 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +35 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +63 -0
- nautobot/project-static/docs/development/apps/api/testing.html +0 -87
- nautobot/project-static/docs/development/apps/migration/dependency-updates.html +1 -1
- nautobot/project-static/docs/development/core/best-practices.html +3 -3
- nautobot/project-static/docs/development/core/getting-started.html +78 -107
- nautobot/project-static/docs/development/core/release-checklist.html +1 -1
- nautobot/project-static/docs/development/core/style-guide.html +1 -1
- nautobot/project-static/docs/development/core/testing.html +24 -198
- nautobot/project-static/docs/development/jobs/index.html +27 -14
- nautobot/project-static/docs/media/user-guide/administration/getting-started/nautobot-cloud.png +0 -0
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/overview/application_stack.html +1 -1
- nautobot/project-static/docs/release-notes/version-2.4.html +409 -1
- nautobot/project-static/docs/requirements.txt +1 -1
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +290 -290
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/configuration/settings.html +2 -48
- nautobot/project-static/docs/user-guide/administration/guides/permissions.html +71 -0
- nautobot/project-static/docs/user-guide/administration/installation/http-server.html +3 -1
- nautobot/project-static/docs/user-guide/administration/installation/index.html +257 -16
- nautobot/project-static/docs/user-guide/administration/tools/nautobot-server.html +1 -1
- nautobot/project-static/docs/user-guide/administration/upgrading/upgrading.html +2 -2
- nautobot/project-static/docs/user-guide/core-data-model/dcim/softwareimagefile.html +4 -0
- nautobot/project-static/docs/user-guide/feature-guides/contacts-and-teams.html +11 -11
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-devices.html +8 -8
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-location-types-and-locations.html +1 -0
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/interfaces.html +40 -25
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/ipam.html +4 -4
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/platforms.html +1 -1
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/search-bar.html +77 -5
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/tenants.html +1 -1
- nautobot/project-static/docs/user-guide/feature-guides/getting-started/vlans-and-vlan-groups.html +0 -1
- nautobot/project-static/docs/user-guide/feature-guides/git-data-source.html +1 -1
- nautobot/project-static/docs/user-guide/index.html +89 -2
- nautobot/project-static/docs/user-guide/platform-functionality/webhook.html +207 -122
- nautobot/virtualization/forms.py +20 -0
- nautobot/virtualization/templates/virtualization/clustergroup.html +1 -39
- nautobot/virtualization/templates/virtualization/clustertype.html +1 -0
- nautobot/virtualization/tests/test_api.py +14 -3
- nautobot/virtualization/tests/test_views.py +10 -2
- nautobot/virtualization/urls.py +10 -93
- nautobot/virtualization/views.py +33 -72
- {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/METADATA +8 -7
- {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/RECORD +137 -132
- {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/WHEEL +1 -1
- nautobot/core/tests/performance_baselines.yml +0 -8900
- nautobot/ipam/tests/test_migrations.py +0 -462
- /nautobot/ipam/templates/ipam/{namespace_ipaddresses.html → namespace_ip_addresses.html} +0 -0
- {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/NOTICE +0 -0
- {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/entry_points.txt +0 -0
nautobot/core/tests/test_api.py
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
490
|
-
tests =
|
|
491
|
-
None
|
|
492
|
-
""
|
|
493
|
-
"80,443-444"
|
|
494
|
-
"1024-1028,31337"
|
|
495
|
-
|
|
496
|
-
|
|
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)
|
nautobot/core/tests/test_jobs.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
+
]
|