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/models/extras/customfield.md b/docs/models/extras/customfield.md index 0932791e7..e3462a6a7 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -20,7 +20,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net * Selection: A selection of one of several pre-defined custom choices * Multiple selection: A selection field which supports the assignment of multiple values -Each custom field must have a name; this should be a simple database-friendly string, e.g. `tps_report`. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. +Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields. diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index b117107b6..0224b9c15 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,5 +1,33 @@ # NetBox v3.1 +## 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 +* [#8003](https://github.com/netbox-community/netbox/issues/8003) - Fix cable tracing across bridged interfaces with no cable +* [#8005](https://github.com/netbox-community/netbox/issues/8005) - Fix contact email display +* [#8009](https://github.com/netbox-community/netbox/issues/8009) - Validate IP addresses for uniqueness when creating an FHRP group +* [#8010](https://github.com/netbox-community/netbox/issues/8010) - Allow filtering devices by multiple serial numbers +* [#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 + +--- + ## v3.1.0 (2021-12-06) !!! warning "PostgreSQL 10 Required" diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index bd2a75fe0..d9c75d3fa 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -718,7 +718,7 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex field_name='interfaces__mac_address', label='MAC address', ) - serial = django_filters.CharFilter( + serial = MultiValueCharFilter( lookup_expr='iexact' ) has_primary_ip = django_filters.BooleanFilter( @@ -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' ) 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/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index cdda4c0f5..92b92ef3e 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -465,12 +465,17 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): query_params={ 'device_id': '$device', 'type': 'lag', - } + }, + label='LAG' ) mac_address = forms.CharField( required=False, label='MAC Address' ) + wwn = forms.CharField( + required=False, + label='WWN' + ) mgmt_only = forms.BooleanField( required=False, label='Management only', @@ -503,15 +508,17 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + label='Untagged VLAN' ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + label='Tagged VLANs' ) field_order = ( 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', - 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'wwn', 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 75363b4f0..e105bd804 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -193,7 +193,7 @@ class PathEndpoint(models.Model): while origin is not None: if origin._path is None: - return path + break path.extend([origin, *origin._path.get_path()]) while (len(path) + 1) % 3: diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 6bca25d50..ab290f791 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1420,10 +1420,10 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_serial(self): - params = {'serial': 'ABC'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'serial': 'abc'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'serial': ['ABC', 'DEF']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'serial': ['abc', 'def']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_has_primary_ip(self): params = {'has_primary_ip': 'true'} diff --git a/netbox/extras/migrations/0066_customfield_name_validation.py b/netbox/extras/migrations/0066_customfield_name_validation.py new file mode 100644 index 000000000..7a768c10c --- /dev/null +++ b/netbox/extras/migrations/0066_customfield_name_validation.py @@ -0,0 +1,18 @@ +import django.core.validators +from django.db import migrations, models +import re + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0065_imageattachment_change_logging'), + ] + + operations = [ + migrations.AlterField( + model_name='customfield', + name='name', + field=models.CharField(max_length=50, unique=True, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['IGNORECASE'], message='Only alphanumeric characters and underscores are allowed.', regex='^[a-z0-9_]+$')]), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 1c511a852..713ef6c93 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -22,6 +22,12 @@ from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex +__all__ = ( + 'CustomField', + 'CustomFieldManager', +) + + class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): use_in_migrations = True @@ -49,7 +55,14 @@ class CustomField(ChangeLoggedModel): name = models.CharField( max_length=50, unique=True, - help_text='Internal field name' + help_text='Internal field name', + validators=( + RegexValidator( + regex=r'^[a-z0-9_]+$', + message="Only alphanumeric characters and underscores are allowed.", + flags=re.IGNORECASE + ), + ) ) label = models.CharField( max_length=50, 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/forms/models.py b/netbox/ipam/forms/models.py index aa2fa3214..6185b9198 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -575,9 +575,9 @@ class FHRPGroupForm(CustomFieldModelForm): vrf=self.cleaned_data['ip_vrf'], address=self.cleaned_data['ip_address'], status=self.cleaned_data['ip_status'], + role=FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']], assigned_object=instance ) - ipaddress.role = FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']] ipaddress.save() # Check that the new IPAddress conforms with any assigned object-level permissions @@ -587,13 +587,20 @@ class FHRPGroupForm(CustomFieldModelForm): return instance def clean(self): + ip_vrf = self.cleaned_data.get('ip_vrf') ip_address = self.cleaned_data.get('ip_address') ip_status = self.cleaned_data.get('ip_status') - if ip_address and not ip_status: - raise forms.ValidationError({ - 'ip_status': "Status must be set when creating a new IP address." + if ip_address: + ip_form = IPAddressForm({ + 'address': ip_address, + 'vrf': ip_vrf, + 'status': ip_status, }) + if not ip_form.is_valid(): + self.errors.update({ + f'ip_{field}': error for field, error in ip_form.errors.items() + }) class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm): 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/netbox/settings.py b/netbox/netbox/settings.py index 20a6d5d02..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.0' +VERSION = '3.1.1' # Hostname HOSTNAME = platform.node() @@ -424,7 +424,7 @@ EXEMPT_PATHS = ( f'/{BASE_PATH}graphql/', f'/{BASE_PATH}login/', f'/{BASE_PATH}oauth/', - f'/{BASE_PATH}metrics/', + f'/{BASE_PATH}metrics', ) 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/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index 2c7cef040..7349a9e16 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -33,7 +33,7 @@ Title - {{ object.tile|placeholder }} + {{ object.title|placeholder }} Phone @@ -48,7 +48,7 @@ Email - {% if object.phone %} + {% if object.email %} {{ object.email }} {% else %} None diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html index 156767e8d..bbb92bde0 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/preferences.html @@ -4,56 +4,54 @@ {% block title %}User Preferences{% endblock %} {% block content %} -
+ {% csrf_token %}
-
Color Mode
-

