Merge branch 'develop' into feature

This commit is contained in:
jeremystretch 2021-12-13 11:29:54 -05:00
commit f2f6edabf9
23 changed files with 283 additions and 300 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.1.0 placeholder: v3.1.1
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.1.0 placeholder: v3.1.1
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -1,6 +1,6 @@
# The Python web framework on which NetBox is built # The Python web framework on which NetBox is built
# https://github.com/django/django # https://github.com/django/django
Django Django<4.0
# Django middleware which permits cross-domain API requests # Django middleware which permits cross-domain API requests
# https://github.com/OttoYiu/django-cors-headers # https://github.com/OttoYiu/django-cors-headers

View File

@ -1,9 +1,16 @@
# NetBox v3.1 # NetBox v3.1
## v3.1.1 (FUTURE) ## v3.1.1 (2021-12-13)
### Enhancements
* [#8047](https://github.com/netbox-community/netbox/issues/8047) - Display sorting indicator in table column headers
### Bug Fixes ### Bug Fixes
* [#5869](https://github.com/netbox-community/netbox/issues/5869) - Fix permissions evaluation under available prefix/IP REST API endpoints
* [#7519](https://github.com/netbox-community/netbox/issues/7519) - Return a 409 status for unfulfillable available prefix/IP requests
* [#7690](https://github.com/netbox-community/netbox/issues/7690) - Fix custom field integer support for MultiValueNumberFilter
* [#7990](https://github.com/netbox-community/netbox/issues/7990) - Fix `title` display on contact detail view * [#7990](https://github.com/netbox-community/netbox/issues/7990) - Fix `title` display on contact detail view
* [#7996](https://github.com/netbox-community/netbox/issues/7996) - Show WWN field in interface creation form * [#7996](https://github.com/netbox-community/netbox/issues/7996) - Show WWN field in interface creation form
* [#8001](https://github.com/netbox-community/netbox/issues/8001) - Correct verbose name for wireless LAN group model * [#8001](https://github.com/netbox-community/netbox/issues/8001) - Correct verbose name for wireless LAN group model
@ -14,6 +21,10 @@
* [#8019](https://github.com/netbox-community/netbox/issues/8019) - Exclude metrics endpoint when `LOGIN_REQUIRED` is true * [#8019](https://github.com/netbox-community/netbox/issues/8019) - Exclude metrics endpoint when `LOGIN_REQUIRED` is true
* [#8030](https://github.com/netbox-community/netbox/issues/8030) - Validate custom field names * [#8030](https://github.com/netbox-community/netbox/issues/8030) - Validate custom field names
* [#8033](https://github.com/netbox-community/netbox/issues/8033) - Fix display of zero values for custom integer fields in tables * [#8033](https://github.com/netbox-community/netbox/issues/8033) - Fix display of zero values for custom integer fields in tables
* [#8035](https://github.com/netbox-community/netbox/issues/8035) - Redirect back to parent prefix after creating IP address(es) where applicable
* [#8038](https://github.com/netbox-community/netbox/issues/8038) - Placeholder filter should display zero integer values
* [#8042](https://github.com/netbox-community/netbox/issues/8042) - Fix filtering cables list by site slug or rack name
* [#8051](https://github.com/netbox-community/netbox/issues/8051) - Contact group parent assignment should not be required under REST API
--- ---

View File

@ -1260,7 +1260,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
method='filter_device', method='filter_device',
field_name='device__rack_id' field_name='device__rack_id'
) )
rack = MultiValueNumberFilter( rack = MultiValueCharFilter(
method='filter_device', method='filter_device',
field_name='device__rack__name' field_name='device__rack__name'
) )
@ -1268,7 +1268,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
method='filter_device', method='filter_device',
field_name='device__site_id' field_name='device__site_id'
) )
site = MultiValueNumberFilter( site = MultiValueCharFilter(
method='filter_device', method='filter_device',
field_name='device__site__slug' field_name='device__site__slug'
) )

View File

@ -48,9 +48,6 @@ __all__ = (
class DeviceComponentFilterForm(CustomFieldModelFilterForm): class DeviceComponentFilterForm(CustomFieldModelFilterForm):
field_order = [
'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id',
]
name = forms.CharField( name = forms.CharField(
required=False required=False
) )
@ -131,7 +128,6 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm):
class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Site model = Site
field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id', 'asn_id']
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['status', 'region_id', 'group_id'], ['status', 'region_id', 'group_id'],
@ -213,7 +209,6 @@ class RackRoleFilterForm(CustomFieldModelFilterForm):
class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Rack model = Rack
field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id']
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['region_id', 'site_id', 'location_id'], ['region_id', 'site_id', 'location_id'],
@ -278,10 +273,6 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
class RackElevationFilterForm(RackFilterForm): class RackElevationFilterForm(RackFilterForm):
field_order = [
'q', 'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id',
'tenant_id',
]
id = DynamicModelMultipleChoiceField( id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
label=_('Rack'), label=_('Rack'),
@ -296,7 +287,6 @@ class RackElevationFilterForm(RackFilterForm):
class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = RackReservation model = RackReservation
field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id']
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['user_id'], ['user_id'],
@ -428,10 +418,6 @@ class PlatformFilterForm(CustomFieldModelFilterForm):
class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
model = Device model = Device
field_order = [
'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id',
'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
]
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'],
@ -595,7 +581,6 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = VirtualChassis model = VirtualChassis
field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id']
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['region_id', 'site_group_id', 'site_id'], ['region_id', 'site_group_id', 'site_id'],

View File

@ -1,185 +0,0 @@
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from ipam.models import *
from netbox.config import get_config
from utilities.constants import ADVISORY_LOCK_KEYS
from . import serializers
class AvailablePrefixesMixin:
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
@swagger_auto_schema(method='post', responses={201: serializers.PrefixSerializer(many=False)})
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def available_prefixes(self, request, pk=None):
"""
A convenience method for returning available child prefixes within a parent.
The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
invoked in parallel, which results in a race condition where multiple insertions can occur.
"""
prefix = get_object_or_404(self.queryset, pk=pk)
available_prefixes = prefix.get_available_prefixes()
if request.method == 'POST':
# Validate Requested Prefixes' length
serializer = serializers.PrefixLengthSerializer(
data=request.data if isinstance(request.data, list) else [request.data],
many=True,
context={
'request': request,
'prefix': prefix,
}
)
if not serializer.is_valid():
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
requested_prefixes = serializer.validated_data
# Allocate prefixes to the requested objects based on availability within the parent
for i, requested_prefix in enumerate(requested_prefixes):
# Find the first available prefix equal to or larger than the requested size
for available_prefix in available_prefixes.iter_cidrs():
if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
requested_prefix['prefix'] = allocated_prefix
requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
break
else:
return Response(
{
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
},
status=status.HTTP_204_NO_CONTENT
)
# Remove the allocated prefix from the list of available prefixes
available_prefixes.remove(allocated_prefix)
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
else:
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
# Create the new Prefix(es)
if serializer.is_valid():
try:
with transaction.atomic():
created = serializer.save()
self._validate_objects(created)
except ObjectDoesNotExist:
raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
'request': request,
'vrf': prefix.vrf,
})
return Response(serializer.data)
class AvailableIPsMixin:
parent_model = Prefix
@swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
@swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
request_body=serializers.AvailableIPSerializer(many=True))
@action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def available_ips(self, request, pk=None):
"""
A convenience method for returning available IP addresses within a Prefix or IPRange. By default, the number of
IPs returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be
passed, however results will not be paginated.
The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
invoked in parallel, which results in a race condition where multiple insertions can occur.
"""
parent = get_object_or_404(self.parent_model.objects.restrict(request.user), pk=pk)
# Create the next available IP
if request.method == 'POST':
# Normalize to a list of objects
requested_ips = request.data if isinstance(request.data, list) else [request.data]
# Determine if the requested number of IPs is available
available_ips = parent.get_available_ips()
if available_ips.size < len(requested_ips):
return Response(
{
"detail": f"An insufficient number of IP addresses are available within {parent} "
f"({len(requested_ips)} requested, {len(available_ips)} available)"
},
status=status.HTTP_204_NO_CONTENT
)
# Assign addresses from the list of available IPs and copy VRF assignment from the parent
available_ips = iter(available_ips)
for requested_ip in requested_ips:
requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}'
requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
else:
serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
# Create the new IP address(es)
if serializer.is_valid():
try:
with transaction.atomic():
created = serializer.save()
self._validate_objects(created)
except ObjectDoesNotExist:
raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Determine the maximum number of IPs to return
else:
config = get_config()
PAGINATE_COUNT = config.PAGINATE_COUNT
MAX_PAGE_SIZE = config.MAX_PAGE_SIZE
try:
limit = int(request.query_params.get('limit', PAGINATE_COUNT))
except ValueError:
limit = PAGINATE_COUNT
if MAX_PAGE_SIZE:
limit = min(limit, MAX_PAGE_SIZE)
# Calculate available IPs within the parent
ip_list = []
for index, ip in enumerate(parent.get_available_ips(), start=1):
ip_list.append(ip)
if index == limit:
break
serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={
'request': request,
'parent': parent,
'vrf': parent.vrf,
})
return Response(serializer.data)

View File

@ -1,4 +1,7 @@
from django.urls import path
from netbox.api import OrderedDefaultRouter from netbox.api import OrderedDefaultRouter
from ipam.models import IPRange, Prefix
from . import views from . import views
@ -42,4 +45,23 @@ router.register('vlans', views.VLANViewSet)
router.register('services', views.ServiceViewSet) router.register('services', views.ServiceViewSet)
app_name = 'ipam-api' app_name = 'ipam-api'
urlpatterns = router.urls
urlpatterns = [
path(
'ip-ranges/<int:pk>/available-ips/',
views.IPRangeAvailableIPAddressesView.as_view(),
name='iprange-available-ips'
),
path(
'prefixes/<int:pk>/available-prefixes/',
views.AvailablePrefixesView.as_view(),
name='prefix-available-prefixes'
),
path(
'prefixes/<int:pk>/available-ips/',
views.PrefixAvailableIPAddressesView.as_view(),
name='prefix-available-ips'
),
]
urlpatterns += router.urls

View File

@ -1,12 +1,23 @@
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django_pglocks import advisory_lock
from django.shortcuts import get_object_or_404
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.views import APIView
from dcim.models import Site 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, ObjectValidationMixin
from netbox.config import get_config
from utilities.constants import ADVISORY_LOCK_KEYS
from utilities.utils import count_related from utilities.utils import count_related
from . import mixins, serializers from . import serializers
class IPAMRootView(APIRootView): class IPAMRootView(APIRootView):
@ -18,7 +29,7 @@ class IPAMRootView(APIRootView):
# #
# ASNs # Viewsets
# #
class ASNViewSet(CustomFieldModelViewSet): class ASNViewSet(CustomFieldModelViewSet):
@ -27,10 +38,6 @@ class ASNViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.ASNFilterSet filterset_class = filtersets.ASNFilterSet
#
# VRFs
#
class VRFViewSet(CustomFieldModelViewSet): class VRFViewSet(CustomFieldModelViewSet):
queryset = VRF.objects.prefetch_related('tenant').prefetch_related( queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
'import_targets', 'export_targets', 'tags' 'import_targets', 'export_targets', 'tags'
@ -42,20 +49,12 @@ class VRFViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VRFFilterSet filterset_class = filtersets.VRFFilterSet
#
# Route targets
#
class RouteTargetViewSet(CustomFieldModelViewSet): class RouteTargetViewSet(CustomFieldModelViewSet):
queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags') queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags')
serializer_class = serializers.RouteTargetSerializer serializer_class = serializers.RouteTargetSerializer
filterset_class = filtersets.RouteTargetFilterSet filterset_class = filtersets.RouteTargetFilterSet
#
# RIRs
#
class RIRViewSet(CustomFieldModelViewSet): class RIRViewSet(CustomFieldModelViewSet):
queryset = RIR.objects.annotate( queryset = RIR.objects.annotate(
aggregate_count=count_related(Aggregate, 'rir') aggregate_count=count_related(Aggregate, 'rir')
@ -64,20 +63,12 @@ class RIRViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.RIRFilterSet filterset_class = filtersets.RIRFilterSet
#
# Aggregates
#
class AggregateViewSet(CustomFieldModelViewSet): class AggregateViewSet(CustomFieldModelViewSet):
queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags') queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
serializer_class = serializers.AggregateSerializer serializer_class = serializers.AggregateSerializer
filterset_class = filtersets.AggregateFilterSet filterset_class = filtersets.AggregateFilterSet
#
# Roles
#
class RoleViewSet(CustomFieldModelViewSet): class RoleViewSet(CustomFieldModelViewSet):
queryset = Role.objects.annotate( queryset = Role.objects.annotate(
prefix_count=count_related(Prefix, 'role'), prefix_count=count_related(Prefix, 'role'),
@ -87,11 +78,7 @@ class RoleViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.RoleFilterSet filterset_class = filtersets.RoleFilterSet
# class PrefixViewSet(CustomFieldModelViewSet):
# Prefixes
#
class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, CustomFieldModelViewSet):
queryset = Prefix.objects.prefetch_related( queryset = Prefix.objects.prefetch_related(
'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags' 'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
) )
@ -106,11 +93,7 @@ class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, Cus
return super().get_serializer_class() return super().get_serializer_class()
# class IPRangeViewSet(CustomFieldModelViewSet):
# IP ranges
#
class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet):
queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags') queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags')
serializer_class = serializers.IPRangeSerializer serializer_class = serializers.IPRangeSerializer
filterset_class = filtersets.IPRangeFilterSet filterset_class = filtersets.IPRangeFilterSet
@ -118,10 +101,6 @@ class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet):
parent_model = IPRange # AvailableIPsMixin parent_model = IPRange # AvailableIPsMixin
#
# IP addresses
#
class IPAddressViewSet(CustomFieldModelViewSet): class IPAddressViewSet(CustomFieldModelViewSet):
queryset = IPAddress.objects.prefetch_related( queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object' 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
@ -130,10 +109,6 @@ class IPAddressViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.IPAddressFilterSet filterset_class = filtersets.IPAddressFilterSet
#
# FHRP groups
#
class FHRPGroupViewSet(CustomFieldModelViewSet): class FHRPGroupViewSet(CustomFieldModelViewSet):
queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags') queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags')
serializer_class = serializers.FHRPGroupSerializer serializer_class = serializers.FHRPGroupSerializer
@ -147,10 +122,6 @@ class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.FHRPGroupAssignmentFilterSet filterset_class = filtersets.FHRPGroupAssignmentFilterSet
#
# VLAN groups
#
class VLANGroupViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(CustomFieldModelViewSet):
queryset = VLANGroup.objects.annotate( queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group') vlan_count=count_related(VLAN, 'group')
@ -159,10 +130,6 @@ class VLANGroupViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VLANGroupFilterSet filterset_class = filtersets.VLANGroupFilterSet
#
# VLANs
#
class VLANViewSet(CustomFieldModelViewSet): class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.prefetch_related( queryset = VLAN.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'tags' 'site', 'group', 'tenant', 'role', 'tags'
@ -173,13 +140,190 @@ class VLANViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VLANFilterSet filterset_class = filtersets.VLANFilterSet
#
# Services
#
class ServiceViewSet(ModelViewSet): class ServiceViewSet(ModelViewSet):
queryset = Service.objects.prefetch_related( queryset = Service.objects.prefetch_related(
'device', 'virtual_machine', 'tags', 'ipaddresses' 'device', 'virtual_machine', 'tags', 'ipaddresses'
) )
serializer_class = serializers.ServiceSerializer serializer_class = serializers.ServiceSerializer
filterset_class = filtersets.ServiceFilterSet filterset_class = filtersets.ServiceFilterSet
#
# Views
#
class AvailablePrefixesView(ObjectValidationMixin, APIView):
queryset = Prefix.objects.all()
@swagger_auto_schema(responses={200: serializers.AvailablePrefixSerializer(many=True)})
def get(self, request, pk):
prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
available_prefixes = prefix.get_available_prefixes()
serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
'request': request,
'vrf': prefix.vrf,
})
return Response(serializer.data)
@swagger_auto_schema(
request_body=serializers.PrefixLengthSerializer,
responses={201: serializers.PrefixSerializer(many=True)}
)
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add')
prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
available_prefixes = prefix.get_available_prefixes()
# Validate Requested Prefixes' length
serializer = serializers.PrefixLengthSerializer(
data=request.data if isinstance(request.data, list) else [request.data],
many=True,
context={
'request': request,
'prefix': prefix,
}
)
if not serializer.is_valid():
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
requested_prefixes = serializer.validated_data
# Allocate prefixes to the requested objects based on availability within the parent
for i, requested_prefix in enumerate(requested_prefixes):
# Find the first available prefix equal to or larger than the requested size
for available_prefix in available_prefixes.iter_cidrs():
if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
requested_prefix['prefix'] = allocated_prefix
requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
break
else:
return Response(
{
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
},
status=status.HTTP_409_CONFLICT
)
# Remove the allocated prefix from the list of available prefixes
available_prefixes.remove(allocated_prefix)
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
else:
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
# Create the new Prefix(es)
if serializer.is_valid():
try:
with transaction.atomic():
created = serializer.save()
self._validate_objects(created)
except ObjectDoesNotExist:
raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class AvailableIPAddressesView(ObjectValidationMixin, APIView):
queryset = IPAddress.objects.all()
def get_parent(self, request, pk):
raise NotImplemented()
@swagger_auto_schema(responses={200: serializers.AvailableIPSerializer(many=True)})
def get(self, request, pk):
parent = self.get_parent(request, pk)
config = get_config()
PAGINATE_COUNT = config.PAGINATE_COUNT
MAX_PAGE_SIZE = config.MAX_PAGE_SIZE
try:
limit = int(request.query_params.get('limit', PAGINATE_COUNT))
except ValueError:
limit = PAGINATE_COUNT
if MAX_PAGE_SIZE:
limit = min(limit, MAX_PAGE_SIZE)
# Calculate available IPs within the parent
ip_list = []
for index, ip in enumerate(parent.get_available_ips(), start=1):
ip_list.append(ip)
if index == limit:
break
serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={
'request': request,
'parent': parent,
'vrf': parent.vrf,
})
return Response(serializer.data)
@swagger_auto_schema(
request_body=serializers.AvailableIPSerializer,
responses={201: serializers.IPAddressSerializer(many=True)}
)
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def post(self, request, pk):
self.queryset = self.queryset.restrict(request.user, 'add')
parent = self.get_parent(request, pk)
# Normalize to a list of objects
requested_ips = request.data if isinstance(request.data, list) else [request.data]
# Determine if the requested number of IPs is available
available_ips = parent.get_available_ips()
if available_ips.size < len(requested_ips):
return Response(
{
"detail": f"An insufficient number of IP addresses are available within {parent} "
f"({len(requested_ips)} requested, {len(available_ips)} available)"
},
status=status.HTTP_409_CONFLICT
)
# Assign addresses from the list of available IPs and copy VRF assignment from the parent
available_ips = iter(available_ips)
for requested_ip in requested_ips:
requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}'
requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
else:
serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
# Create the new IP address(es)
if serializer.is_valid():
try:
with transaction.atomic():
created = serializer.save()
self._validate_objects(created)
except ObjectDoesNotExist:
raise PermissionDenied()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class PrefixAvailableIPAddressesView(AvailableIPAddressesView):
def get_parent(self, request, pk):
return get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
class IPRangeAvailableIPAddressesView(AvailableIPAddressesView):
def get_parent(self, request, pk):
return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk)

View File

@ -277,10 +277,6 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = IPAddress model = IPAddress
field_order = [
'q', 'parent', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'role',
'assigned_to_interface', 'tenant_group_id', 'tenant_id',
]
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'], ['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'],

View File

@ -289,7 +289,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
vrf = VRF.objects.create(name='VRF 1') vrf = VRF.objects.create(name='VRF 1')
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True) prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk}) url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
self.add_permissions('ipam.add_prefix') self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
# Create four available prefixes with individual requests # Create four available prefixes with individual requests
prefixes_to_be_created = [ prefixes_to_be_created = [
@ -311,7 +311,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
# Try to create one more prefix # Try to create one more prefix
response = self.client.post(url, {'prefix_length': 30}, format='json', **self.header) response = self.client.post(url, {'prefix_length': 30}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data) self.assertIn('detail', response.data)
# Try to create invalid prefix type # Try to create invalid prefix type
@ -337,7 +337,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
{'prefix_length': 30, 'description': 'Prefix 5'}, {'prefix_length': 30, 'description': 'Prefix 5'},
] ]
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data) self.assertIn('detail', response.data)
# Verify that no prefixes were created (the entire /28 is still available) # Verify that no prefixes were created (the entire /28 is still available)
@ -391,7 +391,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
# Try to create one more IP # Try to create one more IP
response = self.client.post(url, {}, **self.header) response = self.client.post(url, {}, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data) self.assertIn('detail', response.data)
def test_create_multiple_available_ips(self): def test_create_multiple_available_ips(self):
@ -406,7 +406,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
# Try to create nine IPs (only eight are available) # Try to create nine IPs (only eight are available)
data = [{'description': f'Test IP {i}'} for i in range(1, 10)] # 9 IPs data = [{'description': f'Test IP {i}'} for i in range(1, 10)] # 9 IPs
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data) self.assertIn('detail', response.data)
# Create all eight available IPs in a single request # Create all eight available IPs in a single request
@ -488,7 +488,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
# Try to create one more IP # Try to create one more IP
response = self.client.post(url, {}, **self.header) response = self.client.post(url, {}, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data) self.assertIn('detail', response.data)
def test_create_multiple_available_ips(self): def test_create_multiple_available_ips(self):
@ -505,7 +505,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
# Try to create nine IPs (only eight are available) # Try to create nine IPs (only eight are available)
data = [{'description': f'Test IP #{i}'} for i in range(1, 10)] # 9 IPs data = [{'description': f'Test IP #{i}'} for i in range(1, 10)] # 9 IPs
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data) self.assertIn('detail', response.data)
# Create all eight available IPs in a single request # Create all eight available IPs in a single request

