nautobot 2.3.7__py3-none-any.whl → 2.3.8__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 (31) hide show
  1. nautobot/core/templates/inc/paginator.html +3 -0
  2. nautobot/core/templates/utilities/obj_table.html +1 -1
  3. nautobot/dcim/api/serializers.py +10 -5
  4. nautobot/dcim/forms.py +11 -7
  5. nautobot/dcim/models/device_components.py +7 -4
  6. nautobot/dcim/tests/test_api.py +28 -0
  7. nautobot/dcim/tests/test_forms.py +17 -1
  8. nautobot/dcim/tests/test_models.py +42 -4
  9. nautobot/dcim/utils.py +9 -6
  10. nautobot/extras/plugins/views.py +18 -3
  11. nautobot/ipam/filters.py +2 -2
  12. nautobot/ipam/models.py +29 -2
  13. nautobot/ipam/templates/ipam/ipaddress.html +2 -2
  14. nautobot/ipam/templates/ipam/ipaddress_interfaces.html +3 -0
  15. nautobot/ipam/templates/ipam/ipaddress_vm_interfaces.html +3 -0
  16. nautobot/ipam/templates/ipam/prefix.html +3 -3
  17. nautobot/ipam/templates/ipam/routetarget.html +2 -2
  18. nautobot/ipam/templates/ipam/vlan.html +3 -0
  19. nautobot/ipam/templates/ipam/vrf.html +7 -4
  20. nautobot/ipam/tests/test_models.py +68 -12
  21. nautobot/ipam/views.py +43 -0
  22. nautobot/project-static/docs/release-notes/version-2.3.html +86 -29
  23. nautobot/project-static/docs/search/search_index.json +1 -1
  24. nautobot/project-static/docs/sitemap.xml +269 -269
  25. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  26. {nautobot-2.3.7.dist-info → nautobot-2.3.8.dist-info}/METADATA +1 -1
  27. {nautobot-2.3.7.dist-info → nautobot-2.3.8.dist-info}/RECORD +31 -31
  28. {nautobot-2.3.7.dist-info → nautobot-2.3.8.dist-info}/LICENSE.txt +0 -0
  29. {nautobot-2.3.7.dist-info → nautobot-2.3.8.dist-info}/NOTICE +0 -0
  30. {nautobot-2.3.7.dist-info → nautobot-2.3.8.dist-info}/WHEEL +0 -0
  31. {nautobot-2.3.7.dist-info → nautobot-2.3.8.dist-info}/entry_points.txt +0 -0
@@ -29,6 +29,9 @@
29
29
  {% endif %}
30
30
  {% endfor %}
31
31
  <select name="per_page" id="per_page" class="form-control">
32
+ {% if page.paginator.per_page not in "PER_PAGE_DEFAULTS"|settings_or_config %}
33
+ <option value="{{ page.paginator.per_page }}" selected="selected">{{ page.paginator.per_page }}</option>
34
+ {% endif %}
32
35
  {% for n in "PER_PAGE_DEFAULTS"|settings_or_config %}
33
36
  <option value="{{ n }}"{% if page.paginator.per_page == n %} selected="selected"{% endif %}>{{ n }}</option>
34
37
  {% endfor %}
@@ -45,7 +45,7 @@
45
45
  {% else %}
46
46
  {% include table_template|default:'responsive_table.html' %}
47
47
  {% endif %}
48
- {% if not disable_pagination %}
48
+ {% if table.paginator.num_pages > 1 and not disable_pagination %}
49
49
  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
50
50
  {% endif %}
51
51
  <div class="clearfix"></div>