Set preferred UI color mode

- {% with color_mode=preferences|get_key:'ui.colormode'%} -
- - -
-
- - -
- {% endwith %} +
Color Mode
+

Set preferred UI color mode

+ {% with color_mode=preferences|get_key:'ui.colormode'%} +
+ + +
+
+ + +
+ {% endwith %}
-
-
- -
+
+
+ +
-{% if preferences %} -
-
Other Preferences
- - + {% if preferences %} +
+
Other Preferences
+
+ - - - + + + - - + + {% for key, value in preferences.items %} - - - - - + + + + + {% endfor %} - -
PreferenceValuePreferenceValue
{{ key }}{{ value }}
{{ key }}{{ value }}
- -
-{% else %} -

No Preferences Found

-{% endif %} - + + + +
+ {% else %} +

No preferences found

+ {% endif %} + {% endblock %} diff --git a/netbox/templates/users/profile.html b/netbox/templates/users/profile.html index aacee591d..460ca63e7 100644 --- a/netbox/templates/users/profile.html +++ b/netbox/templates/users/profile.html @@ -1,42 +1,85 @@ {% extends 'users/base.html' %} {% load helpers %} +{% load render_table from django_tables2 %} {% block title %}User Profile{% endblock %} {% block content %} -
-
- User Login -
{{ request.user.username }}
- - Full Name -
- {% if request.user.first_name and request.user.last_name %} - {{ request.user.first_name }} {{ request.user.last_name }} - {% elif request.user.first_name and not request.user.last_name %} - {{ request.user.first_name }} - {% else %} - {{ request.user.last_name|placeholder }} - {% endif %} -
- - Email -
{{ request.user.email|placeholder }}
- - Registered -
{{ request.user.date_joined|annotated_date }}
- - Groups -
- {% for group in request.user.groups.all %} - {{ group }} - {% empty %} - None - {% endfor %} -
- - Admin Access -
{{ request.user.is_staff|yesno|capfirst }}
+
+
+
+
Account Details
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Username{{ request.user.username }}
Full Name + {% if request.user.first_name or request.user.last_name %} + {{ request.user.first_name }} {{ request.user.last_name }} + {% else %} + + {% endif %} +
Email{{ request.user.email|placeholder }}
Account Created{{ request.user.date_joined|annotated_date }}
Superuser + {% if request.user.is_superuser %} + + {% else %} + + {% endif %} +
Admin Access + {% if request.user.is_staff %} + + {% else %} + + {% endif %} +
+
+
+
+
+
+
Assigned Groups
+
    + {% for group in request.user.groups.all %} +
  • {{ group }}
  • + {% empty %} +
  • None
  • + {% endfor %} +
+
+ {% if perms.extras.view_objectchange %} +
+
+
+
Recent Activity
+
+ {% render_table changelog_table 'inc/table.html' %} +
+
+
+
+ {% endif %} {% endblock %} 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/users/views.py b/netbox/users/views.py index 5acb593b4..ecf3295b5 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -15,6 +15,8 @@ from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import View from social_core.backends.utils import load_backends +from extras.models import ObjectChange +from extras.tables import ObjectChangeTable from netbox.config import get_config from utilities.forms import ConfirmationForm from .forms import LoginForm, PasswordChangeForm, TokenForm @@ -119,7 +121,14 @@ class ProfileView(LoginRequiredMixin, View): def get(self, request): + # Compile changelog table + changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user).prefetch_related( + 'changed_object_type' + )[:20] + changelog_table = ObjectChangeTable(changelog) + return render(request, self.template_name, { + 'changelog_table': changelog_table, 'active_tab': 'profile', }) 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/tables.py b/netbox/utilities/tables.py index 7b348b5ac..8f5692de9 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -415,7 +415,9 @@ class CustomFieldColumn(tables.Column): elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL: # Linkify custom URLs return mark_safe(f'{value}') - return value or self.default + if value is not None: + return value + return self.default class MPTTColumn(tables.TemplateColumn): 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/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py index 64a0f7732..10b6e585b 100644 --- a/netbox/wireless/migrations/0001_wireless.py +++ b/netbox/wireless/migrations/0001_wireless.py @@ -36,6 +36,7 @@ class Migration(migrations.Migration): options={ 'ordering': ('name', 'pk'), 'unique_together': {('parent', 'name')}, + 'verbose_name': 'Wireless LAN Group', }, ), migrations.CreateModel( diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 4d6d26a92..151828c88 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -72,6 +72,7 @@ class WirelessLANGroup(NestedGroupModel): unique_together = ( ('parent', 'name') ) + verbose_name = 'Wireless LAN Group' def __str__(self): return self.name 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