Просмотр исходного кода

Merge pull request #8053 from netbox-community/develop

Release v3.1.1
Jeremy Stretch 4 лет назад
Родитель
Сommit
779249ff81
38 измененных файлов с 496 добавлено и 397 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 1 1
      base_requirements.txt
  4. 1 1
      docs/models/extras/customfield.md
  5. 28 0
      docs/release-notes/version-3.1.md
  6. 3 3
      netbox/dcim/filtersets.py
  7. 0 15
      netbox/dcim/forms/filtersets.py
  8. 11 4
      netbox/dcim/forms/object_create.py
  9. 1 1
      netbox/dcim/models/device_components.py
  10. 4 4
      netbox/dcim/tests/test_filtersets.py
  11. 18 0
      netbox/extras/migrations/0066_customfield_name_validation.py
  12. 14 1
      netbox/extras/models/customfields.py
  13. 0 185
      netbox/ipam/api/mixins.py
  14. 23 1
      netbox/ipam/api/urls.py
  15. 197 53
      netbox/ipam/api/views.py
  16. 0 4
      netbox/ipam/forms/filtersets.py
  17. 11 4
      netbox/ipam/forms/models.py
  18. 7 7
      netbox/ipam/tests/test_api.py
  19. 18 15
      netbox/netbox/api/views.py
  20. 2 2
      netbox/netbox/settings.py
  21. 0 0
      netbox/project-static/dist/netbox-dark.css
  22. 0 0
      netbox/project-static/dist/netbox-light.css
  23. 0 0
      netbox/project-static/dist/netbox-print.css
  24. 10 0
      netbox/project-static/styles/netbox.scss
  25. 1 1
      netbox/templates/ipam/prefix/ip_addresses.html
  26. 2 2
      netbox/templates/tenancy/contact.html
  27. 42 44
      netbox/templates/users/preferences.html
  28. 76 33
      netbox/templates/users/profile.html
  29. 1 1
      netbox/tenancy/api/serializers.py
  30. 0 1
      netbox/tenancy/forms/filtersets.py
  31. 9 0
      netbox/users/views.py
  32. 5 4
      netbox/utilities/filters.py
  33. 3 1
      netbox/utilities/tables.py
  34. 1 1
      netbox/utilities/templatetags/helpers.py
  35. 0 3
      netbox/virtualization/forms/filtersets.py
  36. 1 0
      netbox/wireless/migrations/0001_wireless.py
  37. 1 0
      netbox/wireless/models.py
  38. 3 3
      requirements.txt

+ 1 - 1
.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

+ 1 - 1
.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

+ 1 - 1
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

+ 1 - 1
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.
 

+ 28 - 0
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"

+ 3 - 3
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'
     )

+ 0 - 15
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'],

+ 11 - 4
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'
     )
 

+ 1 - 1
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:

+ 4 - 4
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'}

+ 18 - 0
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_]+$')]),
+        ),
+    ]

+ 14 - 1
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,

+ 0 - 185
netbox/ipam/api/mixins.py

@@ -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)

+ 23 - 1
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/<int:pk>/available-ips/',
+        views.IPRangeAvailableIPAddressesView.as_view(),
+        name='iprange-available-ips'
+    ),
+    path(
+        'prefixes/<int:pk>/available-prefixes/',
+        views.AvailablePrefixesView.as_view(),
+        name='prefix-available-prefixes'
+    ),
+    path(
+        'prefixes/<int:pk>/available-ips/',
+        views.PrefixAvailableIPAddressesView.as_view(),
+        name='prefix-available-ips'
+    ),
+]
+
+urlpatterns += router.urls

+ 197 - 53
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)

+ 0 - 4
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'],

+ 11 - 4
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):

+ 7 - 7
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

+ 18 - 15
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.

+ 2 - 2
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',
 )
 
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-print.css


+ 10 - 0
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;

+ 1 - 1
netbox/templates/ipam/prefix/ip_addresses.html

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

+ 2 - 2
netbox/templates/tenancy/contact.html

@@ -33,7 +33,7 @@
             </tr>
             <tr>
               <td>Title</td>
