Daniel Sheppard 4 лет назад
Родитель
Сommit
a01068949c

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

@@ -116,16 +116,18 @@ class SiteSerializer(PrimaryModelSerializer):
     device_count = serializers.IntegerField(read_only=True)
     prefix_count = serializers.IntegerField(read_only=True)
     rack_count = serializers.IntegerField(read_only=True)
+    asn_count = serializers.IntegerField(read_only=True)
     virtualmachine_count = serializers.IntegerField(read_only=True)
     vlan_count = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = Site
         fields = [
-            'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn',
+            'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asns',
             'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
             'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
-            'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
+            'asn_count', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count',
+            'vlan_count',
         ]
 
 

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

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

+ 14 - 2
netbox/dcim/filtersets.py

@@ -3,6 +3,7 @@ from django.contrib.auth.models import User
 
 from extras.filters import TagFilter
 from extras.filtersets import LocalConfigContextFilterSet
+from ipam.models import ASN
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
 )
@@ -127,12 +128,23 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         to_field_name='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()
 
     class Meta:
         model = Site
         fields = [
-            'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone',
+            'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'contact_name', 'contact_phone',
             'contact_email',
         ]
 
@@ -151,7 +163,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
             Q(comments__icontains=value)
         )
         try:
-            qs_filter |= Q(asn=int(value.strip()))
+            qs_filter |= Q(asns=int(value.strip()))
         except ValueError:
             pass
         return queryset.filter(qs_filter)

+ 7 - 8
netbox/dcim/forms/bulk_edit.py

@@ -1,4 +1,5 @@
 from django import forms
+from django.utils.translation import gettext as _
 from django.contrib.auth.models import User
 from timezone_field import TimeZoneFormField
 
@@ -6,8 +7,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
 from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
