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

More work on IP Address/Range and Prefix relationship

Daniel Sheppard 7 месяцев назад
Родитель
Сommit
b19f81cede

+ 3 - 0
netbox/ipam/api/serializers_/ip.py

@@ -44,6 +44,7 @@ class AggregateSerializer(NetBoxModelSerializer):
 
 
 class PrefixSerializer(NetBoxModelSerializer):
+    # TODO: Alter for parent prefix
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     vrf = VRFSerializer(nested=True, required=False, allow_null=True)
     scope_type = ContentTypeField(
@@ -134,6 +135,7 @@ class AvailablePrefixSerializer(serializers.Serializer):
 #
 
 class IPRangeSerializer(NetBoxModelSerializer):
+    # TODO: Alter for prefix
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     start_address = IPAddressField()
     end_address = IPAddressField()
@@ -158,6 +160,7 @@ class IPRangeSerializer(NetBoxModelSerializer):
 #
 
 class IPAddressSerializer(NetBoxModelSerializer):
+    # TODO: Alter for prefix
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     address = IPAddressField()
     vrf = VRFSerializer(nested=True, required=False, allow_null=True)

+ 14 - 0
netbox/ipam/filtersets.py

@@ -291,6 +291,8 @@ class RoleFilterSet(OrganizationalModelFilterSet):
 
 
 class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, ContactModelFilterSet):
+    # TODO: Alter for aggregate
+    # TODO: Alter for parent prefix
     family = django_filters.NumberFilter(
         field_name='prefix',
         lookup_expr='family'
@@ -457,6 +459,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, C
 
 
 class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilterSet):
+    # TODO: Alter for prefix
     family = django_filters.NumberFilter(
         field_name='start_address',
         lookup_expr='family'
@@ -473,6 +476,16 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilte
         method='search_contains',
         label=_('Ranges which contain this prefix or IP'),
     )
+    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'),
+    )
     vrf_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VRF.objects.all(),
         label=_('VRF'),
@@ -549,6 +562,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilte
 
 
 class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
+    # TODO: Alter for prefix
     family = django_filters.NumberFilter(
         field_name='address',
         lookup_expr='family'

+ 3 - 0
netbox/ipam/forms/bulk_edit.py

@@ -207,6 +207,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):
+    # TODO: Alter for parent prefix
     vlan_group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         required=False,
@@ -276,6 +277,7 @@ class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):
 
 
 class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
+    # TODO: Alter for prefix
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
@@ -323,6 +325,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
+    # TODO: Alter for prefix
     prefix = DynamicModelChoiceField(
         queryset=Prefix.objects.all(),
         required=False,

+ 4 - 0
netbox/ipam/forms/bulk_import.py

@@ -156,6 +156,8 @@ class RoleImportForm(NetBoxModelImportForm):
 
 
 class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
+    # TODO: Alter for aggregate
+    # TODO: Alter for parent prefix
     vrf = CSVModelChoiceField(
         label=_('VRF'),
         queryset=VRF.objects.all(),
@@ -245,6 +247,7 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
 
 
 class IPRangeImportForm(NetBoxModelImportForm):
+    # TODO: Alter for prefix
     vrf = CSVModelChoiceField(
         label=_('VRF'),
         queryset=VRF.objects.all(),
@@ -281,6 +284,7 @@ class IPRangeImportForm(NetBoxModelImportForm):
 
 
 class IPAddressImportForm(NetBoxModelImportForm):
+    # TODO: Alter for prefix
     prefix = CSVModelChoiceField(
         label=_('Prefix'),
         queryset=Prefix.objects.all(),

+ 9 - 1
netbox/ipam/forms/filtersets.py

@@ -278,10 +278,18 @@ class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFi
     model = IPRange
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')),
+        FieldSet(
+            'prefix', 'family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')
+        ),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
+    prefix = DynamicModelMultipleChoiceField(
+        queryset=Prefix.objects.all(),
+        required=False,
+        label=_('Prefix'),
+        null_option='None'
+    )
     family = forms.ChoiceField(
         required=False,
         choices=add_blank_choice(IPAddressFamilyChoices),

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

@@ -250,6 +250,11 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
 
 
 class IPRangeForm(TenancyForm, NetBoxModelForm):
+    prefix = DynamicModelChoiceField(
+        queryset=Prefix.objects.all(),
+        required=False,
+        label=_('Prefix')
+    )
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
@@ -265,8 +270,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
 
     fieldsets = (
         FieldSet(
-            'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_populated', 'mark_utilized', 'description',
-            'tags', name=_('IP Range')
+            'prefix', 'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_populated', 'mark_utilized',
+            'description', 'tags', name=_('IP Range')
         ),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
@@ -274,8 +279,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
     class Meta:
         model = IPRange
         fields = [
-            'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'mark_populated',
-            'mark_utilized', 'description', 'comments', 'tags',
+            'prefix', 'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant',
+            'mark_populated', 'mark_utilized', 'description', 'comments', 'tags',
         ]
 
 

+ 58 - 0
netbox/ipam/migrations/0082_ipaddress_iprange_prefix_parent.py

@@ -0,0 +1,58 @@
+# Generated by Django 5.0.9 on 2025-02-20 16:49
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0081_remove_service_device_virtual_machine_add_parent_gfk_index'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='prefix',
+            name='parent',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='children',
+                to='ipam.prefix',
+            ),
+        ),
+        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.AddField(
+            model_name='iprange',
+            name='prefix',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='ip_ranges',
+                to='ipam.prefix',
+            ),
+        ),
+        migrations.AddField(
+            model_name='prefix',
+            name='aggregate',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='prefixes',
+                to='ipam.aggregate',
+            ),
+        ),
+    ]