View File

@ -123,11 +123,28 @@ class BulkDestroyModelMixin:
self.perform_destroy(obj) self.perform_destroy(obj)
class ObjectValidationMixin:
def _validate_objects(self, instance):
"""
Check that the provided instance or list of instances are matched by the current queryset. This confirms that
any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
"""
if type(instance) is list:
# Check that all instances are still included in the view's queryset
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
if conforming_count != len(instance):
raise ObjectDoesNotExist
else:
# Check that the instance is matched by the view's queryset
self.queryset.get(pk=instance.pk)
# #
# Viewsets # Viewsets
# #
class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_): class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet_):
""" """
Extend DRF's ModelViewSet to support bulk update and delete functions. Extend DRF's ModelViewSet to support bulk update and delete functions.
""" """
@ -211,20 +228,6 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
**kwargs **kwargs
) )
def _validate_objects(self, instance):
"""
Check that the provided instance or list of instances are matched by the current queryset. This confirms that
any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
"""
if type(instance) is list:
# Check that all instances are still included in the view's queryset
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
if conforming_count != len(instance):
raise ObjectDoesNotExist
else:
# Check that the instance is matched by the view's queryset
self.queryset.get(pk=instance.pk)
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
""" """
Overrides ListModelMixin to allow processing ExportTemplates. Overrides ListModelMixin to allow processing ExportTemplates.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -235,6 +235,16 @@ table {
} }
} }
th.asc a::after {
content: "\f0140";
font-family: 'Material Design Icons';
}
th.desc a::after {
content: "\f0143";
font-family: 'Material Design Icons';
}
&.table > :not(caption) > * > * { &.table > :not(caption) > * > * {
padding-right: $table-cell-padding-x-sm !important; padding-right: $table-cell-padding-x-sm !important;
padding-left: $table-cell-padding-x-sm !important; padding-left: $table-cell-padding-x-sm !important;

View File

@ -4,7 +4,7 @@
{% block extra_controls %} {% block extra_controls %}
{% if perms.ipam.add_ipaddress and first_available_ip %} {% if perms.ipam.add_ipaddress and first_available_ip %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-success"> <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}&return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-sm btn-success">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add IP Address <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add IP Address
</a> </a>
{% endif %} {% endif %}

View File

@ -56,7 +56,7 @@ class TenantSerializer(PrimaryModelSerializer):
class ContactGroupSerializer(NestedGroupModelSerializer): class ContactGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail') url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail')
parent = NestedContactGroupSerializer(required=False, allow_null=True) parent = NestedContactGroupSerializer(required=False, allow_null=True, default=None)
contact_count = serializers.IntegerField(read_only=True) contact_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:

View File

@ -1,4 +1,3 @@
from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from extras.forms import CustomFieldModelFilterForm from extras.forms import CustomFieldModelFilterForm

View File

@ -17,9 +17,10 @@ def multivalue_field_factory(field_class):
def to_python(self, value): def to_python(self, value):
if not value: if not value:
return [] return []
field = field_class()
return [ return [
# Only append non-empty values (this avoids e.g. trying to cast '' as an integer) # Only append non-empty values (this avoids e.g. trying to cast '' as an integer)
super(field_class, self).to_python(v) for v in value if v field.to_python(v) for v in value if v
] ]
return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict()) return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict())
@ -50,15 +51,15 @@ class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
class MACAddressFilter(django_filters.CharFilter): class MACAddressFilter(django_filters.CharFilter):
field_class = MACAddressField pass
class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter): class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(MACAddressField) field_class = multivalue_field_factory(forms.CharField)
class MultiValueWWNFilter(django_filters.MultipleChoiceFilter): class MultiValueWWNFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(MACAddressField) field_class = multivalue_field_factory(forms.CharField)
class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):

