Merge pull request #7662 from netbox-community/6732-asn-model

Closes: #6732 - Add new ASN model
This commit is contained in:
Jeremy Stretch 2021-11-03 14:15:24 -04:00 committed by GitHub
commit 8305f6d1f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 902 additions and 39 deletions

View File

@ -18,6 +18,10 @@
{!models/ipam/vrf.md!} {!models/ipam/vrf.md!}
{!models/ipam/routetarget.md!} {!models/ipam/routetarget.md!}
__ ---
{!models/ipam/fhrpgroup.md!} {!models/ipam/fhrpgroup.md!}
---
{!models/ipam/asn.md!}

15
docs/models/ipam/asn.md Normal file
View File

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

View File

@ -7,7 +7,7 @@ from timezone_field.rest_framework import TimeZoneSerializerField
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models 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 ipam.models import VLAN
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import ( from netbox.api.serializers import (
@ -113,21 +113,24 @@ class SiteSerializer(PrimaryModelSerializer):
region = NestedRegionSerializer(required=False, allow_null=True) region = NestedRegionSerializer(required=False, allow_null=True)
group = NestedSiteGroupSerializer(required=False, allow_null=True) group = NestedSiteGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(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) time_zone = TimeZoneSerializerField(required=False)
circuit_count = serializers.IntegerField(read_only=True) circuit_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True)
rack_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) virtualmachine_count = serializers.IntegerField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Site model = Site
fields = [ 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', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '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',
] ]

View File

