1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Initial work on IP ranges

This commit is contained in:
jeremystretch
2021-07-16 09:15:19 -04:00
parent 337f95e269
commit 11a14927c9
24 changed files with 994 additions and 20 deletions

View File

@ -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
#

View File

@ -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
#

View File

@ -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)

View File

@ -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
#

View File

@ -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):

View File

@ -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',

View File

@ -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
#

View File

@ -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)

View File

@ -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:

View File

@ -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'),
},
),
]

View File

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

View File

@ -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):
"""

View File

@ -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('<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
#

View File

@ -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']

View File

@ -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

View File

@ -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

View File

@ -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/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
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
path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
path('ip-addresses/add/', views.IPAddressEditView.as_view(), name='ipaddress_add'),

View File

@ -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
#

View File

@ -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"),
),

View File

@ -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)

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -440,10 +440,6 @@ 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)
@ -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