+ 56 - 41
netbox/ipam/migrations/0082_ipaddress_prefix.py → netbox/ipam/migrations/0083_ipaddress_iprange_prefix_parent_data.py

@@ -3,11 +3,14 @@
 import sys
 import time
 
-import django.db.models.deletion
 from django.db import migrations, models
 
+from ipam.choices import PrefixStatusChoices
+
 
 def draw_progress(count, total, length=20):
+    if total == 0:
+        return
     progress = count / total
     percent = int(progress * 100)
     bar = int(progress * length)
@@ -24,8 +27,9 @@ def set_ipaddress_prefix(apps, schema_editor):
     addresses = IPAddress.objects.all()
     i = 0
     total = addresses.count()
-    if total > 0:
-        print('\r\n')
+    if total == 0:
+        return
+    print('\r\n')
     draw_progress(i, total, 50)
     for address in addresses:
         i += 1
@@ -55,8 +59,10 @@ def set_iprange_prefix(apps, schema_editor):
     addresses = IPRange.objects.all()
     i = 0
     total = addresses.count()
-    if total > 0:
-        print('\r\n')
+    if total == 0:
+        return
+
+    print('\r\n')
     draw_progress(i, total, 50)
     for address in addresses:
         i += 1
@@ -86,8 +92,10 @@ def set_prefix_aggregate(apps, schema_editor):
     addresses = Prefix.objects.all()
     i = 0
     total = addresses.count()
-    if total > 0:
-        print('\r\n')
+    if total == 0:
+        return
+
+    print('\r\n')
     draw_progress(i, total, 50)
     for address in addresses:
         i += 1
@@ -108,47 +116,54 @@ def unset_prefix_aggregate(apps, schema_editor):
     Prefix.objects.update(aggregate=None)
 
 
+def set_prefix_parent(apps, schema_editor):
+    Prefix = apps.get_model('ipam', 'Prefix')
+    start = time.time()
+    addresses = Prefix.objects.all()
+    i = 0
+    total = addresses.count()
+    if total == 0:
+        return
+
+    print('\r\n')
+    draw_progress(i, total, 50)
+    for address in addresses:
+        i += 1
+        prefixes = Prefix.objects.exclude(pk=address.pk).filter(
+            models.Q(
+                vrf=address.vrf,
+                prefix__net_contains=str(address.prefix.ip)
+            ) | models.Q(
+                vrf=None,
+                status=PrefixStatusChoices.STATUS_CONTAINER,
+                prefix__net_contains=str(address.prefix.ip),
+            )
+        )
+        if not prefixes.exists():
+            draw_progress(i, total, 50)
+            continue
+
+        address.parent = prefixes.last()
+        address.save()
+        draw_progress(i, total, 50)
+    end = time.time()
+    print(f"\r\nElapsed Time: {end - start:.2f}s")
+
+
+def unset_prefix_parent(apps, schema_editor):
+    Prefix = apps.get_model('ipam', 'Prefix')
+    Prefix.objects.update(parent=None)
+
+
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('ipam', '0081_remove_service_device_virtual_machine_add_parent_gfk_index'),
+        ('ipam', '0082_ipaddress_iprange_prefix_parent'),
     ]
 
     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.AddField(
-            model_name='iprange',
-            name='prefix',
-            field=models.ForeignKey(
-                blank=True,
-                null=True,
-                on_delete=django.db.models.deletion.PROTECT,
-                related_name='ip_ranges',
-                to='ipam.prefix',
-            ),
-        ),
-        migrations.AddField(
-            model_name='prefix',
-            name='aggregate',
-            field=models.ForeignKey(
-                blank=True,
-                null=True,
-                on_delete=django.db.models.deletion.PROTECT,
-                related_name='prefixes',
-                to='ipam.aggregate',
-            ),
-        ),
         migrations.RunPython(set_ipaddress_prefix, unset_ipaddress_prefix),
         migrations.RunPython(set_iprange_prefix, unset_iprange_prefix),
         migrations.RunPython(set_prefix_aggregate, unset_prefix_aggregate),
+        migrations.RunPython(set_prefix_parent, unset_prefix_parent),
     ]

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

@@ -6,6 +6,7 @@ from django.db.models import F
 from django.db.models.functions import Cast
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
+from netaddr.ip import IPNetwork
 
 from core.models import ObjectType
 from dcim.models.mixins import CachedScopeMixin