@@ -798,14 +798,19 @@ class InterfaceSerializer(
798
798
  def validate(self, data):
799
799
  # Validate many-to-many VLAN assignments
800
800
  device = self.instance.device if self.instance else data.get("device")
801
- # TODO: after Location model replaced Site, which was not a hierarchical model, should we allow users to assign a VLAN belongs to
802
- # the parent Location or the child location of `device.location`?
801
+ location = None
802
+ if device:
803
+ location = device.location
804
+ if location:
805
+ location_ids = location.ancestors(include_self=True).values_list("id", flat=True)
806
+ else:
807
+ location_ids = []
803
808
  for vlan in data.get("tagged_vlans", []):
804
- if vlan.locations.exists() and not vlan.locations.filter(pk=device.location.pk).exists():
809
+ if vlan.locations.exists() and not vlan.locations.filter(pk__in=location_ids).exists():
805
810
  raise serializers.ValidationError(
806
811
  {
807
- "tagged_vlans": f"VLAN {vlan} must have a common location as the interface's parent device, or "
808
- f"it must be global."
812
+ "tagged_vlans": f"VLAN {vlan} must have the same location as the interface's parent device, "
813
+ f"or is in one of the parents of the interface's parent device's location, or it must be global."
809
814
  }
810
815
  )
811
816
 
nautobot/dcim/forms.py CHANGED
@@ -213,23 +213,27 @@ class InterfaceCommonForm(forms.Form):
213
213
  elif mode == InterfaceModeChoices.MODE_TAGGED_ALL:
214
214
  self.cleaned_data["tagged_vlans"] = []
215
215
 
216
- # Validate tagged VLANs; must be a global VLAN or in the same location
217
- # TODO: after Location model replaced Site, which was not a hierarchical model, should we allow users to add a VLAN
218
- # belongs to the parent Location or the child location of the parent device to the `tagged_vlan` field of the interface?
216
+ # Validate tagged VLANs; must be a global VLAN or in the same location as the
217
+ # parent device/VM or any of that location's parent locations
219
218
  elif mode == InterfaceModeChoices.MODE_TAGGED:
220
- valid_location = self.cleaned_data[parent_field].location
219
+ location = self.cleaned_data[parent_field].location
220
+ if location:
221
+ location_ids = location.ancestors(include_self=True).values_list("id", flat=True)
222
+ else:
223
+ location_ids = []
221
224
  invalid_vlans = [
222
225
  str(v)
223
226
  for v in tagged_vlans
224
227
  if v.locations.without_tree_fields().exists()
225
- and not VLANLocationAssignment.objects.filter(location=valid_location, vlan=v).exists()
228
+ and not VLANLocationAssignment.objects.filter(location__in=location_ids, vlan=v).exists()
226
229
  ]
227
230
 
228
231
  if invalid_vlans:
229
232
  raise forms.ValidationError(
230
233
  {
231
- "tagged_vlans": f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same location as "
232
- f"the interface's parent device/VM, or they must be global"
234
+ "tagged_vlans": f"The tagged VLANs ({', '.join(invalid_vlans)}) must have the same location as the "
235
+ "interface's parent device, or is in one of the parents of the interface's parent device's location, "
236
+ "or it must be global."
233
237
  }
234
238
  )
235
239
 
@@ -727,19 +727,22 @@ class Interface(ModularComponentModel, CableTermination, PathEndpoint, BaseInter
727
727
  )
728
728
 
729
729
  # Validate untagged VLAN
730
- # TODO: after Location model replaced Site, which was not a hierarchical model, should we allow users to assign a VLAN belongs to
731
- # the parent Locations or the child locations of `device.location`?
730
+ location = self.parent.location if self.parent is not None else None
731
+ if location:
732
+ location_ids = location.ancestors(include_self=True).values_list("id", flat=True)
733
+ else:
734
+ location_ids = []
732
735
  if (
733
736
  self.untagged_vlan
734
737
  and self.untagged_vlan.locations.exists()
735
738
  and self.parent
736
- and not self.untagged_vlan.locations.filter(id=self.parent.location_id).exists()
739
+ and not self.untagged_vlan.locations.filter(pk__in=location_ids).exists()
737
740
  ):
738
741
  raise ValidationError(
739
742
  {
740
743
  "untagged_vlan": (
741
744
  f"The untagged VLAN ({self.untagged_vlan}) must have a common location as the interface's parent "
742
- f"device, or it must be global."
745
+ f"device, or is in one of the parents of the interface's parent device's location, or it must be global."
743
746
  )
744
747
  }
745
748
  )
@@ -2198,6 +2198,34 @@ class InterfaceTest(Mixins.ModularDeviceComponentMixin, Mixins.BasePortTestMixin
2198
2198
  self.client.post(url, self.untagged_vlan_data, format="json", **self.header), status.HTTP_201_CREATED
2199
2199
  )
2200
2200
 
2201
+ def test_tagged_vlan_must_be_in_the_location_or_parent_locations_of_the_parent_device(self):
2202
+ self.add_permissions("dcim.add_interface")
2203
+
2204
+ interface_status = Status.objects.get_for_model(Interface).first()
2205
+ location = self.devices[0].location
2206
+ location_ids = [ancestor.id for ancestor in location.ancestors()]
2207
+ non_valid_locations = Location.objects.exclude(pk__in=location_ids)
2208
+ faulty_vlan = self.vlans[0]
2209
+ faulty_vlan.locations.set([non_valid_locations.first().pk])
2210
+ faulty_vlan.validated_save()
2211
+ faulty_data = {
2212
+ "device": self.devices[0].pk,
2213
+ "name": "Test Vlans Interface",
2214
+ "type": "virtual",
2215
+ "status": interface_status.pk,
2216
+ "mode": InterfaceModeChoices.MODE_TAGGED,
2217
+ "parent_interface": self.interfaces[1].pk,
2218
+ "tagged_vlans": [faulty_vlan.pk, self.vlans[1].pk],
2219
+ "untagged_vlan": self.vlans[2].pk,
2220
+ }
2221
+ response = self.client.post(self._get_list_url(), data=faulty_data, format="json", **self.header)
2222
+ self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
2223
+ self.assertIn(
2224
+ b"must have the same location as the interface's parent device, or is in one of the parents of the interface's parent device's location, or "
2225
+ b"it must be global.",
2226
+ response.content,
2227
+ )
2228
+
2201
2229
  def test_interface_belonging_to_common_device_or_vc_allowed(self):
2202
2230
  """Test parent, bridge, and LAG interfaces belonging to common device or VC is valid"""
2203
2231
  self.add_permissions("dcim.add_interface")
@@ -341,9 +341,25 @@ class InterfaceTestCase(TestCase):
341
341
  "tagged_vlans": [cls.vlan.pk],
342
342
  }
