Просмотр исходного кода

Work on IP to Prefix ForeignKey relationship

Daniel Sheppard 1 год назад
Родитель
Сommit
747fef0bc2

+ 10 - 0
netbox/ipam/filtersets.py

@@ -531,6 +531,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         method='search_by_parent',
         method='search_by_parent',
         label=_('Parent prefix'),
         label=_('Parent prefix'),
     )
     )
+    prefix_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Prefix.objects.all(),
+        label=_('Prefix (ID)'),
+    )
+    prefix = django_filters.ModelMultipleChoiceFilter(
+        field_name='prefix__prefix',
+        queryset=Prefix.objects.all(),
+        to_field_name='prefix',
+        label=_('Prefix (prefix)'),
+    )
     address = MultiValueCharFilter(
     address = MultiValueCharFilter(
         method='filter_address',
         method='filter_address',
         label=_('Address'),
         label=_('Address'),

+ 7 - 2
netbox/ipam/forms/bulk_edit.py

@@ -318,6 +318,11 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
 
 
 
 
 class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
 class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
+    prefix = DynamicModelChoiceField(
+        queryset=Prefix.objects.all(),
+        required=False,
+        label=_('Prefix')
+    )
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
@@ -359,10 +364,10 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
     model = IPAddress
     model = IPAddress
     fieldsets = (
     fieldsets = (
         FieldSet('status', 'role', 'tenant', 'description'),
         FieldSet('status', 'role', 'tenant', 'description'),
-        FieldSet('vrf', 'mask_length', 'dns_name', name=_('Addressing')),
+        FieldSet('prefix', 'vrf', 'mask_length', 'dns_name', name=_('Addressing')),
     )
     )
     nullable_fields = (
     nullable_fields = (
-        'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments',
+        'prefix', 'vrf', 'role', 'tenant', 'dns_name', 'description', 'comments',
     )
     )
 
 
 
 

+ 9 - 2
netbox/ipam/forms/bulk_import.py

@@ -274,6 +274,13 @@ class IPRangeImportForm(NetBoxModelImportForm):
 
 
 
 
 class IPAddressImportForm(NetBoxModelImportForm):
 class IPAddressImportForm(NetBoxModelImportForm):
+    prefix = CSVModelChoiceField(
+        label=_('Prefix'),
+        queryset=Prefix.objects.all(),
+        required=False,
+        to_field_name='prefix',
+        help_text=_('Assigned prefix')
+    )
     vrf = CSVModelChoiceField(
     vrf = CSVModelChoiceField(
         label=_('VRF'),
         label=_('VRF'),
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
@@ -334,8 +341,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
-            'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
-            'is_oob', 'dns_name', 'description', 'comments', 'tags',
+            'prefix', 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface',
+            'is_primary', 'is_oob', 'dns_name', 'description', 'comments', 'tags',
         ]
         ]
 
 
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):

+ 7 - 2
netbox/ipam/forms/filtersets.py

@@ -306,14 +306,14 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet(
         FieldSet(
-            'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name',
+            'prefix', 'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name',
             name=_('Attributes')
             name=_('Attributes')
         ),
         ),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
         FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
     )
     )
