From 88fae2171d40a6da2796ac528bee00b6469f511d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 08:57:19 -0500 Subject: [PATCH 01/17] Closes #7691: Remove field_order from filterset forms --- netbox/dcim/forms/filtersets.py | 15 --------------- netbox/ipam/forms/filtersets.py | 4 ---- netbox/tenancy/forms/filtersets.py | 1 - netbox/virtualization/forms/filtersets.py | 3 --- 4 files changed, 23 deletions(-) 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/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/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/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'], From ef5bbdb1e24ec114f7718414510db11f2953b1b2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 11:38:58 -0500 Subject: [PATCH 02/17] Move available prefixes endpoint to its own view --- netbox/ipam/api/mixins.py | 84 ---------------------- netbox/ipam/api/urls.py | 9 ++- netbox/ipam/api/views.py | 143 ++++++++++++++++++++++++------------- netbox/netbox/api/views.py | 33 +++++---- 4 files changed, 118 insertions(+), 151 deletions(-) diff --git a/netbox/ipam/api/mixins.py b/netbox/ipam/api/mixins.py index 552c77d57..9d7f4e4d0 100644 --- a/netbox/ipam/api/mixins.py +++ b/netbox/ipam/api/mixins.py @@ -13,90 +13,6 @@ 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 diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index e465fbd89..a3bfcb330 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,3 +1,5 @@ +from django.urls import path + from netbox.api import OrderedDefaultRouter from . import views @@ -42,4 +44,9 @@ router.register('vlans', views.VLANViewSet) router.register('services', views.ServiceViewSet) app_name = 'ipam-api' -urlpatterns = router.urls + +urlpatterns = [ + path('prefixes//available-prefixes/', views.AvailablePrefixesView.as_view(), name='prefix-available-prefixes'), +] + +urlpatterns += router.urls diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index cdb40333d..b410c7b74 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,10 +1,19 @@ +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 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 utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import count_related from . import mixins, serializers @@ -18,7 +27,7 @@ class IPAMRootView(APIRootView): # -# ASNs +# Viewsets # class ASNViewSet(CustomFieldModelViewSet): @@ -27,10 +36,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 +47,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 +61,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 +76,7 @@ class RoleViewSet(CustomFieldModelViewSet): filterset_class = filtersets.RoleFilterSet -# -# Prefixes -# - -class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, CustomFieldModelViewSet): +class PrefixViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet): queryset = Prefix.objects.prefetch_related( 'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags' ) @@ -106,10 +91,6 @@ class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, Cus return super().get_serializer_class() -# -# IP ranges -# - class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet): queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags') serializer_class = serializers.IPRangeSerializer @@ -118,10 +99,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 +107,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 +120,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 +128,6 @@ class VLANGroupViewSet(CustomFieldModelViewSet): filterset_class = filtersets.VLANGroupFilterSet -# -# VLANs -# - class VLANViewSet(CustomFieldModelViewSet): queryset = VLAN.objects.prefetch_related( 'site', 'group', 'tenant', 'role', 'tags' @@ -173,13 +138,89 @@ 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() + + def get(self, request, pk): + prefix = get_object_or_404(self.queryset, 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) + + @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) + def post(self, request, pk): + prefix = get_object_or_404(self.queryset, 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_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) 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. From 35eabc0353f282a6ed56690dc13570ae5df136b2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 12:37:55 -0500 Subject: [PATCH 03/17] Move available IPs endpoints to separate views --- netbox/ipam/api/mixins.py | 101 -------------------------------------- netbox/ipam/api/urls.py | 17 ++++++- netbox/ipam/api/views.py | 100 +++++++++++++++++++++++++++++++++++-- 3 files changed, 111 insertions(+), 107 deletions(-) delete mode 100644 netbox/ipam/api/mixins.py diff --git a/netbox/ipam/api/mixins.py b/netbox/ipam/api/mixins.py deleted file mode 100644 index 9d7f4e4d0..000000000 --- a/netbox/ipam/api/mixins.py +++ /dev/null @@ -1,101 +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 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 a3bfcb330..26a36325f 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,6 +1,7 @@ from django.urls import path from netbox.api import OrderedDefaultRouter +from ipam.models import IPRange, Prefix from . import views @@ -46,7 +47,21 @@ router.register('services', views.ServiceViewSet) app_name = 'ipam-api' urlpatterns = [ - path('prefixes//available-prefixes/', views.AvailablePrefixesView.as_view(), name='prefix-available-prefixes'), + 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 b410c7b74..98420826a 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -13,9 +13,10 @@ from extras.api.views import CustomFieldModelViewSet from ipam import filtersets from ipam.models import * 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): @@ -76,7 +77,7 @@ class RoleViewSet(CustomFieldModelViewSet): filterset_class = filtersets.RoleFilterSet -class PrefixViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet): +class PrefixViewSet(CustomFieldModelViewSet): queryset = Prefix.objects.prefetch_related( 'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags' ) @@ -91,7 +92,7 @@ class PrefixViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet): return super().get_serializer_class() -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 @@ -154,7 +155,7 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView): queryset = Prefix.objects.all() def get(self, request, pk): - prefix = get_object_or_404(self.queryset, pk=pk) + prefix = get_object_or_404(self.queryset.restrict(request.user), pk=pk) available_prefixes = prefix.get_available_prefixes() serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={ @@ -166,7 +167,7 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView): @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) def post(self, request, pk): - prefix = get_object_or_404(self.queryset, pk=pk) + prefix = get_object_or_404(self.queryset.restrict(request.user), pk=pk) available_prefixes = prefix.get_available_prefixes() # Validate Requested Prefixes' length @@ -224,3 +225,92 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView): 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() + + 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) + + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) + def post(self, request, pk): + 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_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) + + +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) From 661b3c4bfbe4b9898366b45171240590b124434c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 12:52:48 -0500 Subject: [PATCH 04/17] Fix queryset restrictions --- netbox/ipam/api/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 98420826a..fe828e520 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -155,7 +155,7 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView): queryset = Prefix.objects.all() def get(self, request, pk): - prefix = get_object_or_404(self.queryset.restrict(request.user), pk=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={ @@ -167,7 +167,8 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView): @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) def post(self, request, pk): - prefix = get_object_or_404(self.queryset.restrict(request.user), pk=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 @@ -262,6 +263,7 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView): @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 From 91fe158c263b9e0a485a7c35a3ed3c9973d7b163 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 13:23:49 -0500 Subject: [PATCH 05/17] Restore endpoint schema documentation --- netbox/ipam/api/views.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index fe828e520..f3937c7d6 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -2,6 +2,7 @@ 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 @@ -154,6 +155,7 @@ class ServiceViewSet(ModelViewSet): 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() @@ -165,6 +167,10 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView): 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') @@ -234,6 +240,7 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView): 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() @@ -261,6 +268,10 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView): 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') From 08de6c32c94d62d0bf46d78ef31e8d829cb4838e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 13:26:56 -0500 Subject: [PATCH 06/17] Changelog for #5869 --- docs/release-notes/version-3.1.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 7e2305e6f..fb02055f2 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -4,6 +4,7 @@ ### Bug Fixes +* [#5869](https://github.com/netbox-community/netbox/issues/5869) - Fix permissions evaluation under available prefix/IP REST API endpoints * [#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 From d850b3ac7eefeb5e70b4941cde3d408c5338dfde Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 13:58:11 -0500 Subject: [PATCH 07/17] Fix available prefix creation test --- netbox/ipam/tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 50eb64060..c3875420f 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 = [ From 7c14c0812b92507082116b760211555f1fc75a76 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 15:11:45 -0500 Subject: [PATCH 08/17] Fixes #7519: Return a 409 status for unfulfillable available prefix/IP requests --- docs/release-notes/version-3.1.md | 1 + netbox/ipam/api/views.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index fb02055f2..ee7df6d0c 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -5,6 +5,7 @@ ### 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 * [#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 diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f3937c7d6..0d098db4b 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -208,7 +208,7 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView): { "detail": "Insufficient space is available to accommodate the requested prefix size(s)" }, - status=status.HTTP_204_NO_CONTENT + status=status.HTTP_409_CONFLICT ) # Remove the allocated prefix from the list of available prefixes @@ -288,7 +288,7 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView): "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 + status=status.HTTP_409_CONFLICT ) # Assign addresses from the list of available IPs and copy VRF assignment from the parent From 3dae077b4de899a90285e0c16fa3f1247646819d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 15:34:12 -0500 Subject: [PATCH 09/17] Fixes #8035: Redirect back to parent prefix after creating IP address(es) where applicable --- docs/release-notes/version-3.1.md | 1 + netbox/templates/ipam/prefix/ip_addresses.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index ee7df6d0c..4c0049f9d 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -16,6 +16,7 @@ * [#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 --- 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 %} From 58095e1916b09ef2b3c98833b7a64cf641a1111c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 15:38:51 -0500 Subject: [PATCH 10/17] Fixes #8038: Placeholder filter should display zero integer values --- docs/release-notes/version-3.1.md | 1 + netbox/utilities/templatetags/helpers.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 4c0049f9d..48129163b 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -17,6 +17,7 @@ * [#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 --- 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) From 326a6be91cb5dd274ba775dc724046734a288c6d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 15:45:22 -0500 Subject: [PATCH 11/17] #7519: Update REST API tests --- netbox/ipam/tests/test_api.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index c3875420f..478c7f29b 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -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 From ee6e2e0af1e66ec44126e10448edf7c8145c6643 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 16:34:38 -0500 Subject: [PATCH 12/17] Fixes #7690: Fix custom field integer support for MultiValueNumberFilter --- docs/release-notes/version-3.1.md | 1 + netbox/utilities/filters.py | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 48129163b..3506ee458 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -6,6 +6,7 @@ * [#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 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): From 7922d3909a625a29cbef5b202f39634b7cf84b24 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 16:41:03 -0500 Subject: [PATCH 13/17] Fixes #8042: Fix filtering cables list by site slug or rack name --- docs/release-notes/version-3.1.md | 1 + netbox/dcim/filtersets.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 3506ee458..c38fd442d 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -19,6 +19,7 @@ * [#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 --- diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 35d20f44b..d9c75d3fa 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1258,7 +1258,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' ) @@ -1266,7 +1266,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' ) From 5b0c79629ede940a50a1b52516cb3bb531bd5bd7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 10 Dec 2021 21:03:24 -0500 Subject: [PATCH 14/17] Closes #8047: Display sorting indicator in table column headers --- docs/release-notes/version-3.1.md | 4 ++++ netbox/project-static/dist/netbox-dark.css | Bin 788878 -> 789095 bytes netbox/project-static/dist/netbox-light.css | Bin 493634 -> 493783 bytes netbox/project-static/dist/netbox-print.css | Bin 1623706 -> 1624117 bytes netbox/project-static/styles/netbox.scss | 10 ++++++++++ 5 files changed, 14 insertions(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index c38fd442d..db320ba37 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -2,6 +2,10 @@ ## v3.1.1 (FUTURE) +### 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 diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index adc964ea1302762736a51eb3348693ed9ad69b60..4def5c73e9cd8268cae13669b5a6239b73f21f16 100644 GIT binary patch delta 133 zcmeC1Z18-FK|>2;3sVbo3rh=Y3tJ0&3r7oQ3)dFz&oieVn9j{2lUSUrkZ6^dR+3s& zot&RnlA2dyr4*B9U}$2XG=0ZeZk6dq=eU)pKe))vE}x;7l8RHgF`9DL?Vo3I&&~$` Dq)jo1 delta 37 tcmaDp#h`DpK|>2;3sVbo3rh=Y3tJ0&3r7oQ3)dFz&oj55nZ-Rj9{>m24p{&I diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index a072cda9fd81c5f3c22fb17501c6aa629a799928..fa8a82cc400231d95ad23c80a3044c132a4988da 100644 GIT binary patch delta 112 zcmX>!LGJoQxrP?T7N!>F7M2#)7Pc+yD{K@LN;32ki<1=+trF8pQj4mS^Ycnl^Gd9g nV$uu@O$?N#Gah4AQLimYOv*_GsZB}6sn{5zc>5w7_66Pmkt8Nz delta 29 lcmcaUQSQ(LxrP?T7N!>F7M2#)7Pc+yD{Qvg+Op5`1^}mp3Sa;L diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 2093ef4d1970d9d187f66187e76be42acd2eedca..3fda1a02699a2a0d8b170c4faeb4d452b4981a6b 100644 GIT binary patch delta 246 zcmbQ$o4j>GazhJa3sVbo3rh=Y3tJ0&3r7oQ3s(zw3r`Dg3ttO=i$IHDi%^Sji%5%T zi`W+N~p?iAzkE YS7Vf&UT{~O8_rb$xruc :not(caption) > * > * { padding-right: $table-cell-padding-x-sm !important; padding-left: $table-cell-padding-x-sm !important; From bfc1cab6dfe620d6d0615158b4730fb5af7ede19 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 13 Dec 2021 08:22:48 -0500 Subject: [PATCH 15/17] Fixes #8051: Contact group parent assignment should not be required under REST API --- docs/release-notes/version-3.1.md | 1 + netbox/tenancy/api/serializers.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index db320ba37..a89ec5fd7 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -24,6 +24,7 @@ * [#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/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: From 66d206a710e2afefe03d16b926c94cfdf3085a6a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 13 Dec 2021 08:51:55 -0500 Subject: [PATCH 16/17] Release v3.1.1 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- base_requirements.txt | 2 +- docs/release-notes/version-3.1.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) 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 a89ec5fd7..0224b9c15 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,6 +1,6 @@ # NetBox v3.1 -## v3.1.1 (FUTURE) +## v3.1.1 (2021-12-13) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 40c31b74d..1f0745c2b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -19,7 +19,7 @@ from netbox.config import PARAMS # Environment setup # -VERSION = '3.1.1-dev' +VERSION = '3.1.1' # Hostname HOSTNAME = platform.node() 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 From e9549ab0bd9f8a2447ccfc644749dccebb2284d3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 13 Dec 2021 09:16:55 -0500 Subject: [PATCH 17/17] PRVB --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 1f0745c2b..10a30b282 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -19,7 +19,7 @@ from netbox.config import PARAMS # Environment setup # -VERSION = '3.1.1' +VERSION = '3.1.2-dev' # Hostname HOSTNAME = platform.node()