diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index dd05d6a01..9fa5e0eb4 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -18,6 +18,10 @@ {!models/ipam/vrf.md!} {!models/ipam/routetarget.md!} -__ +--- {!models/ipam/fhrpgroup.md!} + +--- + +{!models/ipam/asn.md!} diff --git a/docs/models/ipam/asn.md b/docs/models/ipam/asn.md new file mode 100644 index 000000000..cfef1da29 --- /dev/null +++ b/docs/models/ipam/asn.md @@ -0,0 +1,15 @@ +# ASN + +ASN is short for Autonomous System Number. This identifier is used in the BGP protocol to identify which "autonomous system" a particular prefix is originating and transiting through. + +The AS number model within NetBox allows you to model some of this real-world relationship. + +Within NetBox: + +* AS numbers are globally unique +* Each AS number must be associated with a RIR (ARIN, RFC 6996, etc) +* Each AS number can be associated with many different sites +* Each site can have many different AS numbers +* Each AS number can be assigned to a single tenant + + diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index a1e8156dc..6fd67bf69 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -7,7 +7,7 @@ from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer +from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedASNSerializer from ipam.models import VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( @@ -113,21 +113,24 @@ class SiteSerializer(PrimaryModelSerializer): region = NestedRegionSerializer(required=False, allow_null=True) group = NestedSiteGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) + asns = NestedASNSerializer(many=True, required=False, allow_null=True) time_zone = TimeZoneSerializerField(required=False) circuit_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True) rack_count = serializers.IntegerField(read_only=True) + asn_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) class Meta: model = Site fields = [ - 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', + 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'asns', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', + 'asn_count', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', + 'vlan_count', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 3248891f2..e05ccaed2 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -15,7 +15,7 @@ from circuits.models import Circuit from dcim import filtersets from dcim.models import * from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet -from ipam.models import Prefix, VLAN +from ipam.models import Prefix, VLAN, ASN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata @@ -139,6 +139,7 @@ class SiteViewSet(CustomFieldModelViewSet): queryset = Site.objects.prefetch_related( 'region', 'tenant', 'tags' ).annotate( + asn_count=count_related(ASN, 'sites'), device_count=count_related(Device, 'site'), rack_count=count_related(Rack, 'site'), prefix_count=count_related(Prefix, 'site'), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index bced6d882..aad02592e 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import User from extras.filters import TagFilter from extras.filtersets import LocalConfigContextFilterSet +from ipam.models import ASN from netbox.filtersets import ( BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet, ) @@ -130,6 +131,17 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Group (slug)', ) + asns_id = django_filters.ModelMultipleChoiceFilter( + field_name='asns', + queryset=ASN.objects.all(), + label='AS (ID)', + ) + asns = django_filters.ModelMultipleChoiceFilter( + field_name='asns__asn', + queryset=ASN.objects.all(), + to_field_name='asn', + label='AS (Number)', + ) tag = TagFilter() class Meta: @@ -155,6 +167,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) try: qs_filter |= Q(asn=int(value.strip())) + qs_filter |= Q(asns__asn=int(value.strip())) except ValueError: pass return queryset.filter(qs_filter) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index eac2ed314..453cead1c 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from django.contrib.auth.models import User from timezone_field import TimeZoneFormField @@ -6,8 +7,8 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm -from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN -from ipam.models import VLAN +from ipam.constants import BGP_ASN_MIN, BGP_ASN_MAX +from ipam.models import VLAN, ASN from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, @@ -116,6 +117,11 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd required=False, label='ASN' ) + asns = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), + required=False + ) description = forms.CharField( max_length=100, required=False @@ -128,7 +134,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd class Meta: nullable_fields = [ - 'region', 'group', 'tenant', 'asn', 'description', 'time_zone', + 'region', 'group', 'tenant', 'asn', 'asns', 'description', 'time_zone', ] diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 94c56b22a..df8c4ec01 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -95,7 +95,7 @@ class SiteCSVForm(CustomFieldModelCSVForm): class Meta: model = Site fields = ( - 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', + 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index b57282120..11fc69745 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -6,6 +6,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm +from ipam.models import ASN from tenancy.forms import TenancyFilterForm from utilities.forms import ( APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, @@ -138,11 +139,12 @@ class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): model = Site - field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id'] + field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id', 'asn_id'] field_groups = [ ['q', 'tag'], ['status', 'region_id', 'group_id'], ['tenant_group_id', 'tenant_id'], + ['asn_id'] ] q = forms.CharField( required=False, @@ -166,6 +168,12 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo label=_('Site group'), fetch_trigger='open' ) + asn_id = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + required=False, + label=_('ASNs'), + fetch_trigger='open' + ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 3f87d1bd7..36c349740 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from timezone_field import TimeZoneFormField @@ -8,7 +9,7 @@ from dcim.constants import * from dcim.models import * from extras.forms import CustomFieldModelForm from extras.models import Tag -from ipam.models import IPAddress, VLAN, VLANGroup +from ipam.models import IPAddress, VLAN, VLANGroup, ASN from tenancy.forms import TenancyForm from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField, @@ -110,6 +111,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=SiteGroup.objects.all(), required=False ) + asns = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), + required=False + ) slug = SlugField() time_zone = TimeZoneFormField( choices=add_blank_choice(TimeZoneFormField().choices), @@ -125,13 +131,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Site fields = [ - 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', - 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', + 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'asns', + 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'tags', ] fieldsets = ( ('Site', ( - 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'time_zone', 'description', 'tags', + 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'asns', 'time_zone', 'description', + 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ('Contact Info', ( @@ -155,8 +162,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } help_texts = { 'name': "Full name of the site", + 'asn': "BGP autonomous system number. This field is depreciated in favour of the ASN model", 'facility': "Data center provider and facility (e.g. Equinix NY7)", - 'asn': "BGP autonomous system number", 'time_zone': "Local time zone", 'description': "Short description (will appear in sites list)", 'physical_address': "Physical location of the building (e.g. for GPS)", @@ -791,7 +798,6 @@ class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm): class DeviceVCMembershipForm(forms.ModelForm): - class Meta: model = Device fields = [ @@ -887,7 +893,6 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = ConsolePortTemplate fields = [ @@ -899,7 +904,6 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = ConsoleServerPortTemplate fields = [ @@ -911,7 +915,6 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = PowerPortTemplate fields = [ @@ -923,7 +926,6 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = PowerOutletTemplate fields = [ @@ -934,7 +936,6 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): } def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) # Limit power_port choices to current DeviceType @@ -945,7 +946,6 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = InterfaceTemplate fields = [ @@ -958,7 +958,6 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = FrontPortTemplate fields = [ @@ -970,7 +969,6 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): } def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) # Limit rear_port choices to current DeviceType @@ -981,7 +979,6 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = RearPortTemplate fields = [ @@ -994,7 +991,6 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = DeviceBayTemplate fields = [ @@ -1257,7 +1253,6 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ) def __init__(self, device_bay, *args, **kwargs): - super().__init__(*args, **kwargs) self.fields['installed_device'].queryset = Device.objects.filter( diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 39f08ffdc..8ce10979e 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -1,8 +1,11 @@ +import graphene + from dcim import filtersets, models from extras.graphql.mixins import ( ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin +from netbox.graphql.scalars import BigInt from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType __all__ = ( @@ -380,6 +383,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType): class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): + asn = graphene.Field(BigInt) class Meta: model = models.Site diff --git a/netbox/dcim/migrations/0141_asn_model.py b/netbox/dcim/migrations/0141_asn_model.py new file mode 100644 index 000000000..6f011f35d --- /dev/null +++ b/netbox/dcim/migrations/0141_asn_model.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-11-02 16:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0053_asn_model'), + ('dcim', '0140_wireless'), + ] + + operations = [ + migrations.AddField( + model_name='site', + name='asns', + field=models.ManyToManyField(blank=True, related_name='sites', to='ipam.ASN'), + ), + ] diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index a978e69e6..79f8921d5 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -195,6 +195,11 @@ class Site(PrimaryModel): verbose_name='ASN', help_text='32-bit autonomous system number' ) + asns = models.ManyToManyField( + to='ipam.ASN', + related_name='sites', + blank=True + ) time_zone = TimeZoneField( blank=True ) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 815160df8..5a5975e4a 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -81,6 +81,11 @@ class SiteTable(BaseTable): group = tables.Column( linkify=True ) + asn_count = LinkedCountColumn( + viewname='ipam:asn_list', + url_params={'site_id': 'pk'}, + verbose_name='ASNs' + ) tenant = TenantColumn() comments = MarkdownColumn() tags = TagColumn( @@ -90,11 +95,11 @@ class SiteTable(BaseTable): class Meta(BaseTable.Meta): model = Site fields = ( - 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', 'tags', + 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', + 'contact_phone', 'contact_email', 'comments', 'tags', ) - default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description') + default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'description') # diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 37d11be40..0cbd892f5 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4,7 +4,7 @@ from django.test import TestCase from dcim.choices import * from dcim.filtersets import * from dcim.models import * -from ipam.models import IPAddress +from ipam.models import ASN, IPAddress, RIR from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests @@ -149,6 +149,23 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): ) Site.objects.bulk_create(sites) + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = ( + ASN(asn=64512, rir=rir, tenant=tenants[0]), + ASN(asn=64513, rir=rir, tenant=tenants[0]), + ASN(asn=64514, rir=rir, tenant=tenants[0]), + ASN(asn=65001, rir=rir, tenant=tenants[0]), + ASN(asn=65002, rir=rir, tenant=tenants[0]) + ) + ASN.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[1].sites.set([sites[1]]) + asns[2].sites.set([sites[2]]) + asns[3].sites.set([sites[2]]) + asns[4].sites.set([sites[1]]) + def test_name(self): params = {'name': ['Site 1', 'Site 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -165,6 +182,10 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'asn': [65001, 65002]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_asns(self): + params = {'asns': [64512, 65002]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_latitude(self): params = {'latitude': [10, 20]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index cd354117e..dc22b18a0 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,7 +11,7 @@ from netaddr import EUI from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import VLAN +from ipam.models import ASN, VLAN, RIR from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -110,7 +110,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): for group in groups: group.save() - Site.objects.bulk_create([ + sites = Site.objects.bulk_create([ Site(name='Site 1', slug='site-1', region=regions[0], group=groups[1]), Site(name='Site 2', slug='site-2', region=regions[0], group=groups[1]), Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]), @@ -118,6 +118,33 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = [ + ASN(asn=65000, rir=rir), + ASN(asn=65001, rir=rir), + ASN(asn=65002, rir=rir), + ASN(asn=65003, rir=rir), + ASN(asn=65004, rir=rir), + ASN(asn=65005, rir=rir), + ASN(asn=65006, rir=rir), + ASN(asn=65007, rir=rir), + ASN(asn=65008, rir=rir), + ASN(asn=65009, rir=rir), + ASN(asn=65010, rir=rir), + ] + ASN.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[2].sites.set([sites[0]]) + asns[3].sites.set([sites[1]]) + asns[4].sites.set([sites[2]]) + asns[5].sites.set([sites[1]]) + asns[6].sites.set([sites[2]]) + asns[7].sites.set([sites[2]]) + asns[8].sites.set([sites[2]]) + asns[10].sites.set([sites[0]]) + cls.form_data = { 'name': 'Site X', 'slug': 'site-x', @@ -126,7 +153,6 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'group': groups[1].pk, 'tenant': None, 'facility': 'Facility X', - 'asn': 65001, 'time_zone': pytz.UTC, 'description': 'Site description', 'physical_address': '742 Evergreen Terrace, Springfield, USA', @@ -152,7 +178,6 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'region': regions[1].pk, 'group': groups[1].pk, 'tenant': None, - 'asn': 65009, 'time_zone': pytz.timezone('US/Eastern'), 'description': 'New description', } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index daf3e13b4..f07bc6900 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -14,7 +14,7 @@ from django.views.generic import View from circuits.models import Circuit from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView -from ipam.models import IPAddress, Prefix, Service, VLAN +from ipam.models import ASN, IPAddress, Prefix, Service, VLAN from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.forms import ConfirmationForm @@ -332,9 +332,15 @@ class SiteView(generic.ObjectView): cumulative=True ).restrict(request.user, 'view').filter(site=instance) + asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance) + asn_count = asns.count() + + stats.update({'asn_count': asn_count}) + return { 'stats': stats, 'locations': locations, + 'asns': asns, } diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index e94dad24f..885982afb 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -5,6 +5,7 @@ from netbox.api import WritableNestedSerializer __all__ = [ 'NestedAggregateSerializer', + 'NestedASNSerializer', 'NestedFHRPGroupSerializer', 'NestedIPAddressSerializer', 'NestedIPRangeSerializer', @@ -19,6 +20,18 @@ __all__ = [ ] +# +# ASNs +# + +class NestedASNSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') + + class Meta: + model = models.ASN + fields = ['id', 'url', 'display', 'asn'] + + # # VRFs # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 25c2297ab..eae653ad7 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -16,6 +16,24 @@ from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * +# +# ASNs +# + +class ASNSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') + tenant = NestedTenantSerializer(required=False, allow_null=True) + + site_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ASN + fields = [ + 'id', 'url', 'display', 'asn', 'site_count', 'rir', 'tenant', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + + # # VRFs # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 60f4b6b72..e465fbd89 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -5,6 +5,9 @@ from . import views router = OrderedDefaultRouter() router.APIRootView = views.IPAMRootView +# ASNs +router.register('asns', views.ASNViewSet) + # VRFs router.register('vrfs', views.VRFViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index a0ad4f375..5e40a2081 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,5 +1,6 @@ from rest_framework.routers import APIRootView +from dcim.models import Site from extras.api.views import CustomFieldModelViewSet from ipam import filtersets from ipam.models import * @@ -16,6 +17,16 @@ class IPAMRootView(APIRootView): return 'IPAM' +# +# ASNs +# + +class ASNViewSet(CustomFieldModelViewSet): + queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate(site_count=count_related(Site, 'asns')) + serializer_class = serializers.ASNSerializer + filterset_class = filtersets.ASNFilterSet + + # # VRFs # diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index db2f5aaea..6b6d14f5b 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -9,6 +9,7 @@ from dcim.models import Device, Interface, Region, Site, SiteGroup from extras.filters import TagFilter from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet +from tenancy.models import Tenant from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) @@ -19,6 +20,7 @@ from .models import * __all__ = ( 'AggregateFilterSet', + 'ASNFilterSet', 'FHRPGroupAssignmentFilterSet', 'FHRPGroupFilterSet', 'IPAddressFilterSet', @@ -177,6 +179,41 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.none() +class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): + + rir_id = django_filters.ModelMultipleChoiceFilter( + queryset=RIR.objects.all(), + label='RIR (ID)', + ) + rir = django_filters.ModelMultipleChoiceFilter( + field_name='rir__slug', + queryset=RIR.objects.all(), + to_field_name='slug', + label='RIR (slug)', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + field_name='sites', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='sites__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + + class Meta: + model = ASN + fields = ['id', 'asn'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(Q(description__icontains=value) | Q(asn__icontains=value)) + return queryset.filter(qs_filter) + + class RoleFilterSet(OrganizationalModelFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 7f910faa4..ab53dfb8c 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -5,14 +5,16 @@ from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.choices import * from ipam.constants import * from ipam.models import * +from ipam.models import ASN from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, - StaticSelect, + StaticSelect, DynamicModelMultipleChoiceField, ) __all__ = ( 'AggregateBulkEditForm', + 'ASNBulkEditForm', 'FHRPGroupBulkEditForm', 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', @@ -90,6 +92,38 @@ class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEdi nullable_fields = ['is_private', 'description'] +class ASNBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ASN.objects.all(), + widget=forms.MultipleHiddenInput() + ) + sites = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False + ) + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + required=False, + label='RIR' + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'date_added', 'description', + ] + widgets = { + 'date_added': DatePicker(), + } + + class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Aggregate.objects.all(), diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index fe17cd518..65fc35c34 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -12,6 +12,7 @@ from virtualization.models import VirtualMachine, VMInterface __all__ = ( 'AggregateCSVForm', + 'ASNCSVForm', 'FHRPGroupCSVForm', 'IPAddressCSVForm', 'IPRangeCSVForm', @@ -81,6 +82,25 @@ class AggregateCSVForm(CustomFieldModelCSVForm): fields = ('prefix', 'rir', 'tenant', 'date_added', 'description') +class ASNCSVForm(CustomFieldModelCSVForm): + rir = CSVModelChoiceField( + queryset=RIR.objects.all(), + to_field_name='name', + help_text='Assigned RIR' + ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) + + class Meta: + model = ASN + fields = ('asn', 'rir', 'tenant', 'description') + help_texts = {} + + class RoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 67927a016..b89fa919c 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -6,7 +6,9 @@ from extras.forms import CustomFieldModelFilterForm from ipam.choices import * from ipam.constants import * from ipam.models import * +from ipam.models import ASN from tenancy.forms import TenancyFilterForm +from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, @@ -14,6 +16,7 @@ from utilities.forms import ( __all__ = ( 'AggregateFilterForm', + 'ASNFilterForm', 'FHRPGroupFilterForm', 'IPAddressFilterForm', 'IPRangeFilterForm', @@ -134,6 +137,33 @@ class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil tag = TagFilterField(model) +class ASNFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): + model = ASN + field_groups = [ + ['q'], + ['rir_id'], + ['tenant_group_id', 'tenant_id'], + ['site_id'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + rir_id = DynamicModelMultipleChoiceField( + queryset=RIR.objects.all(), + required=False, + label=_('RIR'), + fetch_trigger='open' + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + label=_('Site'), + fetch_trigger='open' + ) + + class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = Role q = forms.CharField( diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 70094a07a..d69800aa5 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -8,6 +8,7 @@ from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField from ipam.models import * +from ipam.models import ASN from tenancy.forms import TenancyForm from utilities.exceptions import PermissionsViolation from utilities.forms import ( @@ -18,6 +19,7 @@ from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInter __all__ = ( 'AggregateForm', + 'ASNForm', 'FHRPGroupForm', 'FHRPGroupAssignmentForm', 'IPAddressAssignForm', @@ -127,6 +129,50 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } +class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + label='RIR', + ) + sites = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + label='Sites', + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ASN + fields = [ + 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'tags' + ] + fieldsets = ( + ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + help_texts = { + 'asn': "AS number", + 'rir': "Regional Internet Registry responsible for this prefix", + } + widgets = { + 'date_added': DatePicker(), + } + + def __init__(self, data=None, instance=None, *args, **kwargs): + super().__init__(data=data, instance=instance, *args, **kwargs) + + if self.instance and self.instance.pk is not None: + self.fields['sites'].initial = self.instance.sites.all().values_list('id', flat=True) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + instance.sites.set(self.cleaned_data['sites']) + return instance + + class RoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() tags = DynamicModelMultipleChoiceField( diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 8c7830a24..9609d1434 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -5,6 +5,9 @@ from .types import * class IPAMQuery(graphene.ObjectType): + asn = ObjectField(ASNType) + asn_list = ObjectListField(ASNType) + aggregate = ObjectField(AggregateType) aggregate_list = ObjectListField(AggregateType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 8e4119266..72526b3bd 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,7 +1,11 @@ +import graphene + from ipam import filtersets, models +from netbox.graphql.scalars import BigInt from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType __all__ = ( + 'ASNType', 'AggregateType', 'FHRPGroupType', 'FHRPGroupAssignmentType', @@ -18,6 +22,15 @@ __all__ = ( ) +class ASNType(PrimaryObjectType): + asn = graphene.Field(BigInt) + + class Meta: + model = models.ASN + fields = '__all__' + filterset_class = filtersets.ASNFilterSet + + class AggregateType(PrimaryObjectType): class Meta: diff --git a/netbox/ipam/migrations/0053_asn_model.py b/netbox/ipam/migrations/0053_asn_model.py new file mode 100644 index 000000000..1c7ee8e23 --- /dev/null +++ b/netbox/ipam/migrations/0053_asn_model.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.8 on 2021-11-02 16:16 + +import dcim.fields +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0004_extend_tag_support'), + ('extras', '0064_configrevision'), + ('ipam', '0052_fhrpgroup'), + ] + + operations = [ + migrations.CreateModel( + name='ASN', + 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)), + ('asn', dcim.fields.ASNField(unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('rir', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='ipam.rir')), + ('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='asns', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'ASN', + 'verbose_name_plural': 'ASNs', + 'ordering': ['asn'], + }, + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index 9747bcfb0..ab0e4b6ca 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -5,6 +5,7 @@ from .vlans import * from .vrfs import * __all__ = ( + 'ASN', 'Aggregate', 'IPAddress', 'IPRange', diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 6a26f08c3..ad707dda1 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -7,6 +7,7 @@ from django.db.models import F from django.urls import reverse from django.utils.functional import cached_property +from dcim.fields import ASNField from dcim.models import Device from extras.utils import extras_features from netbox.models import OrganizationalModel, PrimaryModel @@ -23,6 +24,7 @@ from virtualization.models import VirtualMachine __all__ = ( 'Aggregate', + 'ASN', 'IPAddress', 'IPRange', 'Prefix', @@ -69,6 +71,49 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ASN(PrimaryModel): + + asn = ASNField( + unique=True, + blank=False, + null=False, + verbose_name='ASN', + help_text='32-bit autonomous system number' + ) + description = models.CharField( + max_length=200, + blank=True + ) + rir = models.ForeignKey( + to='ipam.RIR', + on_delete=models.PROTECT, + related_name='asns', + blank=False, + null=False + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='asns', + blank=True, + null=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ['asn'] + verbose_name = 'ASN' + verbose_name_plural = 'ASNs' + + def __str__(self): + return f'AS{self.asn}' + + def get_absolute_url(self): + return reverse('ipam:asn', args=[self.pk]) + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Aggregate(PrimaryModel): """ diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index f186a5248..1d40acbe3 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -11,6 +11,7 @@ from ipam.models import * __all__ = ( 'AggregateTable', + 'ASNTable', 'AssignedIPAddressesTable', 'IPAddressAssignTable', 'IPAddressTable', @@ -96,6 +97,28 @@ class RIRTable(BaseTable): default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') +# +# ASNs +# + +class ASNTable(BaseTable): + pk = ToggleColumn() + asn = tables.Column( + linkify=True + ) + site_count = LinkedCountColumn( + viewname='dcim:site_list', + url_params={'asn_id': 'pk'}, + verbose_name='Sites' + ) + actions = ButtonsColumn(ASN) + + class Meta(BaseTable.Meta): + model = ASN + fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions') + default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions') + + # # Aggregates # diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index f3796f781..5ec0a0177 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -7,6 +7,7 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * from ipam.models import * +from tenancy.models import Tenant from utilities.testing import APITestCase, APIViewTestCases, disable_warnings @@ -20,6 +21,58 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) +class ASNTest(APIViewTestCases.APIViewTestCase): + model = ASN + brief_fields = ['asn', 'display', 'id', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @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), + ] + 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'), + ] + + 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.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[1].sites.set([sites[1]]) + asns[2].sites.set([sites[0]]) + asns[3].sites.set([sites[1]]) + + cls.create_data = [ + { + 'asn': 64512, + 'rir': rirs[0].pk, + }, + { + 'asn': 65543, + 'rir': rirs[0].pk, + }, + { + 'asn': 4294967294, + 'rir': rirs[0].pk, + }, + ] + + class VRFTest(APIViewTestCases.APIViewTestCase): model = VRF brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url'] diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index e5df58a2b..19b6a8e8f 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -9,6 +9,83 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac from tenancy.models import Tenant, TenantGroup +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), + ] + 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') + ] + 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'), + ] + + asns = ( + ASN(asn=64512, rir=rirs[0], tenant=tenants[0]), + ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), + 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=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.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]]) + + def test_asn(self): + params = {'asn': ['64512', '65535']} + 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) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + + 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) + + 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) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) + + class VRFTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VRF.objects.all() filterset = VRFFilterSet diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index da4627ca8..83de73bde 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -9,6 +9,61 @@ from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags +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), + ] + 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'), + ] + + 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.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[1].sites.set([sites[1]]) + asns[2].sites.set([sites[0]]) + asns[3].sites.set([sites[1]]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'asn': 64512, + 'rir': rirs[0].pk, + 'tenant': tenants[0].pk, + 'site': sites[0].pk, + 'description': 'A new ASN', + } + + cls.csv_data = ( + "asn,rir", + "64533,RFC 6996", + "64523,RFC 6996", + "4200000002,RFC 6996", + ) + + cls.bulk_edit_data = { + 'rir': rirs[1].pk, + 'description': 'Next description', + } + + class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VRF diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index ccce246cd..541acb3ac 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -7,6 +7,18 @@ from .models import * app_name = 'ipam' urlpatterns = [ + # ASNs + path('asns/', views.ASNListView.as_view(), name='asn_list'), + path('asns/add/', views.ASNEditView.as_view(), name='asn_add'), + path('asns/import/', views.ASNBulkImportView.as_view(), name='asn_import'), + path('asns/edit/', views.ASNBulkEditView.as_view(), name='asn_bulk_edit'), + path('asns/delete/', views.ASNBulkDeleteView.as_view(), name='asn_bulk_delete'), + path('asns//', views.ASNView.as_view(), name='asn'), + path('asns//edit/', views.ASNEditView.as_view(), name='asn_edit'), + path('asns//delete/', views.ASNDeleteView.as_view(), name='asn_delete'), + path('asns//changelog/', ObjectChangeLogView.as_view(), name='asn_changelog', kwargs={'model': ASN}), + path('asns//journal/', ObjectJournalView.as_view(), name='asn_journal', kwargs={'model': ASN}), + # VRFs path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 8592fc931..f869a75c1 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -5,7 +5,8 @@ from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from dcim.models import Device, Interface +from dcim.models import Device, Interface, Site +from dcim.tables import SiteTable from netbox.views import generic from utilities.tables import paginate_table from utilities.utils import count_related @@ -13,6 +14,7 @@ from virtualization.models import VirtualMachine, VMInterface from . import filtersets, forms, tables from .constants import * from .models import * +from .models import ASN from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans @@ -197,6 +199,62 @@ class RIRBulkDeleteView(generic.BulkDeleteView): table = tables.RIRTable +# +# ASNs +# + +class ASNListView(generic.ObjectListView): + queryset = ASN.objects.annotate( + site_count=count_related(Site, 'asns'), + ) + filterset = filtersets.ASNFilterSet + filterset_form = forms.ASNFilterForm + table = tables.ASNTable + + +class ASNView(generic.ObjectView): + queryset = ASN.objects.all() + + def get_extra_context(self, request, instance): + sites = instance.sites.restrict(request.user, 'view').all() + + return { + 'sites': sites, + } + + +class ASNEditView(generic.ObjectEditView): + queryset = ASN.objects.all() + model_form = forms.ASNForm + + +class ASNDeleteView(generic.ObjectDeleteView): + queryset = ASN.objects.all() + + +class ASNBulkImportView(generic.BulkImportView): + queryset = ASN.objects.all() + model_form = forms.ASNCSVForm + table = tables.ASNTable + + +class ASNBulkEditView(generic.BulkEditView): + queryset = ASN.objects.annotate( + site_count=count_related(Site, 'asns') + ) + filterset = filtersets.ASNFilterSet + table = tables.ASNTable + form = forms.ASNBulkEditForm + + +class ASNBulkDeleteView(generic.BulkDeleteView): + queryset = ASN.objects.annotate( + site_count=count_related(Site, 'asns') + ) + filterset = filtersets.ASNFilterSet + table = tables.ASNTable + + # # Aggregates # diff --git a/netbox/netbox/graphql/scalars.py b/netbox/netbox/graphql/scalars.py new file mode 100644 index 000000000..7d14189dd --- /dev/null +++ b/netbox/netbox/graphql/scalars.py @@ -0,0 +1,23 @@ +from graphene import Scalar +from graphql.language import ast +from graphql.type.scalars import MAX_INT, MIN_INT + + +class BigInt(Scalar): + """ + Handle any BigInts + """ + @staticmethod + def to_float(value): + num = int(value) + if num > MAX_INT or num < MIN_INT: + return float(num) + return num + + serialize = to_float + parse_value = to_float + + @staticmethod + def parse_literal(node): + if isinstance(node, ast.IntValue): + return BigInt.to_float(node.value) diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 1d06f1d5c..0bd29229f 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -229,6 +229,12 @@ IPAM_MENU = Menu( get_model_item('ipam', 'role', 'Prefix & VLAN Roles'), ), ), + MenuGroup( + label='ASNs', + items=( + get_model_item('ipam', 'asn', 'ASNs'), + ), + ), MenuGroup( label='Aggregates', items=( diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 7429aa4f5..308b09816 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -215,6 +215,10 @@

