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:
@ -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
|
||||
#
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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):
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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:
|
||||
|
43
netbox/ipam/migrations/0050_iprange.py
Normal file
43
netbox/ipam/migrations/0050_iprange.py
Normal 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'),
|
||||
},
|
||||
),
|
||||
]
|
@ -6,6 +6,7 @@ from .vrfs import *
|
||||
__all__ = (
|
||||
'Aggregate',
|
||||
'IPAddress',
|
||||
'IPRange',
|
||||
'Prefix',
|
||||
'RIR',
|
||||
'Role',
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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']
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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"),
|
||||
),
|
||||
|
@ -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)
|
||||
|
||||
|
95
netbox/templates/ipam/iprange.html
Normal file
95
netbox/templates/ipam/iprange.html
Normal 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 %}
|
32
netbox/templates/ipam/iprange/base.html
Normal file
32
netbox/templates/ipam/iprange/base.html
Normal 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 %}
|
18
netbox/templates/ipam/iprange/ip_addresses.html
Normal file
18
netbox/templates/ipam/iprange/ip_addresses.html
Normal 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 %}
|
@ -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
|
||||
|
Reference in New Issue
Block a user