@ -15,7 +15,7 @@ from circuits.models import Circuit
from dcim import filtersets from dcim import filtersets
from dcim.models import * from dcim.models import *
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet 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.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
@ -139,6 +139,7 @@ class SiteViewSet(CustomFieldModelViewSet):
queryset = Site.objects.prefetch_related( queryset = Site.objects.prefetch_related(
'region', 'tenant', 'tags' 'region', 'tenant', 'tags'
).annotate( ).annotate(
asn_count=count_related(ASN, 'sites'),
device_count=count_related(Device, 'site'), device_count=count_related(Device, 'site'),
rack_count=count_related(Rack, 'site'), rack_count=count_related(Rack, 'site'),
prefix_count=count_related(Prefix, 'site'), prefix_count=count_related(Prefix, 'site'),

View File

@ -3,6 +3,7 @@ from django.contrib.auth.models import User
from extras.filters import TagFilter from extras.filters import TagFilter
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import ASN
from netbox.filtersets import ( from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet, BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
) )
@ -130,6 +131,17 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
to_field_name='slug', to_field_name='slug',
label='Group (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() tag = TagFilter()
class Meta: class Meta:
@ -155,6 +167,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
) )
try: try:
qs_filter |= Q(asn=int(value.strip())) qs_filter |= Q(asn=int(value.strip()))
qs_filter |= Q(asns__asn=int(value.strip()))
except ValueError: except ValueError:
pass pass
return queryset.filter(qs_filter) return queryset.filter(qs_filter)

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.utils.translation import gettext as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
@ -6,8 +7,8 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.constants import BGP_ASN_MIN, BGP_ASN_MAX
from ipam.models import VLAN from ipam.models import VLAN, ASN
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField,
@ -116,6 +117,11 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
required=False, required=False,
label='ASN' label='ASN'
) )
asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('ASNs'),
required=False
)
description = forms.CharField( description = forms.CharField(
max_length=100, max_length=100,
required=False required=False
@ -128,7 +134,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
class Meta: class Meta:
nullable_fields = [ nullable_fields = [
'region', 'group', 'tenant', 'asn', 'description', 'time_zone', 'region', 'group', 'tenant', 'asn', 'asns', 'description', 'time_zone',
] ]

View File

@ -95,7 +95,7 @@ class SiteCSVForm(CustomFieldModelCSVForm):
class Meta: class Meta:
model = Site model = Site
fields = ( 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', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments', 'contact_email', 'comments',
) )

View File

@ -6,6 +6,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
from ipam.models import ASN
from tenancy.forms import TenancyFilterForm from tenancy.forms import TenancyFilterForm
from utilities.forms import ( from utilities.forms import (
APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect,
@ -138,11 +139,12 @@ class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
model = Site 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 = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['status', 'region_id', 'group_id'], ['status', 'region_id', 'group_id'],
['tenant_group_id', 'tenant_id'], ['tenant_group_id', 'tenant_id'],
['asn_id']
] ]
q = forms.CharField( q = forms.CharField(
required=False, required=False,
@ -166,6 +168,12 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
label=_('Site group'), label=_('Site group'),
fetch_trigger='open' fetch_trigger='open'
) )
asn_id = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
required=False,
label=_('ASNs'),
fetch_trigger='open'
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.utils.translation import gettext as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
@ -8,7 +9,7 @@ from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.forms import CustomFieldModelForm from extras.forms import CustomFieldModelForm
from extras.models import Tag 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 tenancy.forms import TenancyForm
from utilities.forms import ( from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField, APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField,
@ -110,6 +111,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
required=False required=False
) )
asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('ASNs'),
required=False
)
slug = SlugField() slug = SlugField()
time_zone = TimeZoneFormField( time_zone = TimeZoneFormField(
choices=add_blank_choice(TimeZoneFormField().choices), choices=add_blank_choice(TimeZoneFormField().choices),
@ -125,13 +131,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
class Meta: class Meta:
model = Site model = Site
fields = [ fields = [
'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'asns',
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
'contact_phone', 'contact_email', 'comments', 'tags', 'contact_phone', 'contact_email', 'comments', 'tags',
] ]
fieldsets = ( fieldsets = (
('Site', ( ('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')), ('Tenancy', ('tenant_group', 'tenant')),
('Contact Info', ( ('Contact Info', (
@ -155,8 +162,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
} }
help_texts = { help_texts = {
'name': "Full name of the site", '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)", 'facility': "Data center provider and facility (e.g. Equinix NY7)",
'asn': "BGP autonomous system number",
'time_zone': "Local time zone", 'time_zone': "Local time zone",
'description': "Short description (will appear in sites list)", 'description': "Short description (will appear in sites list)",
'physical_address': "Physical location of the building (e.g. for GPS)", 'physical_address': "Physical location of the building (e.g. for GPS)",
@ -791,7 +798,6 @@ class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm):
class DeviceVCMembershipForm(forms.ModelForm): class DeviceVCMembershipForm(forms.ModelForm):
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
@ -887,7 +893,6 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = ConsolePortTemplate model = ConsolePortTemplate
fields = [ fields = [
@ -899,7 +904,6 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = ConsoleServerPortTemplate model = ConsoleServerPortTemplate
fields = [ fields = [
@ -911,7 +915,6 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = PowerPortTemplate model = PowerPortTemplate
fields = [ fields = [
@ -923,7 +926,6 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = PowerOutletTemplate model = PowerOutletTemplate
fields = [ fields = [
@ -934,7 +936,6 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Limit power_port choices to current DeviceType # Limit power_port choices to current DeviceType
@ -945,7 +946,6 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = InterfaceTemplate model = InterfaceTemplate
fields = [ fields = [
@ -958,7 +958,6 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = FrontPortTemplate model = FrontPortTemplate
fields = [ fields = [
@ -970,7 +969,6 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Limit rear_port choices to current DeviceType # Limit rear_port choices to current DeviceType
@ -981,7 +979,6 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = RearPortTemplate model = RearPortTemplate
fields = [ fields = [
@ -994,7 +991,6 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = DeviceBayTemplate model = DeviceBayTemplate
fields = [ fields = [
@ -1257,7 +1253,6 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
) )
def __init__(self, device_bay, *args, **kwargs): def __init__(self, device_bay, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['installed_device'].queryset = Device.objects.filter( self.fields['installed_device'].queryset = Device.objects.filter(

View File

@ -1,8 +1,11 @@
import graphene
from dcim import filtersets, models from dcim import filtersets, models
from extras.graphql.mixins import ( from extras.graphql.mixins import (
ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
) )
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType
__all__ = ( __all__ = (
@ -380,6 +383,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType):
class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType):
asn = graphene.Field(BigInt)
class Meta: class Meta:
model = models.Site model = models.Site

View File

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

View File

@ -195,6 +195,11 @@ class Site(PrimaryModel):
verbose_name='ASN', verbose_name='ASN',
help_text='32-bit autonomous system number' help_text='32-bit autonomous system number'
) )
asns = models.ManyToManyField(
to='ipam.ASN',
related_name='sites',
blank=True
)
time_zone = TimeZoneField( time_zone = TimeZoneField(
blank=True blank=True
) )

View File

@ -81,6 +81,11 @@ class SiteTable(BaseTable):
group = tables.Column( group = tables.Column(
linkify=True linkify=True
) )
asn_count = LinkedCountColumn(
viewname='ipam:asn_list',
url_params={'site_id': 'pk'},
verbose_name='ASNs'
)
tenant = TenantColumn() tenant = TenantColumn()
comments = MarkdownColumn() comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
@ -90,11 +95,11 @@ class SiteTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Site model = Site
fields = ( fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description', 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
'contact_email', 'comments', 'tags', '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')
# #

View File

@ -4,7 +4,7 @@ from django.test import TestCase
from dcim.choices import * from dcim.choices import *
from dcim.filtersets import * from dcim.filtersets import *
from dcim.models import * from dcim.models import *
from ipam.models import IPAddress from ipam.models import ASN, IPAddress, RIR
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.testing import ChangeLoggedFilterSetTests from utilities.testing import ChangeLoggedFilterSetTests
@ -149,6 +149,23 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Site.objects.bulk_create(sites) 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): def test_name(self):
params = {'name': ['Site 1', 'Site 2']} params = {'name': ['Site 1', 'Site 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -165,6 +182,10 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'asn': [65001, 65002]} params = {'asn': [65001, 65002]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_latitude(self):
params = {'latitude': [10, 20]} params = {'latitude': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -11,7 +11,7 @@ from netaddr import EUI
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from ipam.models import VLAN from ipam.models import ASN, VLAN, RIR
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.testing import ViewTestCases, create_tags, create_test_device from utilities.testing import ViewTestCases, create_tags, create_test_device
@ -110,7 +110,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
for group in groups: for group in groups:
group.save() 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 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 2', slug='site-2', region=regions[0], group=groups[1]),
Site(name='Site 3', slug='site-3', 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') 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 = { cls.form_data = {
'name': 'Site X', 'name': 'Site X',
'slug': 'site-x', 'slug': 'site-x',
@ -126,7 +153,6 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'group': groups[1].pk, 'group': groups[1].pk,
'tenant': None, 'tenant': None,
'facility': 'Facility X', 'facility': 'Facility X',
'asn': 65001,
'time_zone': pytz.UTC, 'time_zone': pytz.UTC,
'description': 'Site description', 'description': 'Site description',
'physical_address': '742 Evergreen Terrace, Springfield, USA', 'physical_address': '742 Evergreen Terrace, Springfield, USA',
@ -152,7 +178,6 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'region': regions[1].pk, 'region': regions[1].pk,
'group': groups[1].pk, 'group': groups[1].pk,
'tenant': None, 'tenant': None,
'asn': 65009,
'time_zone': pytz.timezone('US/Eastern'), 'time_zone': pytz.timezone('US/Eastern'),
'description': 'New description', 'description': 'New description',
} }

View File

@ -14,7 +14,7 @@ from django.views.generic import View
from circuits.models import Circuit from circuits.models import Circuit
from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView 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 ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
@ -332,9 +332,15 @@ class SiteView(generic.ObjectView):
cumulative=True cumulative=True
).restrict(request.user, 'view').filter(site=instance) ).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 { return {
'stats': stats, 'stats': stats,
'locations': locations, 'locations': locations,
'asns': asns,
} }

View File

@ -5,6 +5,7 @@ from netbox.api import WritableNestedSerializer
__all__ = [ __all__ = [
'NestedAggregateSerializer', 'NestedAggregateSerializer',
'NestedASNSerializer',
'NestedFHRPGroupSerializer', 'NestedFHRPGroupSerializer',
'NestedIPAddressSerializer', 'NestedIPAddressSerializer',
'NestedIPRangeSerializer', '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 # VRFs
# #

View File

@ -16,6 +16,24 @@ from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
from .nested_serializers import * 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 # VRFs
# #

View File

@ -5,6 +5,9 @@ from . import views
router = OrderedDefaultRouter() router = OrderedDefaultRouter()
router.APIRootView = views.IPAMRootView router.APIRootView = views.IPAMRootView
# ASNs
router.register('asns', views.ASNViewSet)
# VRFs # VRFs
router.register('vrfs', views.VRFViewSet) router.register('vrfs', views.VRFViewSet)

View File

@ -1,5 +1,6 @@
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from dcim.models import Site
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
from ipam import filtersets from ipam import filtersets
from ipam.models import * from ipam.models import *
@ -16,6 +17,16 @@ class IPAMRootView(APIRootView):
return 'IPAM' 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 # VRFs
# #

View File

@ -9,6 +9,7 @@ from dcim.models import Device, Interface, Region, Site, SiteGroup
from extras.filters import TagFilter from extras.filters import TagFilter
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from tenancy.models import Tenant
from utilities.filters import ( from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
) )
@ -19,6 +20,7 @@ from .models import *
__all__ = ( __all__ = (
'AggregateFilterSet', 'AggregateFilterSet',
'ASNFilterSet',
'FHRPGroupAssignmentFilterSet', 'FHRPGroupAssignmentFilterSet',
'FHRPGroupFilterSet', 'FHRPGroupFilterSet',
'IPAddressFilterSet', 'IPAddressFilterSet',
@ -177,6 +179,41 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
return queryset.none() 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): class RoleFilterSet(OrganizationalModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -5,14 +5,16 @@ from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.models import * from ipam.models import *
from ipam.models import ASN
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField,
StaticSelect, StaticSelect, DynamicModelMultipleChoiceField,
) )
__all__ = ( __all__ = (
'AggregateBulkEditForm', 'AggregateBulkEditForm',
'ASNBulkEditForm',
'FHRPGroupBulkEditForm', 'FHRPGroupBulkEditForm',
'IPAddressBulkEditForm', 'IPAddressBulkEditForm',
'IPRangeBulkEditForm', 'IPRangeBulkEditForm',
@ -90,6 +92,38 @@ class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEdi
nullable_fields = ['is_private', 'description'] 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): class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Aggregate.objects.all(), queryset=Aggregate.objects.all(),

View File

@ -12,6 +12,7 @@ from virtualization.models import VirtualMachine, VMInterface
__all__ = ( __all__ = (
'AggregateCSVForm', 'AggregateCSVForm',
'ASNCSVForm',
'FHRPGroupCSVForm', 'FHRPGroupCSVForm',
'IPAddressCSVForm', 'IPAddressCSVForm',
'IPRangeCSVForm', 'IPRangeCSVForm',
@ -81,6 +82,25 @@ class AggregateCSVForm(CustomFieldModelCSVForm):
fields = ('prefix', 'rir', 'tenant', 'date_added', 'description') 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): class RoleCSVForm(CustomFieldModelCSVForm):
slug = SlugField() slug = SlugField()

View File

@ -6,7 +6,9 @@ from extras.forms import CustomFieldModelFilterForm
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.models import * from ipam.models import *
from ipam.models import ASN
from tenancy.forms import TenancyFilterForm from tenancy.forms import TenancyFilterForm
from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
@ -14,6 +16,7 @@ from utilities.forms import (
__all__ = ( __all__ = (
'AggregateFilterForm', 'AggregateFilterForm',
'ASNFilterForm',
'FHRPGroupFilterForm', 'FHRPGroupFilterForm',
'IPAddressFilterForm', 'IPAddressFilterForm',
'IPRangeFilterForm', 'IPRangeFilterForm',
@ -134,6 +137,33 @@ class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil
tag = TagFilterField(model) 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): class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
model = Role model = Role
q = forms.CharField( q = forms.CharField(

View File

@ -8,6 +8,7 @@ from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.formfields import IPNetworkFormField from ipam.formfields import IPNetworkFormField
from ipam.models import * from ipam.models import *
from ipam.models import ASN
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from utilities.exceptions import PermissionsViolation from utilities.exceptions import PermissionsViolation
from utilities.forms import ( from utilities.forms import (
@ -18,6 +19,7 @@ from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInter
__all__ = ( __all__ = (
'AggregateForm', 'AggregateForm',
'ASNForm',
'FHRPGroupForm', 'FHRPGroupForm',
'FHRPGroupAssignmentForm', 'FHRPGroupAssignmentForm',
'IPAddressAssignForm', '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): class RoleForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(

View File

@ -5,6 +5,9 @@ from .types import *
class IPAMQuery(graphene.ObjectType): class IPAMQuery(graphene.ObjectType):
asn = ObjectField(ASNType)
asn_list = ObjectListField(ASNType)
aggregate = ObjectField(AggregateType) aggregate = ObjectField(AggregateType)
aggregate_list = ObjectListField(AggregateType) aggregate_list = ObjectListField(AggregateType)

View File

@ -1,7 +1,11 @@
import graphene
from ipam import filtersets, models from ipam import filtersets, models
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType
__all__ = ( __all__ = (
'ASNType',
'AggregateType', 'AggregateType',
'FHRPGroupType', 'FHRPGroupType',
'FHRPGroupAssignmentType', '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 AggregateType(PrimaryObjectType):
class Meta: class Meta:

View File

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

View File

@ -5,6 +5,7 @@ from .vlans import *
from .vrfs import * from .vrfs import *
__all__ = ( __all__ = (
'ASN',
'Aggregate', 'Aggregate',
'IPAddress', 'IPAddress',
'IPRange', 'IPRange',

View File

@ -7,6 +7,7 @@ from django.db.models import F
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from dcim.fields import ASNField
from dcim.models import Device from dcim.models import Device
from extras.utils import extras_features from extras.utils import extras_features
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
@ -23,6 +24,7 @@ from virtualization.models import VirtualMachine
__all__ = ( __all__ = (
'Aggregate', 'Aggregate',
'ASN',
'IPAddress', 'IPAddress',
'IPRange', 'IPRange',
'Prefix', 'Prefix',
@ -69,6 +71,49 @@ class RIR(OrganizationalModel):
return reverse('ipam:rir', args=[self.pk]) 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') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Aggregate(PrimaryModel): class Aggregate(PrimaryModel):
""" """

View File

@ -11,6 +11,7 @@ from ipam.models import *
__all__ = ( __all__ = (
'AggregateTable', 'AggregateTable',
'ASNTable',
'AssignedIPAddressesTable', 'AssignedIPAddressesTable',
'IPAddressAssignTable', 'IPAddressAssignTable',
'IPAddressTable', 'IPAddressTable',
@ -96,6 +97,28 @@ class RIRTable(BaseTable):
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') 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 # Aggregates
# #

View File

@ -7,6 +7,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 * from ipam.models import *
from tenancy.models import Tenant
from utilities.testing import APITestCase, APIViewTestCases, disable_warnings from utilities.testing import APITestCase, APIViewTestCases, disable_warnings
@ -20,6 +21,58 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200) 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): class VRFTest(APIViewTestCases.APIViewTestCase):
model = VRF model = VRF
brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url'] brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url']

View File

@ -9,6 +9,83 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac
from tenancy.models import Tenant, TenantGroup 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): class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VRF.objects.all() queryset = VRF.objects.all()
filterset = VRFFilterSet filterset = VRFFilterSet

View File

@ -9,6 +9,61 @@ from tenancy.models import Tenant
from utilities.testing import ViewTestCases, create_tags 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): class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VRF model = VRF

View File

@ -7,6 +7,18 @@ from .models import *
app_name = 'ipam' app_name = 'ipam'
urlpatterns = [ 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/<int:pk>/', views.ASNView.as_view(), name='asn'),
path('asns/<int:pk>/edit/', views.ASNEditView.as_view(), name='asn_edit'),
path('asns/<int:pk>/delete/', views.ASNDeleteView.as_view(), name='asn_delete'),
path('asns/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='asn_changelog', kwargs={'model': ASN}),
path('asns/<int:pk>/journal/', ObjectJournalView.as_view(), name='asn_journal', kwargs={'model': ASN}),
# VRFs # VRFs
path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), path('vrfs/', views.VRFListView.as_view(), name='vrf_list'),
path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'), path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'),

View File

@ -5,7 +5,8 @@ from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse 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 netbox.views import generic
from utilities.tables import paginate_table from utilities.tables import paginate_table
from utilities.utils import count_related from utilities.utils import count_related
@ -13,6 +14,7 @@ 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 * from .models import *
from .models import ASN
from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans
@ -197,6 +199,62 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
table = tables.RIRTable 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 # Aggregates
# #

View File

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

View File

@ -229,6 +229,12 @@ IPAM_MENU = Menu(
get_model_item('ipam', 'role', 'Prefix & VLAN Roles'), get_model_item('ipam', 'role', 'Prefix & VLAN Roles'),
), ),
), ),
MenuGroup(
label='ASNs',
items=(
get_model_item('ipam', 'asn', 'ASNs'),
),
),
MenuGroup( MenuGroup(
label='Aggregates', label='Aggregates',
items=( items=(

View File

@ -215,6 +215,10 @@
<h2><a href="{% url 'virtualization:virtualmachine_list' %}?site_id={{ object.pk }}" class="btn {% if stats.vm_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vm_count }}</a></h2> <h2><a href="{% url 'virtualization:virtualmachine_list' %}?site_id={{ object.pk }}" class="btn {% if stats.vm_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vm_count }}</a></h2>
<p>Virtual Machines</p> <p>Virtual Machines</p>
</div> </div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:asn_list' %}?site_id={{ object.pk }}" class="btn {% if stats.asn_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.asn_count }}</a></h2>
<p>ASNs</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -256,6 +260,20 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="card">
<h5 class="card-header">
ASNs
</h5>
<div class='card-body'>
{% if asns %}
{% for asn in asns %}
<a href="{{ asn.get_absolute_url }}"><span class="badge bg-primary">{{ asn }}</span></a>
{% endfor %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -0,0 +1,77 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load helpers %}
{% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'ipam:asn_list' %}?rir_id={{ object.rir.pk }}">{{ object.rir }}</a></li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
ASN
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<td>AS Number</td>
<td>{{ object.asn }}</td>
</tr>
<tr>
<td>RIR</td>
<td>
<a href="{% url 'ipam:asn_list' %}?rir={{ object.rir.slug }}">{{ object.rir }}</a>
</td>
</tr>
<tr>
<td>Tenant</td>
<td>
{% if object.tenant %}
{% if prefix.object.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>
<td>Description</td>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Sites
</h5>
<div class='card-body'>
{% if sites %}
{% for site in sites %}
<a href="{{ site.get_absolute_url }}"><span class="badge bg-primary">{{ site }}</span></a>
{% endfor %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -5,6 +5,9 @@ from django.test import TestCase
from mptt.fields import TreeForeignKey from mptt.fields import TreeForeignKey
from taggit.managers import TaggableManager 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.choices import *
from dcim.fields import MACAddressField from dcim.fields import MACAddressField
from dcim.filtersets import DeviceFilterSet, SiteFilterSet from dcim.filtersets import DeviceFilterSet, SiteFilterSet
@ -13,6 +16,7 @@ from dcim.models import (
) )
from extras.filters import TagFilter from extras.filters import TagFilter
from extras.models import TaggedItem from extras.models import TaggedItem
from ipam.models import RIR, ASN
from netbox.filtersets import BaseFilterSet from netbox.filtersets import BaseFilterSet
from utilities.filters import ( from utilities.filters import (
MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueNumberFilter, MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueNumberFilter,
@ -337,6 +341,8 @@ class DynamicFilterLookupExpressionTest(TestCase):
device_filterset = DeviceFilterSet device_filterset = DeviceFilterSet
site_queryset = Site.objects.all() site_queryset = Site.objects.all()
site_filterset = SiteFilterSet site_filterset = SiteFilterSet
circuit_queryset = Circuit.objects.all()
circuit_filterset = CircuitFilterSet
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -384,6 +390,19 @@ class DynamicFilterLookupExpressionTest(TestCase):
) )
Site.objects.bulk_create(sites) 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 = ( racks = (
Rack(name='Rack 1', site=sites[0]), Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]), Rack(name='Rack 2', site=sites[1]),