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.
- nautobot/core/api/mixins.py +10 -0
- nautobot/core/celery/encoders.py +2 -2
- nautobot/core/forms/fields.py +21 -5
- nautobot/core/forms/utils.py +1 -0
- 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/api.py +18 -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 +7 -0
- 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_models.py +65 -0
- nautobot/dcim/tests/test_views.py +3 -0
- nautobot/extras/forms/forms.py +7 -3
- nautobot/extras/plugins/marketplace_manifest.yml +18 -0
- nautobot/extras/tables.py +4 -5
- 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/tests/integration/test_notes.py +1 -1
- nautobot/extras/tests/test_api.py +22 -7
- nautobot/extras/tests/test_changelog.py +4 -4
- nautobot/extras/tests/test_customfields.py +3 -0
- nautobot/extras/tests/test_plugins.py +19 -13
- nautobot/extras/tests/test_relationships.py +9 -0
- 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 +10 -54
- nautobot/ipam/tables.py +6 -2
- nautobot/ipam/templates/ipam/namespace_retrieve.html +0 -41
- 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_views.py +44 -2
- nautobot/ipam/urls.py +2 -47
- nautobot/ipam/utils/migrations.py +185 -152
- nautobot/ipam/utils/testing.py +177 -0
- nautobot/ipam/views.py +95 -157
- 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/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/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 +226 -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.5.dist-info → nautobot-2.4.6.dist-info}/METADATA +6 -5
- {nautobot-2.4.5.dist-info → nautobot-2.4.6.dist-info}/RECORD +113 -108
- {nautobot-2.4.5.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.5.dist-info → nautobot-2.4.6.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.5.dist-info → nautobot-2.4.6.dist-info}/NOTICE +0 -0
- {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
|
-
#
|
|
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
|
@@ -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 =
|
|
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
|
+
]
|
nautobot/dcim/models/devices.py
CHANGED
|
@@ -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 =
|
|
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
|
)
|
nautobot/dcim/tables/devices.py
CHANGED