-from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
-from ipam.models import VLAN
+from ipam.models import VLAN, ASN
 from tenancy.models import Tenant
 from utilities.forms import (
     add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField,
@@ -110,11 +110,10 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
         queryset=Tenant.objects.all(),
         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(
         max_length=100,
@@ -128,7 +127,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
 
     class Meta:
         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:
         model = Site
         fields = (
-            'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description',
+            'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description',
             'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
             'contact_email', 'comments',
         )

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

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

+ 9 - 4
netbox/dcim/forms/models.py

@@ -1,4 +1,5 @@
 from django import forms
+from django.utils.translation import gettext as _
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from timezone_field import TimeZoneFormField
@@ -8,7 +9,7 @@ from dcim.constants import *
 from dcim.models import *
 from extras.forms import CustomFieldModelForm
 from extras.models import Tag
-from ipam.models import IPAddress, VLAN, VLANGroup
+from ipam.models import IPAddress, VLAN, VLANGroup, ASN
 from tenancy.forms import TenancyForm
 from utilities.forms import (
     APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField,
@@ -101,6 +102,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         queryset=SiteGroup.objects.all(),
         required=False
     )
+    asns = DynamicModelMultipleChoiceField(
+        queryset=ASN.objects.all(),
+        label=_('ASNs'),
+        required=False
+    )
     slug = SlugField()
     time_zone = TimeZoneFormField(
         choices=add_blank_choice(TimeZoneFormField().choices),
@@ -116,13 +122,13 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     class Meta:
         model = Site
         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',
             'contact_phone', 'contact_email', 'comments', 'tags',
         ]
         fieldsets = (
             ('Site', (
-                'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'time_zone', 'description', 'tags',
+                'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags',
             )),
             ('Tenancy', ('tenant_group', 'tenant')),
             ('Contact Info', (
@@ -147,7 +153,6 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         help_texts = {
             'name': "Full name of the site",
             'facility': "Data center provider and facility (e.g. Equinix NY7)",
-            'asn': "BGP autonomous system number",
             'time_zone': "Local time zone",
             'description': "Short description (will appear in sites list)",
             'physical_address': "Physical location of the building (e.g. for GPS)",

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

@@ -189,12 +189,6 @@ class Site(PrimaryModel):
         blank=True,
         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(
         blank=True
     )
@@ -257,7 +251,7 @@ class Site(PrimaryModel):
     objects = RestrictedQuerySet.as_manager()
 
     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',
     ]
 

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

@@ -75,6 +75,11 @@ class SiteTable(BaseTable):
     group = tables.Column(
         linkify=True
     )
+    asn_count = LinkedCountColumn(
+        viewname='ipam:asn_list',
+        url_params={'site_id': 'pk'},
+        verbose_name='ASNs'
+    )
     tenant = TenantColumn()
     comments = MarkdownColumn()
     tags = TagColumn(
@@ -84,11 +89,11 @@ class SiteTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Site
         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 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 netbox.views import generic
 from utilities.forms import ConfirmationForm
@@ -310,6 +310,7 @@ class SiteView(generic.ObjectView):
 
     def get_extra_context(self, request, instance):
         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(),
             'device_count': Device.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__ = [
     'NestedAggregateSerializer',
+    'NestedASNSerializer',
     'NestedIPAddressSerializer',
     'NestedIPRangeSerializer',
     '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
 #

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

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

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

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

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

@@ -1,11 +1,13 @@
 from rest_framework.routers import APIRootView
 
+from dcim.models import Site
 from extras.api.views import CustomFieldModelViewSet
 from ipam import filtersets
 from ipam.models import *
 from netbox.api.views import ModelViewSet
 from utilities.utils import count_related
 from . import mixins, serializers
+from ..models import ASN
 
 
 class IPAMRootView(APIRootView):
@@ -16,6 +18,16 @@ class IPAMRootView(APIRootView):
         return 'IPAM'
 
 
+#
+# ASNs
+#
+
+class ASNViewSet(CustomFieldModelViewSet):
+    queryset = ASN.objects.prefetch_related('tenant', 'rir').annotate(site_count=count_related(Site, 'asns'))
+    serializer_class = serializers.ASNSerializer
+    filterset_class = filtersets.ASNFilterSet
+
+
 #
 # VRFs
 #

+ 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 netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
 from tenancy.filtersets import TenancyFilterSet
+from tenancy.models import Tenant
 from utilities.filters import (
     ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
 )
@@ -19,6 +20,7 @@ from .models import *
 
 __all__ = (
     'AggregateFilterSet',
+    'ASNFilterSet',
     'IPAddressFilterSet',
     'IPRangeFilterSet',
     'PrefixFilterSet',
@@ -31,6 +33,8 @@ __all__ = (
     'VRFFilterSet',
 )
 
+from .models import ASN
+
 
 class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     q = django_filters.CharFilter(
@@ -174,6 +178,41 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
             return queryset.none()
 
 
+class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
+
+    rir_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=RIR.objects.all(),
+        label='RIR (ID)',
+    )
+    rir = django_filters.ModelMultipleChoiceFilter(
+        field_name='rir__slug',
+        queryset=RIR.objects.all(),
+        to_field_name='slug',
+        label='RIR (slug)',
+    )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='sites',
+        queryset=Site.objects.all(),
+        label='Site (ID)',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        field_name='sites__slug',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label='Site (slug)',
+    )
+
+    class Meta:
+        model = ASN
+        fields = ['id', 'asn']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = Q(Q(description__icontains=value) | Q(asn__icontains=value))
+        return queryset.filter(qs_filter)
+
+
 class RoleFilterSet(OrganizationalModelFilterSet):
     q = django_filters.CharFilter(
         method='search',

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

@@ -5,14 +5,16 @@ from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from ipam.choices import *
 from ipam.constants import *
 from ipam.models import *
+from ipam.models import ASN
 from tenancy.models import Tenant
 from utilities.forms import (
     add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField,
-    StaticSelect,
+    StaticSelect, DynamicModelMultipleChoiceField,
 )
 
 __all__ = (
     'AggregateBulkEditForm',
+    'ASNBulkEditForm',
     'IPAddressBulkEditForm',
     'IPRangeBulkEditForm',
     'PrefixBulkEditForm',
@@ -89,6 +91,38 @@ class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['is_private', 'description']
 
 
+class ASNBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ASN.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    sites = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False
+    )
+    rir = DynamicModelChoiceField(
+        queryset=RIR.objects.all(),
+        required=False,
+        label='RIR'
+    )
+    tenant = DynamicModelChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'date_added', 'description',
+        ]
+        widgets = {
+            'date_added': DatePicker(),
+        }
+
+
 class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Aggregate.objects.all(),

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

@@ -6,12 +6,14 @@ from extras.forms import CustomFieldModelCSVForm
 from ipam.choices import *
 from ipam.constants import *
 from ipam.models import *
+from ipam.models import ASN
 from tenancy.models import Tenant
 from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
 from virtualization.models import VirtualMachine, VMInterface
 
 __all__ = (
     'AggregateCSVForm',
+    'ASNCSVForm',
     'IPAddressCSVForm',
     'IPRangeCSVForm',
     'PrefixCSVForm',
@@ -80,6 +82,31 @@ class AggregateCSVForm(CustomFieldModelCSVForm):
         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):
     slug = SlugField()
 

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

@@ -6,7 +6,9 @@ from extras.forms import CustomFieldModelFilterForm
 from ipam.choices import *
 from ipam.constants import *
 from ipam.models import *
+from ipam.models import ASN
 from tenancy.forms import TenancyFilterForm
+from tenancy.models import Tenant
 from utilities.forms import (
     add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect,
     StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
@@ -14,6 +16,7 @@ from utilities.forms import (
 
 __all__ = (
     'AggregateFilterForm',
+    'ASNFilterForm',
     'IPAddressFilterForm',
     'IPRangeFilterForm',
     'PrefixFilterForm',
@@ -136,6 +139,33 @@ class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil
     tag = TagFilterField(model)
 
 
+class ASNFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = ASN
+    field_groups = [
+        ['q'],
+        ['rir_id'],
+        ['tenant_group_id', 'tenant_id'],
+        ['site_id'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+    rir_id = DynamicModelMultipleChoiceField(
+        queryset=RIR.objects.all(),
+        required=False,
+        label=_('RIR'),
+        fetch_trigger='open'
+    )
+    site_id = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        label=_('Site'),
+        fetch_trigger='open'
+    )
+
+
 class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     model = Role
     field_groups = [

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

@@ -6,6 +6,7 @@ from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from ipam.constants import *
 from ipam.models import *
+from ipam.models import ASN
 from tenancy.forms import TenancyForm
 from utilities.forms import (
     BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
@@ -15,6 +16,7 @@ from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInter
 
 __all__ = (
     'AggregateForm',
+    'ASNForm',
     'IPAddressAssignForm',
     'IPAddressBulkAddForm',
     '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):
     slug = SlugField()
 

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

@@ -4,6 +4,7 @@ from .vlans import *
 from .vrfs import *
 
 __all__ = (
+    'ASN',
     'Aggregate',
     'IPAddress',
     '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.utils.functional import cached_property
 
+from dcim.fields import ASNField
 from dcim.models import Device
 from extras.utils import extras_features
 from netbox.models import OrganizationalModel, PrimaryModel
@@ -23,6 +24,7 @@ from virtualization.models import VirtualMachine
 
 __all__ = (
     'Aggregate',
+    'ASN',
     'IPAddress',
     'IPRange',
     'Prefix',
@@ -69,6 +71,52 @@ class RIR(OrganizationalModel):
         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')
 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_tables2.utils import Accessor
 
+from ipam.models import ASN
 from tenancy.tables import TenantColumn
 from utilities.tables import (
     BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn,
@@ -11,6 +12,7 @@ from ipam.models import *
 
 __all__ = (
     'AggregateTable',
+    'ASNTable',
     'InterfaceIPAddressTable',
     'IPAddressAssignTable',
     'IPAddressTable',
@@ -93,6 +95,29 @@ class RIRTable(BaseTable):
         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
 #

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

@@ -20,6 +20,38 @@ class AppTest(APITestCase):
         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):
     model = VRF
     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'
 urlpatterns = [
 
+    # ASNs
+    path('asns/', views.ASNListView.as_view(), name='asn_list'),
+    path('asns/add/', views.ASNEditView.as_view(), name='asn_add'),
+    path('asns/import/', views.ASNBulkImportView.as_view(), name='asn_import'),
+    path('asns/edit/', views.ASNBulkEditView.as_view(), name='asn_bulk_edit'),
+    path('asns/delete/', views.ASNBulkDeleteView.as_view(), name='asn_bulk_delete'),
+    path('asns/<int:pk>/', views.ASNView.as_view(), name='asn'),
+    path('asns/<int:pk>/edit/', views.ASNEditView.as_view(), name='asn_edit'),
+    path('asns/<int:pk>/delete/', views.ASNDeleteView.as_view(), name='asn_delete'),
+    path('asns/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='asn_changelog', kwargs={'model': ASN}),
+    path('asns/<int:pk>/journal/', ObjectJournalView.as_view(), name='asn_journal', kwargs={'model': ASN}),
+
     # VRFs
     path('vrfs/', views.VRFListView.as_view(), name='vrf_list'),
     path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'),

+ 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.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 utilities.forms import TableConfigForm
 from utilities.tables import paginate_table
@@ -11,6 +12,7 @@ from virtualization.models import VirtualMachine, VMInterface
 from . import filtersets, forms, tables
 from .constants import *
 from .models import *
+from .models import ASN
 from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans
 
 
@@ -195,6 +197,65 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
     table = tables.RIRTable
 
 
+#
+# ASNs
+#
+
+class ASNListView(generic.ObjectListView):
+    queryset = ASN.objects.annotate(
+        site_count=count_related(Site, 'asns'),
+    )
+    filterset = filtersets.ASNFilterSet
+    filterset_form = forms.ASNFilterForm
+    table = tables.ASNTable
+
+
+class ASNView(generic.ObjectView):
+    queryset = ASN.objects.all()
+
+    def get_extra_context(self, request, instance):
+        sites_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
 #

+ 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
                         # 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
-                        resolve_field(field, lookup_expr)  # Will raise FieldLookupError if the lookup is invalid
                         new_filter = type(existing_filter)(
                             field_name=field_name,
                             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'),
             ),
         ),
+        MenuGroup(
+            label='ASNs',
+            items=(
+                get_model_item('ipam', 'asn', 'ASNs'),
+            ),
+        ),
         MenuGroup(
             label='Aggregates',
             items=(

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

@@ -80,10 +80,6 @@
                         <th scope="row">Description</th>
                         <td>{{ object.description|placeholder }}</td>
                     </tr>
-                    <tr>
-                        <th scope="row">AS Number</th>
-                        <td>{{ object.asn|placeholder }}</td>
-                    </tr>
                     <tr>
                         <th scope="row">Time Zone</th>
                         <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>
                         <p>Virtual Machines</p>
                     </div>
+                    <div class="col col-md-4 text-center">
+                        <h2><a href="{% url 'ipam:asn_list' %}?site_id={{ object.pk }}" class="btn {% if stats.asn_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.asn_count }}</a></h2>
+                        <p>ASNs</p>
+                    </div>
                 </div>
             </div>
         </div>

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