jeremystretch 4 лет назад
Родитель
Сommit
11a14927c9

+ 14 - 0
netbox/ipam/api/nested_serializers.py

@@ -6,6 +6,7 @@ from netbox.api import WritableNestedSerializer
 __all__ = [
 __all__ = [
     'NestedAggregateSerializer',
     'NestedAggregateSerializer',
     'NestedIPAddressSerializer',
     'NestedIPAddressSerializer',
+    'NestedIPRangeSerializer',
     'NestedPrefixSerializer',
     'NestedPrefixSerializer',
     'NestedRIRSerializer',
     'NestedRIRSerializer',
     'NestedRoleSerializer',
     'NestedRoleSerializer',
@@ -109,6 +110,19 @@ class NestedPrefixSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'family', 'prefix', '_depth']
         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
 # IP addresses
 #
 #

+ 23 - 1
netbox/ipam/api/serializers.py

@@ -8,7 +8,7 @@ from rest_framework.validators import UniqueTogetherValidator
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
 from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
 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 import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.serializers import OrganizationalModelSerializer
 from netbox.api.serializers import OrganizationalModelSerializer
 from netbox.api.serializers import PrimaryModelSerializer
 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
 # IP addresses
 #
 #

+ 3 - 0
netbox/ipam/api/urls.py

@@ -21,6 +21,9 @@ router.register('aggregates', views.AggregateViewSet)
 router.register('roles', views.RoleViewSet)
 router.register('roles', views.RoleViewSet)
 router.register('prefixes', views.PrefixViewSet)
 router.register('prefixes', views.PrefixViewSet)
 
 
+# IP ranges
+router.register('ip-ranges', views.IPRangeViewSet)
+
 # IP addresses
 # IP addresses
 router.register('ip-addresses', views.IPAddressViewSet)
 router.register('ip-addresses', views.IPAddressViewSet)
 
 

+ 11 - 1
netbox/ipam/api/views.py

@@ -11,7 +11,7 @@ from rest_framework.routers import APIRootView
 
 
 from extras.api.views import CustomFieldModelViewSet
 from extras.api.views import CustomFieldModelViewSet
 from ipam import filtersets
 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 netbox.api.views import ModelViewSet
 from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.utils import count_related
 from utilities.utils import count_related
@@ -266,6 +266,16 @@ class PrefixViewSet(CustomFieldModelViewSet):
             return Response(serializer.data)
             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
 # IP addresses
 #
 #

+ 24 - 1
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):
 class IPAddressStatusChoices(ChoiceSet):

+ 69 - 1
netbox/ipam/filtersets.py

@@ -14,12 +14,13 @@ from utilities.filters import (
 )
 )
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
 from .choices import *
