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.

Files changed (92) hide show
  1. nautobot/apps/views.py +2 -0
  2. nautobot/circuits/templates/circuits/circuittermination_retrieve.html +1 -8
  3. nautobot/circuits/templates/circuits/inc/circuit_termination_speed_fragment.html +9 -0
  4. nautobot/circuits/tests/integration/test_circuit.py +2 -2
  5. nautobot/circuits/views.py +32 -15
  6. nautobot/core/filters.py +2 -2
  7. nautobot/core/settings.py +1 -0
  8. nautobot/core/settings.yaml +9 -0
  9. nautobot/core/tables.py +21 -23
  10. nautobot/core/templates/components/breadcrumbs.html +19 -0
  11. nautobot/core/templates/generic/object_changelog.html +0 -2
  12. nautobot/core/templates/generic/object_list.html +15 -12
  13. nautobot/core/templates/generic/object_notes.html +0 -2
  14. nautobot/core/templates/generic/object_retrieve.html +16 -9
  15. nautobot/core/templatetags/helpers.py +24 -0
  16. nautobot/core/templatetags/ui_framework.py +40 -5
  17. nautobot/core/testing/filters.py +37 -21
  18. nautobot/core/testing/views.py +25 -0
  19. nautobot/core/tests/test_tables.py +43 -6
  20. nautobot/core/tests/test_templatetags_ui_framework.py +146 -0
  21. nautobot/core/tests/test_titles.py +2 -2
  22. nautobot/core/tests/test_ui.py +14 -1
  23. nautobot/core/tests/test_views.py +45 -0
  24. nautobot/core/ui/breadcrumbs.py +13 -8
  25. nautobot/core/ui/object_detail.py +43 -5
  26. nautobot/core/ui/titles.py +9 -5
  27. nautobot/core/views/__init__.py +24 -3
  28. nautobot/core/views/generic.py +42 -17
  29. nautobot/core/views/mixins.py +146 -12
  30. nautobot/core/views/utils.py +117 -0
  31. nautobot/dcim/models/devices.py +4 -0
  32. nautobot/dcim/tables/__init__.py +2 -0
  33. nautobot/dcim/tables/devices.py +24 -0
  34. nautobot/dcim/tables/power.py +2 -2
  35. nautobot/dcim/templates/dcim/device/base.html +1 -11
  36. nautobot/dcim/templates/dcim/device_component.html +0 -19
  37. nautobot/dcim/templates/dcim/modulebay_retrieve.html +0 -16
  38. nautobot/dcim/templates/dcim/virtualchassis_retrieve.html +1 -50
  39. nautobot/dcim/tests/test_views.py +41 -0
  40. nautobot/dcim/views.py +160 -39
  41. nautobot/extras/filters/mixins.py +1 -1
  42. nautobot/extras/forms/forms.py +15 -0
  43. nautobot/extras/models/groups.py +10 -1
  44. nautobot/extras/models/jobs.py +2 -2
  45. nautobot/extras/plugins/views.py +18 -5
  46. nautobot/extras/tables.py +4 -2
  47. nautobot/extras/templates/extras/customfield_retrieve.html +1 -128
  48. nautobot/extras/templates/extras/dynamicgroup.html +2 -99
  49. nautobot/extras/templates/extras/dynamicgroup_edit.html +2 -199
  50. nautobot/extras/templates/extras/dynamicgroup_retrieve.html +99 -0
  51. nautobot/extras/templates/extras/dynamicgroup_update.html +199 -0
  52. nautobot/extras/templates/extras/gitrepository.html +2 -82
  53. nautobot/extras/templates/extras/gitrepository_object_edit.html +2 -13
  54. nautobot/extras/templates/extras/gitrepository_retrieve.html +82 -0
  55. nautobot/extras/templates/extras/gitrepository_update.html +13 -0
  56. nautobot/extras/templates/extras/note_retrieve.html +0 -52
  57. nautobot/extras/templates/extras/plugin_detail.html +3 -7
  58. nautobot/extras/templates/extras/plugins_list.html +0 -2
  59. nautobot/extras/tests/test_dynamicgroups.py +73 -18
  60. nautobot/extras/tests/test_views.py +5 -0
  61. nautobot/extras/urls.py +2 -94
  62. nautobot/extras/views.py +424 -430
  63. nautobot/ipam/querysets.py +3 -3
  64. nautobot/ipam/signals.py +6 -1
  65. nautobot/ipam/templates/ipam/prefix.html +0 -8
  66. nautobot/ipam/tests/test_api.py +5 -0
  67. nautobot/ipam/tests/test_models.py +387 -0
  68. nautobot/ipam/tests/test_querysets.py +46 -0
  69. nautobot/ipam/utils/migrations.py +1 -1
  70. nautobot/ipam/views.py +17 -8
  71. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +72 -0
  72. nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +45 -9
  73. nautobot/project-static/docs/code-reference/nautobot/apps/views.html +393 -15
  74. nautobot/project-static/docs/development/apps/api/nautobot-app-config.html +1 -1
  75. nautobot/project-static/docs/development/core/getting-started.html +0 -15
  76. nautobot/project-static/docs/development/core/ui-component-framework.html +6 -11
  77. nautobot/project-static/docs/objects.inv +0 -0
  78. nautobot/project-static/docs/release-notes/version-2.4.html +222 -0
  79. nautobot/project-static/docs/search/search_index.json +1 -1
  80. nautobot/project-static/docs/sitemap.xml +300 -300
  81. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  82. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +27 -0
  83. nautobot/project-static/img/nautobot_icon.svg +32 -34
  84. nautobot/project-static/js/table_sorting_indicator.js +0 -2
  85. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/METADATA +4 -4
  86. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/RECORD +90 -85
  87. nautobot/core/templates/inc/breadcrumbs.html +0 -14
  88. nautobot/project-static/docs/requirements.txt +0 -14
  89. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/LICENSE.txt +0 -0
  90. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/NOTICE +0 -0
  91. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/WHEEL +0 -0
  92. {nautobot-2.4.17.dist-info → nautobot-2.4.18.dist-info}/entry_points.txt +0 -0
@@ -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.ip)
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.value,
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.value, prefix_length=cidr.prefixlen)
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
- host.save()
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">
@@ -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.ip)
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
- return helpers.render_m2m(value, f"/dcim/locations/?vlans={instance.pk}", key)
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,