diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9b0e7f5b3..3c4021e72 100644 --- a/netbox/dcim/api/serializers.py +++ b/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', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2b9d9734c..0f39c5434 100644 --- a/netbox/dcim/api/views.py +++ b/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'), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index c66397029..82396b64b 100644 --- a/netbox/dcim/filtersets.py +++ b/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) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 06ccc958c..9728f231f 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/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', ] diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 720ea8dbd..10898fb81 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/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', ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 0ee08bc77..24ecf60fe 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/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) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 8236b1a97..9703c4cac 100644 --- a/netbox/dcim/forms/models.py +++ b/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)", diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index ab9d8e82d..a093a4d84 100644 --- a/netbox/dcim/models/sites.py +++ b/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', ] diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 3ff6ab75b..ab9399978 100644 --- a/netbox/dcim/tables/sites.py +++ b/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') # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5079e01a5..e188ecfe5 100644 --- a/netbox/dcim/views.py +++ b/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(), diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index a52a6a03c..da679a01a 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/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 # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 183c45b2a..02e209241 100644 --- a/netbox/ipam/api/serializers.py +++ b/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 # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 06c4ab0ea..b05fcb303 100644 --- a/netbox/ipam/api/urls.py +++ b/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) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 69b6d97f0..18f2e13ce 100644 --- a/netbox/ipam/api/views.py +++ b/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 # diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 37a9299dc..025f4a9cb 100644 --- a/netbox/ipam/filtersets.py +++ b/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', diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 895dbe200..7b7a0fb0d 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/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(), diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 49d5014f9..e4190a66c 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/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() diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 8bc0f10fb..ab084311c 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/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 = [ diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d28f7b3ae..a0163a13f 100644 --- a/netbox/ipam/forms/models.py +++ b/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() diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index cb8b4b932..0f65e6652 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -4,6 +4,7 @@ from .vlans import * from .vrfs import * __all__ = ( + 'ASN', 'Aggregate', 'IPAddress', 'IPRange', diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 4fc2b5dbb..45baf8258 100644 --- a/netbox/ipam/models/ip.py +++ b/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): """ diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index ddad6c573..e624f6f13 100644 --- a/netbox/ipam/tables/ip.py +++ b/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 # diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 5ba45b7fd..5229d3430 100644 --- a/netbox/ipam/tests/test_api.py +++ b/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'] diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 9d9a846bf..88c5d7c9e 100644 --- a/netbox/ipam/urls.py +++ b/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//', views.ASNView.as_view(), name='asn'), + path('asns//edit/', views.ASNEditView.as_view(), name='asn_edit'), + path('asns//delete/', views.ASNDeleteView.as_view(), name='asn_delete'), + path('asns//changelog/', ObjectChangeLogView.as_view(), name='asn_changelog', kwargs={'model': ASN}), + path('asns//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'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index c24a80124..73b228ac4 100644 --- a/netbox/ipam/views.py +++ b/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 # diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 791c21d19..2240ce58d 100644 --- a/netbox/netbox/filtersets.py +++ b/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, diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index de2c170a3..4b1e2a5b5 100644 --- a/netbox/netbox/navigation_menu.py +++ b/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=( diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 8442ae41e..55cc57b50 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -80,10 +80,6 @@ Description {{ object.description|placeholder }} - - AS Number - {{ object.asn|placeholder }} - Time Zone @@ -216,6 +212,10 @@

{{ stats.vm_count }}

Virtual Machines

+ diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html new file mode 100644 index 000000000..8be09c660 --- /dev/null +++ b/netbox/templates/ipam/asn.html @@ -0,0 +1,64 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+
+
+
+ ASN +
+
+ + + + + + + + + + + + + + + + + +
AS Number{{ object.asn }}
RIR + {{ object.rir }} +
Tenant + {% if object.tenant %} + {% if prefix.object.group %} + {{ object.tenant.group }} / + {% endif %} + {{ object.tenant }} + {% else %} + None + {% endif %} +
Description{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} + {% plugin_right_page object %} +
+
+
+
+ {% include 'inc/panel_table.html' with table=sites_table heading='Sites' %} + {% plugin_full_width_page object %} +
+
+{% endblock %}