diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 82e700677..1b4de28fa 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -21,14 +21,15 @@ from .nested_serializers import * class ASNRangeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asnrange-detail') + rir = NestedRIRSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) asn_count = serializers.IntegerField(read_only=True) class Meta: model = ASNRange fields = [ - 'id', 'url', 'display', 'name', 'slug', 'start', 'end', 'tenant', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'asn_count', + 'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'asn_count', ] diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 9695b9dc5..dd178e3a9 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -182,7 +182,7 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): class Meta: model = ASNRange - fields = ['id', 'start', 'end', 'description'] + fields = ['id', 'name', 'start', 'end', 'description'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 216a937b8..803215cb1 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -114,6 +114,7 @@ class ASNImportForm(NetBoxModelImportForm): ) range = CSVModelChoiceField( queryset=ASNRange.objects.all(), + required=False, to_field_name='name', help_text=_('ASN range') ) diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index ce943567f..3f77de749 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -8,8 +8,8 @@ class IPAMQuery(graphene.ObjectType): asn = ObjectField(ASNType) asn_list = ObjectListField(ASNType) - asnrange = ObjectField(ASNRangeType) - asnrange_list = ObjectListField(ASNRangeType) + asn_range = ObjectField(ASNRangeType) + asn_range_list = ObjectListField(ASNRangeType) aggregate = ObjectField(AggregateType) aggregate_list = ObjectListField(AggregateType) diff --git a/netbox/ipam/models/asns.py b/netbox/ipam/models/asns.py index 76b7f8570..5238f226e 100644 --- a/netbox/ipam/models/asns.py +++ b/netbox/ipam/models/asns.py @@ -12,6 +12,66 @@ __all__ = ( ) +class ASNRange(OrganizationalModel): + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + rir = models.ForeignKey( + to='ipam.RIR', + on_delete=models.PROTECT, + related_name='asn_ranges', + verbose_name='RIR' + ) + start = ASNField() + end = ASNField() + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='asn_ranges', + blank=True, + null=True + ) + + class Meta: + ordering = ('name',) + verbose_name = 'ASN range' + verbose_name_plural = 'ASN ranges' + + def __str__(self): + return f'{self.name} ({self.range_as_string()})' + + def get_absolute_url(self): + return reverse('ipam:asnrange', args=[self.pk]) + + @property + def range(self): + return range(self.start, self.end + 1) + + def range_as_string(self): + return f'{self.start}-{self.end}' + + def clean(self): + super().clean() + + if self.end <= self.start: + raise ValidationError(f"Starting ASN ({self.start}) must be lower than ending ASN ({self.end}).") + + def get_available_asns(self): + """ + Return all available ASNs within this range. + """ + range = set(self.range) + existing_asns = set(ASN.objects.filter(range=self).values_list('asn', flat=True)) + available_asns = sorted(range - existing_asns) + + return available_asns + + class ASN(PrimaryModel): """ An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have @@ -77,63 +137,7 @@ class ASN(PrimaryModel): return self.asn def clean(self): + super().clean() + if self.range and self.asn not in self.range.range: raise ValidationError(f"ASN {self.asn} is outside of assigned range ({self.range})") - - -class ASNRange(OrganizationalModel): - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - rir = models.ForeignKey( - to='ipam.RIR', - on_delete=models.PROTECT, - related_name='asn_ranges', - verbose_name='RIR' - ) - start = ASNField() - end = ASNField() - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='asn_ranges', - blank=True, - null=True - ) - - class Meta: - ordering = ('name',) - verbose_name = 'ASN range' - verbose_name_plural = 'ASN ranges' - - def __str__(self): - return f'{self.name} ({self.range_as_string()})' - - def get_absolute_url(self): - return reverse('ipam:asnrange', args=[self.pk]) - - @property - def range(self): - return range(self.start, self.end + 1) - - def range_as_string(self): - return f'{self.start}-{self.end}' - - def clean(self): - if self.end <= self.start: - raise ValidationError(f"Starting ASN ({self.start}) must be lower than ending ASN ({self.end}).") - - def get_available_asns(self): - """ - Return all available ASNs within this range. - """ - range = set(self.range) - existing_asns = set(ASN.objects.filter(range=self).values_list('asn', flat=True)) - available_asns = sorted(range - existing_asns) - - return available_asns diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index ea6441650..2b5903b54 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -21,6 +21,62 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) +class ASNRangeTest(APIViewTestCases.APIViewTestCase): + model = ASNRange + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + rirs = ( + RIR(name='RIR 1', slug='rir-1', is_private=True), + RIR(name='RIR 2', slug='rir-2', is_private=True), + ) + RIR.objects.bulk_create(rirs) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ) + Tenant.objects.bulk_create(tenants) + + asn_ranges = ( + ASNRange(name='ASN Range 1', slug='asn-range-1', rir=rirs[0], tenant=tenants[0], start=100, end=199), + ASNRange(name='ASN Range 2', slug='asn-range-2', rir=rirs[0], tenant=tenants[0], start=200, end=299), + ASNRange(name='ASN Range 3', slug='asn-range-3', rir=rirs[0], tenant=tenants[0], start=300, end=399), + ) + ASNRange.objects.bulk_create(asn_ranges) + + cls.create_data = [ + { + 'name': 'ASN Range 4', + 'slug': 'asn-range-4', + 'rir': rirs[1].pk, + 'start': 400, + 'end': 499, + 'tenant': tenants[1].pk, + }, + { + 'name': 'ASN Range 5', + 'slug': 'asn-range-5', + 'rir': rirs[1].pk, + 'start': 500, + 'end': 599, + 'tenant': tenants[1].pk, + }, + { + 'name': 'ASN Range 6', + 'slug': 'asn-range-6', + 'rir': rirs[1].pk, + 'start': 600, + 'end': 699, + 'tenant': tenants[1].pk, + }, + ] + + class ASNTest(APIViewTestCases.APIViewTestCase): model = ASN brief_fields = ['asn', 'display', 'id', 'url'] @@ -30,25 +86,35 @@ class ASNTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): + rirs = ( + RIR(name='RIR 1', slug='rir-1', is_private=True), + RIR(name='RIR 2', slug='rir-2', is_private=True), + ) + RIR.objects.bulk_create(rirs) - rirs = [ - RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True), - RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True), - ] - sites = [ - Site.objects.create(name='Site 1', slug='site-1'), - Site.objects.create(name='Site 2', slug='site-2') - ] - tenants = [ - Tenant.objects.create(name='Tenant 1', slug='tenant-1'), - Tenant.objects.create(name='Tenant 2', slug='tenant-2'), - ] + ranges = ( + ASNRange(name='ASN Range 1', slug='asn-range-1', rir=rirs[0], start=65001, end=65100), + ASNRange(name='ASN Range 2', slug='asn-range-2', rir=rirs[1], start=4200000001, end=4200000100), + ) + ASNRange.objects.bulk_create(ranges) + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2') + ) + Site.objects.bulk_create(sites) + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ) + Tenant.objects.bulk_create(tenants) asns = ( - ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), - ASN(asn=65534, rir=rirs[0], tenant=tenants[1]), - ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), - ASN(asn=4200002301, rir=rirs[1], tenant=tenants[1]), + ASN(asn=65000, rir=rirs[0], tenant=tenants[0]), + ASN(asn=65001, rir=rirs[0], tenant=tenants[1], range=ranges[0]), + ASN(asn=4200000000, rir=rirs[1], tenant=tenants[0]), + ASN(asn=4200000001, rir=rirs[1], tenant=tenants[1], range=ranges[1]), ) ASN.objects.bulk_create(asns) @@ -63,12 +129,14 @@ class ASNTest(APIViewTestCases.APIViewTestCase): 'rir': rirs[0].pk, }, { - 'asn': 65543, + 'asn': 65002, 'rir': rirs[0].pk, + 'range': ranges[0].pk, }, { - 'asn': 4294967294, - 'rir': rirs[0].pk, + 'asn': 4200000002, + 'rir': rirs[1].pk, + 'range': ranges[1].pk, }, ] diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 13b3ae163..fef4722c4 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -12,84 +12,160 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac from tenancy.models import Tenant, TenantGroup +class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ASNRange.objects.all() + filterset = ASNRangeFilterSet + + @classmethod + def setUpTestData(cls): + rirs = [ + RIR(name='RIR 1', slug='rir-1'), + RIR(name='RIR 2', slug='rir-2'), + RIR(name='RIR 3', slug='rir-3'), + ] + RIR.objects.bulk_create(rirs) + + tenants = [ + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ] + Tenant.objects.bulk_create(tenants) + + asn_ranges = ( + ASNRange( + name='ASN Range 1', + slug='asn-range-1', + rir=rirs[0], + tenant=None, + start=65000, + end=65009, + description='aaa' + ), + ASNRange( + name='ASN Range 2', + slug='asn-range-2', + rir=rirs[1], + tenant=tenants[0], + start=65010, + end=65019, + description='bbb' + ), + ASNRange( + name='ASN Range 3', + slug='asn-range-3', + rir=rirs[2], + tenant=tenants[1], + start=65020, + end=65029, + description='ccc' + ), + ) + ASNRange.objects.bulk_create(asn_ranges) + + def test_name(self): + params = {'name': ['ASN Range 1', 'ASN Range 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rir(self): + rirs = RIR.objects.all()[:2] + params = {'rir_id': [rirs[0].pk, rirs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'rir': [rirs[0].slug, rirs[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + 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(), 2) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_start(self): + params = {'start': [65000, 65010]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_end(self): + params = {'end': [65009, 65019]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['aaa', 'bbb']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ASN.objects.all() filterset = ASNFilterSet @classmethod def setUpTestData(cls): - rirs = [ - RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True), - RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True), + RIR(name='RIR 1', slug='rir-1', is_private=True), + RIR(name='RIR 2', slug='rir-2', is_private=True), + RIR(name='RIR 3', slug='rir-3', is_private=True), ] + RIR.objects.bulk_create(rirs) + sites = [ - Site.objects.create(name='Site 1', slug='site-1'), - Site.objects.create(name='Site 2', slug='site-2'), - Site.objects.create(name='Site 3', slug='site-3') + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3') ] + Site.objects.bulk_create(sites) + tenants = [ - Tenant.objects.create(name='Tenant 1', slug='tenant-1'), - Tenant.objects.create(name='Tenant 2', slug='tenant-2'), - Tenant.objects.create(name='Tenant 3', slug='tenant-3'), - Tenant.objects.create(name='Tenant 4', slug='tenant-4'), - Tenant.objects.create(name='Tenant 5', slug='tenant-5'), + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + Tenant(name='Tenant 4', slug='tenant-4'), + Tenant(name='Tenant 5', slug='tenant-5'), ] + Tenant.objects.bulk_create(tenants) asns = ( - ASN(asn=64512, rir=rirs[0], tenant=tenants[0], description='foobar1'), - ASN(asn=64513, rir=rirs[0], tenant=tenants[0], description='foobar2'), - ASN(asn=64514, rir=rirs[0], tenant=tenants[1]), - ASN(asn=64515, rir=rirs[0], tenant=tenants[2]), - ASN(asn=64516, rir=rirs[0], tenant=tenants[3]), - ASN(asn=65535, rir=rirs[1], tenant=tenants[4]), + ASN(asn=65001, rir=rirs[0], tenant=tenants[0], description='aaa'), + ASN(asn=65002, rir=rirs[1], tenant=tenants[1], description='bbb'), + ASN(asn=65003, rir=rirs[2], tenant=tenants[2], description='ccc'), ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), - ASN(asn=4200000001, rir=rirs[0], tenant=tenants[1]), - ASN(asn=4200000002, rir=rirs[0], tenant=tenants[2]), - ASN(asn=4200000003, rir=rirs[0], tenant=tenants[3]), - ASN(asn=4200002301, rir=rirs[1], tenant=tenants[4]), + ASN(asn=4200000001, rir=rirs[1], tenant=tenants[1]), + ASN(asn=4200000002, rir=rirs[2], tenant=tenants[2]), ) ASN.objects.bulk_create(asns) asns[0].sites.set([sites[0]]) - asns[1].sites.set([sites[0]]) - asns[2].sites.set([sites[1]]) - asns[3].sites.set([sites[2]]) - asns[4].sites.set([sites[0]]) - asns[5].sites.set([sites[1]]) - asns[6].sites.set([sites[0]]) - asns[7].sites.set([sites[1]]) - asns[8].sites.set([sites[2]]) - asns[9].sites.set([sites[0]]) - asns[10].sites.set([sites[1]]) + asns[1].sites.set([sites[1]]) + asns[2].sites.set([sites[2]]) + asns[3].sites.set([sites[0]]) + asns[4].sites.set([sites[1]]) + asns[5].sites.set([sites[2]]) def test_asn(self): - params = {'asn': ['64512', '65535']} + params = {'asn': [65001, 4200000000]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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(), 5) + 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(), 5) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_rir(self): - rirs = RIR.objects.all()[:1] - params = {'rir_id': [rirs[0].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) - params = {'rir': [rirs[0].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) + rirs = RIR.objects.all()[:2] + params = {'rir_id': [rirs[0].pk, rirs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'rir': [rirs[0].slug, rirs[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'site': [sites[0].slug, sites[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_description(self): - params = {'description': ['foobar1', 'foobar2']} + params = {'description': ['aaa', 'bbb']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 8bf19ebfa..eb3ea0038 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -11,16 +11,73 @@ from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_test_device, create_tags +class ASNRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = ASNRange + + @classmethod + def setUpTestData(cls): + rirs = [ + RIR(name='RIR 1', slug='rir-1', is_private=True), + RIR(name='RIR 2', slug='rir-2', is_private=True), + ] + RIR.objects.bulk_create(rirs) + + tenants = [ + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ] + Tenant.objects.bulk_create(tenants) + + asn_ranges = ( + ASNRange(name='ASN Range 1', slug='asn-range-1', rir=rirs[0], tenant=tenants[0], start=100, end=199), + ASNRange(name='ASN Range 2', slug='asn-range-2', rir=rirs[0], tenant=tenants[0], start=200, end=299), + ASNRange(name='ASN Range 3', slug='asn-range-3', rir=rirs[0], tenant=tenants[0], start=300, end=399), + ) + ASNRange.objects.bulk_create(asn_ranges) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'ASN Range X', + 'slug': 'asn-range-x', + 'rir': rirs[1].pk, + 'tenant': tenants[1].pk, + 'start': 1000, + 'end': 1099, + 'description': 'A new ASN range', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + f"name,slug,rir,tenant,start,end,description", + f"ASN Range 4,asn-range-4,{rirs[1].name},{tenants[1].name},400,499,Fourth range", + f"ASN Range 5,asn-range-5,{rirs[1].name},{tenants[1].name},500,599,Fifth range", + f"ASN Range 6,asn-range-6,{rirs[1].name},{tenants[1].name},600,699,Sixth range", + ) + + cls.csv_update_data = ( + "id,description", + f"{asn_ranges[0].pk},New description 1", + f"{asn_ranges[1].pk},New description 2", + f"{asn_ranges[2].pk},New description 3", + ) + + cls.bulk_edit_data = { + 'rir': rirs[1].pk, + 'description': 'Next description', + } + + class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ASN @classmethod def setUpTestData(cls): - rirs = [ - RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True), - RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True), + RIR(name='RIR 1', slug='rir-1', is_private=True), + RIR(name='RIR 2', slug='rir-2', is_private=True), ] + RIR.objects.bulk_create(rirs) sites = [ Site.objects.create(name='Site 1', slug='site-1'), Site.objects.create(name='Site 2', slug='site-2') @@ -31,10 +88,10 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): ] asns = ( - ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), - ASN(asn=65535, rir=rirs[1], tenant=tenants[1]), - ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), - ASN(asn=4200002301, rir=rirs[1], tenant=tenants[1]), + ASN(asn=65001, rir=rirs[0], tenant=tenants[0]), + ASN(asn=65002, rir=rirs[1], tenant=tenants[1]), + ASN(asn=4200000001, rir=rirs[0], tenant=tenants[0]), + ASN(asn=4200000002, rir=rirs[1], tenant=tenants[1]), ) ASN.objects.bulk_create(asns) @@ -46,18 +103,20 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { - 'asn': 64512, + 'asn': 65000, 'rir': rirs[0].pk, 'tenant': tenants[0].pk, 'site': sites[0].pk, 'description': 'A new ASN', + 'tags': [t.pk for t in tags], } cls.csv_data = ( "asn,rir", - "64533,RFC 6996", - "64523,RFC 6996", - "4200000002,RFC 6996", + "65003,RIR 1", + "65004,RIR 2", + "4200000003,RIR 1", + "4200000004,RIR 2", ) cls.csv_update_data = (