Initial work on #6732

This commit is contained in:
Daniel Sheppard 2021-10-24 23:42:47 -05:00
parent 8c058dcd45
commit a01068949c
29 changed files with 515 additions and 38 deletions

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.utils.translation import gettext as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
@ -6,8 +7,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.models import VLAN, ASN
from ipam.models import VLAN
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField,
@ -110,11 +110,10 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False required=False
) )
asn = forms.IntegerField( asns = DynamicModelChoiceField(
min_value=BGP_ASN_MIN, queryset=ASN.objects.all(),
max_value=BGP_ASN_MAX, label=_('ASNs'),
required=False, required=False
label='ASN'
) )
description = forms.CharField( description = forms.CharField(
max_length=100, max_length=100,
@ -128,7 +127,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
class Meta: class Meta:
nullable_fields = [ nullable_fields = [
'region', 'group', 'tenant', 'asn', 'description', 'time_zone', 'region', 'group', 'tenant', 'asns', 'description', 'time_zone',
] ]

View File

@ -94,7 +94,7 @@ class SiteCSVForm(CustomFieldModelCSVForm):
class Meta: class Meta:
model = Site model = Site
fields = ( fields = (
'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments', 'contact_email', 'comments',
) )

View File

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

View File

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

View File

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

View File

@ -75,6 +75,11 @@ class SiteTable(BaseTable):
group = tables.Column( group = tables.Column(
linkify=True linkify=True
) )
asn_count = LinkedCountColumn(
viewname='ipam:asn_list',
url_params={'site_id': 'pk'},
verbose_name='ASNs'
)
tenant = TenantColumn() tenant = TenantColumn()
comments = MarkdownColumn() comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
@ -84,11 +89,11 @@ class SiteTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Site model = Site
fields = ( fields = (
'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description', 'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
'contact_email', 'comments', 'tags', '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')
# #

View File

@ -14,7 +14,7 @@ from django.views.generic import View
from circuits.models import Circuit from circuits.models import Circuit
from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView
from ipam.models import IPAddress, Prefix, Service, VLAN from ipam.models import IPAddress, Prefix, Service, VLAN, ASN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
@ -310,6 +310,7 @@ class SiteView(generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
stats = { stats = {
'asn_count': ASN.objects.restrict(request.user, 'view').filter(sites=instance).count(),
'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(), 'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(),
'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(),
'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(), 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(),

View File

@ -5,6 +5,7 @@ from netbox.api import WritableNestedSerializer
__all__ = [ __all__ = [
'NestedAggregateSerializer', 'NestedAggregateSerializer',
'NestedASNSerializer',
'NestedIPAddressSerializer', 'NestedIPAddressSerializer',
'NestedIPRangeSerializer', 'NestedIPRangeSerializer',
'NestedPrefixSerializer', 'NestedPrefixSerializer',
@ -18,6 +19,18 @@ __all__ = [
] ]
#
# ASNs
#
class NestedASNSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
class Meta:
model = models.ASN
fields = ['id', 'url', 'display', 'asn']
# #
# VRFs # VRFs
# #

View File

@ -17,6 +17,24 @@ from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
from .nested_serializers import * from .nested_serializers import *
#
# ASNs
#
from ..models import ASN
class ASNSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail')
tenant = NestedTenantSerializer(required=False, allow_null=True)
class Meta:
model = ASN
fields = [
'id', 'url', 'display', 'asn', 'site_count', 'rir', 'tenant', 'description', 'tags', 'custom_fields',
'created', 'last_updated',
]
# #
# VRFs # VRFs
# #

View File

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

View File

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

View File

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

View File

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

View File

@ -6,12 +6,14 @@ from extras.forms import CustomFieldModelCSVForm
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.models import * from ipam.models import *
from ipam.models import ASN
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualMachine, VMInterface
__all__ = ( __all__ = (
'AggregateCSVForm', 'AggregateCSVForm',
'ASNCSVForm',
'IPAddressCSVForm', 'IPAddressCSVForm',
'IPRangeCSVForm', 'IPRangeCSVForm',
'PrefixCSVForm', 'PrefixCSVForm',
@ -80,6 +82,31 @@ class AggregateCSVForm(CustomFieldModelCSVForm):
fields = ('prefix', 'rir', 'tenant', 'date_added', 'description') fields = ('prefix', 'rir', 'tenant', 'date_added', 'description')
class ASNCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
rir = CSVModelChoiceField(
queryset=RIR.objects.all(),
to_field_name='name',
help_text='Assigned RIR'
)
sites = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
help_text='Assigned site'
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text='Assigned tenant'
)
class Meta:
model = ASN
fields = ('asn', 'rir', 'tenant', 'description')
help_texts = {}
class RoleCSVForm(CustomFieldModelCSVForm): class RoleCSVForm(CustomFieldModelCSVForm):
slug = SlugField() slug = SlugField()

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ from django.db.models import F, Q
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from dcim.fields import ASNField
from dcim.models import Device from dcim.models import Device
from extras.utils import extras_features from extras.utils import extras_features
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
@ -23,6 +24,7 @@ from virtualization.models import VirtualMachine
__all__ = ( __all__ = (
'Aggregate', 'Aggregate',
'ASN',
'IPAddress', 'IPAddress',
'IPRange', 'IPRange',
'Prefix', 'Prefix',
@ -69,6 +71,52 @@ class RIR(OrganizationalModel):
return reverse('ipam:rir', args=[self.pk]) return reverse('ipam:rir', args=[self.pk])
class ASN(PrimaryModel):
asn = ASNField(
blank=True,
null=True,
verbose_name='ASN',
help_text='32-bit autonomous system number'
)
description = models.CharField(
max_length=200,
blank=True
)
rir = models.ForeignKey(
to='ipam.RIR',
on_delete=models.PROTECT,
related_name='asns',
blank=False,
null=False
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='asns',
blank=True,
null=True
)
sites = models.ManyToManyField(
to='dcim.Site',
related_name='asns',
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['asn']
verbose_name = 'ASN'
verbose_name_plural = 'ASNs'
def __str__(self):
return f'AS{self.asn}'
def get_absolute_url(self):
return reverse('ipam:asn', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Aggregate(PrimaryModel): class Aggregate(PrimaryModel):
""" """

View File

@ -2,6 +2,7 @@ import django_tables2 as tables
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from ipam.models import ASN
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn,
@ -11,6 +12,7 @@ from ipam.models import *
__all__ = ( __all__ = (
'AggregateTable', 'AggregateTable',
'ASNTable',
'InterfaceIPAddressTable', 'InterfaceIPAddressTable',
'IPAddressAssignTable', 'IPAddressAssignTable',
'IPAddressTable', 'IPAddressTable',
@ -93,6 +95,29 @@ class RIRTable(BaseTable):
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
#
# RIRs
#
class ASNTable(BaseTable):
pk = ToggleColumn()
asn = tables.Column(
linkify=True
)
site_count = LinkedCountColumn(
viewname='dcim:site_list',
url_params={'asn_id': 'pk'},
verbose_name='Sites'
)
actions = ButtonsColumn(ASN)
class Meta(BaseTable.Meta):
model = ASN
fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions')
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions')
# #
# Aggregates # Aggregates
# #

View File

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

View File

@ -7,6 +7,18 @@ from .models import *
app_name = 'ipam' app_name = 'ipam'
urlpatterns = [ urlpatterns = [
# ASNs
path('asns/', views.ASNListView.as_view(), name='asn_list'),
path('asns/add/', views.ASNEditView.as_view(), name='asn_add'),
path('asns/import/', views.ASNBulkImportView.as_view(), name='asn_import'),
path('asns/edit/', views.ASNBulkEditView.as_view(), name='asn_bulk_edit'),
path('asns/delete/', views.ASNBulkDeleteView.as_view(), name='asn_bulk_delete'),
path('asns/<int:pk>/', views.ASNView.as_view(), name='asn'),
path('asns/<int:pk>/edit/', views.ASNEditView.as_view(), name='asn_edit'),
path('asns/<int:pk>/delete/', views.ASNDeleteView.as_view(), name='asn_delete'),
path('asns/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='asn_changelog', kwargs={'model': ASN}),
path('asns/<int:pk>/journal/', ObjectJournalView.as_view(), name='asn_journal', kwargs={'model': ASN}),
# VRFs # VRFs
path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), path('vrfs/', views.VRFListView.as_view(), name='vrf_list'),
path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'), path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'),

View File

@ -2,7 +2,8 @@ from django.db.models import Prefetch
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from dcim.models import Device, Interface from dcim.models import Device, Interface, Site
from dcim.tables import SiteTable
from netbox.views import generic from netbox.views import generic
from utilities.forms import TableConfigForm from utilities.forms import TableConfigForm
from utilities.tables import paginate_table from utilities.tables import paginate_table
@ -11,6 +12,7 @@ from virtualization.models import VirtualMachine, VMInterface
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .constants import * from .constants import *
from .models import * from .models import *
from .models import ASN
from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans
@ -195,6 +197,65 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
table = tables.RIRTable table = tables.RIRTable
#
# ASNs
#
class ASNListView(generic.ObjectListView):
queryset = ASN.objects.annotate(
site_count=count_related(Site, 'asns'),
)
filterset = filtersets.ASNFilterSet
filterset_form = forms.ASNFilterForm
table = tables.ASNTable
class ASNView(generic.ObjectView):
queryset = ASN.objects.all()
def get_extra_context(self, request, instance):
sites_table = SiteTable(
list(instance.sites.all()),
orderable=False
)
return {
'sites_table': sites_table,
}
class ASNEditView(generic.ObjectEditView):
queryset = ASN.objects.all()
model_form = forms.ASNForm
class ASNDeleteView(generic.ObjectDeleteView):
queryset = ASN.objects.all()
class ASNBulkImportView(generic.BulkImportView):
queryset = ASN.objects.all()
model_form = forms.ASNCSVForm
table = tables.ASNTable
class ASNBulkEditView(generic.BulkEditView):
queryset = ASN.objects.annotate(
site_count=count_related(Site, 'asns')
)
filterset = filtersets.ASNFilterSet
table = tables.ASNTable
form = forms.ASNBulkEditForm
class ASNBulkDeleteView(generic.BulkDeleteView):
queryset = ASN.objects.annotate(
site_count=count_related(Site, 'asns')
)
filterset = filtersets.ASNFilterSet
table = tables.ASNTable
# #
# Aggregates # Aggregates
# #

View File

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

View File

@ -214,6 +214,12 @@ IPAM_MENU = Menu(
get_model_item('ipam', 'role', 'Prefix & VLAN Roles'), get_model_item('ipam', 'role', 'Prefix & VLAN Roles'),
), ),
), ),
MenuGroup(
label='ASNs',
items=(
get_model_item('ipam', 'asn', 'ASNs'),
),
),
MenuGroup( MenuGroup(
label='Aggregates', label='Aggregates',
items=( items=(

View File

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

View File

@ -0,0 +1,64 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load helpers %}
{% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'ipam:asn_list' %}?rir_id={{ object.rir.pk }}">{{ object.rir }}</a></li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
ASN
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<td>AS Number</td>
<td>{{ object.asn }}</td>
</tr>
<tr>
<td>RIR</td>
<td>
<a href="{% url 'ipam:asn_list' %}?rir={{ object.rir.slug }}">{{ object.rir }}</a>
</td>
</tr>
<tr>
<td>Tenant</td>
<td>
{% if object.tenant %}
{% if prefix.object.group %}
<a href="{{ object.tenant.group.get_absolute_url }}">{{ object.tenant.group }}</a> /
{% endif %}
<a href="{{ object.tenant.get_absolute_url }}">{{ object.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:asn_list' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=sites_table heading='Sites' %}
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}