343
343
 
344
+ def test_interface_form_clean_vlan_location_success(self):
345
+ """Assert that form validation succeeds when matching locations/parent locations are associated to tagged VLAN"""
346
+ location = self.device.location
347
+ location_ids = location.ancestors(include_self=True).values_list("id", flat=True)
348
+ self.vlan.locations.set([location.id])
349
+ self.data["tagged_vlans"] = [self.vlan]
350
+ form = InterfaceForm(data=self.data, instance=self.interface)
351
+ self.assertTrue(form.is_valid())
352
+ self.vlan.locations.set(location_ids[:2])
353
+ self.data["tagged_vlans"] = [self.vlan]
354
+ form = InterfaceForm(data=self.data, instance=self.interface)
355
+ self.assertTrue(form.is_valid())
356
+
344
357
  def test_interface_form_clean_vlan_location_fail(self):
345
358
  """Assert that form validation fails when no matching locations are associated to tagged VLAN"""
346
- self.vlan.locations.set(list(Location.objects.exclude(pk=self.device.location.pk))[:2])
359
+ location = self.device.location
360
+ location_ids = location.ancestors(include_self=True).values_list("id", flat=True)
361
+ self.vlan.locations.set(list(Location.objects.exclude(pk__in=location_ids))[:2])
362
+ self.data["tagged_vlans"] = [self.vlan]
347
363
  form = InterfaceForm(data=self.data, instance=self.interface)
348
364
  self.assertFalse(form.is_valid())
349
365
 
@@ -2376,19 +2376,57 @@ class InterfaceTestCase(ModularDeviceComponentTestCaseMixin, ModelTestCases.Base
2376
2376
  )
2377
2377
 
2378
2378
  def test_error_raised_when_adding_tagged_vlan_with_different_location_from_interface_parent_location(self):
2379
+ intf_status = Status.objects.get_for_model(Interface).first()
2380
+ intf_role = Role.objects.get_for_model(Interface).first()
2381
+ location_type = LocationType.objects.get(name="Campus")
2382
+ child_location = Location.objects.filter(parent__isnull=False, location_type=location_type).first()
2383
+ self.device.location = child_location
2384
+ self.device.validated_save()
2385
+ # Same location as the device
2386
+ interface = Interface.objects.create(
2387
+ name="Test Interface 2",
2388
+ mode=InterfaceModeChoices.MODE_TAGGED,
2389
+ device=self.device,
2390
+ status=intf_status,
2391
+ role=intf_role,
2392
+ )
2393
+ self.other_location_vlan.locations.set([self.device.location.pk])
2394
+ interface.tagged_vlans.set([self.other_location_vlan.pk])
2395
+
2396
+ # One of the parent locations of the device's location
2397
+ interface = Interface.objects.create(
2398
+ name="Test Interface 3",
2399
+ mode=InterfaceModeChoices.MODE_TAGGED,
2400
+ device=self.device,
2401
+ status=intf_status,
2402
+ role=intf_role,
2403
+ )
2404
+ self.other_location_vlan.locations.set([self.device.location.ancestors().first().pk])
2405
+ interface.tagged_vlans.set([self.other_location_vlan.pk])
2406
+
2379
2407
  with self.assertRaises(ValidationError) as err:
2380
2408
  interface = Interface.objects.create(
2381
- name="Test Interface",
2409
+ name="Test Interface 1",
2382
2410
  mode=InterfaceModeChoices.MODE_TAGGED,
2383
2411
  device=self.device,
2384
- status=Status.objects.get_for_model(Interface).first(),
2385
- role=Role.objects.get_for_model(Interface).first(),
2412
+ status=intf_status,
2413
+ role=intf_role,
2414
+ )
2415
+ location_3 = Location.objects.create(
2416
+ name="Invalid VLAN Location",
2417
+ location_type=LocationType.objects.get(name="Campus"),
2418
+ status=Status.objects.get_for_model(Location).first(),
2386
2419
  )
2420
+ # clear the valid locations
2421
+ self.other_location_vlan.locations.set([])
2422
+ # assign the invalid location
2423
+ self.other_location_vlan.location = location_3
2424
+ self.other_location_vlan.validated_save()
2387
2425
  interface.tagged_vlans.add(self.other_location_vlan)
2388
2426
  self.assertEqual(
2389
2427
  err.exception.message_dict["tagged_vlans"][0],
2390
2428
  f"Tagged VLAN with names {[self.other_location_vlan.name]} must all belong to the "
2391
- f"same location as the interface's parent device, or it must be global.",
2429
+ f"same location as the interface's parent device, one of the parent locations of the interface's parent device's location, or it must be global.",
2392
2430
  )
2393
2431
 
2394
2432
  def test_add_ip_addresses(self):
nautobot/dcim/utils.py CHANGED
@@ -139,12 +139,14 @@ def validate_interface_tagged_vlans(instance, model, pk_set):
139
139
  )
140
140
 
141
141
  # Filter the model objects based on the primary keys passed in kwargs and exclude the ones that have
142
- # a location that is not the parent's location or None
143
- # TODO: after Location model replaced Site, which was not a hierarchical model, should we allow users to add a VLAN
144
- # belongs to the parent Location or the child location of the parent device to the `tagged_vlan` field of the interface?
145
- device_location = getattr(instance.parent, "location", None)
142
+ # a location that is not the parent's location, or parent's location's ancestors, or None
143
+ location = getattr(instance.parent, "location", None)
144
+ if location:
145
+ location_ids = location.ancestors(include_self=True).values_list("id", flat=True)
146
+ else:
147
+ location_ids = []
146
148
  tagged_vlans = (
147
- model.objects.filter(pk__in=pk_set).exclude(locations__isnull=True).exclude(locations__in=[device_location])
149
+ model.objects.filter(pk__in=pk_set).exclude(locations__isnull=True).exclude(locations__in=location_ids)
148
150
  )
149
151
 
150
152
  if tagged_vlans.count():
@@ -152,7 +154,8 @@ def validate_interface_tagged_vlans(instance, model, pk_set):
152
154
  {
153
155
  "tagged_vlans": (
154
156
  f"Tagged VLAN with names {list(tagged_vlans.values_list('name', flat=True))} must all belong to the "
155
- f"same location as the interface's parent device, or it must be global."
157
+ "same location as the interface's parent device, "
158
+ "one of the parent locations of the interface's parent device's location, or it must be global."
156
159
  )
157
160
  }
158
161
  )
@@ -30,6 +30,21 @@ class InstalledAppsView(GenericView):
30
30
  data = []
31
31
  for app in apps.get_app_configs():
32
32
  if app.name in settings.PLUGINS:
33
+ try:
34
+ reverse(app.home_view_name)
35
+ home_url = app.home_view_name
36
+ except NoReverseMatch:
37
+ home_url = None
38
+ try:
39
+ reverse(app.config_view_name)
40
+ config_url = app.config_view_name
41
+ except NoReverseMatch:
42
+ config_url = None
43
+ try:
44
+ reverse(app.docs_view_name)
45
+ docs_url = app.docs_view_name
46
+ except NoReverseMatch:
47
+ docs_url = None
33
48
  data.append(
34
49
  {
35
50
  "name": app.verbose_name,
@@ -40,9 +55,9 @@ class InstalledAppsView(GenericView):
40
55
  "description": app.description,
41
56
  "version": app.version,
42
57
  "actions": {
43
- "home": app.home_view_name,
44
- "configure": app.config_view_name,
45
- "docs": app.docs_view_name,
58
+ "home": home_url,
59
+ "configure": config_url,
60
+ "docs": docs_url,
46
61
  },
47
62
  }
48
63
  )
nautobot/ipam/filters.py CHANGED
@@ -586,13 +586,13 @@ class VLANFilterSet(
586
586
  fields = ["id", "name", "tags", "vid"]
587
587
 
588
588
  def get_for_device(self, queryset, name, value):
589
- # TODO: after Location model replaced Site, which was not a hierarchical model, should we consider to include
590
- # VLANs that belong to the parent/child locations of the `device.location`?
591
589
  """Return all VLANs available to the specified Device(value)."""
592
590
  devices = Device.objects.select_related("location").filter(**{f"{name}__in": value})
593
591
  if not devices.exists():
594
592
  return queryset.none()
595
593
  location_ids = list(devices.values_list("location__id", flat=True))
594
+ for location in Location.objects.filter(pk__in=location_ids):
595
+ location_ids.extend([ancestor.id for ancestor in location.ancestors()])
596
596
  return queryset.filter(Q(locations__isnull=True) | Q(locations__in=location_ids))
597
597
 
598
598
 
nautobot/ipam/models.py CHANGED
@@ -890,12 +890,39 @@ class Prefix(PrimaryModel):
890
890
  )
891
891
  return available_ips
892
892
 
893
+ def get_child_ips(self):
894
+ """
895
+ Return IP addresses with this prefix as their *direct* parent.
896
+
897
+ Does *not* include IPs that descend from a descendant prefix; if those are desired, use get_all_ips() instead.
898
+
899
+ ```
900
+ Prefix 10.0.0.0/16
901
+ IPAddress 10.0.0.1/24
902
+ Prefix 10.0.1.0/24
903
+ IPAddress 10.0.1.1/24
904
+ ```
905
+
906
+ In the above example, `<Prefix 10.0.0.0/16>.get_child_ips()` will *only* return 10.0.0.1/24,
907
+ while `<Prefix 10.0.0.0/16>.get_all_ips()` will return *both* 10.0.0.1.24 and 10.0.1.1/24.
908
+ """
909
+ return self.ip_addresses.all()
910
+
893
911
  def get_all_ips(self):
894
912
  """
895
913
  Return all IP addresses contained within this prefix, including child prefixes' IP addresses.
896
914
 
897
- Returns:
898
- IPAddress QuerySet
915
+ This is distinct from the behavior of `get_child_ips()` and in *most* cases is probably preferred.
916
+
917
+ ```
918
+ Prefix 10.0.0.0/16
919
+ IPAddress 10.0.0.1/24
920
+ Prefix 10.0.1.0/24
921
+ IPAddress 10.0.1.1/24
922
+ ```
923
+
924
+ In the above example, `<Prefix 10.0.0.0/16>.get_child_ips()` will *only* return 10.0.0.1/24,
925
+ while `<Prefix 10.0.0.0/16>.get_all_ips()` will return *both* 10.0.0.1.24 and 10.0.1.1/24.
899
926
  """