@@ -206,12 +207,20 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
     """
     aggregate = models.ForeignKey(
         to='ipam.Aggregate',
-        on_delete=models.PROTECT,
+        on_delete=models.SET_NULL,
         related_name='prefixes',
         blank=True,
         null=True,
         verbose_name=_('aggregate')
     )
+    parent = models.ForeignKey(
+        to='ipam.Prefix',
+        on_delete=models.SET_NULL,
+        related_name='children',
+        blank=True,
+        null=True,
+        verbose_name=_('Prefix')
+    )
     prefix = IPNetworkField(
         verbose_name=_('prefix'),
         help_text=_('IPv4 or IPv6 network with mask')
@@ -299,6 +308,8 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         super().clean()
 
         if self.prefix:
+            if not isinstance(self.prefix, IPNetwork):
+                self.prefix = IPNetwork(self.prefix)
 
             # /0 masks are not acceptable
             if self.prefix.prefixlen == 0:
@@ -306,6 +317,17 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
                     'prefix': _("Cannot create prefix with /0 mask.")
                 })
 
+            if self.parent:
+                if self.prefix not in self.parent.prefix:
+                    raise ValidationError({
+                        'parent': _("Prefix must be part of parent prefix.")
+                    })
+
+                if self.parent.status != PrefixStatusChoices.STATUS_CONTAINER and self.vrf != self.parent.vrf:
+                    raise ValidationError({
+                        'vrf': _("VRF must match the parent VRF.")
+                    })
+
             # Enforce unique IP space (if applicable)
             if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
                 duplicate_prefixes = self.get_duplicates()
@@ -319,6 +341,14 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
                     })
 
     def save(self, *args, **kwargs):
+        vrf_id = self.vrf.pk if self.vrf else None
+
+        if not self.pk and not self.parent:
+            parent = self.find_parent_prefix(self)
+            self.parent = parent
+        elif self.parent and (self.prefix != self._prefix or vrf_id != self._vrf_id):
+            parent = self.find_parent_prefix(self)
+            self.parent = parent
 
         if isinstance(self.prefix, netaddr.IPNetwork):
 
@@ -483,6 +513,20 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
 
         return min(utilization, 100)
 
+    @classmethod
+    def find_parent_prefix(cls, network):
+        prefixes = Prefix.objects.filter(
+            models.Q(
+                vrf=network.vrf,
+                prefix__net_contains=str(network)
+            ) | models.Q(
+                vrf=None,
+                status=PrefixStatusChoices.STATUS_CONTAINER,
+                prefix__net_contains=str(network),
+            )
+        )
+        return prefixes.last()
+
 
 class IPRange(ContactsMixin, PrimaryModel):
     """