-from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
+from .models import *
 
 
 
 
 __all__ = (
 __all__ = (
     'AggregateFilterSet',
     'AggregateFilterSet',
     'IPAddressFilterSet',
     'IPAddressFilterSet',
+    'IPRangeFilterSet',
     'PrefixFilterSet',
     'PrefixFilterSet',
     'RIRFilterSet',
     'RIRFilterSet',
     'RoleFilterSet',
     '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):
 class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',

+ 139 - 1
netbox/ipam/forms.py

@@ -18,7 +18,7 @@ from utilities.forms import (
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
 from .choices import *
 from .choices import *
 from .constants 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([
 PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([
     (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1)
     (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)
     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
 # IP addresses
 #
 #

+ 3 - 0
netbox/ipam/graphql/schema.py

@@ -11,6 +11,9 @@ class IPAMQuery(graphene.ObjectType):
     ip_address = ObjectField(IPAddressType)
     ip_address = ObjectField(IPAddressType)
     ip_address_list = ObjectListField(IPAddressType)
     ip_address_list = ObjectListField(IPAddressType)
 
 
+    ip_range = ObjectField(IPRangeType)
+    ip_range_list = ObjectListField(IPRangeType)
+
     prefix = ObjectField(PrefixType)
     prefix = ObjectField(PrefixType)
     prefix_list = ObjectListField(PrefixType)
     prefix_list = ObjectListField(PrefixType)
 
 

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

@@ -4,6 +4,7 @@ from netbox.graphql.types import ObjectType, TaggedObjectType
 __all__ = (
 __all__ = (
     'AggregateType',
     'AggregateType',
     'IPAddressType',
     'IPAddressType',
+    'IPRangeType',
     'PrefixType',
     'PrefixType',
     'RIRType',
     'RIRType',
     'RoleType',
     'RoleType',
@@ -34,6 +35,17 @@ class IPAddressType(TaggedObjectType):
         return self.role or None
         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 PrefixType(TaggedObjectType):
 
 
     class Meta:
     class Meta:

+ 43 - 0
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'),
+            },
+        ),
+    ]

+ 1 - 0
netbox/ipam/models/__init__.py

@@ -6,6 +6,7 @@ from .vrfs import *
 __all__ = (
 __all__ = (
     'Aggregate',
     'Aggregate',
     'IPAddress',
     'IPAddress',
+    'IPRange',
     'Prefix',
     'Prefix',
     'RIR',
     'RIR',
     'Role',
     'Role',

+ 190 - 1
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.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 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.urls import reverse
+from django.utils.functional import cached_property
 
 
 from dcim.models import Device
 from dcim.models import Device
 from extras.utils import extras_features
 from extras.utils import extras_features
@@ -23,6 +24,7 @@ from virtualization.models import VirtualMachine
 __all__ = (
 __all__ = (
     'Aggregate',
     'Aggregate',
     'IPAddress',
     'IPAddress',
+    'IPRange',
     'Prefix',
     'Prefix',
     'RIR',
     'RIR',
     'Role',
     'Role',
@@ -475,6 +477,193 @@ class Prefix(PrimaryModel):
             return int(float(child_count) / prefix_size * 100)
             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')
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class IPAddress(PrimaryModel):
 class IPAddress(PrimaryModel):
     """
     """

+ 34 - 1
netbox/ipam/tables.py

@@ -9,7 +9,7 @@ from utilities.tables import (
     ToggleColumn, UtilizationColumn,
     ToggleColumn, UtilizationColumn,
 )
 )
 from virtualization.models import VMInterface
 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('<span class="badge bg-success">Available</span>')
 AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
 
 
@@ -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
 # IPAddresses
 #
 #

+ 33 - 1
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 dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from ipam.choices import *
 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
 from utilities.testing import APITestCase, APIViewTestCases, disable_warnings
 
 
 
 
@@ -358,6 +358,38 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
         self.assertEqual(len(response.data), 8)
         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):
 class IPAddressTest(APIViewTestCases.APIViewTestCase):
     model = IPAddress
     model = IPAddress
     brief_fields = ['address', 'display', 'family', 'id', 'url']
     brief_fields = ['address', 'display', 'family', 'id', 'url']

+ 92 - 1
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 dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup
 from ipam.choices import *
 from ipam.choices import *
 from ipam.filtersets 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 utilities.testing import ChangeLoggedFilterSetTests
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
@@ -524,6 +524,97 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         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):
 class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = IPAddress.objects.all()
     queryset = IPAddress.objects.all()
     filterset = IPAddressFilterSet
     filterset = IPAddressFilterSet

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

@@ -4,7 +4,7 @@ from netaddr import IPNetwork
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from ipam.choices import *
 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 tenancy.models import Tenant
 from utilities.testing import ViewTestCases, create_tags
 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):
 class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = IPAddress
     model = IPAddress
 
 

+ 14 - 1
netbox/ipam/urls.py

@@ -2,7 +2,7 @@ from django.urls import path
 
 
 from extras.views import ObjectChangeLogView, ObjectJournalView
 from extras.views import ObjectChangeLogView, ObjectJournalView
 from . import views
 from . import views
-from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
+from .models import *
 
 
 app_name = 'ipam'
 app_name = 'ipam'
 urlpatterns = [
 urlpatterns = [
@@ -79,6 +79,19 @@ urlpatterns = [
     path('prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
     path('prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
     path('prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
     path('prefixes/<int:pk>/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/<int:pk>/', views.IPRangeView.as_view(), name='iprange'),
+    path('ip-ranges/<int:pk>/edit/', views.IPRangeEditView.as_view(), name='iprange_edit'),
+    path('ip-ranges/<int:pk>/delete/', views.IPRangeDeleteView.as_view(), name='iprange_delete'),
+    path('ip-ranges/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='iprange_changelog', kwargs={'model': IPRange}),
+    path('ip-ranges/<int:pk>/journal/', ObjectJournalView.as_view(), name='iprange_journal', kwargs={'model': IPRange}),
+    path('ip-ranges/<int:pk>/ip-addresses/', views.IPRangeIPAddressesView.as_view(), name='iprange_ipaddresses'),
+
     # IP addresses
     # IP addresses
     path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
     path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
     path('ip-addresses/add/', views.IPAddressEditView.as_view(), name='ipaddress_add'),
     path('ip-addresses/add/', views.IPAddressEditView.as_view(), name='ipaddress_add'),

+ 78 - 1
netbox/ipam/views.py

@@ -9,7 +9,7 @@ from utilities.utils import count_related
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .constants import *
 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
 from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans
 
 
 
 
@@ -503,6 +503,83 @@ class PrefixBulkDeleteView(generic.BulkDeleteView):
     table = tables.PrefixTable
     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
 # IP addresses
 #
 #

+ 2 - 0
netbox/netbox/navigation_menu.py

@@ -153,6 +153,8 @@ IPAM_MENU = Menu(
         MenuGroup(
         MenuGroup(
             label="IP Addresses",
             label="IP Addresses",
             items=(
             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",
                 MenuItem(label="IP Addresses", url="ipam:ipaddress_list",
                          add_url="ipam:ipaddress_add", import_url="ipam:ipaddress_import"),
                          add_url="ipam:ipaddress_add", import_url="ipam:ipaddress_import"),
             ),
             ),

+ 2 - 1
netbox/netbox/views/__init__.py

@@ -21,7 +21,7 @@ from dcim.models import (
 )
 )
 from extras.choices import JobResultStatusChoices
 from extras.choices import JobResultStatusChoices
 from extras.models import ObjectChange, JobResult
 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.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
 from netbox.forms import SearchForm
 from netbox.forms import SearchForm
 from tenancy.models import Tenant
 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_vrf", "VRFs", VRF.objects.restrict(request.user, 'view').count),
                 ("ipam.view_aggregate", "Aggregates", Aggregate.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_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_ipaddress", "IP Addresses", IPAddress.objects.restrict(request.user, 'view').count),
                 ("ipam.view_vlan", "VLANs", VLAN.objects.restrict(request.user, 'view').count)
                 ("ipam.view_vlan", "VLANs", VLAN.objects.restrict(request.user, 'view').count)
 
 

+ 95 - 0
netbox/templates/ipam/iprange.html

@@ -0,0 +1,95 @@
+{% extends 'ipam/iprange/base.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block content %}
+<div class="row">
+    <div class="col col-md-6">
+        <div class="card">
+            <h5 class="card-header">
+              IP Range
+            </h5>
+            <div class="card-body">
+              <table class="table table-hover attr-table">
+                <tr>
+                    <th scope="row">Family</th>
+                    <td>IPv{{ object.family }}</td>
+                </tr>
+                <tr>
+                    <th scope="row">Starting Address</th>
+                    <td>{{ object.start_address }}</td>
+                </tr>
+                <tr>
+                    <th scope="row">Ending Address</th>
+                    <td>{{ object.end_address }}</td>
+                </tr>
+                <tr>
+                    <th scope="row">Size</th>
+                    <td>{{ object.size }}</td>
+                </tr>
+                <tr>
+                    <th scope="row">Utilization</th>
+                    <td>
+                      {% utilization_graph object.utilization %}
+                    </td>
+                </tr>
+                <tr>
+                    <th scope="row">VRF</th>
+                    <td>
+                        {% if object.vrf %}
+                            <a href="{{ object.vrf.get_absolute_url }}">{{ object.vrf }}</a> ({{ object.vrf.rd }})
+                        {% else %}
+                            <span>Global</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <th scope="row">Role</th>
+                    <td>
+                        {% if object.role %}
+                            <a href="{{ object.role.get_absolute_url }}">{{ object.role }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <th scope="row">Status</th>
+                    <td>
+                        <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
+                    </td>
+                </tr>
+                <tr>
+                    <th scope="row">Tenant</th>
+                    <td>
+                        {% if object.tenant %}
+                            {% if object.tenant.group %}
+                                <a href="{{ object.tenant.group.get_absolute_url }}">{{ object.tenant.group }}</a> /
+                            {% endif %}
+                            <a href="{{ object.tenant.get_absolute_url }}">{{ object.tenant }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <th scope="row">Description</th>
+                    <td>{{ object.description|placeholder }}</td>
+                </tr>
+              </table>
+            </div>
+        </div>
+        {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+        {% 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 %}
+    </div>
+</div>
+<div class="row">
+    <div class="col col-md-12">
+        {% plugin_full_width_page object %}
+    </div>
+</div>
+{% endblock %}

+ 32 - 0
netbox/templates/ipam/iprange/base.html

@@ -0,0 +1,32 @@
+{% extends 'generic/object.html' %}
+{% load buttons %}
+{% load helpers %}
+
+{% block breadcrumbs %}
+  <li class="breadcrumb-item">
+    <a href="{% url 'ipam:iprange_list' %}">IP Ranges</a>
+  </li>
+  {% if object.vrf %}
+    <li class="breadcrumb-item">
+      <a href="{% url 'ipam:vrf' pk=object.vrf.pk %}">{{ object.vrf }}</a>
+    </li>
+  {% endif %}
+  <li class="breadcrumb-item">
+    {{ object }}
+  </li>
+{% endblock %}
+
+{% block tab_items %}
+  <li role="presentation" class="nav-item">
+    <a class="nav-link{% if not active_tab %} active{% endif %}" href="{{ object.get_absolute_url }}">
+      IP Range
+    </a>
+  </li>
+  {% if perms.ipam.view_ipaddress %}
+    <li role="presentation" class="nav-item">
+      <a class="nav-link{% if active_tab == 'ip-addresses' %} active{% endif %}" href="{% url 'ipam:iprange_ipaddresses' pk=object.pk %}">
+        IP Addresses <span class="badge bg-primary">{{ object.get_child_ips.count }}</span>
+      </a>
+    </li>
+  {% endif %}
+{% endblock %}

+ 18 - 0
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 %}
+    <a href="{% url 'ipam:ipaddress_add' %}?address={{ object.first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-outline-success m-1">
+        <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
+        Add an IP Address
+    </a>
+  {% endif %}
+{% endblock %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-12">
+      {% 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' %}
+    </div>
+  </div>
+{% endblock %}

+ 3 - 7
netbox/utilities/testing/views.py

@@ -440,12 +440,8 @@ class ViewTestCases:
             response = self.client.get(self._get_url('list'))
             response = self.client.get(self._get_url('list'))
             self.assertHttpStatus(response, 200)
             self.assertHttpStatus(response, 200)
             content = str(response.content)
             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=['*'])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
         def test_export_objects(self):
         def test_export_objects(self):
@@ -641,7 +637,7 @@ class ViewTestCases:
 
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
         def test_bulk_edit_objects_with_permission(self):
         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 = {
             data = {
                 'pk': pk_list,
                 'pk': pk_list,
                 '_apply': True,  # Form button
                 '_apply': True,  # Form button