900
927
  return IPAddress.objects.filter(
901
928
  parent__namespace=self.namespace, host__gte=self.network, host__lte=self.broadcast
@@ -150,9 +150,9 @@
150
150
  </tr>
151
151
  </table>
152
152
  </div>
153
- {% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
154
153
  {% endblock content_right_page %}
155
154
 
156
155
  {% block content_full_width_page %}
157
- {% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default' %}
156
+ {% include 'utilities/obj_table.html' with table=parent_prefixes_table table_template='panel_table.html' heading='Parent Prefixes' %}
157
+ {% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' %}
158
158
  {% endblock content_full_width_page %}
@@ -21,6 +21,9 @@
21
21
  {% render_table interface_table 'inc/table.html' %}
22
22
  </div>
23
23
  </form>
24
+ {% if interface_table.paginator.num_pages > 1 %}
25
+ {% include "inc/paginator.html" with paginator=interface_table.paginator page=interface_table.page %}
26
+ {% endif %}
24
27
  {% table_config_form interface_table %}
25
28
  {% endblock content %}
26
29
 
@@ -21,6 +21,9 @@
21
21
  {% render_table vm_interface_table 'inc/table.html' %}
22
22
  </div>
23
23
  </form>
24
+ {% if vm_interface_table.paginator.num_pages > 1 %}
25
+ {% include "inc/paginator.html" with paginator=vm_interface_table.paginator page=vm_interface_table.page %}
26
+ {% endif %}
24
27
  {% table_config_form vm_interface_table %}
25
28
  {% endblock content %}
26
29
 
@@ -117,7 +117,7 @@
117
117
  {% endblock content_left_page %}
118
118
 
119
119
  {% block content_right_page %}
120
- {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
121
- {% include "utilities/obj_table.html" with table=vrf_table table_template="panel_table.html" heading="Assigned VRFs" disable_pagination=False %}
122
- {% include "utilities/obj_table.html" with table=cloud_network_table table_template="panel_table.html" heading="Assigned Cloud Networks" disable_pagination=False %}
120
+ {% include "utilities/obj_table.html" with table=parent_prefix_table table_template="panel_table.html" heading="Parent Prefixes" %}
121
+ {% include "utilities/obj_table.html" with table=vrf_table table_template="panel_table.html" heading="Assigned VRFs" %}
122
+ {% include "utilities/obj_table.html" with table=cloud_network_table table_template="panel_table.html" heading="Assigned Cloud Networks" %}
123
123
  {% endblock content_right_page %}
@@ -24,6 +24,6 @@
24
24
  {% endblock content_left_page %}
25
25
 
26
26
  {% block content_right_page %}
27
- {% include 'panel_table.html' with table=importing_vrfs_table heading="Importing VRFs" %}
28
- {% include 'panel_table.html' with table=exporting_vrfs_table heading="Exporting VRFs" %}
27
+ {% include 'utilities/obj_table.html' with table=importing_vrfs_table table_template='panel_table.html' heading="Importing VRFs" %}
28
+ {% include 'utilities/obj_table.html' with table=exporting_vrfs_table table_template='panel_table.html' heading="Exporting VRFs" %}
29
29
  {% endblock content_right_page %}
@@ -86,4 +86,7 @@
86
86
  </div>
87
87
  {% endif %}
88
88
  </div>
89
+ {% if prefix_table.paginator.num_pages > 1 %}
90
+ {% include "inc/paginator.html" with paginator=prefix_table.paginator page=prefix_table.page %}
91
+ {% endif %}
89
92
  {% endblock content_full_width_page %}
@@ -38,8 +38,11 @@
38
38
  {% endblock content_left_page %}
39
39
 
40
40
  {% block content_right_page %}
41
- {% include 'panel_table.html' with table=import_targets_table heading="Import Route Targets" %}
42
- {% include 'panel_table.html' with table=export_targets_table heading="Export Route Targets" %}
43
- {% include 'panel_table.html' with table=prefix_table heading="Assigned Prefixes" %}
44
- {% include 'panel_table.html' with table=device_table heading="Assigned Devices" %}
41
+ {% include 'utilities/obj_table.html' with table=import_targets_table table_template='panel_table.html' heading="Import Route Targets" %}
42
+ {% include 'utilities/obj_table.html' with table=export_targets_table table_template='panel_table.html' heading="Export Route Targets" %}
45
43
  {% endblock content_right_page %}
44
+
45
+ {% block content_full_width_page %}
46
+ {% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading="Assigned Prefixes" %}
47
+ {% include 'utilities/obj_table.html' with table=device_table table_template='panel_table.html' heading="Assigned Devices" %}
48
+ {% endblock content_full_width_page %}
@@ -555,8 +555,9 @@ class TestPrefix(ModelTestCases.BaseModelTestCase):
555
555
  IPAddress.objects.create(address="10.0.2.1/24", status=self.status, namespace=self.namespace),
556
556
  IPAddress.objects.create(address="10.0.3.1/24", status=self.status, namespace=self.namespace),
557
557
  )
558
+ self.assertQuerysetEqualAndNotEmpty(parent_prefix.ip_addresses.all(), parent_prefix.get_child_ips())
559
+ self.assertQuerysetEqualAndNotEmpty(parent_prefix.ip_addresses.all(), parent_prefix.get_all_ips())
558
560
  child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()}
559
-
560
561
  # Global container should return all children
561
562
  self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk})
562
563
 
@@ -566,6 +567,8 @@ class TestPrefix(ModelTestCases.BaseModelTestCase):
566
567
  IPAddress.objects.create(address="20.0.4.0/31", status=self.status, namespace=self.namespace),
567
568
  IPAddress.objects.create(address="20.0.4.1/31", status=self.status, namespace=self.namespace),
568
569
  )