-    selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
+    selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'prefix_id', 'parent', 'status', 'role')
     parent = forms.CharField(
     parent = forms.CharField(
         required=False,
         required=False,
         widget=forms.TextInput(
         widget=forms.TextInput(
@@ -333,6 +333,11 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         choices=IPADDRESS_MASK_LENGTH_CHOICES,
         choices=IPADDRESS_MASK_LENGTH_CHOICES,
         label=_('Mask length')
         label=_('Mask length')
     )
     )
+    prefix_id = DynamicModelMultipleChoiceField(
+        queryset=Prefix.objects.all(),
+        required=False,
+        label=_('Prefix'),
+    )
     vrf_id = DynamicModelMultipleChoiceField(
     vrf_id = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,

+ 22 - 4
netbox/ipam/forms/model_forms.py

@@ -272,6 +272,15 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
 
 
 
 
 class IPAddressForm(TenancyForm, NetBoxModelForm):
 class IPAddressForm(TenancyForm, NetBoxModelForm):
+    prefix = DynamicModelChoiceField(
+        queryset=Prefix.objects.all(),
+        required=False,
+        context={
+            'vrf': 'vrf',
+        },
+        selector=True,
+        label=_('Prefix'),
+    )
     interface = DynamicModelChoiceField(
     interface = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False,
         required=False,
@@ -318,7 +327,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     fieldsets = (
-        FieldSet('address', 'status', 'role', 'vrf', 'dns_name', 'description', 'tags', name=_('IP Address')),
+        FieldSet('prefix', 'address', 'status', 'role', 'vrf', 'dns_name', 'description', 'tags', name=_('IP Address')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
         FieldSet(
         FieldSet(
             TabbedGroups(
             TabbedGroups(
@@ -334,8 +343,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
-            'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent', 'nat_inside',
-            'tenant_group', 'tenant', 'description', 'comments', 'tags',
+            'prefix', 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent',
+            'nat_inside', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -460,6 +469,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
 
 
 
 
 class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
 class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
+    prefix = DynamicModelChoiceField(
+        queryset=Prefix.objects.all(),
+        required=False,
+        context={
+            'vrf': 'vrf',
+        },
+        selector=True,
+        label=_('Prefix'),
+    )
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
@@ -469,7 +487,7 @@ class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
-            'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
+            'address', 'prefix', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
         ]
         ]
 
 
 
 

+ 1 - 0
netbox/ipam/graphql/types.py

@@ -122,6 +122,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
 )
 )
 class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
 class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
     address: str
     address: str
+    prefix: Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
     nat_inside: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
     nat_inside: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None

+ 67 - 0
netbox/ipam/migrations/0077_ipaddress_prefix.py

@@ -0,0 +1,67 @@
+# Generated by Django 5.0.9 on 2025-02-20 16:49
+
+import sys
+import time
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def draw_progress(count, total, length=20):
+    progress = count / total
+    percent = int(progress * 100)
+    bar = int(progress * length)
+    sys.stdout.write('\r')
+    sys.stdout.write(f"[{'=' * bar:{length}s}] {percent}%")
+    sys.stdout.flush()
+
+
+def set_prefix(apps, schema_editor):
+    start = time.time()
+    IPAddress = apps.get_model('ipam', 'IPAddress')
+    Prefix = apps.get_model('ipam', 'Prefix')
+
+    addresses = IPAddress.objects.all()
+    i = 0
+    total = addresses.count()
+    draw_progress(i, total, 50)
+    for ip in addresses:
+        i += 1
+        prefixes = Prefix.objects.filter(
+            vrf=ip.vrf,
+            prefix__net_contains_or_equals=str(ip.address.ip),
+            prefix__net_mask_length__lte=ip.address.prefixlen,
+        )
+        ip.prefix = prefixes.last()
+        ip.save()
+        draw_progress(i, total, 50)
+
+    end = time.time()
+    print(f"\r\nElapsed Time: {end - start:.2f}s")
+
+
+def unset_prefix(apps, schema_editor):
+    IPAddress = apps.get_model('ipam', 'IPAddress')
+    IPAddress.objects.update(prefix=None)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0076_natural_ordering'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='ipaddress',
+            name='prefix',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='ip_addresses',
+                to='ipam.prefix',
+            ),
+        ),
+        migrations.RunPython(set_prefix, unset_prefix)
+    ]

+ 15 - 2
netbox/ipam/models/ip.py

@@ -411,7 +411,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
             return netaddr.IPSet()
             return netaddr.IPSet()
 
 
         prefix = netaddr.IPSet(self.prefix)
         prefix = netaddr.IPSet(self.prefix)
-        child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
+        child_ips = netaddr.IPSet([ip.address.ip for ip in self.ip_addresses.all()])
         child_ranges = []
         child_ranges = []
         for iprange in self.get_child_ranges():
         for iprange in self.get_child_ranges():
             child_ranges.append(iprange.range)
             child_ranges.append(iprange.range)
@@ -462,7 +462,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         else:
         else:
             # Compile an IPSet to avoid counting duplicate IPs
             # Compile an IPSet to avoid counting duplicate IPs
             child_ips = netaddr.IPSet(
             child_ips = netaddr.IPSet(
-                [_.range for _ in self.get_child_ranges()] + [_.address.ip for _ in self.get_child_ips()]
+                [_.range for _ in self.get_child_ranges()] + [_.address.ip for _ in self.ip_addresses.all()]
             )
             )
 
 
             prefix_size = self.prefix.size
             prefix_size = self.prefix.size
@@ -706,6 +706,14 @@ class IPAddress(ContactsMixin, PrimaryModel):
     for example, when mapping public addresses to private addresses. When an Interface has been assigned an IPAddress
     for example, when mapping public addresses to private addresses. When an Interface has been assigned an IPAddress
     which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP.
     which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP.
     """
     """
+    prefix = models.ForeignKey(
+        to='ipam.Prefix',
+        on_delete=models.SET_NULL,
+        related_name='ip_addresses',
+        blank=True,
+        null=True,
+        verbose_name=_('Prefix')
+    )
     address = IPAddressField(
     address = IPAddressField(
         verbose_name=_('address'),
         verbose_name=_('address'),
         help_text=_('IPv4 or IPv6 address (with mask)')
         help_text=_('IPv4 or IPv6 address (with mask)')
@@ -835,6 +843,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
         super().clean()
         super().clean()
 
 
         if self.address:
         if self.address:
+            if self.prefix:
+                if self.address not in self.prefix.prefix:
+                    raise ValidationError({
+                        'prefix': _("IP address must be part of the selected prefix.")
+                    })
 
 
             # /0 masks are not acceptable
             # /0 masks are not acceptable
             if self.address.prefixlen == 0:
             if self.address.prefixlen == 0:

+ 2 - 1
netbox/ipam/search.py

@@ -52,11 +52,12 @@ class IPAddressIndex(SearchIndex):
     model = models.IPAddress
     model = models.IPAddress
     fields = (
     fields = (
         ('address', 100),
         ('address', 100),
+        ('prefix', 200),
         ('dns_name', 300),
         ('dns_name', 300),
         ('description', 500),
         ('description', 500),
         ('comments', 5000),
         ('comments', 5000),
     )
     )
-    display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
+    display_attrs = ('prefix', 'vrf', 'tenant', 'status', 'role', 'description')
 
 
 
 
 @register_search
 @register_search

+ 46 - 0
netbox/ipam/signals.py

@@ -1,3 +1,4 @@
+from django.db.models import Q
 from django.db.models.signals import post_delete, post_save, pre_delete
 from django.db.models.signals import post_delete, post_save, pre_delete
 from django.dispatch import receiver
 from django.dispatch import receiver
 
 
@@ -26,12 +27,51 @@ def update_children_depth(prefix):
     Prefix.objects.bulk_update(children, ['_depth'], batch_size=100)
     Prefix.objects.bulk_update(children, ['_depth'], batch_size=100)
 
 
 
 
+def update_ipaddress_prefix(prefix, delete=False):
+    if delete:
+        # Get all possible addresses
+        addresses = IPAddress.objects.filter(prefix=prefix)
+        # Find a new containing prefix
+        prefix = Prefix.objects.filter(
+            prefix__net_contains_or_equals=prefix.prefix,
+            vrf=prefix.vrf
+        ).exclude(pk=prefix.pk).last()
+
+        for address in addresses:
+            # Set contained addresses to the containing prefix if it exists
+            address.prefix = prefix
+    else:
+        # Get all possible modified addresses
+        addresses = IPAddress.objects.filter(
+            Q(address__net_contained_or_equal=prefix.prefix, vrf=prefix.vrf) |
+            Q(prefix=prefix)
+        )
+        for address in addresses:
+            if not address.prefix or (prefix.prefix in address.prefix.prefix and address.address in prefix.prefix):
+                # Set to new Prefix as the prefix is a child of the old prefix and the address is contained in the
+                # prefix
+                address.prefix = prefix
+            elif address.prefix and address.address not in prefix.prefix:
+                # Find a new prefix as the prefix no longer contains the address
+                address.prefix = Prefix.objects.filter(
+                    prefix__net_contains_or_equals=address.address,
+                    vrf=prefix.vrf
+                ).last()
+            else:
+                # No-OP as the prefix does not require modification
+                pass
+
+    # Update the addresses
+    IPAddress.objects.bulk_update(addresses, ['prefix'], batch_size=100)
+
+
 @receiver(post_save, sender=Prefix)
 @receiver(post_save, sender=Prefix)
 def handle_prefix_saved(instance, created, **kwargs):
 def handle_prefix_saved(instance, created, **kwargs):
 
 
     # Prefix has changed (or new instance has been created)
     # Prefix has changed (or new instance has been created)
     if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix:
     if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix:
 
 
+        update_ipaddress_prefix(instance)
         update_parents_children(instance)
         update_parents_children(instance)
         update_children_depth(instance)
         update_children_depth(instance)
 
 
@@ -42,11 +82,17 @@ def handle_prefix_saved(instance, created, **kwargs):
             update_children_depth(old_prefix)
             update_children_depth(old_prefix)
 
 
 
 
+@receiver(pre_delete, sender=Prefix)
+def pre_handle_prefix_deleted(instance, **kwargs):
+    update_ipaddress_prefix(instance, True)
+
+
 @receiver(post_delete, sender=Prefix)
 @receiver(post_delete, sender=Prefix)
 def handle_prefix_deleted(instance, **kwargs):
 def handle_prefix_deleted(instance, **kwargs):
 
 
     update_parents_children(instance)
     update_parents_children(instance)
     update_children_depth(instance)
     update_children_depth(instance)
+    update_ipaddress_prefix(instance, delete=True)
 
 
 
 
 @receiver(pre_delete, sender=IPAddress)
 @receiver(pre_delete, sender=IPAddress)

+ 4 - 0
netbox/ipam/tables/ip.py

@@ -307,6 +307,10 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
         template_code=IPADDRESS_LINK,
         template_code=IPADDRESS_LINK,
         verbose_name=_('IP Address')
         verbose_name=_('IP Address')
     )
     )
+    prefix = tables.Column(
+        linkify=True,
+        verbose_name=_('Prefix')
+    )
     vrf = tables.TemplateColumn(
     vrf = tables.TemplateColumn(
         template_code=VRF_LINK,
         template_code=VRF_LINK,
         verbose_name=_('VRF')
         verbose_name=_('VRF')

+ 2 - 2
netbox/ipam/tests/test_models.py

@@ -172,14 +172,14 @@ class TestPrefix(TestCase):
             IPAddress(address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]),
             IPAddress(address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]),
             IPAddress(address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]),
             IPAddress(address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]),
         ))
         ))
