From a01068949c54c2ec6f428245f9cc68f400075e09 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Sun, 24 Oct 2021 23:42:47 -0500 Subject: [PATCH 01/30] Initial work on #6732 --- netbox/dcim/api/serializers.py | 6 ++- netbox/dcim/api/views.py | 3 +- netbox/dcim/filtersets.py | 16 ++++++- netbox/dcim/forms/bulk_edit.py | 15 +++---- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/filtersets.py | 10 ++++- netbox/dcim/forms/models.py | 13 ++++-- netbox/dcim/models/sites.py | 8 +--- netbox/dcim/tables/sites.py | 13 ++++-- netbox/dcim/views.py | 3 +- netbox/ipam/api/nested_serializers.py | 13 ++++++ netbox/ipam/api/serializers.py | 18 ++++++++ netbox/ipam/api/urls.py | 3 ++ netbox/ipam/api/views.py | 12 +++++ netbox/ipam/filtersets.py | 39 ++++++++++++++++ netbox/ipam/forms/bulk_edit.py | 36 ++++++++++++++- netbox/ipam/forms/bulk_import.py | 27 +++++++++++ netbox/ipam/forms/filtersets.py | 30 +++++++++++++ netbox/ipam/forms/models.py | 26 +++++++++++ netbox/ipam/models/__init__.py | 1 + netbox/ipam/models/ip.py | 48 ++++++++++++++++++++ netbox/ipam/tables/ip.py | 25 +++++++++++ netbox/ipam/tests/test_api.py | 32 ++++++++++++++ netbox/ipam/urls.py | 12 +++++ netbox/ipam/views.py | 63 +++++++++++++++++++++++++- netbox/netbox/filtersets.py | 1 - netbox/netbox/navigation_menu.py | 6 +++ netbox/templates/dcim/site.html | 8 ++-- netbox/templates/ipam/asn.html | 64 +++++++++++++++++++++++++++ 29 files changed, 515 insertions(+), 38 deletions(-) create mode 100644 netbox/templates/ipam/asn.html 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 %} From 8b529abfe10f1a05b5449a964118a8608f96b750 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Sun, 24 Oct 2021 23:47:31 -0500 Subject: [PATCH 02/30] Initial work on #6732 --- .../dcim/migrations/0138_remove_site_asn.py | 18 +++++++++ netbox/ipam/migrations/0051_asn_model.py | 40 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 netbox/dcim/migrations/0138_remove_site_asn.py create mode 100644 netbox/ipam/migrations/0051_asn_model.py diff --git a/netbox/dcim/migrations/0138_remove_site_asn.py b/netbox/dcim/migrations/0138_remove_site_asn.py new file mode 100644 index 000000000..a4100ea14 --- /dev/null +++ b/netbox/dcim/migrations/0138_remove_site_asn.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.8 on 2021-10-25 04:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0137_relax_uniqueness_constraints'), + ('ipam', '0051_asn_model') + ] + + operations = [ + migrations.RemoveField( + model_name='site', + name='asn', + ), + ] diff --git a/netbox/ipam/migrations/0051_asn_model.py b/netbox/ipam/migrations/0051_asn_model.py new file mode 100644 index 000000000..b397532ea --- /dev/null +++ b/netbox/ipam/migrations/0051_asn_model.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.8 on 2021-10-25 04:34 + +import dcim.fields +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('tenancy', '0003_contacts'), + ('dcim', '0137_relax_uniqueness_constraints'), + ('ipam', '0050_iprange'), + ] + + operations = [ + migrations.CreateModel( + name='ASN', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('asn', dcim.fields.ASNField(blank=True, null=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('rir', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='ipam.rir')), + ('sites', models.ManyToManyField(blank=True, related_name='asns', to='dcim.Site')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'ASN', + 'verbose_name_plural': 'ASNs', + 'ordering': ['asn'], + }, + ), + ] From 3185cd0b1fa907b91e175ed4d01715e5f77f3959 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 27 Oct 2021 21:31:59 -0500 Subject: [PATCH 03/30] #6732 - Correct incorrect field definition in field order --- netbox/dcim/forms/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 24ecf60fe..328ed37c2 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -144,7 +144,7 @@ class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): model = Site - field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id', 'asns'] + field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id', 'asn_id'] field_groups = [ ['q', 'tag'], ['status', 'region_id', 'group_id'], From 8235b339eedc28b29d0b640c4523460ce7aaaf4e Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 27 Oct 2021 22:25:31 -0500 Subject: [PATCH 04/30] #6732 - Revert some changes to legacy ASN field on site model * Re-instates ASN field on Site model * Re-instates ASN field on Site view * Re-instates ASN field on edit form and API, except for where forms instances are new (add site) or instance does not have any existing AS data * Does not re-instate asn field on SiteBulkEditForm * Does not re-instate ASN field on SiteTable * Does not re-instate filter for filterset, but does allow filtering by query (q=34342) * Does not include tests for ASN field on Site model due to planned deprecation --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/filtersets.py | 1 + netbox/dcim/forms/models.py | 20 ++++++++++++++++--- .../dcim/migrations/0138_remove_site_asn.py | 18 ----------------- netbox/dcim/models/sites.py | 8 +++++++- netbox/templates/dcim/site.html | 4 ++++ 6 files changed, 30 insertions(+), 23 deletions(-) delete mode 100644 netbox/dcim/migrations/0138_remove_site_asn.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 3c4021e72..83e0fb271 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -123,7 +123,7 @@ class SiteSerializer(PrimaryModelSerializer): class Meta: model = Site fields = [ - 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asns', + 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'asns', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'asn_count', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 82396b64b..0142b36c6 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -163,6 +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 diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 9703c4cac..805788c04 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -122,13 +122,14 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Site fields = [ - 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone', - 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', + 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'asns', + 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'tags', ] fieldsets = ( ('Site', ( - 'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags', + 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'asns', 'time_zone', 'description', + 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ('Contact Info', ( @@ -152,6 +153,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } help_texts = { 'name': "Full name of the site", + 'asn': "BGP autonomous system number. This field is depreciated in favour of the many-to-many field for ASNs", 'facility': "Data center provider and facility (e.g. Equinix NY7)", 'time_zone': "Local time zone", 'description': "Short description (will appear in sites list)", @@ -161,6 +163,18 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'longitude': "Longitude in decimal format (xx.yyyyyy)" } + def __init__(self, instance, *args, **kwargs): + super(SiteForm, self).__init__(instance=instance, *args, **kwargs) + if instance is None or (instance and (instance.asn is None or instance.asn == '')): + site_fieldset = list(self.Meta.fieldsets[0][1]) + site_fieldset.pop(6) + self.Meta.fieldsets = ( + ('Site', tuple(site_fieldset)), + self.Meta.fieldsets[1], + self.Meta.fieldsets[2], + ) + del self.fields['asn'] + class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = DynamicModelChoiceField( diff --git a/netbox/dcim/migrations/0138_remove_site_asn.py b/netbox/dcim/migrations/0138_remove_site_asn.py deleted file mode 100644 index a4100ea14..000000000 --- a/netbox/dcim/migrations/0138_remove_site_asn.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.8 on 2021-10-25 04:33 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0137_relax_uniqueness_constraints'), - ('ipam', '0051_asn_model') - ] - - operations = [ - migrations.RemoveField( - model_name='site', - name='asn', - ), - ] diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index a093a4d84..ab9d8e82d 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -189,6 +189,12 @@ 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 ) @@ -251,7 +257,7 @@ class Site(PrimaryModel): objects = RestrictedQuerySet.as_manager() clone_fields = [ - 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', 'physical_address', + 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', ] diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 55cc57b50..260412815 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -80,6 +80,10 @@ Description {{ object.description|placeholder }} + + AS Number + {{ object.asn|placeholder }} + Time Zone From 0ad440fea57cd5c252f8f9bb972cb257b7b20403 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 27 Oct 2021 23:06:09 -0500 Subject: [PATCH 05/30] #6732 - GraphQL support --- netbox/ipam/graphql/schema.py | 3 +++ netbox/ipam/graphql/types.py | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 58909e57f..aa9f89f2b 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -5,6 +5,9 @@ from .types import * class IPAMQuery(graphene.ObjectType): + asn = ObjectField(ASNType) + asn_list = ObjectListField(ASNType) + aggregate = ObjectField(AggregateType) aggregate_list = ObjectListField(AggregateType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index c822dab6b..0fbe06c50 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -2,6 +2,7 @@ from ipam import filtersets, models from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType __all__ = ( + 'ASNType', 'AggregateType', 'IPAddressType', 'IPRangeType', @@ -16,6 +17,14 @@ __all__ = ( ) +class ASNType(PrimaryObjectType): + + class Meta: + model = models.ASN + fields = '__all__' + filterset_class = filtersets.ASNFilterSet + + class AggregateType(PrimaryObjectType): class Meta: From 93de6c9f88cf0f3f786b755d775f66f3f3f2ea71 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 27 Oct 2021 23:06:37 -0500 Subject: [PATCH 06/30] #6732 - Tests --- netbox/ipam/tests/test_api.py | 59 +++++++++++++++------- netbox/ipam/tests/test_filtersets.py | 75 ++++++++++++++++++++++++++++ netbox/ipam/tests/test_views.py | 55 ++++++++++++++++++++ 3 files changed, 170 insertions(+), 19 deletions(-) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 5229d3430..42fc7132b 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -7,6 +7,7 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * from ipam.models import * +from tenancy.models import Tenant from utilities.testing import APITestCase, APIViewTestCases, disable_warnings @@ -23,20 +24,6 @@ class AppTest(APITestCase): 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', } @@ -44,12 +31,46 @@ class ASNTest(APIViewTestCases.APIViewTestCase): @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 + rirs = [ + RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True), + RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True), + ] + sites = [ + Site.objects.create(name='Site 1', slug='site-1'), + Site.objects.create(name='Site 2', slug='site-2') + ] + tenants = [ + Tenant.objects.create(name='Tenant 1', slug='tenant-1'), + Tenant.objects.create(name='Tenant 2', slug='tenant-2'), + ] + + asns = ( + ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), + ASN(asn=65534, rir=rirs[0], tenant=tenants[1]), + ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), + ASN(asn=4200002301, rir=rirs[1], tenant=tenants[1]), ) - VRF.objects.bulk_create(vrfs) + ASN.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[1].sites.set([sites[1]]) + asns[2].sites.set([sites[0]]) + asns[3].sites.set([sites[1]]) + + cls.create_data = [ + { + 'asn': 64512, + 'rir': rirs[0].pk, + }, + { + 'asn': 65543, + 'rir': rirs[0].pk, + }, + { + 'asn': 4294967294, + 'rir': rirs[0].pk, + }, + ] class VRFTest(APIViewTestCases.APIViewTestCase): diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index ff9dbfece..602fdd0f9 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -9,6 +9,81 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac from tenancy.models import Tenant, TenantGroup +class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ASN.objects.all() + filterset = ASNFilterSet + + @classmethod + def setUpTestData(cls): + + rirs = [ + RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True), + RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True), + ] + sites = [ + Site.objects.create(name='Site 1', slug='site-1'), + Site.objects.create(name='Site 2', slug='site-2'), + Site.objects.create(name='Site 3', slug='site-3') + ] + tenants = [ + Tenant.objects.create(name='Tenant 1', slug='tenant-1'), + Tenant.objects.create(name='Tenant 2', slug='tenant-2'), + Tenant.objects.create(name='Tenant 3', slug='tenant-3'), + Tenant.objects.create(name='Tenant 4', slug='tenant-4'), + Tenant.objects.create(name='Tenant 5', slug='tenant-5'), + ] + + asns = ( + ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), + ASN(asn=64514, rir=rirs[0], tenant=tenants[1]), + ASN(asn=64515, rir=rirs[0], tenant=tenants[2]), + ASN(asn=64516, rir=rirs[0], tenant=tenants[3]), + ASN(asn=65535, rir=rirs[1], tenant=tenants[5]), + ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), + ASN(asn=4200000001, rir=rirs[0], tenant=tenants[1]), + ASN(asn=4200000002, rir=rirs[0], tenant=tenants[2]), + ASN(asn=4200000003, rir=rirs[0], tenant=tenants[3]), + ASN(asn=4200002301, rir=rirs[1], tenant=tenants[5]), + ) + ASN.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[1].sites.set([sites[1]]) + asns[2].sites.set([sites[2]]) + asns[3].sites.set([sites[0]]) + asns[4].sites.set([sites[1]]) + asns[5].sites.set([sites[0]]) + asns[6].sites.set([sites[1]]) + asns[7].sites.set([sites[2]]) + asns[8].sites.set([sites[0]]) + asns[9].sites.set([sites[1]]) + + def test_asn(self): + params = {'asn': ['64512', '65535']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_tenant(self): + tenants = Tenant.objects.all()[:2] + params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_rir(self): + rirs = RIR.objects.all()[:1] + params = {'rir_id': [rirs[0].pk, rirs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'rir': [rirs[0].slug, rirs[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + sites = Site.objects.all()[:2] + params = {'site_id': [sites[0].pk, sites[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + + class VRFTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VRF.objects.all() filterset = VRFFilterSet diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 2a0bfdf32..3bd22b112 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -9,6 +9,61 @@ from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags +class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = ASN + + @classmethod + def setUpTestData(cls): + + rirs = [ + RIR.objects.create(name='RFC 6996', slug='rfc-6996', description='Private Use', is_private=True), + RIR.objects.create(name='RFC 7300', slug='rfc-7300', description='IANA Use', is_private=True), + ] + sites = [ + Site.objects.create(name='Site 1', slug='site-1'), + Site.objects.create(name='Site 2', slug='site-2') + ] + tenants = [ + Tenant.objects.create(name='Tenant 1', slug='tenant-1'), + Tenant.objects.create(name='Tenant 2', slug='tenant-2'), + ] + + asns = ( + ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), + ASN(asn=65535, rir=rirs[1], tenant=tenants[1]), + ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), + ASN(asn=4200002301, rir=rirs[1], tenant=tenants[1]), + ) + ASN.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[1].sites.set([sites[1]]) + asns[2].sites.set([sites[0]]) + asns[3].sites.set([sites[1]]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'asn': 64512, + 'rir': rirs[0].pk, + 'tenant': tenants[0], + 'site': sites[0], + 'description': 'A new ASN', + } + + cls.csv_data = ( + "asn,rir", + "64533,RFC 6996", + "64523,RFC 6996", + "64513,RFC 6996", + ) + + cls.bulk_edit_data = { + 'rir': rirs[1].pk, + 'description': 'Next description', + } + + class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VRF From 9b5f45aee1c06bb2752bdfb22e156791bcbd1042 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 27 Oct 2021 23:07:04 -0500 Subject: [PATCH 07/30] #6732 - Serializers --- netbox/dcim/api/serializers.py | 3 ++- netbox/ipam/api/serializers.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 83e0fb271..d5d7bd52c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -7,7 +7,7 @@ from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer +from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedASNSerializer from ipam.models import VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( @@ -111,6 +111,7 @@ class SiteSerializer(PrimaryModelSerializer): region = NestedRegionSerializer(required=False, allow_null=True) group = NestedSiteGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) + asns = NestedASNSerializer(many=True, required=False, allow_null=True) time_zone = TimeZoneSerializerField(required=False) circuit_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 02e209241..84502ca51 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -27,6 +27,8 @@ class ASNSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') tenant = NestedTenantSerializer(required=False, allow_null=True) + site_count = serializers.IntegerField(read_only=True) + class Meta: model = ASN fields = [ From 96565c31d93c8a1446020e449cf9dd04c3791c57 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 10:04:12 -0500 Subject: [PATCH 08/30] #6732 - ASN should be unique --- netbox/ipam/migrations/0052_unique_asn.py | 19 +++++++++++++++++++ netbox/ipam/models/ip.py | 1 + 2 files changed, 20 insertions(+) create mode 100644 netbox/ipam/migrations/0052_unique_asn.py diff --git a/netbox/ipam/migrations/0052_unique_asn.py b/netbox/ipam/migrations/0052_unique_asn.py new file mode 100644 index 000000000..8a5bb1da9 --- /dev/null +++ b/netbox/ipam/migrations/0052_unique_asn.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-10-28 15:03 + +import dcim.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0051_asn_model'), + ] + + operations = [ + migrations.AlterField( + model_name='asn', + name='asn', + field=dcim.fields.ASNField(blank=True, null=True, unique=True), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 45baf8258..110d93cfe 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -74,6 +74,7 @@ class RIR(OrganizationalModel): class ASN(PrimaryModel): asn = ASNField( + unique=True, blank=True, null=True, verbose_name='ASN', From 1902e112f643a134433b995610746bc62830cbd4 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 11:46:55 -0500 Subject: [PATCH 09/30] #6732 - Fix tests for utilities --- netbox/utilities/tests/test_filters.py | 63 ++++++++++++++++++++------ 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 374167f1c..e4609ef9b 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -5,6 +5,9 @@ from django.test import TestCase from mptt.fields import TreeForeignKey from taggit.managers import TaggableManager +from circuits.choices import CircuitStatusChoices +from circuits.filtersets import CircuitFilterSet +from circuits.models import Circuit, Provider, CircuitType from dcim.choices import * from dcim.fields import MACAddressField from dcim.filtersets import DeviceFilterSet, SiteFilterSet @@ -13,6 +16,7 @@ from dcim.models import ( ) from extras.filters import TagFilter from extras.models import TaggedItem +from ipam.models import RIR, ASN from netbox.filtersets import BaseFilterSet from utilities.filters import ( MACAddressFilter, MultiValueCharFilter, MultiValueDateFilter, MultiValueDateTimeFilter, MultiValueNumberFilter, @@ -337,10 +341,26 @@ class DynamicFilterLookupExpressionTest(TestCase): device_filterset = DeviceFilterSet site_queryset = Site.objects.all() site_filterset = SiteFilterSet + circuit_queryset = Circuit.objects.all() + circuit_filterset = CircuitFilterSet @classmethod def setUpTestData(cls): + provider = Provider.objects.create(name='Test Provider', slug='test-provider') + circuit_type = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type') + + circuits = ( + Circuit(cid='CID12123', provider=provider, type=circuit_type, + status=CircuitStatusChoices.STATUS_ACTIVE, commit_rate=1000), + Circuit(cid='CID12124', provider=provider, type=circuit_type, + status=CircuitStatusChoices.STATUS_ACTIVE, commit_rate=10000), + Circuit(cid='CID12125', provider=provider, type=circuit_type, + status=CircuitStatusChoices.STATUS_ACTIVE, commit_rate=100000) + + ) + Circuit.objects.bulk_create(circuits) + manufacturers = ( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), @@ -378,12 +398,25 @@ class DynamicFilterLookupExpressionTest(TestCase): region.save() sites = ( - Site(name='Site 1', slug='abc-site-1', region=regions[0], asn=65001), - Site(name='Site 2', slug='def-site-2', region=regions[1], asn=65101), - Site(name='Site 3', slug='ghi-site-3', region=regions[2], asn=65201), + Site(name='Site 1', slug='abc-site-1', region=regions[0]), + Site(name='Site 2', slug='def-site-2', region=regions[1]), + Site(name='Site 3', slug='ghi-site-3', region=regions[2]), ) Site.objects.bulk_create(sites) + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = [ + ASN(asn=65001, rir=rir), + ASN(asn=65101, rir=rir), + ASN(asn=65201, rir=rir) + ] + ASN.objects.bulk_create(asns) + + asns[0].sites.add(sites[0]) + asns[1].sites.add(sites[1]) + asns[2].sites.add(sites[2]) + racks = ( Rack(name='Rack 1', site=sites[0]), Rack(name='Rack 2', site=sites[1]), @@ -436,21 +469,21 @@ class DynamicFilterLookupExpressionTest(TestCase): params = {'slug__niew': ['-1']} self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) - def test_site_asn_lt(self): - params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + def test_circuit_commit_lt(self): + params = {'commit_rate__lt': [10000]} + self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 1) - def test_site_asn_lte(self): - params = {'asn__lte': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + def test_circuit_commit_lte(self): + params = {'commit_rate__lte': [10000]} + self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 2) - def test_site_asn_gt(self): - params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + def test_circuit_commit_gt(self): + params = {'commit_rate__gt': [10000]} + self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 1) - def test_site_asn_gte(self): - params = {'asn__gte': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + def test_circuit_commit_gte(self): + params = {'commit_rate__gte': [10000]} + self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 2) def test_site_region_negation(self): params = {'region__n': ['region-1']} From 0f68ecda785a4512fbaee337dff18ba2218a3013 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 11:47:54 -0500 Subject: [PATCH 10/30] #6732 - Fix hiding of ASN field in Site creation form --- netbox/dcim/forms/models.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 805788c04..ca7074e05 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -166,13 +166,15 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): def __init__(self, instance, *args, **kwargs): super(SiteForm, self).__init__(instance=instance, *args, **kwargs) if instance is None or (instance and (instance.asn is None or instance.asn == '')): - site_fieldset = list(self.Meta.fieldsets[0][1]) - site_fieldset.pop(6) - self.Meta.fieldsets = ( - ('Site', tuple(site_fieldset)), - self.Meta.fieldsets[1], - self.Meta.fieldsets[2], - ) + if 'asn' in self.Meta.fieldsets[0][1]: + site_fieldset = list(self.Meta.fieldsets[0][1]) + index = site_fieldset.index('asn') + site_fieldset.pop(index) + self.Meta.fieldsets = ( + ('Site', tuple(site_fieldset)), + self.Meta.fieldsets[1], + self.Meta.fieldsets[2], + ) del self.fields['asn'] From ada81e31c9bac8422d7e3ebb3b6053f7c66765af Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 11:48:50 -0500 Subject: [PATCH 11/30] #6732 - Fix CSV import form --- netbox/ipam/forms/bulk_import.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index e4190a66c..41604f0e4 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.forms import IntegerField from dcim.models import Device, Interface, Site from extras.forms import CustomFieldModelCSVForm @@ -83,17 +84,12 @@ class AggregateCSVForm(CustomFieldModelCSVForm): class ASNCSVForm(CustomFieldModelCSVForm): - slug = SlugField() + asn = IntegerField() 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, From 3c261b05d964bc573f1fe4008e22535e96d4e5c4 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 11:49:21 -0500 Subject: [PATCH 12/30] #6732 - Fix ASN tests --- netbox/dcim/tests/test_filtersets.py | 19 ++++++++++++- netbox/dcim/tests/test_views.py | 33 ++++++++++++++++++++--- netbox/ipam/tests/test_api.py | 2 +- netbox/ipam/tests/test_filtersets.py | 40 +++++++++++++++------------- netbox/ipam/tests/test_views.py | 6 ++--- 5 files changed, 72 insertions(+), 28 deletions(-) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ce78e0470..c4558b882 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4,7 +4,7 @@ from django.test import TestCase from dcim.choices import * from dcim.filtersets import * from dcim.models import * -from ipam.models import IPAddress +from ipam.models import IPAddress, RIR, ASN from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests @@ -148,6 +148,23 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): ) Site.objects.bulk_create(sites) + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = ( + ASN(asn=64512, rir=rir, tenant=tenants[0]), + ASN(asn=64513, rir=rir, tenant=tenants[0]), + ASN(asn=64514, rir=rir, tenant=tenants[0]), + ASN(asn=65001, rir=rir, tenant=tenants[0]), + ASN(asn=65002, rir=rir, tenant=tenants[0]) + ) + ASN.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[1].sites.set([sites[1]]) + asns[2].sites.set([sites[2]]) + asns[3].sites.set([sites[2]]) + asns[4].sites.set([sites[1]]) + def test_name(self): params = {'name': ['Site 1', 'Site 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index a9c191679..df0cfcf5d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,7 +11,7 @@ from netaddr import EUI from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import VLAN +from ipam.models import VLAN, ASN, RIR from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -104,7 +104,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): for group in groups: group.save() - Site.objects.bulk_create([ + sites = Site.objects.bulk_create([ Site(name='Site 1', slug='site-1', region=regions[0], group=groups[1]), Site(name='Site 2', slug='site-2', region=regions[0], group=groups[1]), Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]), @@ -112,6 +112,33 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') + rir = RIR.objects.create(name='RFC 6996', is_private=True) + + asns = [ + ASN(asn=65000, rir=rir), + ASN(asn=65001, rir=rir), + ASN(asn=65002, rir=rir), + ASN(asn=65003, rir=rir), + ASN(asn=65004, rir=rir), + ASN(asn=65005, rir=rir), + ASN(asn=65006, rir=rir), + ASN(asn=65007, rir=rir), + ASN(asn=65008, rir=rir), + ASN(asn=65009, rir=rir), + ASN(asn=65010, rir=rir), + ] + ASN.objects.bulk_create(asns) + + asns[0].sites.set([sites[0]]) + asns[2].sites.set([sites[0]]) + asns[3].sites.set([sites[1]]) + asns[4].sites.set([sites[2]]) + asns[5].sites.set([sites[1]]) + asns[6].sites.set([sites[2]]) + asns[7].sites.set([sites[2]]) + asns[8].sites.set([sites[2]]) + asns[10].sites.set([sites[0]]) + cls.form_data = { 'name': 'Site X', 'slug': 'site-x', @@ -120,7 +147,6 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'group': groups[1].pk, 'tenant': None, 'facility': 'Facility X', - 'asn': 65001, 'time_zone': pytz.UTC, 'description': 'Site description', 'physical_address': '742 Evergreen Terrace, Springfield, USA', @@ -146,7 +172,6 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'region': regions[1].pk, 'group': groups[1].pk, 'tenant': None, - 'asn': 65009, 'time_zone': pytz.timezone('US/Eastern'), 'description': 'New description', } diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 42fc7132b..77473e504 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -23,7 +23,7 @@ class AppTest(APITestCase): class ASNTest(APIViewTestCases.APIViewTestCase): model = ASN - brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url'] + brief_fields = ['asn', 'display', 'id', 'url'] bulk_update_data = { 'description': 'New description', } diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 602fdd0f9..523680767 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -34,29 +34,31 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): ] asns = ( + ASN(asn=64512, rir=rirs[0], tenant=tenants[0]), ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), ASN(asn=64514, rir=rirs[0], tenant=tenants[1]), ASN(asn=64515, rir=rirs[0], tenant=tenants[2]), ASN(asn=64516, rir=rirs[0], tenant=tenants[3]), - ASN(asn=65535, rir=rirs[1], tenant=tenants[5]), + ASN(asn=65535, rir=rirs[1], tenant=tenants[4]), ASN(asn=4200000000, rir=rirs[0], tenant=tenants[0]), ASN(asn=4200000001, rir=rirs[0], tenant=tenants[1]), ASN(asn=4200000002, rir=rirs[0], tenant=tenants[2]), ASN(asn=4200000003, rir=rirs[0], tenant=tenants[3]), - ASN(asn=4200002301, rir=rirs[1], tenant=tenants[5]), + ASN(asn=4200002301, rir=rirs[1], tenant=tenants[4]), ) ASN.objects.bulk_create(asns) asns[0].sites.set([sites[0]]) - asns[1].sites.set([sites[1]]) - asns[2].sites.set([sites[2]]) - asns[3].sites.set([sites[0]]) - asns[4].sites.set([sites[1]]) - asns[5].sites.set([sites[0]]) - asns[6].sites.set([sites[1]]) - asns[7].sites.set([sites[2]]) - asns[8].sites.set([sites[0]]) - asns[9].sites.set([sites[1]]) + asns[1].sites.set([sites[0]]) + asns[2].sites.set([sites[1]]) + asns[3].sites.set([sites[2]]) + asns[4].sites.set([sites[0]]) + asns[5].sites.set([sites[1]]) + asns[6].sites.set([sites[0]]) + asns[7].sites.set([sites[1]]) + asns[8].sites.set([sites[2]]) + asns[9].sites.set([sites[0]]) + asns[10].sites.set([sites[1]]) def test_asn(self): params = {'asn': ['64512', '65535']} @@ -65,23 +67,23 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): def test_tenant(self): tenants = Tenant.objects.all()[:2] params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) params = {'tenant': [tenants[0].slug, tenants[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) def test_rir(self): rirs = RIR.objects.all()[:1] - params = {'rir_id': [rirs[0].pk, rirs[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'rir': [rirs[0].slug, rirs[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'rir_id': [rirs[0].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) + params = {'rir': [rirs[0].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) params = {'site': [sites[0].slug, sites[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) class VRFTestCase(TestCase, ChangeLoggedFilterSetTests): diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 3bd22b112..86f11bf3d 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -46,8 +46,8 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'asn': 64512, 'rir': rirs[0].pk, - 'tenant': tenants[0], - 'site': sites[0], + 'tenant': tenants[0].pk, + 'site': sites[0].pk, 'description': 'A new ASN', } @@ -55,7 +55,7 @@ class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): "asn,rir", "64533,RFC 6996", "64523,RFC 6996", - "64513,RFC 6996", + "4200000002,RFC 6996", ) cls.bulk_edit_data = { From de5c9ef4b2addaa850d93b1ebcf7dc7bcd50aca9 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 11:49:59 -0500 Subject: [PATCH 13/30] #6732 - Add graphql support for new ASN model and fix ASN overflow on longs --- netbox/dcim/graphql/types.py | 4 ++++ netbox/ipam/graphql/scalars.py | 5 +++++ netbox/ipam/graphql/types.py | 4 ++++ netbox/netbox/graphql/scalars.py | 23 +++++++++++++++++++++++ 4 files changed, 36 insertions(+) create mode 100644 netbox/ipam/graphql/scalars.py create mode 100644 netbox/netbox/graphql/scalars.py diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 80c32e66d..6d93452cd 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -1,8 +1,11 @@ +import graphene + from dcim import filtersets, models from extras.graphql.mixins import ( ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin +from ipam.graphql.scalars import ASNField from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType __all__ = ( @@ -374,6 +377,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType): class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): + asn = graphene.Field(ASNField) class Meta: model = models.Site diff --git a/netbox/ipam/graphql/scalars.py b/netbox/ipam/graphql/scalars.py new file mode 100644 index 000000000..d59375ba3 --- /dev/null +++ b/netbox/ipam/graphql/scalars.py @@ -0,0 +1,5 @@ +from netbox.graphql.scalars import BigInt + + +class ASNField(BigInt): + pass diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 0fbe06c50..71c7fd24e 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,4 +1,7 @@ +import graphene + from ipam import filtersets, models +from ipam.graphql.scalars import ASNField from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType __all__ = ( @@ -18,6 +21,7 @@ __all__ = ( class ASNType(PrimaryObjectType): + asn = graphene.Field(ASNField) class Meta: model = models.ASN diff --git a/netbox/netbox/graphql/scalars.py b/netbox/netbox/graphql/scalars.py new file mode 100644 index 000000000..7d14189dd --- /dev/null +++ b/netbox/netbox/graphql/scalars.py @@ -0,0 +1,23 @@ +from graphene import Scalar +from graphql.language import ast +from graphql.type.scalars import MAX_INT, MIN_INT + + +class BigInt(Scalar): + """ + Handle any BigInts + """ + @staticmethod + def to_float(value): + num = int(value) + if num > MAX_INT or num < MIN_INT: + return float(num) + return num + + serialize = to_float + parse_value = to_float + + @staticmethod + def parse_literal(node): + if isinstance(node, ast.IntValue): + return BigInt.to_float(node.value) From fff124ebb1d6c2ec104102e3e225cd6d72a582e8 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 12:06:41 -0500 Subject: [PATCH 14/30] #6732 - Update migration file --- .../{0051_asn_model.py => 0052_asn_model.py} | 4 ++-- netbox/ipam/migrations/0052_unique_asn.py | 19 ------------------- 2 files changed, 2 insertions(+), 21 deletions(-) rename netbox/ipam/migrations/{0051_asn_model.py => 0052_asn_model.py} (96%) delete mode 100644 netbox/ipam/migrations/0052_unique_asn.py diff --git a/netbox/ipam/migrations/0051_asn_model.py b/netbox/ipam/migrations/0052_asn_model.py similarity index 96% rename from netbox/ipam/migrations/0051_asn_model.py rename to netbox/ipam/migrations/0052_asn_model.py index b397532ea..4adafd411 100644 --- a/netbox/ipam/migrations/0051_asn_model.py +++ b/netbox/ipam/migrations/0052_asn_model.py @@ -13,7 +13,7 @@ class Migration(migrations.Migration): ('extras', '0062_clear_secrets_changelog'), ('tenancy', '0003_contacts'), ('dcim', '0137_relax_uniqueness_constraints'), - ('ipam', '0050_iprange'), + ('ipam', '0051_extend_tag_support'), ] operations = [ @@ -24,7 +24,7 @@ class Migration(migrations.Migration): ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('asn', dcim.fields.ASNField(blank=True, null=True)), + ('asn', dcim.fields.ASNField(blank=True, null=True, unique=True)), ('description', models.CharField(blank=True, max_length=200)), ('rir', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='ipam.rir')), ('sites', models.ManyToManyField(blank=True, related_name='asns', to='dcim.Site')), diff --git a/netbox/ipam/migrations/0052_unique_asn.py b/netbox/ipam/migrations/0052_unique_asn.py deleted file mode 100644 index 8a5bb1da9..000000000 --- a/netbox/ipam/migrations/0052_unique_asn.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.2.8 on 2021-10-28 15:03 - -import dcim.fields -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('ipam', '0051_asn_model'), - ] - - operations = [ - migrations.AlterField( - model_name='asn', - name='asn', - field=dcim.fields.ASNField(blank=True, null=True, unique=True), - ), - ] From 9565addcd4bf8d7f402746d836307396b24e80d3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 13:12:55 -0500 Subject: [PATCH 15/30] #6732 - Fix test failure when sending data --- netbox/dcim/forms/models.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 24a1e8140..bda6c4348 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -172,9 +172,12 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'longitude': "Longitude in decimal format (xx.yyyyyy)" } - def __init__(self, instance, *args, **kwargs): - super(SiteForm, self).__init__(instance=instance, *args, **kwargs) - if instance is None or (instance and (instance.asn is None or instance.asn == '')): + def __init__(self, data=None, instance=None, *args, **kwargs): + super().__init__(data=data, instance=instance, *args, **kwargs) + if instance is None or \ + (instance and (instance.asn is None or instance.asn == '')) or \ + (data and (data.get('asn') is None or instance.get('asn')) == ''): + print(f'{instance}') if 'asn' in self.Meta.fieldsets[0][1]: site_fieldset = list(self.Meta.fieldsets[0][1]) index = site_fieldset.index('asn') @@ -812,7 +815,6 @@ class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm): class DeviceVCMembershipForm(forms.ModelForm): - class Meta: model = Device fields = [ @@ -908,7 +910,6 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = ConsolePortTemplate fields = [ @@ -920,7 +921,6 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = ConsoleServerPortTemplate fields = [ @@ -932,7 +932,6 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = PowerPortTemplate fields = [ @@ -944,7 +943,6 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = PowerOutletTemplate fields = [ @@ -955,7 +953,6 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): } def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) # Limit power_port choices to current DeviceType @@ -966,7 +963,6 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = InterfaceTemplate fields = [ @@ -979,7 +975,6 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = FrontPortTemplate fields = [ @@ -991,7 +986,6 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): } def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) # Limit rear_port choices to current DeviceType @@ -1002,7 +996,6 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = RearPortTemplate fields = [ @@ -1015,7 +1008,6 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): - class Meta: model = DeviceBayTemplate fields = [ @@ -1278,7 +1270,6 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ) def __init__(self, device_bay, *args, **kwargs): - super().__init__(*args, **kwargs) self.fields['installed_device'].queryset = Device.objects.filter( From 0a8788eb976ac7a433e73c9683b092393b69f4d7 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 13:57:36 -0500 Subject: [PATCH 16/30] #6732 - Fix Site form and ASN form --- netbox/dcim/forms/models.py | 10 +++++++++- netbox/ipam/forms/models.py | 5 +++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index bda6c4348..2d2b28ee0 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -174,10 +174,13 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): def __init__(self, data=None, instance=None, *args, **kwargs): super().__init__(data=data, instance=instance, *args, **kwargs) + + self.fields['asns'].initial = self.instance.asns.all().values_list('id', flat=True) + + # Hide the ASN field if there is nothing there as this is deprecated if instance is None or \ (instance and (instance.asn is None or instance.asn == '')) or \ (data and (data.get('asn') is None or instance.get('asn')) == ''): - print(f'{instance}') if 'asn' in self.Meta.fieldsets[0][1]: site_fieldset = list(self.Meta.fieldsets[0][1]) index = site_fieldset.index('asn') @@ -189,6 +192,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) del self.fields['asn'] + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + instance.asns.set(self.cleaned_data['asns']) + return instance + class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = DynamicModelChoiceField( diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index aff071e5d..abf2aa4a1 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -129,6 +129,11 @@ class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=RIR.objects.all(), label='RIR', ) + sites = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + label='Sites', + required=False + ) class Meta: model = ASN From 7c147db3241ce9c63ed3b9632c1fb9053baf22e5 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 28 Oct 2021 16:08:32 -0500 Subject: [PATCH 17/30] #6732 - Fix test exception in Site form --- netbox/dcim/forms/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 2d2b28ee0..3f88fee04 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -175,7 +175,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): def __init__(self, data=None, instance=None, *args, **kwargs): super().__init__(data=data, instance=instance, *args, **kwargs) - self.fields['asns'].initial = self.instance.asns.all().values_list('id', flat=True) + if self.instance and self.instance.pk is not None: + self.fields['asns'].initial = self.instance.asns.all().values_list('id', flat=True) # Hide the ASN field if there is nothing there as this is deprecated if instance is None or \ From d3364ef4d1c7d8fc1ae8e2aa92aa23f84f970d08 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 29 Oct 2021 14:15:37 -0500 Subject: [PATCH 18/30] #6732 - Restore resolve_field to the filterset --- netbox/netbox/filtersets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 2240ce58d..91108a318 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -153,6 +153,7 @@ 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) new_filter = type(existing_filter)( field_name=field_name, lookup_expr=lookup_expr, From 43b983054ae1eecc2d918e15e6b16fd74122395c Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 29 Oct 2021 14:26:19 -0500 Subject: [PATCH 19/30] #6732 - Corrected model field definitions --- netbox/ipam/migrations/0052_asn_model.py | 2 +- netbox/ipam/models/ip.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/migrations/0052_asn_model.py b/netbox/ipam/migrations/0052_asn_model.py index 4adafd411..1a69f0e42 100644 --- a/netbox/ipam/migrations/0052_asn_model.py +++ b/netbox/ipam/migrations/0052_asn_model.py @@ -24,7 +24,7 @@ class Migration(migrations.Migration): ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('asn', dcim.fields.ASNField(blank=True, null=True, unique=True)), + ('asn', dcim.fields.ASNField(blank=False, null=False, unique=True)), ('description', models.CharField(blank=True, max_length=200)), ('rir', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='ipam.rir')), ('sites', models.ManyToManyField(blank=True, related_name='asns', to='dcim.Site')), diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 03fdbeae5..d61ad4c25 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -75,8 +75,8 @@ class ASN(PrimaryModel): asn = ASNField( unique=True, - blank=True, - null=True, + blank=False, + null=False, verbose_name='ASN', help_text='32-bit autonomous system number' ) From a30e7bf34f07dcf328907f6040ecf305a2829262 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 29 Oct 2021 14:28:13 -0500 Subject: [PATCH 20/30] #6732 - Add ASN field back to bulk edit --- netbox/dcim/forms/bulk_edit.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 045dbf737..57c74cf84 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -7,6 +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_MIN, BGP_ASN_MAX from ipam.models import VLAN, ASN from tenancy.models import Tenant from utilities.forms import ( @@ -110,6 +111,12 @@ 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'), @@ -127,7 +134,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd class Meta: nullable_fields = [ - 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', + 'region', 'group', 'tenant', 'asn', 'asns', 'description', 'time_zone', ] From 3991115ae57f0866c81b451a2484ca652580c5bc Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 29 Oct 2021 14:54:55 -0500 Subject: [PATCH 21/30] #6732 - Fix imports and other small items --- netbox/dcim/graphql/types.py | 4 ++-- netbox/dcim/tests/test_filtersets.py | 2 +- netbox/dcim/tests/test_views.py | 2 +- netbox/dcim/views.py | 2 +- netbox/ipam/api/serializers.py | 2 -- netbox/ipam/api/views.py | 1 - netbox/ipam/filtersets.py | 2 -- netbox/ipam/forms/bulk_import.py | 3 --- netbox/ipam/graphql/scalars.py | 5 ----- netbox/ipam/graphql/types.py | 4 ++-- netbox/ipam/tables/ip.py | 4 +--- 11 files changed, 8 insertions(+), 23 deletions(-) delete mode 100644 netbox/ipam/graphql/scalars.py diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8b9bd76ef..8ce10979e 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -5,7 +5,7 @@ from extras.graphql.mixins import ( ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin -from ipam.graphql.scalars import ASNField +from netbox.graphql.scalars import BigInt from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType __all__ = ( @@ -383,7 +383,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType): class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): - asn = graphene.Field(ASNField) + asn = graphene.Field(BigInt) class Meta: model = models.Site diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index eb37f061a..1b27a43e3 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4,7 +4,7 @@ from django.test import TestCase from dcim.choices import * from dcim.filtersets import * from dcim.models import * -from ipam.models import IPAddress, RIR, ASN +from ipam.models import ASN, IPAddress, RIR from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 9c446fc8b..dc22b18a0 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,7 +11,7 @@ from netaddr import EUI from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import VLAN, ASN, RIR +from ipam.models import ASN, VLAN, RIR from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e188ecfe5..a05f62621 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, ASN +from ipam.models import ASN, IPAddress, Prefix, Service, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from netbox.views import generic from utilities.forms import ConfirmationForm diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 28ce1575e..4b68c0c1b 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -19,8 +19,6 @@ from .nested_serializers import * # # ASNs # -from ..models import ASN - class ASNSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index e066e0f57..274ce29e8 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -7,7 +7,6 @@ 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): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 81727edd1..1dd8f97d6 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -33,8 +33,6 @@ __all__ = ( 'VRFFilterSet', ) -from .models import ASN - class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 41604f0e4..1d18e94c7 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,13 +1,11 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.forms import IntegerField from dcim.models import Device, Interface, Site 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 @@ -84,7 +82,6 @@ class AggregateCSVForm(CustomFieldModelCSVForm): class ASNCSVForm(CustomFieldModelCSVForm): - asn = IntegerField() rir = CSVModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', diff --git a/netbox/ipam/graphql/scalars.py b/netbox/ipam/graphql/scalars.py deleted file mode 100644 index d59375ba3..000000000 --- a/netbox/ipam/graphql/scalars.py +++ /dev/null @@ -1,5 +0,0 @@ -from netbox.graphql.scalars import BigInt - - -class ASNField(BigInt): - pass diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 71c7fd24e..3ba27fcf0 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,7 +1,7 @@ import graphene from ipam import filtersets, models -from ipam.graphql.scalars import ASNField +from netbox.graphql.scalars import BigInt from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType __all__ = ( @@ -21,7 +21,7 @@ __all__ = ( class ASNType(PrimaryObjectType): - asn = graphene.Field(ASNField) + asn = graphene.Field(BigInt) class Meta: model = models.ASN diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 95376aad6..32937d17e 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -2,7 +2,6 @@ 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, @@ -99,7 +98,7 @@ class RIRTable(BaseTable): # -# RIRs +# ASNs # class ASNTable(BaseTable): @@ -112,7 +111,6 @@ class ASNTable(BaseTable): url_params={'asn_id': 'pk'}, verbose_name='Sites' ) - actions = ButtonsColumn(ASN) class Meta(BaseTable.Meta): From 87e07e731d51d50519f53edb26fa533520ceb2c2 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 29 Oct 2021 14:56:58 -0500 Subject: [PATCH 22/30] #6732 - Removed ASN field hiding --- netbox/dcim/forms/models.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 3f88fee04..a9fdb3652 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -162,7 +162,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } help_texts = { 'name': "Full name of the site", - 'asn': "BGP autonomous system number. This field is depreciated in favour of the many-to-many field for ASNs", + 'asn': "BGP autonomous system number. This field is depreciated in favour of the ASN model", 'facility': "Data center provider and facility (e.g. Equinix NY7)", 'time_zone': "Local time zone", 'description': "Short description (will appear in sites list)", @@ -178,21 +178,6 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): if self.instance and self.instance.pk is not None: self.fields['asns'].initial = self.instance.asns.all().values_list('id', flat=True) - # Hide the ASN field if there is nothing there as this is deprecated - if instance is None or \ - (instance and (instance.asn is None or instance.asn == '')) or \ - (data and (data.get('asn') is None or instance.get('asn')) == ''): - if 'asn' in self.Meta.fieldsets[0][1]: - site_fieldset = list(self.Meta.fieldsets[0][1]) - index = site_fieldset.index('asn') - site_fieldset.pop(index) - self.Meta.fieldsets = ( - ('Site', tuple(site_fieldset)), - self.Meta.fieldsets[1], - self.Meta.fieldsets[2], - ) - del self.fields['asn'] - def save(self, *args, **kwargs): instance = super().save(*args, **kwargs) instance.asns.set(self.cleaned_data['asns']) From 8c27ff38590752656f049aef44bef6e6cd39572d Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 2 Nov 2021 11:07:19 -0500 Subject: [PATCH 23/30] #6732 - Add ASN back to filtersets --- netbox/dcim/filtersets.py | 8 ++--- netbox/dcim/tests/test_filtersets.py | 4 +++ netbox/utilities/tests/test_filters.py | 44 +++++++++----------------- 3 files changed, 23 insertions(+), 33 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index c58e9d17e..aad02592e 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -131,12 +131,12 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Group (slug)', ) - asn_id = django_filters.ModelMultipleChoiceFilter( + asns_id = django_filters.ModelMultipleChoiceFilter( field_name='asns', queryset=ASN.objects.all(), label='AS (ID)', ) - asn = django_filters.ModelMultipleChoiceFilter( + asns = django_filters.ModelMultipleChoiceFilter( field_name='asns__asn', queryset=ASN.objects.all(), to_field_name='asn', @@ -147,7 +147,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class Meta: model = Site fields = [ - 'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', ] @@ -167,7 +167,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) try: qs_filter |= Q(asn=int(value.strip())) - qs_filter |= Q(asns=int(value.strip())) + qs_filter |= Q(asns__asn=int(value.strip())) except ValueError: pass return queryset.filter(qs_filter) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 1b27a43e3..d05d2b2f2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -182,6 +182,10 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'asn': [65001, 65002]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_asns(self): + params = {'asns': [65001, 65002]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_latitude(self): params = {'latitude': [10, 20]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index e4609ef9b..2616dbf36 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -347,20 +347,6 @@ class DynamicFilterLookupExpressionTest(TestCase): @classmethod def setUpTestData(cls): - provider = Provider.objects.create(name='Test Provider', slug='test-provider') - circuit_type = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type') - - circuits = ( - Circuit(cid='CID12123', provider=provider, type=circuit_type, - status=CircuitStatusChoices.STATUS_ACTIVE, commit_rate=1000), - Circuit(cid='CID12124', provider=provider, type=circuit_type, - status=CircuitStatusChoices.STATUS_ACTIVE, commit_rate=10000), - Circuit(cid='CID12125', provider=provider, type=circuit_type, - status=CircuitStatusChoices.STATUS_ACTIVE, commit_rate=100000) - - ) - Circuit.objects.bulk_create(circuits) - manufacturers = ( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), @@ -398,9 +384,9 @@ class DynamicFilterLookupExpressionTest(TestCase): region.save() sites = ( - Site(name='Site 1', slug='abc-site-1', region=regions[0]), - Site(name='Site 2', slug='def-site-2', region=regions[1]), - Site(name='Site 3', slug='ghi-site-3', region=regions[2]), + Site(name='Site 1', slug='abc-site-1', region=regions[0], asn=65001), + Site(name='Site 2', slug='def-site-2', region=regions[1], asn=65101), + Site(name='Site 3', slug='ghi-site-3', region=regions[2], asn=65201), ) Site.objects.bulk_create(sites) @@ -469,21 +455,21 @@ class DynamicFilterLookupExpressionTest(TestCase): params = {'slug__niew': ['-1']} self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) - def test_circuit_commit_lt(self): - params = {'commit_rate__lt': [10000]} - self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 1) + def test_site_asn_lt(self): + params = {'asn__lt': [65101]} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) - def test_circuit_commit_lte(self): - params = {'commit_rate__lte': [10000]} - self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 2) + def test_site_asn_lte(self): + params = {'asn__lte': [65101]} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) - def test_circuit_commit_gt(self): - params = {'commit_rate__gt': [10000]} - self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 1) + def test_site_asn_gt(self): + params = {'asn__lt': [65101]} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) - def test_circuit_commit_gte(self): - params = {'commit_rate__gte': [10000]} - self.assertEqual(CircuitFilterSet(params, self.circuit_queryset).qs.count(), 2) + def test_site_asn_gte(self): + params = {'asn__gte': [65101]} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_region_negation(self): params = {'region__n': ['region-1']} From 5d0a7cb3079db63d9fa0efa2df32105102855bf4 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 2 Nov 2021 11:10:50 -0500 Subject: [PATCH 24/30] #6732 - Remove migration --- netbox/ipam/migrations/0052_asn_model.py | 40 ------------------------ 1 file changed, 40 deletions(-) delete mode 100644 netbox/ipam/migrations/0052_asn_model.py diff --git a/netbox/ipam/migrations/0052_asn_model.py b/netbox/ipam/migrations/0052_asn_model.py deleted file mode 100644 index 1a69f0e42..000000000 --- a/netbox/ipam/migrations/0052_asn_model.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 3.2.8 on 2021-10-25 04:34 - -import dcim.fields -import django.core.serializers.json -from django.db import migrations, models -import django.db.models.deletion -import taggit.managers - - -class Migration(migrations.Migration): - - dependencies = [ - ('extras', '0062_clear_secrets_changelog'), - ('tenancy', '0003_contacts'), - ('dcim', '0137_relax_uniqueness_constraints'), - ('ipam', '0051_extend_tag_support'), - ] - - operations = [ - migrations.CreateModel( - name='ASN', - fields=[ - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('asn', dcim.fields.ASNField(blank=False, null=False, unique=True)), - ('description', models.CharField(blank=True, max_length=200)), - ('rir', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='ipam.rir')), - ('sites', models.ManyToManyField(blank=True, related_name='asns', to='dcim.Site')), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='tenancy.tenant')), - ], - options={ - 'verbose_name': 'ASN', - 'verbose_name_plural': 'ASNs', - 'ordering': ['asn'], - }, - ), - ] From 7625a2dd3c474fc41bc0b8342422e097ff8492a8 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 2 Nov 2021 12:26:06 -0500 Subject: [PATCH 25/30] #6732 - Swap ASN M2M to Site model and update some templates/filters --- netbox/dcim/forms/bulk_edit.py | 2 +- netbox/dcim/forms/models.py | 11 ------- netbox/dcim/migrations/0141_asn_model.py | 19 ++++++++++++ netbox/dcim/models/sites.py | 5 ++++ netbox/dcim/tests/test_filtersets.py | 2 +- netbox/dcim/views.py | 7 ++++- netbox/ipam/forms/models.py | 19 ++++++++++-- netbox/ipam/migrations/0052_asn_model.py | 38 ++++++++++++++++++++++++ netbox/ipam/models/ip.py | 6 +--- netbox/ipam/views.py | 7 ++--- netbox/templates/dcim/site.html | 14 +++++++++ netbox/templates/ipam/asn.html | 19 ++++++++++-- 12 files changed, 120 insertions(+), 29 deletions(-) create mode 100644 netbox/dcim/migrations/0141_asn_model.py create mode 100644 netbox/ipam/migrations/0052_asn_model.py diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 57c74cf84..453cead1c 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -117,7 +117,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd required=False, label='ASN' ) - asns = DynamicModelChoiceField( + asns = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), label=_('ASNs'), required=False diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index a9fdb3652..36c349740 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -172,17 +172,6 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'longitude': "Longitude in decimal format (xx.yyyyyy)" } - def __init__(self, data=None, instance=None, *args, **kwargs): - super().__init__(data=data, instance=instance, *args, **kwargs) - - if self.instance and self.instance.pk is not None: - self.fields['asns'].initial = self.instance.asns.all().values_list('id', flat=True) - - def save(self, *args, **kwargs): - instance = super().save(*args, **kwargs) - instance.asns.set(self.cleaned_data['asns']) - return instance - class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = DynamicModelChoiceField( diff --git a/netbox/dcim/migrations/0141_asn_model.py b/netbox/dcim/migrations/0141_asn_model.py new file mode 100644 index 000000000..7650679f1 --- /dev/null +++ b/netbox/dcim/migrations/0141_asn_model.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-11-02 16:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0052_asn_model'), + ('dcim', '0140_wireless'), + ] + + operations = [ + migrations.AddField( + model_name='site', + name='asns', + field=models.ManyToManyField(blank=True, related_name='sites', to='ipam.ASN'), + ), + ] diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index a978e69e6..79f8921d5 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -195,6 +195,11 @@ class Site(PrimaryModel): verbose_name='ASN', help_text='32-bit autonomous system number' ) + asns = models.ManyToManyField( + to='ipam.ASN', + related_name='sites', + blank=True + ) time_zone = TimeZoneField( blank=True ) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index d05d2b2f2..0cbd892f5 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -183,7 +183,7 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_asns(self): - params = {'asns': [65001, 65002]} + params = {'asns': [64512, 65002]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_latitude(self): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a05f62621..9b8ac3e45 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -310,7 +310,6 @@ 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(), @@ -333,9 +332,15 @@ class SiteView(generic.ObjectView): cumulative=True ).restrict(request.user, 'view').filter(site=instance) + asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance) + asn_count = asns.count() + + stats.update({'asn_count': asn_count}) + return { 'stats': stats, 'locations': locations, + 'asns': asns, } diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index abf2aa4a1..ea00b6914 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -134,14 +134,18 @@ class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): label='Sites', required=False ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ASN fields = [ - 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description' + 'asn', 'rir', 'sites', 'tenant_group', 'tenant', 'description', 'tags' ] fieldsets = ( - ('ASN', ('asn', 'rir', 'sites', 'description')), + ('ASN', ('asn', 'rir', 'sites', 'description', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) help_texts = { @@ -152,6 +156,17 @@ class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'date_added': DatePicker(), } + def __init__(self, data=None, instance=None, *args, **kwargs): + super().__init__(data=data, instance=instance, *args, **kwargs) + + if self.instance and self.instance.pk is not None: + self.fields['sites'].initial = self.instance.sites.all().values_list('id', flat=True) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + instance.sites.set(self.cleaned_data['sites']) + return instance + class RoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() diff --git a/netbox/ipam/migrations/0052_asn_model.py b/netbox/ipam/migrations/0052_asn_model.py new file mode 100644 index 000000000..04eac76c3 --- /dev/null +++ b/netbox/ipam/migrations/0052_asn_model.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.8 on 2021-11-02 16:16 + +import dcim.fields +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0004_extend_tag_support'), + ('extras', '0064_configrevision'), + ('ipam', '0051_extend_tag_support'), + ] + + operations = [ + migrations.CreateModel( + name='ASN', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('asn', dcim.fields.ASNField(unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('rir', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='ipam.rir')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='asns', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'ASN', + 'verbose_name_plural': 'ASNs', + 'ordering': ['asn'], + }, + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index d61ad4c25..ad707dda1 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -71,6 +71,7 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ASN(PrimaryModel): asn = ASNField( @@ -98,11 +99,6 @@ class ASN(PrimaryModel): blank=True, null=True ) - sites = models.ManyToManyField( - to='dcim.Site', - related_name='asns', - blank=True - ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 73b228ac4..7801eec23 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -214,13 +214,10 @@ class ASNView(generic.ObjectView): queryset = ASN.objects.all() def get_extra_context(self, request, instance): - sites_table = SiteTable( - list(instance.sites.all()), - orderable=False - ) + sites = instance.sites.restrict(request.user, 'view').all() return { - 'sites_table': sites_table, + 'sites': sites, } diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 0364dee64..308b09816 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -260,6 +260,20 @@ {% endif %} +
+
+ ASNs +
+
+ {% if asns %} + {% for asn in asns %} + {{ asn }} + {% endfor %} + {% else %} + None + {% endif %} +
+
{% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/ipam/asn.html b/netbox/templates/ipam/asn.html index 8be09c660..8eafe7633 100644 --- a/netbox/templates/ipam/asn.html +++ b/netbox/templates/ipam/asn.html @@ -47,17 +47,30 @@ + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} {% plugin_left_page object %}
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %} +
+
+ Sites +
+
+ {% if sites %} + {% for site in sites %} + {{ site }} + {% endfor %} + {% else %} + None + {% endif %} +
+
{% plugin_right_page object %}
- {% include 'inc/panel_table.html' with table=sites_table heading='Sites' %} {% plugin_full_width_page object %}
From 25957bfae3086e769f22ee337311154182344f23 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 3 Nov 2021 08:56:04 -0500 Subject: [PATCH 26/30] Fix migration issues --- netbox/ipam/migrations/{0052_asn_model.py => 0053_asn_model.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename netbox/ipam/migrations/{0052_asn_model.py => 0053_asn_model.py} (97%) diff --git a/netbox/ipam/migrations/0052_asn_model.py b/netbox/ipam/migrations/0053_asn_model.py similarity index 97% rename from netbox/ipam/migrations/0052_asn_model.py rename to netbox/ipam/migrations/0053_asn_model.py index 04eac76c3..1c7ee8e23 100644 --- a/netbox/ipam/migrations/0052_asn_model.py +++ b/netbox/ipam/migrations/0053_asn_model.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('tenancy', '0004_extend_tag_support'), ('extras', '0064_configrevision'), - ('ipam', '0051_extend_tag_support'), + ('ipam', '0052_fhrpgroup'), ] operations = [ From 0ec0185d844b251c04d190b8d51655ea3ca0c2b3 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 3 Nov 2021 09:51:03 -0500 Subject: [PATCH 27/30] Fix Migration --- netbox/dcim/migrations/0141_asn_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/migrations/0141_asn_model.py b/netbox/dcim/migrations/0141_asn_model.py index 7650679f1..6f011f35d 100644 --- a/netbox/dcim/migrations/0141_asn_model.py +++ b/netbox/dcim/migrations/0141_asn_model.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('ipam', '0052_asn_model'), + ('ipam', '0053_asn_model'), ('dcim', '0140_wireless'), ] From 76d73abd81dfb1653565b96d18401ed9a8ae3e9b Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 3 Nov 2021 10:04:26 -0500 Subject: [PATCH 28/30] Update ip.py --- netbox/ipam/tables/ip.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 5c41a3f0b..462fc8845 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -12,7 +12,6 @@ from ipam.models import * __all__ = ( 'AggregateTable', 'ASNTable', - 'InterfaceIPAddressTable', 'AssignedIPAddressesTable', 'IPAddressAssignTable', 'IPAddressTable', From cf9eaf2eff576041cff85d739aa22e1fd18ea2de Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 3 Nov 2021 11:36:54 -0500 Subject: [PATCH 29/30] Fix dcim/views.py merge error --- netbox/dcim/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 7eef45f1b..f07bc6900 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -15,7 +15,7 @@ from django.views.generic import View from circuits.models import Circuit from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView from ipam.models import ASN, IPAddress, Prefix, Service, VLAN -from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count From c72f25c69376bd423ab79b9026c58e11e9f97622 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 3 Nov 2021 12:22:44 -0500 Subject: [PATCH 30/30] #6732 - Add documentation --- docs/core-functionality/ipam.md | 6 +++++- docs/models/ipam/asn.md | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 docs/models/ipam/asn.md diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index dd05d6a01..9fa5e0eb4 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -18,6 +18,10 @@ {!models/ipam/vrf.md!} {!models/ipam/routetarget.md!} -__ +--- {!models/ipam/fhrpgroup.md!} + +--- + +{!models/ipam/asn.md!} diff --git a/docs/models/ipam/asn.md b/docs/models/ipam/asn.md new file mode 100644 index 000000000..cfef1da29 --- /dev/null +++ b/docs/models/ipam/asn.md @@ -0,0 +1,15 @@ +# ASN + +ASN is short for Autonomous System Number. This identifier is used in the BGP protocol to identify which "autonomous system" a particular prefix is originating and transiting through. + +The AS number model within NetBox allows you to model some of this real-world relationship. + +Within NetBox: + +* AS numbers are globally unique +* Each AS number must be associated with a RIR (ARIN, RFC 6996, etc) +* Each AS number can be associated with many different sites +* Each site can have many different AS numbers +* Each AS number can be assigned to a single tenant + +