570
+ self.assertQuerysetEqualAndNotEmpty(parent_prefix_31.ip_addresses.all(), parent_prefix_31.get_child_ips())
571
+ self.assertQuerysetEqualAndNotEmpty(parent_prefix_31.ip_addresses.all(), parent_prefix_31.get_all_ips())
569
572
  child_ip_pks = {p.pk for p in parent_prefix_31.ip_addresses.all()}
570
573
  self.assertSetEqual(child_ip_pks, {ips_31[0].pk, ips_31[1].pk})
571
574
 
@@ -660,10 +663,22 @@ class TestPrefix(ModelTestCases.BaseModelTestCase):
660
663
  slash25 = Prefix.objects.create(prefix="10.0.0.128/25", status=self.status, namespace=self.namespace)
661
664
  self.assertEqual(prefix.get_utilization(), (192, 256))
662
665
 
663
- # Create 32 IPAddresses within the Prefix
666
+ # Create 32 IPAddresses within the /26 Prefix
664
667
  for i in range(1, 33):
665
668
  IPAddress.objects.create(address=f"10.0.0.{i}/32", status=self.status, namespace=self.namespace)
666
669
 
670
+ # Assert differing behavior of get_all_ips() versus get_child_ips() for the /24 and /26 prefixes
671
+ self.assertQuerysetEqual(prefix.get_child_ips(), IPAddress.objects.none())
672
+ self.assertQuerysetEqualAndNotEmpty(
673
+ prefix.get_all_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.0/24")
674
+ )
675
+ self.assertQuerysetEqualAndNotEmpty(
676
+ slash26.get_child_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.0/24")
677
+ )
678
+ self.assertQuerysetEqualAndNotEmpty(
679
+ slash26.get_all_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.0/24")
680
+ )
681
+
667
682
  # The parent prefix utilization does not change because the ip addresses are parented to the child /26 prefix.
668
683
  self.assertEqual(prefix.get_utilization(), (192, 256))
669
684
 
@@ -674,6 +689,17 @@ class TestPrefix(ModelTestCases.BaseModelTestCase):
674
689
  IPAddress.objects.create(address="10.0.0.0/32", status=self.status, namespace=self.namespace)
675
690
  IPAddress.objects.create(address="10.0.0.63/32", status=self.status, namespace=self.namespace)
676
691
 
692
+ self.assertQuerysetEqual(prefix.get_child_ips(), IPAddress.objects.none())
693
+ self.assertQuerysetEqualAndNotEmpty(
694
+ prefix.get_all_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.0/24")
695
+ )
696
+ self.assertQuerysetEqualAndNotEmpty(
697
+ slash26.get_child_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.0/24")
698
+ )
699
+ self.assertQuerysetEqualAndNotEmpty(
700
+ slash26.get_all_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.0/24")
701
+ )
702
+
677
703
  # The /26 denominator will change to 64
678
704
  self.assertEqual(slash26.get_utilization(), (34, 64))
679
705
 
@@ -688,30 +714,60 @@ class TestPrefix(ModelTestCases.BaseModelTestCase):
688
714
  pool.save()
689
715
  self.assertEqual(slash25.get_utilization(), (4, 126))
690
716
 