View File

@ -32,7 +32,7 @@ def placeholder(value):
""" """
Render a muted placeholder if value equates to False. Render a muted placeholder if value equates to False.
""" """
if value: if value not in ('', None):
return value return value
placeholder = '<span class="text-muted">&mdash;</span>' placeholder = '<span class="text-muted">&mdash;</span>'
return mark_safe(placeholder) return mark_safe(placeholder)

View File

@ -31,9 +31,6 @@ class ClusterGroupFilterForm(CustomFieldModelFilterForm):
class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Cluster model = Cluster
field_order = [
'q', 'type_id', 'region_id', 'site_id', 'group_id', 'tenant_group_id', 'tenant_id',
]
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['group_id', 'type_id'], ['group_id', 'type_id'],

View File

@ -1,6 +1,6 @@
Django==3.2.9 Django==3.2.10
django-cors-headers==3.10.1 django-cors-headers==3.10.1
django-debug-toolbar==3.2.2 django-debug-toolbar==3.2.3
django-filter==21.1 django-filter==21.1
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
django-mptt==0.13.4 django-mptt==0.13.4
@ -18,7 +18,7 @@ gunicorn==20.1.0
Jinja2==3.0.3 Jinja2==3.0.3
Markdown==3.3.6 Markdown==3.3.6
markdown-include==0.6.0 markdown-include==0.6.0
mkdocs-material==8.0.4 mkdocs-material==8.1.0
netaddr==0.8.0 netaddr==0.8.0
Pillow==8.4.0 Pillow==8.4.0
psycopg2-binary==2.9.2 psycopg2-binary==2.9.2