-        child_ip_pks = {p.pk for p in parent_prefix.get_child_ips()}
+        child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()}
 
 
         # Global container should return all children
         # Global container should return all children
         self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk})
         self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk})
 
 
         parent_prefix.vrf = vrfs[0]
         parent_prefix.vrf = vrfs[0]
         parent_prefix.save()
         parent_prefix.save()
-        child_ip_pks = {p.pk for p in parent_prefix.get_child_ips()}
+        child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()}
 
 
         # VRF container is limited to its own VRF
         # VRF container is limited to its own VRF
         self.assertSetEqual(child_ip_pks, {ips[1].pk})
         self.assertSetEqual(child_ip_pks, {ips[1].pk})

+ 1 - 1
netbox/ipam/tests/test_views.py

@@ -493,7 +493,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             IPAddress(address=IPNetwork('192.168.0.3/16')),
             IPAddress(address=IPNetwork('192.168.0.3/16')),
         )
         )
         IPAddress.objects.bulk_create(ip_addresses)
         IPAddress.objects.bulk_create(ip_addresses)
-        self.assertEqual(prefix.get_child_ips().count(), 3)
+        self.assertEqual(prefix.ip_addresses.all().count(), 3)
 
 
         url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
         url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)

+ 2 - 2
netbox/ipam/views.py