-              <td>{{ object.tile|placeholder }}</td>
+              <td>{{ object.title|placeholder }}</td>
             </tr>
             <tr>
               <td>Phone</td>
@@ -48,7 +48,7 @@
             <tr>
               <td>Email</td>
               <td>
-                {% if object.phone %}
+                {% if object.email %}
                   <a href="mailto:{{ object.email }}">{{ object.email }}</a>
                 {% else %}
                   <span class="text-muted">None</span>

+ 42 - 44
netbox/templates/users/preferences.html

@@ -4,56 +4,54 @@
 {% block title %}User Preferences{% endblock %}
 
 {% block content %}
-<form method="post" action="" id="preferences-update">
+  <form method="post" action="" id="preferences-update">
     {% csrf_token %}
     <div class="field-group mb-3">
-        <h5 class="text-center">Color Mode</h5>
-        <p class="lead text-muted">Set preferred UI color mode</p>
-        {% with color_mode=preferences|get_key:'ui.colormode'%}
-        <div class="form-check form-check-inline">
-            <input class="form-check-input" type="radio" name="ui.colormode" id="color-mode-preference-dark" value="dark"{% if color_mode == 'dark'%} checked{% endif %}>
-            <label class="form-check-label" for="color-mode-preference-dark">Dark</label>
-        </div>
-        <div class="form-check form-check-inline">
-            <input class="form-check-input" type="radio" name="ui.colormode" id="color-mode-preference-light" value="light"{% if color_mode == 'light'%} checked{% endif %}>
-            <label class="form-check-label" for="color-mode-preference-light">Light</label>
-        </div>
-        {% endwith %}
+      <h5>Color Mode</h5>
+      <p class="text-muted">Set preferred UI color mode</p>
+      {% with color_mode=preferences|get_key:'ui.colormode'%}
+      <div class="form-check form-check-inline">
+        <input class="form-check-input" type="radio" name="ui.colormode" id="color-mode-preference-dark" value="dark"{% if color_mode == 'dark'%} checked{% endif %}>
+        <label class="form-check-label" for="color-mode-preference-dark">Dark</label>
+      </div>
+      <div class="form-check form-check-inline">
+        <input class="form-check-input" type="radio" name="ui.colormode" id="color-mode-preference-light" value="light"{% if color_mode == 'light'%} checked{% endif %}>
+        <label class="form-check-label" for="color-mode-preference-light">Light</label>
+      </div>
+      {% endwith %}
     </div>
-    <div class="row">
-        <div class="col">
-            <button type="submit" class="btn btn-primary" name="_update">
-                Save
-            </button>
-        </div>
+    <div class="row mb-3">
+      <div class="col">
+        <button type="submit" class="btn btn-primary" name="_update">Save</button>
+      </div>
     </div>
-{% if preferences %}
-<div class="field-group mb-3">
-    <h5 class="text-center">Other Preferences</h5>
-    <table class="table table-striped">
-        <thead>
+    {% if preferences %}
+      <div class="field-group mb-3">
+        <h5>Other Preferences</h5>
+        <table class="table table-striped">
+          <thead>
             <tr>
-                <th><input type="checkbox" class="toggle form-check-input" title="Toggle All"></th>
-                <th>Preference</th>
-                <th>Value</th>
+              <th><input type="checkbox" class="toggle form-check-input" title="Toggle All"></th>
+              <th>Preference</th>
+              <th>Value</th>
             </tr>
-        </thead>
-        <tbody>
+          </thead>
+          <tbody>
             {% for key, value in preferences.items %}
-                <tr>
-                    <td class="min-width"><input class="form-check-input" type="checkbox" name="pk" value="{{ key }}"></td>
-                    <td><samp>{{ key }}</samp></td>
-                    <td><samp>{{ value }}</samp></td>
-                </tr>
+              <tr>
+                <td class="min-width"><input class="form-check-input" type="checkbox" name="pk" value="{{ key }}"></td>
+                <td><samp>{{ key }}</samp></td>
+                <td><samp>{{ value }}</samp></td>
+              </tr>
             {% endfor %}