{{ stats.vm_count }}

Virtual Machines

+ @@ -256,6 +260,20 @@ {% endif %} +
+
+ ASNs +
+
+ {% if asns %} + {% for asn in asns %} + {{ asn }} + {% endfor %} + {% else %} + None + {% endif %} +
+
{% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html new file mode 100644 index 000000000..8eafe7633 --- /dev/null +++ b/netbox/templates/ipam/asn.html @@ -0,0 +1,77 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+
+
+
+ ASN +
+
+ + + + + + + + + + + + + + + + + +
AS Number{{ object.asn }}
RIR + {{ object.rir }} +
Tenant + {% if object.tenant %} + {% if prefix.object.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
Description{{ object.description|placeholder }}
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} + {% plugin_left_page object %} +
+
+
+
+ Sites +
+
+ {% if sites %} + {% for site in sites %} + {{ site }} + {% endfor %} + {% else %} + None + {% endif %} +
+
+ {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 374167f1c..2616dbf36 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -5,6 +5,9 @@ from django.test import TestCase from mptt.fields import TreeForeignKey from taggit.managers import TaggableManager +from circuits.choices import CircuitStatusChoices +from circuits.filtersets import CircuitFilterSet +from circuits.models import Circuit, Provider, CircuitType from dcim.choices import * from dcim.fields import MACAddressField from dcim.filtersets import DeviceFilterSet, SiteFilterSet @@ -13,6 +16,7 @@ from dcim.models import ( ) from extras.filters import TagFilter from extras.models import TaggedItem +from ipam.models import RIR, ASN from netbox.filtersets import BaseFilterSet from utilities.filters import ( MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueNumberFilter, @@ -337,6 +341,8 @@ class DynamicFilterLookupExpressionTest(TestCase): device_filterset = DeviceFilterSet site_queryset = Site.objects.all() site_filterset = SiteFilterSet + circuit_queryset = Circuit.objects.all() + circuit_filterset = CircuitFilterSet @classmethod def setUpTestData(cls): @@ -384,6 +390,19 @@ class DynamicFilterLookupExpressionTest(TestCase): ) Site.objects.bulk_create(sites) + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = [ + ASN(asn=65001, rir=rir), + ASN(asn=65101, rir=rir), + ASN(asn=65201, rir=rir) + ] + ASN.objects.bulk_create(asns) + + asns[0].sites.add(sites[0]) + asns[1].sites.add(sites[1]) + asns[2].sites.add(sites[2]) + racks = ( Rack(name='Rack 1', site=sites[0]), Rack(name='Rack 2', site=sites[1]),