Explorar o código

Initial work on #6732

Daniel Sheppard %!s(int64=4) %!d(string=hai) anos
pai
achega
a01068949c

+ 4 - 2
netbox/dcim/api/serializers.py

@@ -116,16 +116,18 @@ class SiteSerializer(PrimaryModelSerializer):
     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', '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

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

+ 14 - 2
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,
 )
 )
@@ -127,12 +128,23 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Group (slug)',
         label='Group (slug)',
     )
     )
+    asn_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='asns',
+        queryset=ASN.objects.all(),
+        label='AS (ID)',
+    )
+    asn = 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:
         model = Site
         model = Site
         fields = [
         fields = [
-            'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone',
+            'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'contact_name', 'contact_phone',
             'contact_email',
             'contact_email',
         ]
         ]
 
 
@@ -151,7 +163,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
             Q(comments__icontains=value)
             Q(comments__icontains=value)
         )
         )
         try:
         try:
-            qs_filter |= Q(asn=int(value.strip()))
+            qs_filter |= Q(asns=int(value.strip()))
         except ValueError:
         except ValueError:
             pass
             pass
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)

+ 7 - 8
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,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 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.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,
@@ -110,11 +110,10 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False
         required=False
     )
     )
-    asn = forms.IntegerField(
-        min_value=BGP_ASN_MIN,
-        max_value=BGP_ASN_MAX,
-        required=False,
-        label='ASN'
+    asns = DynamicModelChoiceField(
+        queryset=ASN.objects.all(),
+        label=_('ASNs'),
+        required=False
     )
     )
     description = forms.CharField(
     description = forms.CharField(
         max_length=100,
         max_length=100,
@@ -128,7 +127,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
 
 
     class Meta:
     class Meta:
         nullable_fields = [
         nullable_fields = [
-            'region', 'group', 'tenant', 'asn', 'description', 'time_zone',
+            'region', 'group', 'tenant', 'asns', 'description', 'time_zone',
         ]
         ]
 
 
 
 

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

@@ -94,7 +94,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,
@@ -143,11 +144,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', 'asns']
     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,
@@ -171,6 +173,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)
 
 
 
 

+ 9 - 4
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,
@@ -101,6 +102,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),
@@ -116,13 +122,13 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     class Meta:
     class Meta:
         model = Site
         model = Site
         fields = [
         fields = [
-            'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone',
+            'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone',
             'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
             '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', 'asns', 'time_zone', 'description', 'tags',
             )),
             )),
             ('Tenancy', ('tenant_group', 'tenant')),
             ('Tenancy', ('tenant_group', 'tenant')),
             ('Contact Info', (
             ('Contact Info', (
@@ -147,7 +153,6 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         help_texts = {
         help_texts = {
             'name': "Full name of the site",
             'name': "Full name of the site",
             '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)",

+ 1 - 7
netbox/dcim/models/sites.py

@@ -189,12 +189,6 @@ class Site(PrimaryModel):
         blank=True,
         blank=True,
         help_text='Local facility ID or description'
         help_text='Local facility ID or description'
     )
     )
-    asn = ASNField(
-        blank=True,
-        null=True,
-        verbose_name='ASN',
-        help_text='32-bit autonomous system number'
-    )
     time_zone = TimeZoneField(
     time_zone = TimeZoneField(
         blank=True
         blank=True
     )
     )
@@ -257,7 +251,7 @@ class Site(PrimaryModel):
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
     clone_fields = [
     clone_fields = [
-        'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
+        'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', 'physical_address',
         'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
         'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
     ]
     ]
 
 

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

@@ -75,6 +75,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(
@@ -84,11 +89,11 @@ class SiteTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Site
         model = Site
         fields = (
         fields = (
-            'pk', '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', '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')
 
 
 
 
 #
 #

+ 2 - 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 IPAddress, Prefix, Service, VLAN, ASN
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from netbox.views import generic
 from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
@@ -310,6 +310,7 @@ class SiteView(generic.ObjectView):
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         stats = {
         stats = {
+            'asn_count': ASN.objects.restrict(request.user, 'view').filter(sites=instance).count(),
             'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(),
             'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(),
             'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(),
             'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(),
             'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(),
             'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(),

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

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

@@ -17,6 +17,24 @@ from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
 from .nested_serializers import *
 from .nested_serializers import *
 
 
 
 
+#
+# ASNs
+#
+from ..models import ASN
+
+
+class ASNSerializer(PrimaryModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
+    tenant = NestedTenantSerializer(required=False, allow_null=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)
 
 

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

@@ -1,11 +1,13 @@
 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 *
 from netbox.api.views import ModelViewSet
 from netbox.api.views import ModelViewSet
 from utilities.utils import count_related
 from utilities.utils import count_related
 from . import mixins, serializers
 from . import mixins, serializers
+from ..models import ASN
 
 
 
 
 class IPAMRootView(APIRootView):
 class IPAMRootView(APIRootView):
@@ -16,6 +18,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
 #
 #

+ 39 - 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 OrganizationalModelFilterSet, PrimaryModelFilterSet
 from netbox.filtersets import 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',
     'IPAddressFilterSet',
     'IPAddressFilterSet',
     'IPRangeFilterSet',
     'IPRangeFilterSet',
     'PrefixFilterSet',
     'PrefixFilterSet',
@@ -31,6 +33,8 @@ __all__ = (
     'VRFFilterSet',
     'VRFFilterSet',
 )
 )
 
 
+from .models import ASN
+
 
 
 class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
 class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
@@ -174,6 +178,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',
     'IPAddressBulkEditForm',
     'IPAddressBulkEditForm',
     'IPRangeBulkEditForm',
     'IPRangeBulkEditForm',
     'PrefixBulkEditForm',
     'PrefixBulkEditForm',
@@ -89,6 +91,38 @@ class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         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(),

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

@@ -6,12 +6,14 @@ from extras.forms import CustomFieldModelCSVForm
 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 CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
 
 
 __all__ = (
 __all__ = (
     'AggregateCSVForm',
     'AggregateCSVForm',
+    'ASNCSVForm',
     'IPAddressCSVForm',
     'IPAddressCSVForm',
     'IPRangeCSVForm',
     'IPRangeCSVForm',
     'PrefixCSVForm',
     'PrefixCSVForm',
@@ -80,6 +82,31 @@ class AggregateCSVForm(CustomFieldModelCSVForm):
         fields = ('prefix', 'rir', 'tenant', 'date_added', 'description')
         fields = ('prefix', 'rir', 'tenant', 'date_added', 'description')
 
 
 
 
+class ASNCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+    rir = CSVModelChoiceField(
+        queryset=RIR.objects.all(),
+        to_field_name='name',
+        help_text='Assigned RIR'
+    )
+    sites = CSVModelChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='name',
+        help_text='Assigned site'
+    )
+    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',
     'IPAddressFilterForm',
     'IPAddressFilterForm',
     'IPRangeFilterForm',
     'IPRangeFilterForm',
     'PrefixFilterForm',
     'PrefixFilterForm',
@@ -136,6 +139,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
     field_groups = [
     field_groups = [

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

@@ -6,6 +6,7 @@ from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
 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 TenancyForm
 from tenancy.forms import TenancyForm
 from utilities.forms import (
 from utilities.forms import (
     BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
     BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
@@ -15,6 +16,7 @@ from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInter
 
 
 __all__ = (
 __all__ = (
     'AggregateForm',
     'AggregateForm',
+    'ASNForm',
     'IPAddressAssignForm',
     'IPAddressAssignForm',
     'IPAddressBulkAddForm',
     'IPAddressBulkAddForm',
     'IPAddressForm',
     'IPAddressForm',
@@ -118,6 +120,30 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         }
         }
 
 
 
 
+class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    rir = DynamicModelChoiceField(
+        queryset=RIR.objects.all(),
+        label='RIR',
+    )
+
+    class Meta:
+        model = ASN
+        fields = [
+            'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description'
+        ]
+        fieldsets = (
+            ('ASN', ('asn', 'rir', 'sites', 'description')),
+            ('Tenancy', ('tenant_group', 'tenant')),
+        )
+        help_texts = {
+            'asn': "AS number",
+            'rir': "Regional Internet Registry responsible for this prefix",
+        }
+        widgets = {
+            'date_added': DatePicker(),
+        }
+
+
 class RoleForm(BootstrapMixin, CustomFieldModelForm):
 class RoleForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
 
 

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

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

+ 48 - 0
netbox/ipam/models/ip.py

@@ -8,6 +8,7 @@ from django.db.models import F, Q
 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,52 @@ class RIR(OrganizationalModel):
         return reverse('ipam:rir', args=[self.pk])
         return reverse('ipam:rir', args=[self.pk])
 
 
 
 
+class ASN(PrimaryModel):
+
+    asn = ASNField(
+        blank=True,
+        null=True,
+        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
+    )
+    sites = models.ManyToManyField(
+        to='dcim.Site',
+        related_name='asns',
+        blank=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):
     """
     """

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

@@ -2,6 +2,7 @@ import django_tables2 as tables
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
+from ipam.models import ASN
 from tenancy.tables import TenantColumn
 from tenancy.tables import TenantColumn
 from utilities.tables import (
 from utilities.tables import (
     BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn,
     BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn,
@@ -11,6 +12,7 @@ from ipam.models import *
 
 
 __all__ = (
 __all__ = (
     'AggregateTable',
     'AggregateTable',
+    'ASNTable',
     'InterfaceIPAddressTable',
     'InterfaceIPAddressTable',
     'IPAddressAssignTable',
     'IPAddressAssignTable',
     'IPAddressTable',
     'IPAddressTable',
@@ -93,6 +95,29 @@ class RIRTable(BaseTable):
         default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
         default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
 
 
 
 
+#
+# RIRs
+#
+
+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
 #
 #

+ 32 - 0
netbox/ipam/tests/test_api.py

@@ -20,6 +20,38 @@ class AppTest(APITestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
+class ASNTest(APIViewTestCases.APIViewTestCase):
+    model = ASN
+    brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url']
+    create_data = [
+        {
+            'name': 'VRF 4',
+            'rd': '65000:4',
+        },
+        {
+            'name': 'VRF 5',
+            'rd': '65000:5',
+        },
+        {
+            'name': 'VRF 6',
+            'rd': '65000:6',
+        },
+    ]
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        vrfs = (
+            VRF(name='VRF 1', rd='65000:1'),
+            VRF(name='VRF 2', rd='65000:2'),
+            VRF(name='VRF 3'),  # No RD
+        )
+        VRF.objects.bulk_create(vrfs)
+
+
 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']

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

+ 62 - 1
netbox/ipam/views.py

@@ -2,7 +2,8 @@ from django.db.models import Prefetch
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 
 
-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.forms import TableConfigForm
 from utilities.forms import TableConfigForm
 from utilities.tables import paginate_table
 from utilities.tables import paginate_table
@@ -11,6 +12,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
 
 
 
 
@@ -195,6 +197,65 @@ 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_table = SiteTable(
+            list(instance.sites.all()),
+            orderable=False
+        )
+
+        return {
+            'sites_table': sites_table,
+        }
+
+
+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
 #
 #

+ 0 - 1
netbox/netbox/filtersets.py

@@ -153,7 +153,6 @@ class BaseFilterSet(django_filters.FilterSet):
                         # The filter field has been explicity defined on the filterset class so we must manually
                         # The filter field has been explicity defined on the filterset class so we must manually
                         # create the new filter with the same type because there is no guarantee the defined type
                         # create the new filter with the same type because there is no guarantee the defined type
                         # is the same as the default type for the field
                         # is the same as the default type for the field
-                        resolve_field(field, lookup_expr)  # Will raise FieldLookupError if the lookup is invalid
                         new_filter = type(existing_filter)(
                         new_filter = type(existing_filter)(
                             field_name=field_name,
                             field_name=field_name,
                             lookup_expr=lookup_expr,
                             lookup_expr=lookup_expr,

+ 6 - 0
netbox/netbox/navigation_menu.py

@@ -214,6 +214,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=(

+ 4 - 4
netbox/templates/dcim/site.html

@@ -80,10 +80,6 @@
                         <th scope="row">Description</th>
                         <th scope="row">Description</th>
                         <td>{{ object.description|placeholder }}</td>
                         <td>{{ object.description|placeholder }}</td>
                     </tr>
                     </tr>
-                    <tr>
-                        <th scope="row">AS Number</th>
-                        <td>{{ object.asn|placeholder }}</td>
-                    </tr>
                     <tr>
                     <tr>
                         <th scope="row">Time Zone</th>
                         <th scope="row">Time Zone</th>
                         <td>
                         <td>
@@ -216,6 +212,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>

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

@@ -0,0 +1,64 @@
+{% 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>
+        {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+        {% include 'inc/panels/custom_fields.html' %}
+        {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %}
+        {% plugin_right_page object %}
+    </div>
+</div>
+<div class="row mb-3">
+    <div class="col col-md-12">
+        {% include 'inc/panel_table.html' with table=sites_table heading='Sites' %}
+        {% plugin_full_width_page object %}
+    </div>
+</div>
+{% endblock %}