diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index ebfb29946..a52a6a03c 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -6,6 +6,7 @@ from netbox.api import WritableNestedSerializer __all__ = [ 'NestedAggregateSerializer', 'NestedIPAddressSerializer', + 'NestedIPRangeSerializer', 'NestedPrefixSerializer', 'NestedRIRSerializer', 'NestedRoleSerializer', @@ -109,6 +110,19 @@ class NestedPrefixSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'family', 'prefix', '_depth'] +# +# IP ranges +# + +class NestedIPRangeSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:iprange-detail') + family = serializers.IntegerField(read_only=True) + + class Meta: + model = models.IPRange + fields = ['id', 'url', 'display', 'family', 'start_address', 'end_address'] + + # # IP addresses # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index ff43e07b2..0d28cae1a 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -8,7 +8,7 @@ from rest_framework.validators import UniqueTogetherValidator from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +from ipam.models import * from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import OrganizationalModelSerializer from netbox.api.serializers import PrimaryModelSerializer @@ -255,6 +255,28 @@ class AvailablePrefixSerializer(serializers.Serializer): ]) +# +# IP ranges +# + +class IPRangeSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:iprange-detail') + family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) + vrf = NestedVRFSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) + status = ChoiceField(choices=IPRangeStatusChoices, required=False) + role = NestedRoleSerializer(required=False, allow_null=True) + children = serializers.IntegerField(read_only=True) + + class Meta: + model = IPRange + fields = [ + 'id', 'url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant', 'status', 'role', + 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'children', + ] + read_only_fields = ['family'] + + # # IP addresses # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 13a1bc770..06c4ab0ea 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -21,6 +21,9 @@ router.register('aggregates', views.AggregateViewSet) router.register('roles', views.RoleViewSet) router.register('prefixes', views.PrefixViewSet) +# IP ranges +router.register('ip-ranges', views.IPRangeViewSet) + # IP addresses router.register('ip-addresses', views.IPAddressViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f3f1335f7..110a9105c 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -11,7 +11,7 @@ from rest_framework.routers import APIRootView from extras.api.views import CustomFieldModelViewSet from ipam import filtersets -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +from ipam.models import * from netbox.api.views import ModelViewSet from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import count_related @@ -266,6 +266,16 @@ class PrefixViewSet(CustomFieldModelViewSet): return Response(serializer.data) +# +# IP ranges +# + +class IPRangeViewSet(CustomFieldModelViewSet): + queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags') + serializer_class = serializers.IPRangeSerializer + filterset_class = filtersets.IPRangeFilterSet + + # # IP addresses # diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 7eeb410a1..e3a45f577 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -39,7 +39,30 @@ class PrefixStatusChoices(ChoiceSet): # -# IPAddresses +# IP Ranges +# + +class IPRangeStatusChoices(ChoiceSet): + + STATUS_ACTIVE = 'active' + STATUS_RESERVED = 'reserved' + STATUS_DEPRECATED = 'deprecated' + + CHOICES = ( + (STATUS_ACTIVE, 'Active'), + (STATUS_RESERVED, 'Reserved'), + (STATUS_DEPRECATED, 'Deprecated'), + ) + + CSS_CLASSES = { + STATUS_ACTIVE: 'primary', + STATUS_RESERVED: 'info', + STATUS_DEPRECATED: 'danger', + } + + +# +# IP Addresses # class IPAddressStatusChoices(ChoiceSet): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index f9747f39d..9525d9bb3 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -14,12 +14,13 @@ from utilities.filters import ( ) from virtualization.models import VirtualMachine, VMInterface from .choices import * -from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +from .models import * __all__ = ( 'AggregateFilterSet', 'IPAddressFilterSet', + 'IPRangeFilterSet', 'PrefixFilterSet', 'RIRFilterSet', 'RoleFilterSet', @@ -375,6 +376,73 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) +class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + family = django_filters.NumberFilter( + field_name='start_address', + lookup_expr='family' + ) + contains = django_filters.CharFilter( + method='search_contains', + label='Ranges which contain this prefix or IP', + ) + vrf_id = django_filters.ModelMultipleChoiceFilter( + queryset=VRF.objects.all(), + label='VRF', + ) + vrf = django_filters.ModelMultipleChoiceFilter( + field_name='vrf__rd', + queryset=VRF.objects.all(), + to_field_name='rd', + label='VRF (RD)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + queryset=Role.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + field_name='role__slug', + queryset=Role.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + status = django_filters.MultipleChoiceFilter( + choices=IPRangeStatusChoices, + null_value=None + ) + tag = TagFilter() + + class Meta: + model = IPRange + fields = ['id'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(description__icontains=value) + try: + ipaddress = str(netaddr.IPNetwork(value.strip()).cidr) + qs_filter |= Q(start_address=ipaddress) + qs_filter |= Q(end_address=ipaddress) + except (AddrFormatError, ValueError): + pass + return queryset.filter(qs_filter) + + def search_contains(self, queryset, name, value): + value = value.strip() + if not value: + return queryset + try: + # Strip mask + ipaddress = netaddr.IPNetwork(value) + return queryset.filter(start_address__lte=ipaddress, end_address__gte=ipaddress) + except (AddrFormatError, ValueError): + return queryset.none() + + class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 00889a38a..d8209f1ba 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -18,7 +18,7 @@ from utilities.forms import ( from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface from .choices import * from .constants import * -from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +from .models import * PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([ (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1) @@ -696,6 +696,144 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter tag = TagFilterField(model) +# +# IP ranges +# + +class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = IPRange + fields = [ + 'vrf', 'start_address', 'end_address', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', + ] + fieldsets = ( + ('IP Range', ('vrf', 'start_address', 'end_address', 'role', 'status', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + widgets = { + 'status': StaticSelect2(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['vrf'].empty_label = 'Global' + + +class IPRangeCSVForm(CustomFieldModelCSVForm): + vrf = CSVModelChoiceField( + queryset=VRF.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned VRF' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + status = CSVChoiceField( + choices=IPRangeStatusChoices, + help_text='Operational status' + ) + role = CSVModelChoiceField( + queryset=Role.objects.all(), + required=False, + to_field_name='name', + help_text='Functional role' + ) + + class Meta: + model = IPRange + fields = ( + 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'description', + ) + + +class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=IPRange.objects.all(), + widget=forms.MultipleHiddenInput() + ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(IPRangeStatusChoices), + required=False, + widget=StaticSelect2() + ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'vrf', 'tenant', 'role', 'description', + ] + + +class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = IPRange + field_order = [ + 'family', 'vrf_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id', + ] + field_groups = [ + ['family', 'vrf_id', 'status', 'role_id'], + ['tenant_group_id', 'tenant_id', 'tag'], + ] + family = forms.ChoiceField( + required=False, + choices=add_blank_choice(IPAddressFamilyChoices), + label=_('Address family'), + widget=StaticSelect2() + ) + vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label=_('Assigned VRF'), + null_option='Global' + ) + status = forms.MultipleChoiceField( + choices=PrefixStatusChoices, + required=False, + widget=StaticSelect2Multiple() + ) + role_id = DynamicModelMultipleChoiceField( + queryset=Role.objects.all(), + required=False, + null_option='None', + label=_('Role') + ) + tag = TagFilterField(model) + + # # IP addresses # diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 0d4b931c7..58909e57f 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -11,6 +11,9 @@ class IPAMQuery(graphene.ObjectType): ip_address = ObjectField(IPAddressType) ip_address_list = ObjectListField(IPAddressType) + ip_range = ObjectField(IPRangeType) + ip_range_list = ObjectListField(IPRangeType) + prefix = ObjectField(PrefixType) prefix_list = ObjectListField(PrefixType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index be85150fa..2172d63fa 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -4,6 +4,7 @@ from netbox.graphql.types import ObjectType, TaggedObjectType __all__ = ( 'AggregateType', 'IPAddressType', + 'IPRangeType', 'PrefixType', 'RIRType', 'RoleType', @@ -34,6 +35,17 @@ class IPAddressType(TaggedObjectType): return self.role or None +class IPRangeType(TaggedObjectType): + + class Meta: + model = models.IPRange + fields = '__all__' + filterset_class = filtersets.IPRangeFilterSet + + def resolve_role(self, info): + return self.role or None + + class PrefixType(TaggedObjectType): class Meta: diff --git a/netbox/ipam/migrations/0050_iprange.py b/netbox/ipam/migrations/0050_iprange.py new file mode 100644 index 000000000..5b8861f29 --- /dev/null +++ b/netbox/ipam/migrations/0050_iprange.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.5 on 2021-07-16 14:15 + +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.expressions +import ipam.fields +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0061_extras_change_logging'), + ('tenancy', '0001_squashed_0012'), + ('ipam', '0049_prefix_mark_utilized'), + ] + + operations = [ + migrations.CreateModel( + name='IPRange', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('start_address', ipam.fields.IPAddressField()), + ('end_address', ipam.fields.IPAddressField()), + ('size', models.PositiveIntegerField(editable=False)), + ('status', models.CharField(default='active', max_length=50)), + ('description', models.CharField(blank=True, max_length=200)), + ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ip_ranges', to='ipam.role')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_ranges', to='tenancy.tenant')), + ('vrf', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_ranges', to='ipam.vrf')), + ], + options={ + 'verbose_name': 'IP range', + 'verbose_name_plural': 'IP ranges', + 'ordering': (django.db.models.expressions.OrderBy(django.db.models.expressions.F('vrf'), nulls_first=True), 'start_address', 'pk'), + }, + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index 7eecf8cfc..cb8b4b932 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -6,6 +6,7 @@ from .vrfs import * __all__ = ( 'Aggregate', 'IPAddress', + 'IPRange', 'Prefix', 'RIR', 'Role', diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 40fa4ad42..8f93c81c1 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -4,8 +4,9 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models -from django.db.models import F +from django.db.models import F, Q from django.urls import reverse +from django.utils.functional import cached_property from dcim.models import Device from extras.utils import extras_features @@ -23,6 +24,7 @@ from virtualization.models import VirtualMachine __all__ = ( 'Aggregate', 'IPAddress', + 'IPRange', 'Prefix', 'RIR', 'Role', @@ -475,6 +477,193 @@ class Prefix(PrimaryModel): return int(float(child_count) / prefix_size * 100) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class IPRange(PrimaryModel): + """ + A range of IP addresses, defined by start and end addresses. + """ + start_address = IPAddressField( + help_text='IPv4 or IPv6 address (with mask)' + ) + end_address = IPAddressField( + help_text='IPv4 or IPv6 address (with mask)' + ) + size = models.PositiveIntegerField( + editable=False + ) + vrf = models.ForeignKey( + to='ipam.VRF', + on_delete=models.PROTECT, + related_name='ip_ranges', + blank=True, + null=True, + verbose_name='VRF' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='ip_ranges', + blank=True, + null=True + ) + status = models.CharField( + max_length=50, + choices=IPRangeStatusChoices, + default=IPRangeStatusChoices.STATUS_ACTIVE, + help_text='Operational status of this range' + ) + role = models.ForeignKey( + to='ipam.Role', + on_delete=models.SET_NULL, + related_name='ip_ranges', + blank=True, + null=True, + help_text='The primary function of this range' + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + clone_fields = [ + 'vrf', 'tenant', 'status', 'role', 'description', + ] + + class Meta: + ordering = (F('vrf').asc(nulls_first=True), 'start_address', 'pk') # (vrf, start_address) may be non-unique + verbose_name = 'IP range' + verbose_name_plural = 'IP ranges' + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('ipam:iprange', args=[self.pk]) + + def clean(self): + super().clean() + + if self.start_address and self.end_address: + + # Check that start & end IP versions match + if self.start_address.version != self.end_address.version: + raise ValidationError({ + 'end_address': f"Ending address version (IPv{self.end_address.version}) does not match starting " + f"address (IPv{self.start_address.version})" + }) + + # Check that the start & end IP prefix lengths match + if self.start_address.prefixlen != self.end_address.prefixlen: + raise ValidationError({ + 'end_address': f"Ending address mask (/{self.end_address.prefixlen}) does not match starting " + f"address mask (/{self.start_address.prefixlen})" + }) + + # Check that the ending address is greater than the starting address + if not self.end_address > self.start_address: + raise ValidationError({ + 'end_address': f"Ending address must be lower than the starting address ({self.start_address})" + }) + + # Check for overlapping ranges + overlapping_range = IPRange.objects.exclude(pk=self.pk).filter(vrf=self.vrf).filter( + Q(start_address__gte=self.start_address, start_address__lte=self.end_address) | # Starts inside + Q(end_address__gte=self.start_address, end_address__lte=self.end_address) | # Ends inside + Q(start_address__lte=self.start_address, end_address__gte=self.end_address) # Starts & ends outside + ).first() + if overlapping_range: + raise ValidationError(f"Defined addresses overlap with range {overlapping_range} in VRF {self.vrf}") + + def save(self, *args, **kwargs): + + # Record the range's size (number of IP addresses) + self.size = int(self.end_address.ip - self.start_address.ip) + 1 + + super().save(*args, **kwargs) + + @property + def family(self): + if self.start_address: + return self.start_address.version + return None + + @cached_property + def name(self): + """ + Return an efficient string representation of the IP range. + """ + separator = ':' if self.family == 6 else '.' + start_chunks = str(self.start_address.ip).split(separator) + end_chunks = str(self.end_address.ip).split(separator) + + base_chunks = [] + for a, b in zip(start_chunks, end_chunks): + if a == b: + base_chunks.append(a) + + base_str = separator.join(base_chunks) + start_str = separator.join(start_chunks[len(base_chunks):]) + end_str = separator.join(end_chunks[len(base_chunks):]) + + return f'{base_str}{separator}{start_str}-{end_str}/{self.start_address.prefixlen}' + + def _set_prefix_length(self, value): + """ + Expose the IPRange object's prefixlen attribute on the parent model so that it can be manipulated directly, + e.g. for bulk editing. + """ + self.start_address.prefixlen = value + self.end_address.prefixlen = value + prefix_length = property(fset=_set_prefix_length) + + def get_status_class(self): + return IPRangeStatusChoices.CSS_CLASSES.get(self.status) + + def get_child_ips(self): + """ + Return all IPAddresses within this IPRange and VRF. + """ + return IPAddress.objects.filter( + address__gte=self.start_address, + address__lte=self.end_address, + vrf=self.vrf + ) + + def get_available_ips(self): + """ + Return all available IPs within this range as an IPSet. + """ + range = netaddr.IPRange(self.start_address.ip, self.end_address.ip) + child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]) + + return netaddr.IPSet(range) - child_ips + + @cached_property + def first_available_ip(self): + """ + Return the first available IP within the range (or None). + """ + available_ips = self.get_available_ips() + if not available_ips: + return None + + return '{}/{}'.format(next(available_ips.__iter__()), self.start_address.prefixlen) + + @cached_property + def utilization(self): + """ + Determine the utilization of the range and return it as a percentage. + """ + # Compile an IPSet to avoid counting duplicate IPs + child_count = netaddr.IPSet([ + ip.address.ip for ip in self.get_child_ips() + ]).size + + return int(float(child_count) / self.size * 100) + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class IPAddress(PrimaryModel): """ diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 0ddc8697e..18b147b75 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -9,7 +9,7 @@ from utilities.tables import ( ToggleColumn, UtilizationColumn, ) from virtualization.models import VMInterface -from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +from .models import * AVAILABLE_LABEL = mark_safe('Available') @@ -351,6 +351,39 @@ class PrefixDetailTable(PrefixTable): ) +# +# IP ranges +# +class IPRangeTable(BaseTable): + pk = ToggleColumn() + start_address = tables.Column( + linkify=True + ) + vrf = tables.TemplateColumn( + template_code=VRF_LINK, + verbose_name='VRF' + ) + status = ChoiceFieldColumn( + default=AVAILABLE_LABEL + ) + role = tables.TemplateColumn( + template_code=PREFIX_ROLE_LINK + ) + tenant = TenantColumn() + + class Meta(BaseTable.Meta): + model = IPRange + fields = ( + 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', + ) + default_columns = ( + 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', + ) + row_attrs = { + 'class': lambda record: 'success' if not record.pk else '', + } + + # # IPAddresses # diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 38339fa14..f1ad4cf9f 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -6,7 +6,7 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +from ipam.models import * from utilities.testing import APITestCase, APIViewTestCases, disable_warnings @@ -358,6 +358,38 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): self.assertEqual(len(response.data), 8) +class IPRangeTest(APIViewTestCases.APIViewTestCase): + model = IPRange + brief_fields = ['display', 'end_address', 'family', 'id', 'start_address', 'url'] + create_data = [ + { + 'start_address': '192.168.4.10/24', + 'end_address': '192.168.4.50/24', + }, + { + 'start_address': '192.168.5.10/24', + 'end_address': '192.168.5.50/24', + }, + { + 'start_address': '192.168.6.10/24', + 'end_address': '192.168.6.50/24', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + ip_ranges = ( + IPRange(start_address=IPNetwork('192.168.1.10/24'), end_address=IPNetwork('192.168.1.50/24'), size=51), + IPRange(start_address=IPNetwork('192.168.2.10/24'), end_address=IPNetwork('192.168.2.50/24'), size=51), + IPRange(start_address=IPNetwork('192.168.3.10/24'), end_address=IPNetwork('192.168.3.50/24'), size=51), + ) + IPRange.objects.bulk_create(ip_ranges) + + class IPAddressTest(APIViewTestCases.APIViewTestCase): model = IPAddress brief_fields = ['address', 'display', 'family', 'id', 'url'] diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index bfb61387e..c82848aef 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -3,7 +3,7 @@ from django.test import TestCase from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup from ipam.choices import * from ipam.filtersets import * -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +from ipam.models import * from utilities.testing import ChangeLoggedFilterSetTests from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from tenancy.models import Tenant, TenantGroup @@ -524,6 +524,97 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) +class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = IPRange.objects.all() + filterset = IPRangeFilterSet + + @classmethod + def setUpTestData(cls): + + vrfs = ( + VRF(name='VRF 1', rd='65000:100'), + VRF(name='VRF 2', rd='65000:200'), + VRF(name='VRF 3', rd='65000:300'), + ) + VRF.objects.bulk_create(vrfs) + + roles = ( + Role(name='Role 1', slug='role-1'), + Role(name='Role 2', slug='role-2'), + Role(name='Role 3', slug='role-3'), + ) + Role.objects.bulk_create(roles) + + tenant_groups = ( + TenantGroup(name='Tenant group 1', slug='tenant-group-1'), + TenantGroup(name='Tenant group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant group 3', slug='tenant-group-3'), + ) + for tenantgroup in tenant_groups: + tenantgroup.save() + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), + Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]), + Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]), + ) + Tenant.objects.bulk_create(tenants) + + ip_ranges = ( + IPRange(start_address='10.0.1.100/24', end_address='10.0.1.199/24', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE), + IPRange(start_address='10.0.2.100/24', end_address='10.0.2.199/24', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE), + IPRange(start_address='10.0.3.100/24', end_address='10.0.3.199/24', size=100, vrf=vrfs[1], tenant=tenants[1], role=roles[1], status=IPRangeStatusChoices.STATUS_DEPRECATED), + IPRange(start_address='10.0.4.100/24', end_address='10.0.4.199/24', size=100, vrf=vrfs[2], tenant=tenants[2], role=roles[2], status=IPRangeStatusChoices.STATUS_RESERVED), + IPRange(start_address='2001:db8:0:1::1/64', end_address='2001:db8:0:1::100/64', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE), + IPRange(start_address='2001:db8:0:2::1/64', end_address='2001:db8:0:2::100/64', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE), + IPRange(start_address='2001:db8:0:3::1/64', end_address='2001:db8:0:3::100/64', size=100, vrf=vrfs[1], tenant=tenants[1], role=roles[1], status=IPRangeStatusChoices.STATUS_DEPRECATED), + IPRange(start_address='2001:db8:0:4::1/64', end_address='2001:db8:0:4::100/64', size=100, vrf=vrfs[2], tenant=tenants[2], role=roles[2], status=IPRangeStatusChoices.STATUS_RESERVED), + ) + IPRange.objects.bulk_create(ip_ranges) + + def test_family(self): + params = {'family': '6'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_contains(self): + params = {'contains': '10.0.1.150/24'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'contains': '2001:db8:0:1::50/64'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_vrf(self): + vrfs = VRF.objects.all()[:2] + params = {'vrf_id': [vrfs[0].pk, vrfs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'vrf': [vrfs[0].rd, vrfs[1].rd]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_role(self): + roles = Role.objects.all()[:2] + params = {'role_id': [roles[0].pk, roles[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'role': [roles[0].slug, roles[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_status(self): + params = {'status': [PrefixStatusChoices.STATUS_DEPRECATED, PrefixStatusChoices.STATUS_RESERVED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_tenant(self): + tenants = Tenant.objects.all()[:2] + params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_tenant_group(self): + tenant_groups = TenantGroup.objects.all()[:2] + params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = IPAddress.objects.all() filterset = IPAddressFilterSet diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 057f9383f..471aa471d 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -4,7 +4,7 @@ from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * -from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +from ipam.models import * from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags @@ -259,6 +259,64 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = IPRange + + @classmethod + def setUpTestData(cls): + + vrfs = ( + VRF(name='VRF 1', rd='65000:1'), + VRF(name='VRF 2', rd='65000:2'), + ) + VRF.objects.bulk_create(vrfs) + + roles = ( + Role(name='Role 1', slug='role-1'), + Role(name='Role 2', slug='role-2'), + ) + Role.objects.bulk_create(roles) + + ip_ranges = ( + IPRange(start_address='192.168.0.10/24', end_address='192.168.0.100/24', size=91), + IPRange(start_address='192.168.1.10/24', end_address='192.168.1.100/24', size=91), + IPRange(start_address='192.168.2.10/24', end_address='192.168.2.100/24', size=91), + IPRange(start_address='192.168.3.10/24', end_address='192.168.3.100/24', size=91), + IPRange(start_address='192.168.4.10/24', end_address='192.168.4.100/24', size=91), + ) + IPRange.objects.bulk_create(ip_ranges) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'start_address': IPNetwork('192.0.5.10/24'), + 'end_address': IPNetwork('192.0.5.100/24'), + 'vrf': vrfs[1].pk, + 'tenant': None, + 'vlan': None, + 'status': IPRangeStatusChoices.STATUS_RESERVED, + 'role': roles[1].pk, + 'is_pool': True, + 'description': 'A new IP range', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "vrf,start_address,end_address,status", + "VRF 1,10.1.0.1/16,10.1.9.254/16,active", + "VRF 1,10.2.0.1/16,10.2.9.254/16,active", + "VRF 1,10.3.0.1/16,10.3.9.254/16,active", + ) + + cls.bulk_edit_data = { + 'vrf': vrfs[1].pk, + 'tenant': None, + 'status': IPRangeStatusChoices.STATUS_RESERVED, + 'role': roles[1].pk, + 'description': 'New description', + } + + class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = IPAddress diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 262537b8b..5a5cf21ac 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -2,7 +2,7 @@ from django.urls import path from extras.views import ObjectChangeLogView, ObjectJournalView from . import views -from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +from .models import * app_name = 'ipam' urlpatterns = [ @@ -79,6 +79,19 @@ urlpatterns = [ path('prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), path('prefixes//ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), + # IP ranges + path('ip-ranges/', views.IPRangeListView.as_view(), name='iprange_list'), + path('ip-ranges/add/', views.IPRangeEditView.as_view(), name='iprange_add'), + path('ip-ranges/import/', views.IPRangeBulkImportView.as_view(), name='iprange_import'), + path('ip-ranges/edit/', views.IPRangeBulkEditView.as_view(), name='iprange_bulk_edit'), + path('ip-ranges/delete/', views.IPRangeBulkDeleteView.as_view(), name='iprange_bulk_delete'), + path('ip-ranges//', views.IPRangeView.as_view(), name='iprange'), + path('ip-ranges//edit/', views.IPRangeEditView.as_view(), name='iprange_edit'), + path('ip-ranges//delete/', views.IPRangeDeleteView.as_view(), name='iprange_delete'), + path('ip-ranges//changelog/', ObjectChangeLogView.as_view(), name='iprange_changelog', kwargs={'model': IPRange}), + path('ip-ranges//journal/', ObjectJournalView.as_view(), name='iprange_journal', kwargs={'model': IPRange}), + path('ip-ranges//ip-addresses/', views.IPRangeIPAddressesView.as_view(), name='iprange_ipaddresses'), + # IP addresses path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), path('ip-addresses/add/', views.IPAddressEditView.as_view(), name='ipaddress_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 95546fcc6..636ebdd29 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -9,7 +9,7 @@ from utilities.utils import count_related from virtualization.models import VirtualMachine, VMInterface from . import filtersets, forms, tables from .constants import * -from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF +from .models import * from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans @@ -503,6 +503,83 @@ class PrefixBulkDeleteView(generic.BulkDeleteView): table = tables.PrefixTable +# +# IP Ranges +# + +class IPRangeListView(generic.ObjectListView): + queryset = IPRange.objects.all() + filterset = filtersets.IPRangeFilterSet + filterset_form = forms.IPRangeFilterForm + table = tables.IPRangeTable + + +class IPRangeView(generic.ObjectView): + queryset = IPRange.objects.all() + + +class IPRangeIPAddressesView(generic.ObjectView): + queryset = IPRange.objects.all() + template_name = 'ipam/iprange/ip_addresses.html' + + def get_extra_context(self, request, instance): + # Find all IPAddresses within this range + ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related( + 'vrf', 'primary_ip4_for', 'primary_ip6_for' + ) + + # Add available IP addresses to the table if requested + # if request.GET.get('show_available', 'true') == 'true': + # ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool) + + ip_table = tables.IPAddressTable(ipaddresses) + if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): + ip_table.columns.show('pk') + paginate_table(ip_table, request) + + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_ipaddress'), + 'change': request.user.has_perm('ipam.change_ipaddress'), + 'delete': request.user.has_perm('ipam.delete_ipaddress'), + } + + return { + 'ip_table': ip_table, + 'permissions': permissions, + 'active_tab': 'ip-addresses', + 'show_available': request.GET.get('show_available', 'true') == 'true', + } + + +class IPRangeEditView(generic.ObjectEditView): + queryset = IPRange.objects.all() + model_form = forms.IPRangeForm + + +class IPRangeDeleteView(generic.ObjectDeleteView): + queryset = IPRange.objects.all() + + +class IPRangeBulkImportView(generic.BulkImportView): + queryset = IPRange.objects.all() + model_form = forms.IPRangeCSVForm + table = tables.IPRangeTable + + +class IPRangeBulkEditView(generic.BulkEditView): + queryset = IPRange.objects.prefetch_related('vrf', 'tenant') + filterset = filtersets.IPRangeFilterSet + table = tables.IPRangeTable + form = forms.IPRangeBulkEditForm + + +class IPRangeBulkDeleteView(generic.BulkDeleteView): + queryset = IPRange.objects.prefetch_related('vrf', 'tenant') + filterset = filtersets.IPRangeFilterSet + table = tables.IPRangeTable + + # # IP addresses # diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 214122f6e..6e8be9043 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -153,6 +153,8 @@ IPAM_MENU = Menu( MenuGroup( label="IP Addresses", items=( + MenuItem(label="IP Ranges", url="ipam:iprange_list", + add_url="ipam:iprange_add", import_url="ipam:iprange_import"), MenuItem(label="IP Addresses", url="ipam:ipaddress_list", add_url="ipam:ipaddress_add", import_url="ipam:ipaddress_import"), ), diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index 81bd9ae18..df5bda4e4 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -21,7 +21,7 @@ from dcim.models import ( ) from extras.choices import JobResultStatusChoices from extras.models import ObjectChange, JobResult -from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES from netbox.forms import SearchForm from tenancy.models import Tenant @@ -68,6 +68,7 @@ class HomeView(View): ("ipam.view_vrf", "VRFs", VRF.objects.restrict(request.user, 'view').count), ("ipam.view_aggregate", "Aggregates", Aggregate.objects.restrict(request.user, 'view').count), ("ipam.view_prefix", "Prefixes", Prefix.objects.restrict(request.user, 'view').count), + ("ipam.view_iprange", "IP Ranges", IPRange.objects.restrict(request.user, 'view').count), ("ipam.view_ipaddress", "IP Addresses", IPAddress.objects.restrict(request.user, 'view').count), ("ipam.view_vlan", "VLANs", VLAN.objects.restrict(request.user, 'view').count) diff --git a/netbox/templates/ipam/iprange.html b/netbox/templates/ipam/iprange.html new file mode 100644 index 000000000..729f1ed42 --- /dev/null +++ b/netbox/templates/ipam/iprange.html @@ -0,0 +1,95 @@ +{% extends 'ipam/iprange/base.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
+ IP Range +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FamilyIPv{{ object.family }}
Starting Address{{ object.start_address }}
Ending Address{{ object.end_address }}
Size{{ object.size }}
Utilization + {% utilization_graph object.utilization %} +
VRF + {% if object.vrf %} + {{ object.vrf }} ({{ object.vrf.rd }}) + {% else %} + Global + {% endif %} +
Role + {% if object.role %} + {{ object.role }} + {% else %} + None + {% endif %} +
Status + {{ object.get_status_display }} +
Tenant + {% if object.tenant %} + {% if object.tenant.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
Description{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='ipam:prefix_list' %} + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/iprange/base.html b/netbox/templates/ipam/iprange/base.html new file mode 100644 index 000000000..9924a6b6b --- /dev/null +++ b/netbox/templates/ipam/iprange/base.html @@ -0,0 +1,32 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} + +{% block breadcrumbs %} + + {% if object.vrf %} + + {% endif %} + +{% endblock %} + +{% block tab_items %} + + {% if perms.ipam.view_ipaddress %} + + {% endif %} +{% endblock %} diff --git a/netbox/templates/ipam/iprange/ip_addresses.html b/netbox/templates/ipam/iprange/ip_addresses.html new file mode 100644 index 000000000..c89007c25 --- /dev/null +++ b/netbox/templates/ipam/iprange/ip_addresses.html @@ -0,0 +1,18 @@ +{% extends 'ipam/iprange/base.html' %} + +{% block extra_controls %} + {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and object.first_available_ip %} + + + Add an IP Address + + {% endif %} +{% endblock %} + +{% block content %} +
+
+ {% include 'utilities/obj_table.html' with table=ip_table heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %} +
+
+{% endblock %} diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 83958225a..7fa9f66bc 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -440,12 +440,8 @@ class ViewTestCases: response = self.client.get(self._get_url('list')) self.assertHttpStatus(response, 200) content = str(response.content) - if hasattr(self.model, 'name'): - self.assertIn(instance1.name, content) - self.assertNotIn(instance2.name, content) - elif hasattr(self.model, 'get_absolute_url'): - self.assertIn(instance1.get_absolute_url(), content) - self.assertNotIn(instance2.get_absolute_url(), content) + self.assertIn(instance1.get_absolute_url(), content) + self.assertNotIn(instance2.get_absolute_url(), content) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_export_objects(self): @@ -641,7 +637,7 @@ class ViewTestCases: @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_bulk_edit_objects_with_permission(self): - pk_list = self._get_queryset().values_list('pk', flat=True)[:3] + pk_list = list(self._get_queryset().values_list('pk', flat=True)[:3]) data = { 'pk': pk_list, '_apply': True, # Form button