-        </tbody>
-    </table>
-    <button type="submit" class="btn btn-danger" name="_delete">
-        <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Clear Selected
-    </button>
-</div>
-{% else %}
-    <h3 class="text-muted text-center">No Preferences Found</h3>
-{% endif %}
-</form>
+          </tbody>
+        </table>
+        <button type="submit" class="btn btn-danger" name="_delete">
+          <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Clear Selected
+        </button>
+      </div>
+    {% else %}
+      <h3 class="text-muted text-center">No preferences found</h3>
+    {% endif %}
+  </form>
 {% endblock %}

+ 76 - 33
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 %}
-  <div class="row">
-    <div class="col-md-8 offset-md-2">
-      <span class="text-muted">User Login</span>
-      <h5 class="mb-3">{{ request.user.username }}</h5>
-
-      <span class="text-muted">Full Name</span>
-      <h5 class="mb-3">
-        {% 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 %}
-      </h5>
-
-      <span class="text-muted">Email</span>
-      <h5 class="mb-3">{{ request.user.email|placeholder }}</h5>
-
-      <span class="text-muted">Registered</span>
-      <h5 class="mb-3">{{ request.user.date_joined|annotated_date }}</h5>
-
-      <span class="text-muted">Groups</span>
-      <h5 class="mb-3">
-        {% for group in request.user.groups.all %}
-          <span class="badge bg-secondary">{{ group }}</span>
-        {% empty %}
-          <span class="text-muted">None</span>
-        {% endfor %}
-      </h5>
-
-      <span class="text-muted">Admin Access</span>
-      <h5 class="mb-3">{{ request.user.is_staff|yesno|capfirst }}</h5>
+  <div class="row mb-3">
+    <div class="col-md-6">
+      <div class="card">
+        <h5 class="card-header">Account Details</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Username</th>
+              <td>{{ request.user.username }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Full Name</th>
+              <td>
+                {% if request.user.first_name or request.user.last_name %}
+                  {{ request.user.first_name }} {{ request.user.last_name }}
+                {% else %}
+                  <span class="text-muted">&mdash;</span>
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Email</th>
+              <td>{{ request.user.email|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Account Created</th>
+              <td>{{ request.user.date_joined|annotated_date }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Superuser</th>
+              <td>
+                {% if request.user.is_superuser %}
+                  <i class="mdi mdi-check-bold text-success" title="Yes"></i>
+                {% else %}
+                  <i class="mdi mdi-close-thick text-danger" title="No"></i>
+                {% endif %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Admin Access</th>
+              <td>
+                {% if request.user.is_staff %}
+                  <i class="mdi mdi-check-bold text-success" title="Yes"></i>
+                {% else %}
+                  <i class="mdi mdi-close-thick text-danger" title="No"></i>
+                {% endif %}
+              </td>
+            </tr>
+          </table>
+        </div>
+      </div>
+    </div>
+    <div class="col-md-6">
+      <div class="card">
+        <h5 class="card-header">Assigned Groups</h5>
+        <ul class="list-group list-group-flush">
+          {% for group in request.user.groups.all %}
+            <li class="list-group-item">{{ group }}</li>
+          {% empty %}
+            <li class="list-group-item text-muted">None</li>
+          {% endfor %}
+        </ul>
+      </div>
     </div>
   </div>
+  {% if perms.extras.view_objectchange %}
+    <div class="row">
+      <div class="col-md-12">
+        <div class="card">
+          <h5 class="card-header text-center">Recent Activity</h5>
+          <div class="card-body table-responsive">
+            {% render_table changelog_table 'inc/table.html' %}
+          </div>
+        </div>
+      </div>
+    </div>
+  {% endif %}
 {% endblock %}

+ 1 - 1
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:

+ 0 - 1
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

+ 9 - 0
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',
         })
 

+ 5 - 4
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):

+ 3 - 1
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'<a href="{value}">{value}</a>')
-        return value or self.default
+        if value is not None:
+            return value
+        return self.default
 
 
 class MPTTColumn(tables.TemplateColumn):

+ 1 - 1
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 = '<span class="text-muted">&mdash;</span>'
     return mark_safe(placeholder)

+ 0 - 3
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'],

+ 1 - 0
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(

+ 1 - 0
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

+ 3 - 3
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

Некоторые файлы не были показаны из-за большого количества измененных файлов