@@ -490,7 +534,7 @@ class IPRange(ContactsMixin, PrimaryModel):
     """
     prefix = models.ForeignKey(
         to='ipam.Prefix',
-        on_delete=models.PROTECT,
+        on_delete=models.SET_NULL,
         related_name='ip_ranges',
         null=True,
         blank=True,
@@ -565,6 +609,27 @@ class IPRange(ContactsMixin, PrimaryModel):
         super().clean()
 
         if self.start_address and self.end_address:
+            # If prefix is set, validate suitability
+            if self.prefix:
+                # Check that start address and end address are within the prefix range
+                if self.start_address not in self.prefix.prefix and self.end_address not in self.prefix.prefix:
+                    raise ValidationError({
+                        'start_address': _("Start address must be part of the selected prefix"),
+                        'end_address': _("End address must be part of the selected prefix.")
+                    })
+                elif self.start_address not in self.prefix.prefix:
+                    raise ValidationError({
+                        'start_address': _("Start address must be part of the selected prefix")
+                    })
+                elif self.end_address not in self.prefix.prefix:
+                    raise ValidationError({
+                        'end_address': _("End address must be part of the selected prefix.")
+                    })
+                # Check that VRF matches prefix VRF
+                if self.vrf != self.prefix.vrf:
+                    raise ValidationError({
+                        'vrf': _("VRF must match the prefix VRF.")
+                    })
 
             # Check that start & end IP versions match
             if self.start_address.version != self.end_address.version:
@@ -828,6 +893,7 @@ class IPAddress(ContactsMixin, PrimaryModel):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
+        self._address = self.address
         # Denote the original assigned object (if any) for validation in clean()
         self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
         self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
@@ -869,11 +935,16 @@ class IPAddress(ContactsMixin, PrimaryModel):
         super().clean()
 
         if self.address:
+            # If prefix is set, validate suitability
             if self.prefix:
                 if self.address not in self.prefix.prefix:
                     raise ValidationError({
                         'prefix': _("IP address must be part of the selected prefix.")
                     })
+                if self.vrf != self.prefix.vrf:
+                    raise ValidationError({
+                        'vrf': _("IP address VRF must match the prefix VRF.")
+                    })
 
             # /0 masks are not acceptable
             if self.address.prefixlen == 0:

+ 5 - 2
netbox/ipam/search.py

@@ -66,10 +66,11 @@ class IPRangeIndex(SearchIndex):
     fields = (
         ('start_address', 100),
         ('end_address', 300),
+        ('prefix', 400),
         ('description', 500),
         ('comments', 5000),
     )
-    display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
+    display_attrs = ('prefix', 'vrf', 'tenant', 'status', 'role', 'description')
 
 
 @register_search
@@ -77,10 +78,12 @@ class PrefixIndex(SearchIndex):
     model = models.Prefix
     fields = (
         ('prefix', 110),
+        ('parent', 200),
+        ('aggregate', 300),
         ('description', 500),
         ('comments', 5000),
     )
-    display_attrs = ('scope', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description')
+    display_attrs = ('scope', 'aggregate', 'parent', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description')
 
 
 @register_search

+ 127 - 1
netbox/ipam/signals.py

@@ -1,10 +1,12 @@
 from django.db.models import Q
 from django.db.models.signals import post_delete, post_save, pre_delete
 from django.dispatch import receiver
+from netaddr.ip import IPNetwork
 
 from dcim.models import Device
 from virtualization.models import VirtualMachine
-from .models import IPAddress, Prefix
+from .choices import PrefixStatusChoices
+from .models import IPAddress, Prefix, IPRange
 
 
 def update_parents_children(prefix):
@@ -46,6 +48,7 @@ def update_ipaddress_prefix(prefix, delete=False):
             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
@@ -65,6 +68,126 @@ def update_ipaddress_prefix(prefix, delete=False):
     IPAddress.objects.bulk_update(addresses, ['prefix'], batch_size=100)
 
 
+def update_iprange_prefix(prefix, delete=False):
+    if delete:
+        # Get all possible addresses
+        addresses = IPRange.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 = IPRange.objects.filter(
+            Q(start_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.start_address in prefix.prefix and
+                    address.end_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(Q(prefix__net_contains_or_equals=address.start_address) &
+                    Q(prefix__net_contains_or_equals=address.end_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)
+
+
+def update_prefix_parents(prefix, delete=False):
+    if delete:
+        # Get all possible addresses
+        prefixes = prefix.children.all()
+
+        for pfx in prefixes:
+            parent = Prefix.objects.exclude(pk=pfx.pk).exclude(pk=prefix.pk).filter(
+                Q(
+                    vrf=pfx.vrf,
+                    prefix__net_contains=str(pfx.prefix)
+                ) | Q(
+                    vrf=None,
+                    status=PrefixStatusChoices.STATUS_CONTAINER,
+                    prefix__net_contains=str(pfx.prefix),
+                )
+            ).last()
+            # Set contained addresses to the containing prefix if it exists
+            pfx.parent = parent
+    else:
+        # Get all possible addresses
+        prefixes = prefix.children.all() | Prefix.objects.filter(
+            Q(
+                parent=prefix.parent,
+                vrf=prefix.vrf,
+                prefix__net_contained=str(prefix.prefix)
+            ) | Q(
+                parent=prefix.parent,
+                vrf=None,
+                status=PrefixStatusChoices.STATUS_CONTAINER,
+                prefix__net_contained=str(prefix.prefix),
+            )
+        )
+
+        if isinstance(prefix.prefix, str):
+            prefix.prefix = IPNetwork(prefix.prefix)
+        for pfx in prefixes:
+            if isinstance(pfx.prefix, str):
+                pfx.prefix = IPNetwork(pfx.prefix)
+
+            if pfx.parent == prefix and pfx.prefix.ip not in prefix.prefix:
+                # Find new parents for orphaned prefixes
+                parent = Prefix.objects.exclude(pk=pfx.pk).filter(
+                    Q(
+                        vrf=pfx.vrf,
+                        prefix__net_contains=str(pfx.prefix)
+                    ) | Q(
+                        vrf=None,
+                        status=PrefixStatusChoices.STATUS_CONTAINER,
+                        prefix__net_contains=str(pfx.prefix),
+                    )
+                ).last()
+                # Set contained addresses to the containing prefix if it exists
+                pfx.parent = parent
+            elif pfx.parent == prefix and pfx.vrf != prefix.vrf:
+                # Find new parents for orphaned prefixes
+                parent = Prefix.objects.exclude(pk=pfx.pk).filter(
+                    Q(
+                        vrf=pfx.vrf,
+                        prefix__net_contains=str(pfx.prefix)
+                    ) | Q(
+                        vrf=None,
+                        status=PrefixStatusChoices.STATUS_CONTAINER,
+                        prefix__net_contains=str(pfx.prefix),
+                    )
+                ).last()
+                # Set contained addresses to the containing prefix if it exists
+                pfx.parent = parent
+            elif pfx.parent != prefix and pfx.vrf == prefix.vrf and pfx.prefix in prefix.prefix:
+                # Set the parent to the prefix
+                pfx.parent = prefix
+            else:
+                # No-OP as the prefix does not require modification
+                pass
+
+    # Update the prefixes
+    Prefix.objects.bulk_update(prefixes, ['parent'], batch_size=100)
+
+
 @receiver(post_save, sender=Prefix)
 def handle_prefix_saved(instance, created, **kwargs):
 
@@ -72,6 +195,7 @@ def handle_prefix_saved(instance, created, **kwargs):
     if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix:
 
         update_ipaddress_prefix(instance)
+        update_prefix_parents(instance)
         update_parents_children(instance)
         update_children_depth(instance)
 
@@ -85,6 +209,7 @@ def handle_prefix_saved(instance, created, **kwargs):
 @receiver(pre_delete, sender=Prefix)
 def pre_handle_prefix_deleted(instance, **kwargs):
     update_ipaddress_prefix(instance, True)
+    update_prefix_parents(instance, delete=True)
 
 
 @receiver(post_delete, sender=Prefix)
@@ -93,6 +218,7 @@ def handle_prefix_deleted(instance, **kwargs):
     update_parents_children(instance)
     update_children_depth(instance)
     update_ipaddress_prefix(instance, delete=True)
+    update_prefix_parents(instance, delete=True)
 
 
 @receiver(pre_delete, sender=IPAddress)

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

@@ -155,6 +155,7 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
 
 
 class PrefixTable(TenancyColumnsMixin, NetBoxTable):
+    # TODO: Alter for parent prefix
     prefix = columns.TemplateColumn(
         verbose_name=_('Prefix'),
         template_code=PREFIX_LINK_WITH_DEPTH,
@@ -253,6 +254,7 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
 # IP ranges
 #
 class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
+    # TODO: Alter for prefix
     start_address = tables.Column(
         verbose_name=_('Start address'),
         linkify=True
@@ -309,6 +311,7 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
 #
 
 class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
+    # TODO: Alter for prefix
     address = tables.TemplateColumn(
         template_code=IPADDRESS_LINK,
         verbose_name=_('IP Address')

+ 3 - 0
netbox/ipam/tests/test_api.py

@@ -356,6 +356,7 @@ class RoleTest(APIViewTestCases.APIViewTestCase):
 
 class PrefixTest(APIViewTestCases.APIViewTestCase):
     model = Prefix
+    # TODO: Alter for parent prefix
     brief_fields = ['_depth', 'description', 'display', 'family', 'id', 'prefix', 'url']
     create_data = [
         {
@@ -535,6 +536,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
 
 class IPRangeTest(APIViewTestCases.APIViewTestCase):
     model = IPRange
+    # TODO: Alter for parent prefix
     brief_fields = ['description', 'display', 'end_address', 'family', 'id', 'start_address', 'url']
     create_data = [
         {
@@ -634,6 +636,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
 
 class IPAddressTest(APIViewTestCases.APIViewTestCase):
     model = IPAddress
+    # TODO: Alter for parent prefix
     brief_fields = ['address', 'description', 'display', 'family', 'id', 'url']
     create_data = [
         {

+ 6 - 0
netbox/ipam/tests/test_filtersets.py

@@ -901,6 +901,10 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    # TODO: Test for parent prefix
+    # TODO: Test for children?
+    # TODO: Test for aggregate
+
 
 class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = IPRange.objects.all()
@@ -1079,6 +1083,7 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_parent(self):
+        # TODO: Alter for prefix
         params = {'parent': ['10.0.1.0/24', '10.0.2.0/24']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'parent': ['10.0.1.0/25']}  # Range 10.0.1.100-199 is not fully contained by 10.0.1.0/25
@@ -1315,6 +1320,7 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_parent(self):
+        # TODO: Alter for prefix
         params = {'parent': ['10.0.0.0/30', '2001:db8::/126']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
 

+ 189 - 7
netbox/ipam/tests/test_models.py

@@ -39,6 +39,26 @@ class TestAggregate(TestCase):
 
 
 class TestIPRange(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.vrf = VRF.objects.create(name='VRF A', rd='1:1')
+
+        cls.prefixes = (
+
+            # IPv4
+            Prefix(prefix='192.0.0.0/16'),
+            Prefix(prefix='192.0.2.0/24'),
+            Prefix(prefix='192.0.0.0/16', vrf=cls.vrf),
+
+            # IPv6
+            Prefix(prefix='2001:db8::/32'),
+            Prefix(prefix='2001:db8::/64'),
+
+        )
+
+        for prefix in cls.prefixes:
+            prefix.clean()
+            prefix.save()
 
     def test_overlapping_range(self):
         iprange_192_168 = IPRange.objects.create(
@@ -87,6 +107,69 @@ class TestIPRange(TestCase):
             )
             iprange_4_198_201.clean()
 
+    def test_parent_prefix(self):
+        ranges = (
+            IPRange(
+                start_address=IPNetwork('192.0.0.1/24'),
+                end_address=IPNetwork('192.0.0.254/24'),
+                prefix=self.prefixes[0]
+            ),
+            IPRange(
+                start_address=IPNetwork('192.0.2.1/24'),
+                end_address=IPNetwork('192.0.2.254/24'),
+                prefix=self.prefixes[1]
+            ),
+            IPRange(
+                start_address=IPNetwork('192.0.2.1/24'),
+                end_address=IPNetwork('192.0.2.254/24'),
+                vrf=self.vrf,
+                prefix=self.prefixes[2]
+            ),
+            IPRange(
+                start_address=IPNetwork('2001:db8::/64'),
+                end_address=IPNetwork('2001:db8::ffff/64'),
+                prefix=self.prefixes[4]
+            ),
+            IPRange(
+                start_address=IPNetwork('2001:db8:2::/64'),
+                end_address=IPNetwork('2001:db8:2::ffff/64'),
+                prefix=self.prefixes[3]
+            ),
+        )
+
+        for range in ranges:
+            range.clean()
+            range.save()
+
+        self.assertEqual(ranges[0].prefix, self.prefixes[0])
+        self.assertEqual(ranges[1].prefix, self.prefixes[1])
+        self.assertEqual(ranges[2].prefix, self.prefixes[2])
+        self.assertEqual(ranges[3].prefix, self.prefixes[4])
+
+    def test_parent_prefix_change(self):
+
+        range = IPRange(
+            start_address=IPNetwork('192.0.1.1/24'),
+            end_address=IPNetwork('192.0.1.254/24'),
+            prefix=self.prefixes[0]
+        )
+        range.clean()
+        range.save()
+
+        prefix = Prefix(prefix='192.0.1.0/17')
+        prefix.clean()
+        prefix.save()
+
+        range.refresh_from_db()
+
+        self.assertEqual(range.prefix, prefix)
+
+        # TODO: Prefix Altered
+        # TODO: Prefix Deleted
+
+    # TODO: Prefix falls outside range
+    # TODO: Prefix VRF does not match range VRF
+
 
 class TestPrefix(TestCase):
 
@@ -344,17 +427,21 @@ class TestPrefixHierarchy(TestCase):
         prefixes = (
 
             # IPv4
-            Prefix(prefix='10.0.0.0/8', _depth=0, _children=2),
-            Prefix(prefix='10.0.0.0/16', _depth=1, _children=1),
-            Prefix(prefix='10.0.0.0/24', _depth=2, _children=0),
+            Prefix(prefix='10.0.0.0/8'),
+            Prefix(prefix='10.0.0.0/16'),
+            Prefix(prefix='10.0.0.0/24'),
+            Prefix(prefix='192.168.0.0/16'),
 
             # IPv6
-            Prefix(prefix='2001:db8::/32', _depth=0, _children=2),
-            Prefix(prefix='2001:db8::/40', _depth=1, _children=1),
-            Prefix(prefix='2001:db8::/48', _depth=2, _children=0),
+            Prefix(prefix='2001:db8::/32'),
+            Prefix(prefix='2001:db8::/40'),
+            Prefix(prefix='2001:db8::/48'),
 
         )
-        Prefix.objects.bulk_create(prefixes)
+
+        for prefix in prefixes:
+            prefix.clean()
+            prefix.save()
 
     def test_create_prefix4(self):
         # Create 10.0.0.0/12
@@ -362,15 +449,19 @@ class TestPrefixHierarchy(TestCase):
 
         prefixes = Prefix.objects.filter(prefix__family=4)
         self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
+        self.assertEqual(prefixes[0].parent, None)
         self.assertEqual(prefixes[0]._depth, 0)
         self.assertEqual(prefixes[0]._children, 3)
         self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/12'))
+        self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8'))
         self.assertEqual(prefixes[1]._depth, 1)
         self.assertEqual(prefixes[1]._children, 2)
         self.assertEqual(prefixes[2].prefix, IPNetwork('10.0.0.0/16'))
+        self.assertEqual(prefixes[2].parent.prefix, IPNetwork('10.0.0.0/12'))
         self.assertEqual(prefixes[2]._depth, 2)
         self.assertEqual(prefixes[2]._children, 1)
         self.assertEqual(prefixes[3].prefix, IPNetwork('10.0.0.0/24'))
+        self.assertEqual(prefixes[3].parent.prefix, IPNetwork('10.0.0.0/16'))
         self.assertEqual(prefixes[3]._depth, 3)
         self.assertEqual(prefixes[3]._children, 0)
 
@@ -380,15 +471,19 @@ class TestPrefixHierarchy(TestCase):
 
         prefixes = Prefix.objects.filter(prefix__family=6)
         self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
+        self.assertEqual(prefixes[0].parent, None)
         self.assertEqual(prefixes[0]._depth, 0)
         self.assertEqual(prefixes[0]._children, 3)
         self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/36'))
+        self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32'))
         self.assertEqual(prefixes[1]._depth, 1)
         self.assertEqual(prefixes[1]._children, 2)
         self.assertEqual(prefixes[2].prefix, IPNetwork('2001:db8::/40'))
+        self.assertEqual(prefixes[2].parent.prefix, IPNetwork('2001:db8::/36'))
         self.assertEqual(prefixes[2]._depth, 2)
         self.assertEqual(prefixes[2]._children, 1)
         self.assertEqual(prefixes[3].prefix, IPNetwork('2001:db8::/48'))
+        self.assertEqual(prefixes[3].parent.prefix, IPNetwork('2001:db8::/40'))
         self.assertEqual(prefixes[3]._depth, 3)
         self.assertEqual(prefixes[3]._children, 0)
 
@@ -400,12 +495,15 @@ class TestPrefixHierarchy(TestCase):
 
         prefixes = Prefix.objects.filter(prefix__family=4)
         self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
+        self.assertEqual(prefixes[0].parent, None)
         self.assertEqual(prefixes[0]._depth, 0)
         self.assertEqual(prefixes[0]._children, 2)
         self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/12'))
+        self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8'))
         self.assertEqual(prefixes[1]._depth, 1)
         self.assertEqual(prefixes[1]._children, 1)
         self.assertEqual(prefixes[2].prefix, IPNetwork('10.0.0.0/16'))
+        self.assertEqual(prefixes[2].parent.prefix, IPNetwork('10.0.0.0/12'))
         self.assertEqual(prefixes[2]._depth, 2)
         self.assertEqual(prefixes[2]._children, 0)
 
@@ -417,12 +515,15 @@ class TestPrefixHierarchy(TestCase):
 
         prefixes = Prefix.objects.filter(prefix__family=6)
         self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
+        self.assertEqual(prefixes[0].parent, None)
         self.assertEqual(prefixes[0]._depth, 0)
         self.assertEqual(prefixes[0]._children, 2)
         self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/36'))
+        self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32'))
         self.assertEqual(prefixes[1]._depth, 1)
         self.assertEqual(prefixes[1]._children, 1)
         self.assertEqual(prefixes[2].prefix, IPNetwork('2001:db8::/40'))
+        self.assertEqual(prefixes[2].parent.prefix, IPNetwork('2001:db8::/36'))
         self.assertEqual(prefixes[2]._depth, 2)
         self.assertEqual(prefixes[2]._children, 0)
 
@@ -437,14 +538,17 @@ class TestPrefixHierarchy(TestCase):
 
         prefixes = Prefix.objects.filter(vrf__isnull=True, prefix__family=4)
         self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
+        self.assertEqual(prefixes[0].parent, None)
         self.assertEqual(prefixes[0]._depth, 0)
         self.assertEqual(prefixes[0]._children, 1)
         self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/24'))
+        self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8'))
         self.assertEqual(prefixes[1]._depth, 1)
         self.assertEqual(prefixes[1]._children, 0)
 
         prefixes = Prefix.objects.filter(vrf=vrf)
         self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/16'))
+        self.assertEqual(prefixes[0].parent, None)
         self.assertEqual(prefixes[0]._depth, 0)
         self.assertEqual(prefixes[0]._children, 0)
 
@@ -459,14 +563,17 @@ class TestPrefixHierarchy(TestCase):
 
         prefixes = Prefix.objects.filter(vrf__isnull=True, prefix__family=6)
         self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
+        self.assertEqual(prefixes[0].parent, None)
         self.assertEqual(prefixes[0]._depth, 0)
         self.assertEqual(prefixes[0]._children, 1)
         self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/48'))
+        self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32'))
         self.assertEqual(prefixes[1]._depth, 1)
         self.assertEqual(prefixes[1]._children, 0)
 
         prefixes = Prefix.objects.filter(vrf=vrf)
         self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/40'))
+        self.assertEqual(prefixes[0].parent, None)
         self.assertEqual(prefixes[0]._depth, 0)
         self.assertEqual(prefixes[0]._children, 0)
 
@@ -476,9 +583,11 @@ class TestPrefixHierarchy(TestCase):
 
         prefixes = Prefix.objects.filter(prefix__family=4)
         self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
+        self.assertEqual(prefixes[0].parent, None)
         self.assertEqual(prefixes[0]._depth, 0)
         self.assertEqual(prefixes[0]._children, 1)
         self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/24'))
+        self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8'))
         self.assertEqual(prefixes[1]._depth, 1)
         self.assertEqual(prefixes[1]._children, 0)
 
@@ -488,9 +597,11 @@ class TestPrefixHierarchy(TestCase):
 
         prefixes = Prefix.objects.filter(prefix__family=6)
         self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
+        self.assertEqual(prefixes[0].parent, None)
         self.assertEqual(prefixes[0]._depth, 0)
         self.assertEqual(prefixes[0]._children, 1)
         self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/48'))
+        self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32'))
         self.assertEqual(prefixes[1]._depth, 1)
         self.assertEqual(prefixes[1]._children, 0)
 
@@ -500,15 +611,20 @@ class TestPrefixHierarchy(TestCase):
 
         prefixes = Prefix.objects.filter(prefix__family=4)
         self.assertEqual(prefixes[0].prefix, IPNetwork('10.0.0.0/8'))
+        self.assertEqual(prefixes[0].parent, None)
         self.assertEqual(prefixes[0]._depth, 0)
         self.assertEqual(prefixes[0]._children, 3)
         self.assertEqual(prefixes[1].prefix, IPNetwork('10.0.0.0/16'))
+        self.assertEqual(prefixes[1].parent.prefix, IPNetwork('10.0.0.0/8'))
         self.assertEqual(prefixes[1]._depth, 1)
         self.assertEqual(prefixes[1]._children, 1)
         self.assertEqual(prefixes[2].prefix, IPNetwork('10.0.0.0/16'))
+        self.assertEqual(prefixes[2].parent.prefix, IPNetwork('10.0.0.0/8'))
         self.assertEqual(prefixes[2]._depth, 1)
         self.assertEqual(prefixes[2]._children, 1)
         self.assertEqual(prefixes[3].prefix, IPNetwork('10.0.0.0/24'))
+        # TODO: How to we resolve the parent for duplicate prefixes
+        self.assertEqual(prefixes[3].parent.prefix, IPNetwork('10.0.0.0/16'))
         self.assertEqual(prefixes[3]._depth, 2)
         self.assertEqual(prefixes[3]._children, 0)
 
@@ -518,20 +634,48 @@ class TestPrefixHierarchy(TestCase):
 
         prefixes = Prefix.objects.filter(prefix__family=6)
         self.assertEqual(prefixes[0].prefix, IPNetwork('2001:db8::/32'))
+        self.assertEqual(prefixes[0].parent, None)
         self.assertEqual(prefixes[0]._depth, 0)
         self.assertEqual(prefixes[0]._children, 3)
         self.assertEqual(prefixes[1].prefix, IPNetwork('2001:db8::/40'))
+        self.assertEqual(prefixes[1].parent.prefix, IPNetwork('2001:db8::/32'))
         self.assertEqual(prefixes[1]._depth, 1)
         self.assertEqual(prefixes[1]._children, 1)
         self.assertEqual(prefixes[2].prefix, IPNetwork('2001:db8::/40'))
+        self.assertEqual(prefixes[2].parent.prefix, IPNetwork('2001:db8::/32'))
         self.assertEqual(prefixes[2]._depth, 1)
         self.assertEqual(prefixes[2]._children, 1)
         self.assertEqual(prefixes[3].prefix, IPNetwork('2001:db8::/48'))
+        self.assertEqual(prefixes[3].parent.prefix, IPNetwork('2001:db8::/40'))
         self.assertEqual(prefixes[3]._depth, 2)
         self.assertEqual(prefixes[3]._children, 0)
 
 
 class TestIPAddress(TestCase):
+    """
+    Test the automatic updating of depth and child count in response to changes made within
+    the prefix hierarchy.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        cls.vrf = VRF.objects.create(name='VRF A', rd='1:1')
+
+        cls.prefixes = (
+
+            # IPv4
+            Prefix(prefix='192.0.0.0/16'),
+            Prefix(prefix='192.0.2.0/24'),
+            Prefix(prefix='192.0.0.0/16', vrf=cls.vrf),
+
+            # IPv6
+            Prefix(prefix='2001:db8::/32'),
+            Prefix(prefix='2001:db8::/64'),
+
+        )
+
+        for prefix in cls.prefixes:
+            prefix.clean()
+            prefix.save()
 
     def test_get_duplicates(self):
         ips = IPAddress.objects.bulk_create((
@@ -543,6 +687,44 @@ class TestIPAddress(TestCase):
 
         self.assertSetEqual(set(duplicate_ip_pks), {ips[1].pk, ips[2].pk})
 
+    def test_parent_prefix(self):
+        ips = (
+            IPAddress(address=IPNetwork('192.0.0.1/24'), prefix=self.prefixes[0]),
+            IPAddress(address=IPNetwork('192.0.2.1/24'), prefix=self.prefixes[1]),
+            IPAddress(address=IPNetwork('192.0.2.1/24'), vrf=self.vrf, prefix=self.prefixes[2]),
+            IPAddress(address=IPNetwork('2001:db8::/64'), prefix=self.prefixes[4]),
+            IPAddress(address=IPNetwork('2001:db8:2::/64'), prefix=self.prefixes[3]),
+        )
+
+        for ip in ips:
+            ip.clean()
+            ip.save()
+
+        self.assertEqual(ips[0].prefix, self.prefixes[0])
+        self.assertEqual(ips[1].prefix, self.prefixes[1])
+        self.assertEqual(ips[2].prefix, self.prefixes[2])
+        self.assertEqual(ips[3].prefix, self.prefixes[4])
+        self.assertEqual(ips[4].prefix, self.prefixes[3])
+
+    def test_parent_prefix_change(self):
+        ip = IPAddress(address=IPNetwork('192.0.1.1/24'), prefix=self.prefixes[0])
+        ip.clean()
+        ip.save()
+
+        prefix = Prefix(prefix='192.0.1.0/17')
+        prefix.clean()
+        prefix.save()
+
+        ip.refresh_from_db()
+
+        self.assertEqual(ip.prefix, prefix)
+
+        # TODO: Prefix Altered
+        # TODO: Prefix Deleted
+
+    # TODO: Prefix does not contain IP Address
+    # TODO: Prefix VRF does not match IP Address VRF
+
     #
     # Uniqueness enforcement tests
     #

+ 12 - 0
netbox/ipam/tests/test_views.py

@@ -421,6 +421,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
+        # TODO: Alter for prefix
         cls.form_data = {
             'prefix': IPNetwork('192.0.2.0/24'),
             'scope_type': ContentType.objects.get_for_model(Site).pk,
@@ -436,6 +437,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         site = sites[0].pk
+        # TODO: Alter for prefix
         cls.csv_data = (
             "vrf,prefix,status,scope_type,scope_id",
             f"VRF 1,10.4.0.0/16,active,dcim.site,{site}",
@@ -443,6 +445,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             f"VRF 1,10.6.0.0/16,active,dcim.site,{site}",
         )
 
+        # TODO: Alter for prefix
         cls.csv_update_data = (
             "id,description,status",
             f"{prefixes[0].pk},New description 7,{PrefixStatusChoices.STATUS_RESERVED}",
@@ -450,6 +453,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             f"{prefixes[2].pk},New description 9,{PrefixStatusChoices.STATUS_RESERVED}",
         )
 
+        # TODO: Alter for prefix
         cls.bulk_edit_data = {
             'vrf': vrfs[1].pk,
             'tenant': None,
@@ -594,6 +598,7 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
+        # TODO: Alter for prefix
         cls.form_data = {
             'start_address': IPNetwork('192.0.5.10/24'),
             'end_address': IPNetwork('192.0.5.100/24'),
@@ -607,6 +612,7 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'tags': [t.pk for t in tags],
         }
 
+        # TODO: Alter for prefix
         cls.csv_data = (
             "vrf,start_address,end_address,status",
             "VRF 1,10.1.0.1/16,10.1.9.254/16,active",
@@ -614,6 +620,7 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             "VRF 1,10.3.0.1/16,10.3.9.254/16,active",
         )
 
+        # TODO: Alter for prefix
         cls.csv_update_data = (
             "id,description,status",
             f"{ip_ranges[0].pk},New description 7,{IPRangeStatusChoices.STATUS_RESERVED}",
@@ -621,6 +628,7 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             f"{ip_ranges[2].pk},New description 9,{IPRangeStatusChoices.STATUS_RESERVED}",
         )
 
+        # TODO: Alter for prefix
         cls.bulk_edit_data = {
             'vrf': vrfs[1].pk,
             'tenant': None,
@@ -687,6 +695,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             ),
         )
         FHRPGroup.objects.bulk_create(fhrp_groups)
+        # TODO: Alter for prefix
         cls.form_data = {
             'vrf': vrfs[1].pk,
             'address': IPNetwork('192.0.2.99/24'),
@@ -699,6 +708,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'tags': [t.pk for t in tags],
         }
 
+        # TODO: Alter for prefix
         cls.csv_data = (
             "vrf,address,status,fhrp_group",
             "VRF 1,192.0.2.4/24,active,FHRP Group 1",
@@ -706,6 +716,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             "VRF 1,192.0.2.6/24,active,FHRP Group 3",
         )
 
+        # TODO: Alter for prefix
         cls.csv_update_data = (
             "id,description,status",
             f"{ipaddresses[0].pk},New description 7,{IPAddressStatusChoices.STATUS_RESERVED}",
@@ -713,6 +724,7 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             f"{ipaddresses[2].pk},New description 9,{IPAddressStatusChoices.STATUS_RESERVED}",
         )
 
+        # TODO: Alter for prefix
         cls.bulk_edit_data = {
             'vrf': vrfs[1].pk,
             'tenant': None,