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__ = [ __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
# #

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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__ = ( __all__ = (
'Aggregate', 'Aggregate',
'IPAddress', 'IPAddress',
'IPRange',
'Prefix', 'Prefix',
'RIR', 'RIR',
'Role', 'Role',

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,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.get_absolute_url(), content)
self.assertIn(instance1.name, content) self.assertNotIn(instance2.get_absolute_url(), 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)
@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