717
+ # Further distinguishing between get_child_ips() and get_all_ips():
718
+ IPAddress.objects.create(address="10.0.0.64/32", status=self.status, namespace=self.namespace)
719
+ self.assertQuerysetEqualAndNotEmpty(
720
+ prefix.get_child_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.64/26")
721
+ )
722
+ self.assertQuerysetEqualAndNotEmpty(
723
+ prefix.get_all_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.0/24")
724
+ )
725
+
726
+ slash27 = Prefix.objects.create(prefix="10.0.0.0/27", status=self.status, namespace=self.namespace)
727
+ self.assertEqual(slash27.get_utilization(), (32, 32))
728
+ self.assertQuerysetEqualAndNotEmpty(
729
+ prefix.get_child_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.64/26")
730
+ )
731
+ self.assertQuerysetEqualAndNotEmpty(
732
+ prefix.get_all_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.0/24")
733
+ )
734
+ self.assertQuerysetEqualAndNotEmpty(
735
+ slash26.get_child_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.32/27")
736
+ )
737
+ self.assertQuerysetEqualAndNotEmpty(
738
+ slash26.get_all_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.0/26")
739
+ )
740
+ self.assertQuerysetEqualAndNotEmpty(
741
+ slash27.get_child_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.0/27")
742
+ )
743
+ self.assertQuerysetEqualAndNotEmpty(
744
+ slash27.get_all_ips(), IPAddress.objects.filter(host__net_host_contained="10.0.0.0/27")
745
+ )
746
+
691
747
  # IPv4 Non-container Prefix /31, network and broadcast addresses count toward utilization
692
- prefix = Prefix.objects.create(prefix="10.0.1.0/31", status=self.status, namespace=self.namespace)
748
+ slash31 = Prefix.objects.create(prefix="10.0.1.0/31", status=self.status, namespace=self.namespace)
693
749
  IPAddress.objects.create(address="10.0.1.0/32", status=self.status, namespace=self.namespace)
694
750
  IPAddress.objects.create(address="10.0.1.1/32", status=self.status, namespace=self.namespace)
695
- self.assertEqual(prefix.get_utilization(), (2, 2))
751
+ self.assertEqual(slash31.get_utilization(), (2, 2))
696
752
 
697
753
  # IPv6 Non-container Prefix, first and last addresses count toward utilization
698
- prefix = Prefix.objects.create(prefix="aaab::/124", status=self.status, namespace=self.namespace)
754
+ slash124_1 = Prefix.objects.create(prefix="aaab::/124", status=self.status, namespace=self.namespace)
699
755
  IPAddress.objects.create(address="aaab::1/128", status=self.status, namespace=self.namespace)
700
756
  IPAddress.objects.create(address="aaab::2/128", status=self.status, namespace=self.namespace)
701
- self.assertEqual(prefix.get_utilization(), (2, 16))
757
+ self.assertEqual(slash124_1.get_utilization(), (2, 16))
702
758
 
703
- prefix = Prefix.objects.create(prefix="aaaa::/124", status=self.status, namespace=self.namespace)
759
+ slash124_2 = Prefix.objects.create(prefix="aaaa::/124", status=self.status, namespace=self.namespace)
704
760
  IPAddress.objects.create(address="aaaa::0/128", status=self.status, namespace=self.namespace)
705
761
  IPAddress.objects.create(address="aaaa::f/128", status=self.status, namespace=self.namespace)
706
- self.assertEqual(prefix.get_utilization(), (2, 16))
762
+ self.assertEqual(slash124_2.get_utilization(), (2, 16))
707
763
 
708
764
  # single address prefixes
709
- prefix = Prefix.objects.create(prefix="cccc::1/128", status=self.status, namespace=self.namespace)
765
+ slash128 = Prefix.objects.create(prefix="cccc::1/128", status=self.status, namespace=self.namespace)
710
766
  IPAddress.objects.create(address="cccc::1/128", status=self.status, namespace=self.namespace)
711
- self.assertEqual(prefix.get_utilization(), (1, 1))
712
- prefix = Prefix.objects.create(prefix="1.1.1.1/32", status=self.status, namespace=self.namespace)
767
+ self.assertEqual(slash128.get_utilization(), (1, 1))
768
+ slash32 = Prefix.objects.create(prefix="1.1.1.1/32", status=self.status, namespace=self.namespace)
713
769
  IPAddress.objects.create(address="1.1.1.1/32", status=self.status, namespace=self.namespace)
714
- self.assertEqual(prefix.get_utilization(), (1, 1))
770
+ self.assertEqual(slash32.get_utilization(), (1, 1))
715
771
 
716
772
  # Large Prefix
717
773
  large_prefix = Prefix.objects.create(