Просмотр исходного кода

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

Closes: #6732 - Add new ASN model
Jeremy Stretch 4 лет назад
Родитель
Сommit
8305f6d1f5
41 измененных файлов с 902 добавлено и 39 удалено
  1. 5 1
      docs/core-functionality/ipam.md
  2. 15 0
      docs/models/ipam/asn.md
  3. 6 3
      netbox/dcim/api/serializers.py
  4. 2 1
      netbox/dcim/api/views.py
  5. 13 0
      netbox/dcim/filtersets.py
  6. 9 3
      netbox/dcim/forms/bulk_edit.py
  7. 1 1
      netbox/dcim/forms/bulk_import.py
  8. 9 1
      netbox/dcim/forms/filtersets.py
  9. 12 17
      netbox/dcim/forms/models.py
  10. 4 0
      netbox/dcim/graphql/types.py
  11. 19 0
      netbox/dcim/migrations/0141_asn_model.py
  12. 5 0
      netbox/dcim/models/sites.py
  13. 9 4
      netbox/dcim/tables/sites.py
  14. 22 1
      netbox/dcim/tests/test_filtersets.py
  15. 29 4
      netbox/dcim/tests/test_views.py
  16. 7 1
      netbox/dcim/views.py
  17. 13 0
      netbox/ipam/api/nested_serializers.py
  18. 18 0
      netbox/ipam/api/serializers.py
  19. 3 0
      netbox/ipam/api/urls.py
  20. 11 0
      netbox/ipam/api/views.py
  21. 37 0
      netbox/ipam/filtersets.py
  22. 35 1
      netbox/ipam/forms/bulk_edit.py
  23. 20 0
      netbox/ipam/forms/bulk_import.py
  24. 30 0
      netbox/ipam/forms/filtersets.py
  25. 46 0
      netbox/ipam/forms/models.py
  26. 3 0
      netbox/ipam/graphql/schema.py
  27. 13 0
      netbox/ipam/graphql/types.py
  28. 38 0
      netbox/ipam/migrations/0053_asn_model.py
  29. 1 0
      netbox/ipam/models/__init__.py
  30. 45 0
      netbox/ipam/models/ip.py
  31. 23 0
      netbox/ipam/tables/ip.py
  32. 53 0
      netbox/ipam/tests/test_api.py
  33. 77 0
      netbox/ipam/tests/test_filtersets.py
  34. 55 0
      netbox/ipam/tests/test_views.py
  35. 12 0
      netbox/ipam/urls.py
  36. 59 1
      netbox/ipam/views.py
  37. 23 0
      netbox/netbox/graphql/scalars.py
  38. 6 0
      netbox/netbox/navigation_menu.py
  39. 18 0
      netbox/templates/dcim/site.html
  40. 77 0
      netbox/templates/ipam/asn.html
  41. 19 0
      netbox/utilities/tests/test_filters.py

+ 5 - 1
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!}

+ 15 - 0
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
+
+

+ 6 - 3
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',
         ]
 
 

+ 2 - 1
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'),

+ 13 - 0
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)

+ 9 - 3
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',
         ]
 
 

+ 1 - 1
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',
         )

+ 9 - 1
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)
 
 

+ 12 - 17
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(

+ 4 - 0
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

+ 19 - 0
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'),
+        ),
+    ]

+ 5 - 0
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
     )

+ 9 - 4
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')
 
 
 #

+ 22 - 1
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)

+ 29 - 4
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',
         }

+ 7 - 1
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,
         }
 
 

+ 13 - 0
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
 #

+ 18 - 0
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
 #

+ 3 - 0
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)
 

+ 11 - 0
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
 #

+ 37 - 0
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',

+ 35 - 1
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(),

+ 20 - 0
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()
 

+ 30 - 0
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(

+ 46 - 0
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(

+ 3 - 0
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)
 

+ 13 - 0
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:

+ 38 - 0
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'],
+            },
+        ),
+    ]

+ 1 - 0
netbox/ipam/models/__init__.py

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

+ 45 - 0
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):
     """

+ 23 - 0
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
 #

+ 53 - 0
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']

+ 77 - 0
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

+ 55 - 0
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
 

+ 12 - 0
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/<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
     path('vrfs/', views.VRFListView.as_view(), name='vrf_list'),
     path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'),

+ 59 - 1
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
 #

+ 23 - 0
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)

+ 6 - 0
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=(

+ 18 - 0
netbox/templates/dcim/site.html

@@ -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>
                         <p>Virtual Machines</p>
                     </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>
@@ -256,6 +260,20 @@
               {% endif %}
             </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' %}
         {% plugin_right_page object %}
 	</div>

+ 77 - 0
netbox/templates/ipam/asn.html

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

+ 19 - 0
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]),