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

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/vrf.md!}
 {!models/ipam/routetarget.md!}
 {!models/ipam/routetarget.md!}
 
 
-__
+---
 
 
 {!models/ipam/fhrpgroup.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.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',
         ]
         ]
 
 
 
 

+ 2 - 1
netbox/dcim/api/views.py

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

+ 13 - 0
netbox/dcim/filtersets.py

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

+ 9 - 3
netbox/dcim/forms/bulk_edit.py

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

+ 1 - 1
netbox/dcim/forms/bulk_import.py

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

+ 9 - 1
netbox/dcim/forms/filtersets.py

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

+ 12 - 17
netbox/dcim/forms/models.py

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

+ 4 - 0
netbox/dcim/graphql/types.py

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

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

+ 9 - 4
netbox/dcim/tables/sites.py

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

+ 29 - 4
netbox/dcim/tests/test_views.py

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

+ 7 - 1
netbox/dcim/views.py

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

+ 13 - 0
netbox/ipam/api/nested_serializers.py

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

+ 18 - 0
netbox/ipam/api/serializers.py

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

+ 3 - 0
netbox/ipam/api/urls.py

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

+ 11 - 0
netbox/ipam/api/views.py

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

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

+ 35 - 1
netbox/ipam/forms/bulk_edit.py

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

+ 20 - 0
netbox/ipam/forms/bulk_import.py

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

+ 30 - 0
netbox/ipam/forms/filtersets.py

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

+ 46 - 0
netbox/ipam/forms/models.py

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

+ 3 - 0
netbox/ipam/graphql/schema.py

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

+ 13 - 0
netbox/ipam/graphql/types.py

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

+ 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 *
 from .vrfs import *
 
 
 __all__ = (
 __all__ = (
+    'ASN',
     'Aggregate',
     'Aggregate',
     'IPAddress',
     'IPAddress',
     'IPRange',
     '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.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):
     """
     """

+ 23 - 0
netbox/ipam/tables/ip.py

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

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

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

+ 55 - 0
netbox/ipam/tests/test_views.py

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

+ 12 - 0
netbox/ipam/urls.py

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

+ 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.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
 #
 #

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

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

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