nautobot 2.4.17__py3-none-any.whl → 2.4.18__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.
Potentially problematic release.
This version of nautobot might be problematic. Click here for more details.
- nautobot/apps/views.py +2 -0
- nautobot/circuits/templates/circuits/circuittermination_retrieve.html +1 -8
- nautobot/circuits/templates/circuits/inc/circuit_termination_speed_fragment.html +9 -0
- nautobot/circuits/tests/integration/test_circuit.py +2 -2
- nautobot/circuits/views.py +32 -15
- nautobot/core/filters.py +2 -2
- nautobot/core/settings.py +1 -0
- nautobot/core/settings.yaml +9 -0
- nautobot/core/tables.py +21 -23
- nautobot/core/templates/components/breadcrumbs.html +19 -0
- nautobot/core/templates/generic/object_changelog.html +0 -2
- nautobot/core/templates/generic/object_list.html +15 -12
- nautobot/core/templates/generic/object_notes.html +0 -2
- nautobot/core/templates/generic/object_retrieve.html +16 -9
- nautobot/core/templatetags/helpers.py +24 -0
- nautobot/core/templatetags/ui_framework.py +40 -5
- nautobot/core/testing/filters.py +37 -21
- nautobot/core/testing/views.py +25 -0
- nautobot/core/tests/test_tables.py +43 -6
- nautobot/core/tests/test_templatetags_ui_framework.py +146 -0
- nautobot/core/tests/test_titles.py +2 -2
- nautobot/core/tests/test_ui.py +14 -1
- nautobot/core/tests/test_views.py +45 -0
- nautobot/core/ui/breadcrumbs.py +13 -8
- nautobot/core/ui/object_detail.py +43 -5
- nautobot/core/ui/titles.py +9 -5
- nautobot/core/views/__init__.py +24 -3
- nautobot/core/views/generic.py +42 -17
- nautobot/core/views/mixins.py +146 -12
- nautobot/core/views/utils.py +117 -0
- nautobot/dcim/models/devices.py +4 -0
- nautobot/dcim/tables/__init__.py +2 -0
- nautobot/dcim/tables/devices.py +24 -0
- nautobot/dcim/tables/power.py +2 -2
- nautobot/dcim/templates/dcim/device/base.html +1 -11
- nautobot/dcim/templates/dcim/device_component.html +0 -19
- nautobot/dcim/templates/dcim/modulebay_retrieve.html +0 -16
- nautobot/dcim/templates/dcim/virtualchassis_retrieve.html +1 -50
- nautobot/dcim/tests/test_views.py +41 -0
- nautobot/dcim/views.py +160 -39
- nautobot/extras/filters/mixins.py +1 -1
- nautobot/extras/forms/forms.py +15 -0
- nautobot/extras/models/groups.py +10 -1
- nautobot/extras/models/jobs.py +2 -2
- nautobot/extras/plugins/views.py +18 -5
- nautobot/extras/tables.py +4 -2
- nautobot/extras/templates/extras/customfield_retrieve.html +1 -128
- nautobot/extras/templates/extras/dynamicgroup.html +2 -99
- nautobot/extras/templates/extras/dynamicgroup_edit.html +2 -199
- nautobot/extras/templates/extras/dynamicgroup_retrieve.html +99 -0
- nautobot/extras/templates/extras/dynamicgroup_update.html +199 -0
- nautobot/extras/templates/extras/gitrepository.html +2 -82
- nautobot/extras/templates/extras/gitrepository_object_edit.html +2 -13
- nautobot/extras/templates/extras/gitrepository_retrieve.html +82 -0
- nautobot/extras/templates/extras/gitrepository_update.html +13 -0
- nautobot/extras/templates/extras/note_retrieve.html +0 -52
- nautobot/extras/templates/extras/plugin_detail.html +3 -7
- nautobot/extras/templates/extras/plugins_list.html +0 -2
- nautobot/extras/tests/test_dynamicgroups.py +73 -18
- nautobot/extras/tests/test_views.py +5 -0
- nautobot/extras/urls.py +2 -94
- nautobot/extras/views.py +424 -430
- nautobot/ipam/querysets.py +3 -3
- nautobot/ipam/signals.py +6 -1
- nautobot/ipam/templates/ipam/prefix.html +0 -8
- nautobot/ipam/tests/test_api.py +5 -0
- nautobot/ipam/tests/test_models.py +387 -0
- nautobot/ipam/tests/test_querysets.py +46 -0
- nautobot/ipam/utils/migrations.py +1 -1
- nautobot/ipam/views.py +17 -8
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +72 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +45 -9
- nautobot/project-static/docs/code-reference/nautobot/apps/views.html +393 -15
- nautobot/project-static/docs/development/apps/api/nautobot-app-config.html +1 -1
- nautobot/project-static/docs/development/core/getting-started.html +0 -15
- nautobot/project-static/docs/development/core/ui-component-framework.html +6 -11
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-2.4.html +222 -0
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +300 -300
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/configuration/settings.html +27 -0
- nautobot/project-static/img/nautobot_icon.svg +32 -34
- nautobot/project-static/js/table_sorting_indicator.js +0 -2
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/METADATA +4 -4
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/RECORD +90 -85
- nautobot/core/templates/inc/breadcrumbs.html +0 -14
- nautobot/project-static/docs/requirements.txt +0 -14
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/NOTICE +0 -0
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/WHEEL +0 -0
- {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/entry_points.txt +0 -0
nautobot/ipam/querysets.py
CHANGED
|
@@ -356,7 +356,7 @@ class PrefixQuerySet(LocationToLocationsQuerySetMixin, BaseNetworkQuerySet):
|
|
|
356
356
|
"""
|
|
357
357
|
# Validate that it's a real CIDR
|
|
358
358
|
cidr = self._validate_cidr(cidr)
|
|
359
|
-
broadcast = str(cidr.broadcast or cidr
|
|
359
|
+
broadcast = str(cidr.broadcast or cidr[-1])
|
|
360
360
|
ip_version = cidr.version
|
|
361
361
|
|
|
362
362
|
try:
|
|
@@ -366,7 +366,7 @@ class PrefixQuerySet(LocationToLocationsQuerySetMixin, BaseNetworkQuerySet):
|
|
|
366
366
|
|
|
367
367
|
# Prepare the queryset filter
|
|
368
368
|
lookup_kwargs = {
|
|
369
|
-
"network__lte": cidr.
|
|
369
|
+
"network__lte": cidr.network,
|
|
370
370
|
"prefix_length__gte": shortest_prefix_length,
|
|
371
371
|
"broadcast__gte": broadcast,
|
|
372
372
|
"ip_version": ip_version,
|
|
@@ -376,7 +376,7 @@ class PrefixQuerySet(LocationToLocationsQuerySetMixin, BaseNetworkQuerySet):
|
|
|
376
376
|
# we can choose the first one.
|
|
377
377
|
possible_ancestors = self.filter(**lookup_kwargs).order_by("-prefix_length")
|
|
378
378
|
if not include_self:
|
|
379
|
-
possible_ancestors = possible_ancestors.exclude(network=cidr.
|
|
379
|
+
possible_ancestors = possible_ancestors.exclude(network=cidr.network, prefix_length=cidr.prefixlen)
|
|
380
380
|
|
|
381
381
|
# If we've got any matches, the first one is our closest parent.
|
|
382
382
|
try:
|
nautobot/ipam/signals.py
CHANGED
|
@@ -80,11 +80,16 @@ def ip_address_to_interface_pre_delete(instance, raw=False, **kwargs):
|
|
|
80
80
|
)
|
|
81
81
|
|
|
82
82
|
# Only nullify the primary_ip field if no other interfaces/vm_interfaces have the ip_address
|
|
83
|
+
host_needs_save = False
|
|
83
84
|
if not other_assignments_exist and instance.ip_address == host.primary_ip4:
|
|
84
85
|
host.primary_ip4 = None
|
|
86
|
+
host_needs_save = True
|
|
85
87
|
elif not other_assignments_exist and instance.ip_address == host.primary_ip6:
|
|
86
88
|
host.primary_ip6 = None
|
|
87
|
-
|
|
89
|
+
host_needs_save = True
|
|
90
|
+
|
|
91
|
+
if host_needs_save:
|
|
92
|
+
host.save()
|
|
88
93
|
|
|
89
94
|
|
|
90
95
|
@receiver(pre_save, sender=IPAddressToInterface)
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
{% extends 'generic/object_retrieve.html' %}
|
|
2
2
|
{% load helpers %}
|
|
3
3
|
|
|
4
|
-
{% block breadcrumbs %}
|
|
5
|
-
<li><a href="{% url 'ipam:namespace_list' %}">Namespaces</a></li>
|
|
6
|
-
<li>{{ object.namespace | hyperlinked_object }}</li>
|
|
7
|
-
<li><a href="{% url list_url %}?namespace={{ object.namespace.pk }}">{{ verbose_name_plural|bettertitle }}</a></li>
|
|
8
|
-
{% block extra_breadcrumbs %}{% endblock extra_breadcrumbs %}
|
|
9
|
-
<li>{{ object|hyperlinked_object }}</li>
|
|
10
|
-
{% endblock breadcrumbs %}
|
|
11
|
-
|
|
12
4
|
{% block extra_buttons %}
|
|
13
5
|
{% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
|
|
14
6
|
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&namespace={{ object.namespace.pk }}&tenant_group={{ object.tenant.tenant_group.pk }}&tenant={{ object.tenant.pk }}{% for loc in object.locations.all %}&locations={{ loc.pk }}{% endfor %}" class="btn btn-success">
|
nautobot/ipam/tests/test_api.py
CHANGED
|
@@ -413,6 +413,11 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
|
|
413
413
|
}
|
|
414
414
|
cls.choices_fields = ["type"]
|
|
415
415
|
|
|
416
|
+
# Generic `test_update_object()` will grab and update the first Prefix
|
|
417
|
+
first_pfx = Prefix.objects.first()
|
|
418
|
+
cls.update_data = cls.create_data[0].copy()
|
|
419
|
+
cls.update_data["namespace"] = first_pfx.namespace.pk # can't change network and namespace in the same update
|
|
420
|
+
|
|
416
421
|
def test_legacy_api_behavior(self):
|
|
417
422
|
"""
|
|
418
423
|
Tests for the 2.0/2.1 REST API of Prefixes.
|
|
@@ -284,6 +284,393 @@ class IPAddressToInterfaceTest(TestCase):
|
|
|
284
284
|
self.assertEqual(remaining_assignments.count(), 1)
|
|
285
285
|
self.assertIn(assignment_nested_module, remaining_assignments)
|
|
286
286
|
|
|
287
|
+
def test_ip_address_to_interface_delete_signal_no_save_when_device_has_no_primary_ips(self):
|
|
288
|
+
"""
|
|
289
|
+
Test that Device is not saved when removing an IP from an interface and the device
|
|
290
|
+
has no primary IPs assigned.
|
|
291
|
+
"""
|
|
292
|
+
# Create an IP address
|
|
293
|
+
ip_addr = IPAddress.objects.create(address="192.0.2.1/24", status=self.status, namespace=self.namespace)
|
|
294
|
+
|
|
295
|
+
# Assign IP to interface
|
|
296
|
+
assignment_device_int1 = IPAddressToInterface.objects.create(interface=self.test_int1, ip_address=ip_addr)
|
|
297
|
+
|
|
298
|
+
# Mock the device save method to verify it's not called
|
|
299
|
+
with patch.object(self.test_device, "save") as mock_save:
|
|
300
|
+
# Remove the IP assignment from interface - this should NOT trigger a device save
|
|
301
|
+
assignment_device_int1.delete()
|
|
302
|
+
|
|
303
|
+
# Assert save was not called since no primary IPs were affected
|
|
304
|
+
mock_save.assert_not_called()
|
|
305
|
+
|
|
306
|
+
def test_ip_address_to_interface_delete_signal_no_save_when_removing_non_primary_ip_from_device(self):
|
|
307
|
+
"""
|
|
308
|
+
Test that Device is not saved when removing an IP from an interface and the IP
|
|
309
|
+
is not the device's primary IP.
|
|
310
|
+
"""
|
|
311
|
+
# Test removing non-primary IPv4 assignment
|
|
312
|
+
with self.subTest("IPv4 non-primary IP removal"):
|
|
313
|
+
# Create IPv4 addresses
|
|
314
|
+
primary_ipv4_addr = IPAddress.objects.create(
|
|
315
|
+
address="192.0.2.1/24", status=self.status, namespace=self.namespace
|
|
316
|
+
)
|
|
317
|
+
other_ipv4_addr = IPAddress.objects.create(
|
|
318
|
+
address="192.0.2.2/24", status=self.status, namespace=self.namespace
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Assign primary IP to interface first (required before setting as primary)
|
|
322
|
+
IPAddressToInterface.objects.create(interface=self.test_int2, ip_address=primary_ipv4_addr)
|
|
323
|
+
|
|
324
|
+
# Set it as the device's primary IP
|
|
325
|
+
self.test_device.primary_ip4 = primary_ipv4_addr
|
|
326
|
+
self.test_device.save()
|
|
327
|
+
|
|
328
|
+
# Assign the other IP to a different interface
|
|
329
|
+
assignment_other_ipv4 = IPAddressToInterface.objects.create(
|
|
330
|
+
interface=self.test_int1, ip_address=other_ipv4_addr
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
with patch.object(self.test_device, "save") as mock_save:
|
|
334
|
+
assignment_other_ipv4.delete()
|
|
335
|
+
mock_save.assert_not_called()
|
|
336
|
+
|
|
337
|
+
# Test removing non-primary IPv6 assignment
|
|
338
|
+
with self.subTest("IPv6 non-primary IP removal"):
|
|
339
|
+
# Create IPv6 prefix first
|
|
340
|
+
Prefix.objects.create(prefix="2001:db8::/64", status=self.status, namespace=self.namespace)
|
|
341
|
+
|
|
342
|
+
# Create IPv6 addresses
|
|
343
|
+
primary_ipv6_addr = IPAddress.objects.create(
|
|
344
|
+
address="2001:db8::1/64", status=self.status, namespace=self.namespace
|
|
345
|
+
)
|
|
346
|
+
other_ipv6_addr = IPAddress.objects.create(
|
|
347
|
+
address="2001:db8::2/64", status=self.status, namespace=self.namespace
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Assign primary IP to interface first (required before setting as primary)
|
|
351
|
+
IPAddressToInterface.objects.create(interface=self.test_int2, ip_address=primary_ipv6_addr)
|
|
352
|
+
|
|
353
|
+
# Set it as the device's primary IP
|
|
354
|
+
self.test_device.primary_ip6 = primary_ipv6_addr
|
|
355
|
+
self.test_device.save()
|
|
356
|
+
|
|
357
|
+
# Assign the other IP to a different interface
|
|
358
|
+
assignment_other_ipv6 = IPAddressToInterface.objects.create(
|
|
359
|
+
interface=self.test_int1, ip_address=other_ipv6_addr
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
with patch.object(self.test_device, "save") as mock_save:
|
|
363
|
+
assignment_other_ipv6.delete()
|
|
364
|
+
mock_save.assert_not_called()
|
|
365
|
+
|
|
366
|
+
def test_ip_address_to_interface_delete_signal_save_when_removing_primary_ip_from_device(self):
|
|
367
|
+
"""
|
|
368
|
+
Test that Device is saved when removing an IP from an interface and the IP is the device's
|
|
369
|
+
primary IP and no other assignments exist.
|
|
370
|
+
"""
|
|
371
|
+
# Test removing primary IPv4 assignment
|
|
372
|
+
with self.subTest("IPv4 primary IP removal"):
|
|
373
|
+
# Create primary IPv4 address
|
|
374
|
+
primary_ipv4_addr = IPAddress.objects.create(
|
|
375
|
+
address="192.0.2.1/24", status=self.status, namespace=self.namespace
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Assign primary IPv4 to an interface first
|
|
379
|
+
assignment_primary_ipv4 = IPAddressToInterface.objects.create(
|
|
380
|
+
interface=self.test_int1, ip_address=primary_ipv4_addr
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Set it as the device's primary IP
|
|
384
|
+
self.test_device.primary_ip4 = primary_ipv4_addr
|
|
385
|
+
self.test_device.save()
|
|
386
|
+
|
|
387
|
+
# Mock the device save method to verify it's called
|
|
388
|
+
with patch.object(self.test_device, "save") as mock_save:
|
|
389
|
+
# Remove the primary IP assignment - this SHOULD trigger a device save
|
|
390
|
+
assignment_primary_ipv4.delete()
|
|
391
|
+
|
|
392
|
+
# Assert save was called to nullify primary_ip4
|
|
393
|
+
mock_save.assert_called_once()
|
|
394
|
+
|
|
395
|
+
# Test removing primary IPv6 assignment
|
|
396
|
+
with self.subTest("IPv6 primary IP removal"):
|
|
397
|
+
# Create IPv6 prefix first
|
|
398
|
+
Prefix.objects.create(prefix="2001:db8::/64", status=self.status, namespace=self.namespace)
|
|
399
|
+
|
|
400
|
+
# Create primary IPv6 address
|
|
401
|
+
primary_ipv6_addr = IPAddress.objects.create(
|
|
402
|
+
address="2001:db8::1/64", status=self.status, namespace=self.namespace
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Assign primary IPv6 to an interface first
|
|
406
|
+
assignment_primary_ipv6 = IPAddressToInterface.objects.create(
|
|
407
|
+
interface=self.test_int1, ip_address=primary_ipv6_addr
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# Set it as the device's primary IP
|
|
411
|
+
self.test_device.primary_ip6 = primary_ipv6_addr
|
|
412
|
+
self.test_device.save()
|
|
413
|
+
|
|
414
|
+
# Mock the device save method to verify it's called
|
|
415
|
+
with patch.object(self.test_device, "save") as mock_save:
|
|
416
|
+
# Remove the primary IP assignment - this SHOULD trigger a device save
|
|
417
|
+
assignment_primary_ipv6.delete()
|
|
418
|
+
|
|
419
|
+
# Assert save was called to nullify primary_ip6
|
|
420
|
+
mock_save.assert_called_once()
|
|
421
|
+
|
|
422
|
+
def test_ip_address_to_interface_delete_signal_no_save_when_device_primary_ip_has_multiple_assignments(self):
|
|
423
|
+
"""
|
|
424
|
+
Test that Device is not saved when removing an IP from an interface and the IP is the device's
|
|
425
|
+
primary IP but is assigned to other interfaces on that device.
|
|
426
|
+
"""
|
|
427
|
+
# Test IPv4 primary IP with multiple assignments
|
|
428
|
+
with self.subTest("IPv4 primary IP with multiple assignments"):
|
|
429
|
+
# Create primary IPv4 address
|
|
430
|
+
primary_ipv4_addr = IPAddress.objects.create(
|
|
431
|
+
address="192.0.2.1/24", status=self.status, namespace=self.namespace
|
|
432
|
+
)
|
|
433
|
+
# Assign primary IPv4 to multiple interfaces
|
|
434
|
+
assignment_ipv4_int1 = IPAddressToInterface.objects.create(
|
|
435
|
+
interface=self.test_int1, ip_address=primary_ipv4_addr
|
|
436
|
+
)
|
|
437
|
+
IPAddressToInterface.objects.create(interface=self.test_int2, ip_address=primary_ipv4_addr)
|
|
438
|
+
|
|
439
|
+
# Set it as the device's primary IP
|
|
440
|
+
self.test_device.primary_ip4 = primary_ipv4_addr
|
|
441
|
+
self.test_device.save()
|
|
442
|
+
|
|
443
|
+
# Mock the device save method to verify it's not called
|
|
444
|
+
with patch.object(self.test_device, "save") as mock_save:
|
|
445
|
+
# Remove primary IP from one interface - should NOT trigger save since other assignment exists
|
|
446
|
+
assignment_ipv4_int1.delete()
|
|
447
|
+
|
|
448
|
+
# Assert save was not called since IP is still assigned to test_int2
|
|
449
|
+
mock_save.assert_not_called()
|
|
450
|
+
|
|
451
|
+
# Test IPv6 primary IP with multiple assignments
|
|
452
|
+
with self.subTest("IPv6 primary IP with multiple assignments"):
|
|
453
|
+
# Create IPv6 prefix first
|
|
454
|
+
Prefix.objects.create(prefix="2001:db8::/64", status=self.status, namespace=self.namespace)
|
|
455
|
+
|
|
456
|
+
# Create primary IPv6 address
|
|
457
|
+
primary_ipv6_addr = IPAddress.objects.create(
|
|
458
|
+
address="2001:db8::1/64", status=self.status, namespace=self.namespace
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Assign primary IPv6 to multiple interfaces
|
|
462
|
+
assignment_ipv6_int1 = IPAddressToInterface.objects.create(
|
|
463
|
+
interface=self.test_int1, ip_address=primary_ipv6_addr
|
|
464
|
+
)
|
|
465
|
+
IPAddressToInterface.objects.create(interface=self.test_int2, ip_address=primary_ipv6_addr)
|
|
466
|
+
|
|
467
|
+
# Set it as the device's primary IP
|
|
468
|
+
self.test_device.primary_ip6 = primary_ipv6_addr
|
|
469
|
+
self.test_device.save()
|
|
470
|
+
|
|
471
|
+
# Mock the device save method to verify it's not called
|
|
472
|
+
with patch.object(self.test_device, "save") as mock_save:
|
|
473
|
+
# Remove primary IP from one interface - should NOT trigger save since other assignment exists
|
|
474
|
+
assignment_ipv6_int1.delete()
|
|
475
|
+
|
|
476
|
+
# Assert save was not called since IP is still assigned to test_int2
|
|
477
|
+
mock_save.assert_not_called()
|
|
478
|
+
|
|
479
|
+
def test_ip_address_to_interface_delete_signal_no_save_when_vm_has_no_primary_ips(self):
|
|
480
|
+
"""
|
|
481
|
+
Test that VM is not saved when removing an IP from a VM interface and the VM
|
|
482
|
+
has no primary IPs assigned.
|
|
483
|
+
"""
|
|
484
|
+
# Create an IP address
|
|
485
|
+
ip_addr = IPAddress.objects.create(address="192.0.2.1/24", status=self.status, namespace=self.namespace)
|
|
486
|
+
|
|
487
|
+
# VM has no primary IPs by default (both are None)
|
|
488
|
+
|
|
489
|
+
# Assign IP to VM interface
|
|
490
|
+
assignment_vm_int1 = IPAddressToInterface.objects.create(vm_interface=self.test_vmint1, ip_address=ip_addr)
|
|
491
|
+
|
|
492
|
+
# Mock the VM save method to verify it's not called
|
|
493
|
+
with patch.object(self.test_vm, "save") as mock_save:
|
|
494
|
+
# Remove the IP assignment from VM interface - this should NOT trigger a VM save
|
|
495
|
+
assignment_vm_int1.delete()
|
|
496
|
+
|
|
497
|
+
# Assert save was not called since no primary IPs were affected
|
|
498
|
+
mock_save.assert_not_called()
|
|
499
|
+
|
|
500
|
+
def test_ip_address_to_interface_delete_signal_no_save_when_removing_non_primary_ip_from_vm(self):
|
|
501
|
+
"""
|
|
502
|
+
Test that VM is not saved when removing an IP from a VM interface and the IP
|
|
503
|
+
is not the VM's primary IP.
|
|
504
|
+
"""
|
|
505
|
+
# Test removing non-primary IPv4 assignment
|
|
506
|
+
with self.subTest("IPv4 non-primary IP removal"):
|
|
507
|
+
# Create IPv4 addresses
|
|
508
|
+
primary_ipv4_addr = IPAddress.objects.create(
|
|
509
|
+
address="192.0.2.1/24", status=self.status, namespace=self.namespace
|
|
510
|
+
)
|
|
511
|
+
other_ipv4_addr = IPAddress.objects.create(
|
|
512
|
+
address="192.0.2.2/24", status=self.status, namespace=self.namespace
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Assign primary IP to VM interface first (required before setting as primary)
|
|
516
|
+
IPAddressToInterface.objects.create(vm_interface=self.test_vmint2, ip_address=primary_ipv4_addr)
|
|
517
|
+
|
|
518
|
+
# Set it as the VM's primary IP
|
|
519
|
+
self.test_vm.primary_ip4 = primary_ipv4_addr
|
|
520
|
+
self.test_vm.save()
|
|
521
|
+
|
|
522
|
+
# Assign the other IP to a different VM interface
|
|
523
|
+
assignment_vm_int1 = IPAddressToInterface.objects.create(
|
|
524
|
+
vm_interface=self.test_vmint1, ip_address=other_ipv4_addr
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
with patch.object(self.test_vm, "save") as mock_save:
|
|
528
|
+
assignment_vm_int1.delete()
|
|
529
|
+
mock_save.assert_not_called()
|
|
530
|
+
|
|
531
|
+
# Test removing non-primary IPv6 assignment
|
|
532
|
+
with self.subTest("IPv6 non-primary IP removal"):
|
|
533
|
+
# Create IPv6 prefix first
|
|
534
|
+
Prefix.objects.create(prefix="2001:db8::/64", status=self.status, namespace=self.namespace)
|
|
535
|
+
|
|
536
|
+
# Create IPv6 addresses
|
|
537
|
+
primary_ipv6_addr = IPAddress.objects.create(
|
|
538
|
+
address="2001:db8::1/64", status=self.status, namespace=self.namespace
|
|
539
|
+
)
|
|
540
|
+
other_ipv6_addr = IPAddress.objects.create(
|
|
541
|
+
address="2001:db8::2/64", status=self.status, namespace=self.namespace
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# Assign primary IP to VM interface first (required before setting as primary)
|
|
545
|
+
IPAddressToInterface.objects.create(vm_interface=self.test_vmint2, ip_address=primary_ipv6_addr)
|
|
546
|
+
|
|
547
|
+
# Set it as the VM's primary IP
|
|
548
|
+
self.test_vm.primary_ip6 = primary_ipv6_addr
|
|
549
|
+
self.test_vm.save()
|
|
550
|
+
|
|
551
|
+
# Assign the other IP to a different VM interface
|
|
552
|
+
assignment_vm_int1 = IPAddressToInterface.objects.create(
|
|
553
|
+
vm_interface=self.test_vmint1, ip_address=other_ipv6_addr
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
with patch.object(self.test_vm, "save") as mock_save:
|
|
557
|
+
assignment_vm_int1.delete()
|
|
558
|
+
mock_save.assert_not_called()
|
|
559
|
+
|
|
560
|
+
def test_ip_address_to_interface_delete_signal_save_when_removing_primary_ip_from_vm(self):
|
|
561
|
+
"""
|
|
562
|
+
Test that VM is saved when removing an IP from a VM interface and the IP is the VM's
|
|
563
|
+
primary IP and no other assignments exist.
|
|
564
|
+
"""
|
|
565
|
+
# Test removing primary IPv4 assignment
|
|
566
|
+
with self.subTest("IPv4 primary IP removal"):
|
|
567
|
+
# Create primary IPv4 address
|
|
568
|
+
primary_ipv4_addr = IPAddress.objects.create(
|
|
569
|
+
address="192.0.2.1/24", status=self.status, namespace=self.namespace
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
# Assign primary IPv4 to VM interface first
|
|
573
|
+
assignment_primary_ipv4 = IPAddressToInterface.objects.create(
|
|
574
|
+
vm_interface=self.test_vmint1, ip_address=primary_ipv4_addr
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
# Set it as the VM's primary IP
|
|
578
|
+
self.test_vm.primary_ip4 = primary_ipv4_addr
|
|
579
|
+
self.test_vm.save()
|
|
580
|
+
|
|
581
|
+
# Mock the VM save method to verify it's called
|
|
582
|
+
with patch.object(self.test_vm, "save") as mock_save:
|
|
583
|
+
# Remove the primary IP assignment - this SHOULD trigger a VM save
|
|
584
|
+
assignment_primary_ipv4.delete()
|
|
585
|
+
|
|
586
|
+
# Assert save was called to nullify primary_ip4
|
|
587
|
+
mock_save.assert_called_once()
|
|
588
|
+
|
|
589
|
+
# Test removing primary IPv6 assignment
|
|
590
|
+
with self.subTest("IPv6 primary IP removal"):
|
|
591
|
+
# Create IPv6 prefix first
|
|
592
|
+
Prefix.objects.create(prefix="2001:db8::/64", status=self.status, namespace=self.namespace)
|
|
593
|
+
|
|
594
|
+
# Create primary IPv6 address
|
|
595
|
+
primary_ipv6_addr = IPAddress.objects.create(
|
|
596
|
+
address="2001:db8::1/64", status=self.status, namespace=self.namespace
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
# Assign primary IPv6 to VM interface first
|
|
600
|
+
assignment_primary_ipv6 = IPAddressToInterface.objects.create(
|
|
601
|
+
vm_interface=self.test_vmint1, ip_address=primary_ipv6_addr
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# Set it as the VM's primary IP
|
|
605
|
+
self.test_vm.primary_ip6 = primary_ipv6_addr
|
|
606
|
+
self.test_vm.save()
|
|
607
|
+
|
|
608
|
+
# Mock the VM save method to verify it's called
|
|
609
|
+
with patch.object(self.test_vm, "save") as mock_save:
|
|
610
|
+
# Remove the primary IP assignment - this SHOULD trigger a VM save
|
|
611
|
+
assignment_primary_ipv6.delete()
|
|
612
|
+
|
|
613
|
+
# Assert save was called to nullify primary_ip6
|
|
614
|
+
mock_save.assert_called_once()
|
|
615
|
+
|
|
616
|
+
def test_ip_address_to_interface_delete_signal_no_save_when_vm_primary_ip_has_multiple_assignments(self):
|
|
617
|
+
"""
|
|
618
|
+
Test that VM is not saved when removing an IP from a VM interface and the IP is the VM's
|
|
619
|
+
primary IP but is assigned to other VM interfaces on that VM.
|
|
620
|
+
"""
|
|
621
|
+
# Test IPv4 primary IP with multiple assignments
|
|
622
|
+
with self.subTest("IPv4 primary IP with multiple assignments"):
|
|
623
|
+
# Create primary IPv4 address
|
|
624
|
+
primary_ipv4_addr = IPAddress.objects.create(
|
|
625
|
+
address="192.0.2.1/24", status=self.status, namespace=self.namespace
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
# Assign primary IPv4 to multiple VM interfaces
|
|
629
|
+
assignment_ipv4_vmint1 = IPAddressToInterface.objects.create(
|
|
630
|
+
vm_interface=self.test_vmint1, ip_address=primary_ipv4_addr
|
|
631
|
+
)
|
|
632
|
+
IPAddressToInterface.objects.create(vm_interface=self.test_vmint2, ip_address=primary_ipv4_addr)
|
|
633
|
+
|
|
634
|
+
# Set it as the VM's primary IP
|
|
635
|
+
self.test_vm.primary_ip4 = primary_ipv4_addr
|
|
636
|
+
self.test_vm.save()
|
|
637
|
+
|
|
638
|
+
# Mock the VM save method to verify it's not called
|
|
639
|
+
with patch.object(self.test_vm, "save") as mock_save:
|
|
640
|
+
# Remove primary IP from one VM interface - should NOT trigger save since other assignment exists
|
|
641
|
+
assignment_ipv4_vmint1.delete()
|
|
642
|
+
|
|
643
|
+
# Assert save was not called since IP is still assigned to test_vmint2
|
|
644
|
+
mock_save.assert_not_called()
|
|
645
|
+
|
|
646
|
+
# Test IPv6 primary IP with multiple assignments
|
|
647
|
+
with self.subTest("IPv6 primary IP with multiple assignments"):
|
|
648
|
+
# Create IPv6 prefix first
|
|
649
|
+
Prefix.objects.create(prefix="2001:db8::/64", status=self.status, namespace=self.namespace)
|
|
650
|
+
|
|
651
|
+
# Create primary IPv6 address
|
|
652
|
+
primary_ipv6_addr = IPAddress.objects.create(
|
|
653
|
+
address="2001:db8::1/64", status=self.status, namespace=self.namespace
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
# Assign primary IPv6 to multiple VM interfaces
|
|
657
|
+
assignment_ipv6_vmint1 = IPAddressToInterface.objects.create(
|
|
658
|
+
vm_interface=self.test_vmint1, ip_address=primary_ipv6_addr
|
|
659
|
+
)
|
|
660
|
+
IPAddressToInterface.objects.create(vm_interface=self.test_vmint2, ip_address=primary_ipv6_addr)
|
|
661
|
+
|
|
662
|
+
# Set it as the VM's primary IP
|
|
663
|
+
self.test_vm.primary_ip6 = primary_ipv6_addr
|
|
664
|
+
self.test_vm.save()
|
|
665
|
+
|
|
666
|
+
# Mock the VM save method to verify it's not called
|
|
667
|
+
with patch.object(self.test_vm, "save") as mock_save:
|
|
668
|
+
# Remove primary IP from one VM interface - should NOT trigger save since other assignment exists
|
|
669
|
+
assignment_ipv6_vmint1.delete()
|
|
670
|
+
|
|
671
|
+
# Assert save was not called since IP is still assigned to test_vmint2
|
|
672
|
+
mock_save.assert_not_called()
|
|
673
|
+
|
|
287
674
|
|
|
288
675
|
class TestVarbinaryIPField(TestCase):
|
|
289
676
|
"""Tests for `nautobot.ipam.fields.VarbinaryIPField`."""
|
|
@@ -802,6 +802,23 @@ class PrefixQuerysetTestCase(TestCase):
|
|
|
802
802
|
status=self.status,
|
|
803
803
|
)
|
|
804
804
|
|
|
805
|
+
# same but for IPv6
|
|
806
|
+
container_v6 = netaddr.IPNetwork("2001:db8::/120")
|
|
807
|
+
Prefix.objects.create(
|
|
808
|
+
prefix=container_v6,
|
|
809
|
+
type=choices.PrefixTypeChoices.TYPE_CONTAINER,
|
|
810
|
+
namespace=namespace,
|
|
811
|
+
status=self.status,
|
|
812
|
+
)
|
|
813
|
+
for prefix_length in range(121, 129):
|
|
814
|
+
network = list(container_v6.subnet(prefix_length))[1]
|
|
815
|
+
Prefix.objects.create(
|
|
816
|
+
prefix=network,
|
|
817
|
+
type=choices.PrefixTypeChoices.TYPE_NETWORK,
|
|
818
|
+
namespace=namespace,
|
|
819
|
+
status=self.status,
|
|
820
|
+
)
|
|
821
|
+
|
|
805
822
|
for last_octet in range(1, 255):
|
|
806
823
|
ip = netaddr.IPAddress(f"10.0.0.{last_octet}")
|
|
807
824
|
expected_prefix_length = 33 - len(bin(last_octet)[2:]) # [1] = 32, [2,3] = 31, [4,5,6,7] = 30, etc.
|
|
@@ -819,3 +836,32 @@ class PrefixQuerysetTestCase(TestCase):
|
|
|
819
836
|
.order_by("-prefix_length")
|
|
820
837
|
.first(),
|
|
821
838
|
)
|
|
839
|
+
|
|
840
|
+
ip = netaddr.IPAddress(f"2001:db8::{last_octet:x}")
|
|
841
|
+
expected_prefix_length = 129 - len(bin(last_octet)[2:]) # [1] = 128, [2,3] = 127, [4,5,6,7] = 126, etc.
|
|
842
|
+
with self.subTest(ip=ip, expected_prefix_length=expected_prefix_length):
|
|
843
|
+
closest_parent = Prefix.objects.filter(namespace=namespace).get_closest_parent(ip, include_self=True)
|
|
844
|
+
expected_parent = list(container_v6.subnet(expected_prefix_length))[1]
|
|
845
|
+
self.assertEqual(closest_parent.prefix, expected_parent)
|
|
846
|
+
self.assertEqual(
|
|
847
|
+
closest_parent,
|
|
848
|
+
Prefix.objects.filter(
|
|
849
|
+
network__lte=ip.value,
|
|
850
|
+
broadcast__gte=ip.value,
|
|
851
|
+
namespace=namespace,
|
|
852
|
+
)
|
|
853
|
+
.order_by("-prefix_length")
|
|
854
|
+
.first(),
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
slash32 = Prefix.objects.create(prefix="10.1.1.154/32", namespace=namespace, status=self.status)
|
|
858
|
+
slash31 = Prefix.objects.create(prefix="10.1.1.154/31", namespace=namespace, status=self.status)
|
|
859
|
+
slash30 = Prefix.objects.create(prefix="10.1.1.154/30", namespace=namespace, status=self.status)
|
|
860
|
+
self.assertEqual(slash31, Prefix.objects.get_closest_parent(slash32.prefix))
|
|
861
|
+
self.assertEqual(slash30, Prefix.objects.get_closest_parent(slash31.prefix))
|
|
862
|
+
|
|
863
|
+
slash126 = Prefix.objects.create(prefix="::1/126", namespace=namespace, status=self.status)
|
|
864
|
+
slash127 = Prefix.objects.create(prefix="::1/127", namespace=namespace, status=self.status)
|
|
865
|
+
slash128 = Prefix.objects.create(prefix="::1/128", namespace=namespace, status=self.status)
|
|
866
|
+
self.assertEqual(slash126, Prefix.objects.get_closest_parent(slash127.prefix))
|
|
867
|
+
self.assertEqual(slash127, Prefix.objects.get_closest_parent(slash128.prefix))
|
|
@@ -587,7 +587,7 @@ def get_closest_parent(obj, qs):
|
|
|
587
587
|
"""
|
|
588
588
|
# Validate that it's a real CIDR
|
|
589
589
|
cidr = validate_cidr(obj)
|
|
590
|
-
broadcast = str(cidr.broadcast or cidr
|
|
590
|
+
broadcast = str(cidr.broadcast or cidr[-1])
|
|
591
591
|
|
|
592
592
|
# Prepare the queryset filter
|
|
593
593
|
lookup_kwargs = {
|
nautobot/ipam/views.py
CHANGED
|
@@ -24,6 +24,7 @@ from nautobot.core.constants import MAX_PAGE_SIZE_DEFAULT
|
|
|
24
24
|
from nautobot.core.models.querysets import count_related
|
|
25
25
|
from nautobot.core.templatetags import helpers
|
|
26
26
|
from nautobot.core.ui import object_detail
|
|
27
|
+
from nautobot.core.ui.breadcrumbs import Breadcrumbs, InstanceBreadcrumbItem, ModelBreadcrumbItem
|
|
27
28
|
from nautobot.core.ui.choices import SectionChoices
|
|
28
29
|
from nautobot.core.utils.config import get_settings_or_config
|
|
29
30
|
from nautobot.core.utils.permissions import get_permission_for_model
|
|
@@ -335,6 +336,19 @@ class PrefixView(generic.ObjectView):
|
|
|
335
336
|
"vlan__vlan_group",
|
|
336
337
|
"namespace",
|
|
337
338
|
).prefetch_related("locations")
|
|
339
|
+
breadcrumbs = Breadcrumbs(
|
|
340
|
+
items={
|
|
341
|
+
"detail": [
|
|
342
|
+
ModelBreadcrumbItem(model=Namespace),
|
|
343
|
+
InstanceBreadcrumbItem(instance=lambda context: context["object"].namespace),
|
|
344
|
+
ModelBreadcrumbItem(
|
|
345
|
+
model=Prefix,
|
|
346
|
+
reverse_query_params=lambda context: {"namespace": context["object"].namespace.pk},
|
|
347
|
+
label_key="verbose_name_plural",
|
|
348
|
+
),
|
|
349
|
+
]
|
|
350
|
+
}
|
|
351
|
+
)
|
|
338
352
|
|
|
339
353
|
def get_extra_context(self, request, instance):
|
|
340
354
|
# Parent prefixes table
|
|
@@ -1102,16 +1116,10 @@ class VLANUIViewSet(NautobotUIViewSet): # 3.0 TODO: remove, unused BulkImportVi
|
|
|
1102
1116
|
queryset = VLAN.objects.all()
|
|
1103
1117
|
|
|
1104
1118
|
class VLANObjectFieldsPanel(object_detail.ObjectFieldsPanel):
|
|
1105
|
-
def get_data(self, context):
|
|
1106
|
-
instance = get_obj_from_context(context, self.context_object_key)
|
|
1107
|
-
data = super().get_data(context)
|
|
1108
|
-
data["locations"] = instance.locations.all()
|
|
1109
|
-
return data
|
|
1110
|
-
|
|
1111
1119
|
def render_value(self, key, value, context):
|
|
1112
|
-
instance = get_obj_from_context(context)
|
|
1113
1120
|
if key == "locations":
|
|
1114
|
-
|
|
1121
|
+
instance = get_obj_from_context(context)
|
|
1122
|
+
return helpers.render_m2m(value.all(), f"/dcim/locations/?vlans={instance.pk}", key)
|
|
1115
1123
|
return super().render_value(key, value, context)
|
|
1116
1124
|
|
|
1117
1125
|
class PrefixObjectsTablePanel(object_detail.ObjectsTablePanel):
|
|
@@ -1138,6 +1146,7 @@ class VLANUIViewSet(NautobotUIViewSet): # 3.0 TODO: remove, unused BulkImportVi
|
|
|
1138
1146
|
weight=100,
|
|
1139
1147
|
section=SectionChoices.LEFT_HALF,
|
|
1140
1148
|
fields="__all__",
|
|
1149
|
+
additional_fields=["locations"],
|
|
1141
1150
|
),
|
|
1142
1151
|
PrefixObjectsTablePanel(
|
|
1143
1152
|
weight=100,
|