Merge pull request #8053 from netbox-community/develop

Release v3.1.1
This commit is contained in:
Jeremy Stretch 2021-12-13 09:08:33 -05:00 committed by GitHub
commit 779249ff81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 496 additions and 397 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'],

View File

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

View File

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

View File

@ -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'}

View File

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

View File

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

View File

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

View File

@ -1,4 +1,7 @@
from django.urls import path
from netbox.api import OrderedDefaultRouter
from 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

View File

@ -1,12 +1,23 @@
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django_pglocks import advisory_lock
from django.shortcuts import get_object_or_404
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.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)

View File

@ -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'],

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

@ -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 %}

View File

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

View File

@ -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 %}

View File

@ -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 %}

View File

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

View File

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

View File

@ -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',
})

View File

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

View File

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

View File

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

View File

@ -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'],

View File

@ -36,6 +36,7 @@ class Migration(migrations.Migration):
options={
'ordering': ('name', 'pk'),
'unique_together': {('parent', 'name')},
'verbose_name': 'Wireless LAN Group',
},
),
migrations.CreateModel(

View File

@ -72,6 +72,7 @@ class WirelessLANGroup(NestedGroupModel):
unique_together = (
('parent', 'name')
)
verbose_name = 'Wireless LAN Group'
def __str__(self):
return self.name

View File

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