Explorar o código

Work on IP to Prefix ForeignKey relationship

Daniel Sheppard hai 1 ano
pai
achega
747fef0bc2

+ 10 - 0
netbox/ipam/filtersets.py

@@ -531,6 +531,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         method='search_by_parent',
         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(
         method='filter_address',
         label=_('Address'),

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

@@ -318,6 +318,11 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
+    prefix = DynamicModelChoiceField(
+        queryset=Prefix.objects.all(),
+        required=False,
+        label=_('Prefix')
+    )
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
@@ -359,10 +364,10 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
     model = IPAddress
     fieldsets = (
         FieldSet('status', 'role', 'tenant', 'description'),
-        FieldSet('vrf', 'mask_length', 'dns_name', name=_('Addressing')),
+        FieldSet('prefix', 'vrf', 'mask_length', 'dns_name', name=_('Addressing')),
     )
     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):
+    prefix = CSVModelChoiceField(
+        label=_('Prefix'),
+        queryset=Prefix.objects.all(),
+        required=False,
+        to_field_name='prefix',
+        help_text=_('Assigned prefix')
+    )
     vrf = CSVModelChoiceField(
         label=_('VRF'),
         queryset=VRF.objects.all(),
@@ -334,8 +341,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
     class Meta:
         model = IPAddress
         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):

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

@@ -306,14 +306,14 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         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')
         ),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         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(
         required=False,
         widget=forms.TextInput(
@@ -333,6 +333,11 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         choices=IPADDRESS_MASK_LENGTH_CHOICES,
         label=_('Mask length')
     )
+    prefix_id = DynamicModelMultipleChoiceField(
+        queryset=Prefix.objects.all(),
+        required=False,
+        label=_('Prefix'),
+    )
     vrf_id = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         required=False,

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

@@ -272,6 +272,15 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
 
 
 class IPAddressForm(TenancyForm, NetBoxModelForm):
+    prefix = DynamicModelChoiceField(
+        queryset=Prefix.objects.all(),
+        required=False,
+        context={
+            'vrf': 'vrf',
+        },
+        selector=True,
+        label=_('Prefix'),
+    )
     interface = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         required=False,
@@ -318,7 +327,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
 
     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(
             TabbedGroups(
@@ -334,8 +343,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
     class Meta:
         model = IPAddress
         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):
@@ -460,6 +469,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
 
 
 class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
+    prefix = DynamicModelChoiceField(
+        queryset=Prefix.objects.all(),
+        required=False,
+        context={
+            'vrf': 'vrf',
+        },
+        selector=True,
+        label=_('Prefix'),
+    )
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
@@ -469,7 +487,7 @@ class IPAddressBulkAddForm(TenancyForm, NetBoxModelForm):
     class Meta:
         model = IPAddress
         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):
     address: str
+    prefix: Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.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()
 
         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 = []
         for iprange in self.get_child_ranges():
             child_ranges.append(iprange.range)
@@ -462,7 +462,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         else:
             # Compile an IPSet to avoid counting duplicate IPs
             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
@@ -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
     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(
         verbose_name=_('address'),
         help_text=_('IPv4 or IPv6 address (with mask)')
@@ -835,6 +843,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
         super().clean()
 
         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
             if self.address.prefixlen == 0:

+ 2 - 1
netbox/ipam/search.py

@@ -52,11 +52,12 @@ class IPAddressIndex(SearchIndex):
     model = models.IPAddress
     fields = (
         ('address', 100),
+        ('prefix', 200),
         ('dns_name', 300),
         ('description', 500),
         ('comments', 5000),
     )
-    display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
+    display_attrs = ('prefix', 'vrf', 'tenant', 'status', 'role', 'description')
 
 
 @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.dispatch import receiver
 
@@ -26,12 +27,51 @@ def update_children_depth(prefix):
     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)
 def handle_prefix_saved(instance, created, **kwargs):
 
     # Prefix has changed (or new instance has been created)
     if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix:
 
+        update_ipaddress_prefix(instance)
         update_parents_children(instance)
         update_children_depth(instance)
 
@@ -42,11 +82,17 @@ def handle_prefix_saved(instance, created, **kwargs):
             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)
 def handle_prefix_deleted(instance, **kwargs):
 
     update_parents_children(instance)
     update_children_depth(instance)
+    update_ipaddress_prefix(instance, delete=True)
 
 
 @receiver(pre_delete, sender=IPAddress)

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

@@ -307,6 +307,10 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
         template_code=IPADDRESS_LINK,
         verbose_name=_('IP Address')
     )
+    prefix = tables.Column(
+        linkify=True,
+        verbose_name=_('Prefix')
+    )
     vrf = tables.TemplateColumn(
         template_code=VRF_LINK,
         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.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
         self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk})
 
         parent_prefix.vrf = vrfs[0]
         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
         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.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})
         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'
     tab = ViewTab(
         label=_('IP Addresses'),
-        badge=lambda x: x.get_child_ips().count(),
+        badge=lambda x: x.ip_addresses.count(),
         permission='ipam.view_ipaddress',
         weight=700
     )
 
     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):
         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>
                   <td>IPv{{ object.family }}</td>
               </tr>
+              <tr>
+                  <th scope="row">{% trans "Prefix" %}</th>
+                  <td>{{ object.prefix|linkify|placeholder }}</td>
+              </tr>
               <tr>
                   <th scope="row">{% trans "VRF" %}</th>
                   <td>

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

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

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

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