diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 66ead4f47..4b85a1628 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.1.0 + placeholder: v3.1.1 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index dcc0b1a5f..d63c2567c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.1.0 + placeholder: v3.1.1 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index 7295607f3..cbc893aa9 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,6 +1,6 @@ # The Python web framework on which NetBox is built # https://github.com/django/django -Django +Django<4.0 # Django middleware which permits cross-domain API requests # https://github.com/OttoYiu/django-cors-headers diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 7e2305e6f..0224b9c15 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,9 +1,16 @@ # 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 +* [#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 * [#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 @@ -14,6 +21,10 @@ * [#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 * [#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 --- diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 990c55115..8b1369be9 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1260,7 +1260,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): method='filter_device', field_name='device__rack_id' ) - rack = MultiValueNumberFilter( + rack = MultiValueCharFilter( method='filter_device', field_name='device__rack__name' ) @@ -1268,7 +1268,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): method='filter_device', field_name='device__site_id' ) - site = MultiValueNumberFilter( + site = MultiValueCharFilter( method='filter_device', field_name='device__site__slug' ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 21e8c9c97..a1d996b2c 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -48,9 +48,6 @@ __all__ = ( class DeviceComponentFilterForm(CustomFieldModelFilterForm): - field_order = [ - 'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id', - ] name = forms.CharField( required=False ) @@ -131,7 +128,6 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm): class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = Site - field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id', 'asn_id'] field_groups = [ ['q', 'tag'], ['status', 'region_id', 'group_id'], @@ -213,7 +209,6 @@ class RackRoleFilterForm(CustomFieldModelFilterForm): class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = Rack - field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id'] field_groups = [ ['q', 'tag'], ['region_id', 'site_id', 'location_id'], @@ -278,10 +273,6 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class RackElevationFilterForm(RackFilterForm): - field_order = [ - 'q', 'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id', - 'tenant_id', - ] id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), label=_('Rack'), @@ -296,7 +287,6 @@ class RackElevationFilterForm(RackFilterForm): class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = RackReservation - field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id'] field_groups = [ ['q', 'tag'], ['user_id'], @@ -428,10 +418,6 @@ class PlatformFilterForm(CustomFieldModelFilterForm): class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): 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 = [ ['q', 'tag'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'], @@ -595,7 +581,6 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = VirtualChassis - field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id'] field_groups = [ ['q', 'tag'], ['region_id', 'site_group_id', 'site_id'], diff --git a/netbox/ipam/api/mixins.py b/netbox/ipam/api/mixins.py deleted file mode 100644 index 552c77d57..000000000 --- a/netbox/ipam/api/mixins.py +++ /dev/null @@ -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) diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index e465fbd89..26a36325f 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,4 +1,7 @@ +from django.urls import path + from netbox.api import OrderedDefaultRouter +from ipam.models import IPRange, Prefix from . import views @@ -42,4 +45,23 @@ router.register('vlans', views.VLANViewSet) router.register('services', views.ServiceViewSet) app_name = 'ipam-api' -urlpatterns = router.urls + +urlpatterns = [ + path( + 'ip-ranges//available-ips/', + views.IPRangeAvailableIPAddressesView.as_view(), + name='iprange-available-ips' + ), + path( + 'prefixes//available-prefixes/', + views.AvailablePrefixesView.as_view(), + name='prefix-available-prefixes' + ), + path( + 'prefixes//available-ips/', + views.PrefixAvailableIPAddressesView.as_view(), + name='prefix-available-ips' + ), +] + +urlpatterns += router.urls diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index cdb40333d..0d098db4b 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -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.views import APIView + 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 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 . import mixins, serializers +from . import serializers class IPAMRootView(APIRootView): @@ -18,7 +29,7 @@ class IPAMRootView(APIRootView): # -# ASNs +# Viewsets # class ASNViewSet(CustomFieldModelViewSet): @@ -27,10 +38,6 @@ class ASNViewSet(CustomFieldModelViewSet): filterset_class = filtersets.ASNFilterSet -# -# VRFs -# - class VRFViewSet(CustomFieldModelViewSet): queryset = VRF.objects.prefetch_related('tenant').prefetch_related( 'import_targets', 'export_targets', 'tags' @@ -42,20 +49,12 @@ class VRFViewSet(CustomFieldModelViewSet): filterset_class = filtersets.VRFFilterSet -# -# Route targets -# - class RouteTargetViewSet(CustomFieldModelViewSet): queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags') serializer_class = serializers.RouteTargetSerializer filterset_class = filtersets.RouteTargetFilterSet -# -# RIRs -# - class RIRViewSet(CustomFieldModelViewSet): queryset = RIR.objects.annotate( aggregate_count=count_related(Aggregate, 'rir') @@ -64,20 +63,12 @@ class RIRViewSet(CustomFieldModelViewSet): filterset_class = filtersets.RIRFilterSet -# -# Aggregates -# - class AggregateViewSet(CustomFieldModelViewSet): queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags') serializer_class = serializers.AggregateSerializer filterset_class = filtersets.AggregateFilterSet -# -# Roles -# - class RoleViewSet(CustomFieldModelViewSet): queryset = Role.objects.annotate( prefix_count=count_related(Prefix, 'role'), @@ -87,11 +78,7 @@ class RoleViewSet(CustomFieldModelViewSet): filterset_class = filtersets.RoleFilterSet -# -# Prefixes -# - -class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, CustomFieldModelViewSet): +class PrefixViewSet(CustomFieldModelViewSet): queryset = Prefix.objects.prefetch_related( 'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags' ) @@ -106,11 +93,7 @@ class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, Cus return super().get_serializer_class() -# -# IP ranges -# - -class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet): +class IPRangeViewSet(CustomFieldModelViewSet): queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags') serializer_class = serializers.IPRangeSerializer filterset_class = filtersets.IPRangeFilterSet @@ -118,10 +101,6 @@ class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet): parent_model = IPRange # AvailableIPsMixin -# -# IP addresses -# - class IPAddressViewSet(CustomFieldModelViewSet): queryset = IPAddress.objects.prefetch_related( 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object' @@ -130,10 +109,6 @@ class IPAddressViewSet(CustomFieldModelViewSet): filterset_class = filtersets.IPAddressFilterSet -# -# FHRP groups -# - class FHRPGroupViewSet(CustomFieldModelViewSet): queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags') serializer_class = serializers.FHRPGroupSerializer @@ -147,10 +122,6 @@ class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet): filterset_class = filtersets.FHRPGroupAssignmentFilterSet -# -# VLAN groups -# - class VLANGroupViewSet(CustomFieldModelViewSet): queryset = VLANGroup.objects.annotate( vlan_count=count_related(VLAN, 'group') @@ -159,10 +130,6 @@ class VLANGroupViewSet(CustomFieldModelViewSet): filterset_class = filtersets.VLANGroupFilterSet -# -# VLANs -# - class VLANViewSet(CustomFieldModelViewSet): queryset = VLAN.objects.prefetch_related( 'site', 'group', 'tenant', 'role', 'tags' @@ -173,13 +140,190 @@ class VLANViewSet(CustomFieldModelViewSet): filterset_class = filtersets.VLANFilterSet -# -# Services -# - class ServiceViewSet(ModelViewSet): queryset = Service.objects.prefetch_related( 'device', 'virtual_machine', 'tags', 'ipaddresses' ) serializer_class = serializers.ServiceSerializer 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) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 75953001b..b21dbd6cd 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -277,10 +277,6 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): 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 = [ ['q', 'tag'], ['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'], diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 50eb64060..478c7f29b 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -289,7 +289,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): vrf = VRF.objects.create(name='VRF 1') 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}) - self.add_permissions('ipam.add_prefix') + self.add_permissions('ipam.view_prefix', 'ipam.add_prefix') # Create four available prefixes with individual requests prefixes_to_be_created = [ @@ -311,7 +311,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): # Try to create one more prefix 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) # Try to create invalid prefix type @@ -337,7 +337,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): {'prefix_length': 30, 'description': 'Prefix 5'}, ] 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) # 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 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) def test_create_multiple_available_ips(self): @@ -406,7 +406,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): # Try to create nine IPs (only eight are available) data = [{'description': f'Test IP {i}'} for i in range(1, 10)] # 9 IPs 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) # Create all eight available IPs in a single request @@ -488,7 +488,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase): # Try to create one more IP 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) def test_create_multiple_available_ips(self): @@ -505,7 +505,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase): # Try to create nine IPs (only eight are available) data = [{'description': f'Test IP #{i}'} for i in range(1, 10)] # 9 IPs 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) # Create all eight available IPs in a single request diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 7ad64aeae..2df0a4c83 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -123,11 +123,28 @@ class BulkDestroyModelMixin: 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 # -class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_): +class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet_): """ Extend DRF's ModelViewSet to support bulk update and delete functions. """ @@ -211,20 +228,6 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_): **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): """ Overrides ListModelMixin to allow processing ExportTemplates. diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index adc964ea1..4def5c73e 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index a072cda9f..fa8a82cc4 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 2093ef4d1..3fda1a026 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index 89adfc8bc..58bf18286 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -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) > * > * { padding-right: $table-cell-padding-x-sm !important; padding-left: $table-cell-padding-x-sm !important; diff --git a/netbox/templates/ipam/prefix/ip_addresses.html b/netbox/templates/ipam/prefix/ip_addresses.html index 5aaacabe1..bedd960d4 100644 --- a/netbox/templates/ipam/prefix/ip_addresses.html +++ b/netbox/templates/ipam/prefix/ip_addresses.html @@ -4,7 +4,7 @@ {% block extra_controls %} {% if perms.ipam.add_ipaddress and first_available_ip %} - + Add IP Address {% endif %} diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index f60c8f258..a0482aa1d 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -56,7 +56,7 @@ class TenantSerializer(PrimaryModelSerializer): class ContactGroupSerializer(NestedGroupModelSerializer): 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) class Meta: diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 957f0ab7b..b08a33fa6 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -1,4 +1,3 @@ -from django import forms from django.utils.translation import gettext as _ from extras.forms import CustomFieldModelFilterForm diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index fe4bae3b4..543449b73 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -17,9 +17,10 @@ def multivalue_field_factory(field_class): def to_python(self, value): if not value: return [] + field = field_class() return [ # 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()) @@ -50,15 +51,15 @@ class MultiValueTimeFilter(django_filters.MultipleChoiceFilter): class MACAddressFilter(django_filters.CharFilter): - field_class = MACAddressField + pass class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter): - field_class = multivalue_field_factory(MACAddressField) + field_class = multivalue_field_factory(forms.CharField) class MultiValueWWNFilter(django_filters.MultipleChoiceFilter): - field_class = multivalue_field_factory(MACAddressField) + field_class = multivalue_field_factory(forms.CharField) class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 2d0c8edd1..267bf7115 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -32,7 +32,7 @@ def placeholder(value): """ Render a muted placeholder if value equates to False. """ - if value: + if value not in ('', None): return value placeholder = '' return mark_safe(placeholder) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 7132ba316..2980e97de 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -31,9 +31,6 @@ class ClusterGroupFilterForm(CustomFieldModelFilterForm): class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = Cluster - field_order = [ - 'q', 'type_id', 'region_id', 'site_id', 'group_id', 'tenant_group_id', 'tenant_id', - ] field_groups = [ ['q', 'tag'], ['group_id', 'type_id'], diff --git a/requirements.txt b/requirements.txt index d728493dc..f744fea2a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -Django==3.2.9 +Django==3.2.10 django-cors-headers==3.10.1 -django-debug-toolbar==3.2.2 +django-debug-toolbar==3.2.3 django-filter==21.1 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.13.4 @@ -18,7 +18,7 @@ gunicorn==20.1.0 Jinja2==3.0.3 Markdown==3.3.6 markdown-include==0.6.0 -mkdocs-material==8.0.4 +mkdocs-material==8.1.0 netaddr==0.8.0 Pillow==8.4.0 psycopg2-binary==2.9.2