@@ -631,13 +631,13 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
     template_name = 'ipam/prefix/ip_addresses.html'
     template_name = 'ipam/prefix/ip_addresses.html'
     tab = ViewTab(
     tab = ViewTab(
         label=_('IP Addresses'),
         label=_('IP Addresses'),
-        badge=lambda x: x.get_child_ips().count(),
+        badge=lambda x: x.ip_addresses.count(),
         permission='ipam.view_ipaddress',
         permission='ipam.view_ipaddress',
         weight=700
         weight=700
     )
     )
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
-        return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
+        return parent.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
 
 
     def prep_table_data(self, request, queryset, parent):
     def prep_table_data(self, request, queryset, parent):
         if not request.GET.get('q') and not get_table_ordering(request, self.table):
         if not request.GET.get('q') and not get_table_ordering(request, self.table):

+ 4 - 0
netbox/templates/ipam/ipaddress.html

@@ -14,6 +14,10 @@
                   <th scope="row">{% trans "Family" %}</th>
                   <th scope="row">{% trans "Family" %}</th>
                   <td>IPv{{ object.family }}</td>
                   <td>IPv{{ object.family }}</td>
               </tr>
               </tr>
+              <tr>
+                  <th scope="row">{% trans "Prefix" %}</th>
+                  <td>{{ object.prefix|linkify|placeholder }}</td>
+              </tr>
               <tr>
               <tr>
                   <th scope="row">{% trans "VRF" %}</th>
                   <th scope="row">{% trans "VRF" %}</th>
                   <td>
                   <td>

+ 1 - 0
netbox/templates/ipam/ipaddress_bulk_add.html

@@ -14,6 +14,7 @@
         <div class="row">
         <div class="row">
           <h2 class="col-9 offset-3">{% trans "IP Addresses" %}</h2>
           <h2 class="col-9 offset-3">{% trans "IP Addresses" %}</h2>
         </div>
         </div>
+        {% render_field model_form.prefix %}
         {% render_field form.pattern %}
         {% render_field form.pattern %}
         {% render_field model_form.status %}
         {% render_field model_form.status %}
         {% render_field model_form.role %}
         {% render_field model_form.role %}

+ 1 - 1
netbox/templates/ipam/prefix.html

@@ -109,7 +109,7 @@
             {% endif %}
             {% endif %}
           </td>
           </td>
         </tr>
         </tr>
-        {% with child_ip_count=object.get_child_ips.count %}
+        {% with child_ip_count=object.ip_addresses.count %}
           <tr>
           <tr>
             <th scope="row">{% trans "Child IPs" %}</th>
             <th scope="row">{% trans "Child IPs" %}</th>
             <td>
             <td>