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: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.1.0 placeholder: v3.1.1
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.1.0 placeholder: v3.1.1
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -1,6 +1,6 @@
# The Python web framework on which NetBox is built # The Python web framework on which NetBox is built
# https://github.com/django/django # https://github.com/django/django
Django Django<4.0
# Django middleware which permits cross-domain API requests # Django middleware which permits cross-domain API requests
# https://github.com/OttoYiu/django-cors-headers # 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 * Selection: A selection of one of several pre-defined custom choices
* Multiple selection: A selection field which supports the assignment of multiple values * 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. 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 # 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) ## v3.1.0 (2021-12-06)
!!! warning "PostgreSQL 10 Required" !!! warning "PostgreSQL 10 Required"

View File

@ -718,7 +718,7 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex
field_name='interfaces__mac_address', field_name='interfaces__mac_address',
label='MAC address', label='MAC address',
) )
serial = django_filters.CharFilter( serial = MultiValueCharFilter(
lookup_expr='iexact' lookup_expr='iexact'
) )
has_primary_ip = django_filters.BooleanFilter( has_primary_ip = django_filters.BooleanFilter(
@ -1258,7 +1258,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
method='filter_device', method='filter_device',
field_name='device__rack_id' field_name='device__rack_id'
) )
rack = MultiValueNumberFilter( rack = MultiValueCharFilter(
method='filter_device', method='filter_device',
field_name='device__rack__name' field_name='device__rack__name'
) )
@ -1266,7 +1266,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
method='filter_device', method='filter_device',
field_name='device__site_id' field_name='device__site_id'
) )
site = MultiValueNumberFilter( site = MultiValueCharFilter(
method='filter_device', method='filter_device',
field_name='device__site__slug' field_name='device__site__slug'
) )

View File

@ -48,9 +48,6 @@ __all__ = (
class DeviceComponentFilterForm(CustomFieldModelFilterForm): class DeviceComponentFilterForm(CustomFieldModelFilterForm):
field_order = [
'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id',
]
name = forms.CharField( name = forms.CharField(
required=False required=False
) )
@ -131,7 +128,6 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm):
class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Site model = Site
field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id', 'asn_id']
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['status', 'region_id', 'group_id'], ['status', 'region_id', 'group_id'],
@ -213,7 +209,6 @@ class RackRoleFilterForm(CustomFieldModelFilterForm):
class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Rack model = Rack
field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id']
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['region_id', 'site_id', 'location_id'], ['region_id', 'site_id', 'location_id'],
@ -278,10 +273,6 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
class RackElevationFilterForm(RackFilterForm): class RackElevationFilterForm(RackFilterForm):
field_order = [
'q', 'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id',
'tenant_id',
]
id = DynamicModelMultipleChoiceField( id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
label=_('Rack'), label=_('Rack'),
@ -296,7 +287,6 @@ class RackElevationFilterForm(RackFilterForm):
class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = RackReservation model = RackReservation
field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id']
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['user_id'], ['user_id'],
@ -428,10 +418,6 @@ class PlatformFilterForm(CustomFieldModelFilterForm):
class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
model = Device 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 = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'],
@ -595,7 +581,6 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = VirtualChassis model = VirtualChassis
field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id']
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['region_id', 'site_group_id', 'site_id'], ['region_id', 'site_group_id', 'site_id'],

View File

@ -465,12 +465,17 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
query_params={ query_params={
'device_id': '$device', 'device_id': '$device',
'type': 'lag', 'type': 'lag',
} },
label='LAG'
) )
mac_address = forms.CharField( mac_address = forms.CharField(
required=False, required=False,
label='MAC Address' label='MAC Address'
) )
wwn = forms.CharField(
required=False,
label='WWN'
)
mgmt_only = forms.BooleanField( mgmt_only = forms.BooleanField(
required=False, required=False,
label='Management only', label='Management only',
@ -503,15 +508,17 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
) )
untagged_vlan = DynamicModelChoiceField( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False required=False,
label='Untagged VLAN'
) )
tagged_vlans = DynamicModelMultipleChoiceField( tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False required=False,
label='Tagged VLANs'
) )
field_order = ( field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', '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' 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
) )

View File

@ -193,7 +193,7 @@ class PathEndpoint(models.Model):
while origin is not None: while origin is not None:
if origin._path is None: if origin._path is None:
return path break
path.extend([origin, *origin._path.get_path()]) path.extend([origin, *origin._path.get_path()])
while (len(path) + 1) % 3: 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) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_serial(self): def test_serial(self):
params = {'serial': 'ABC'} params = {'serial': ['ABC', 'DEF']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'serial': 'abc'} params = {'serial': ['abc', 'def']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_has_primary_ip(self): def test_has_primary_ip(self):
params = {'has_primary_ip': 'true'} 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 from utilities.validators import validate_regex
__all__ = (
'CustomField',
'CustomFieldManager',
)
class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
use_in_migrations = True use_in_migrations = True
@ -49,7 +55,14 @@ class CustomField(ChangeLoggedModel):
name = models.CharField( name = models.CharField(
max_length=50, max_length=50,
unique=True, 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( label = models.CharField(
max_length=50, 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 netbox.api import OrderedDefaultRouter
from ipam.models import IPRange, Prefix
from . import views from . import views
@ -42,4 +45,23 @@ router.register('vlans', views.VLANViewSet)
router.register('services', views.ServiceViewSet) router.register('services', views.ServiceViewSet)
app_name = 'ipam-api' 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.routers import APIRootView
from rest_framework.views import APIView
from dcim.models import Site from dcim.models import Site
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
from ipam import filtersets from ipam import filtersets
from ipam.models import * 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 utilities.utils import count_related
from . import mixins, serializers from . import serializers
class IPAMRootView(APIRootView): class IPAMRootView(APIRootView):
@ -18,7 +29,7 @@ class IPAMRootView(APIRootView):
# #
# ASNs # Viewsets
# #
class ASNViewSet(CustomFieldModelViewSet): class ASNViewSet(CustomFieldModelViewSet):
@ -27,10 +38,6 @@ class ASNViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.ASNFilterSet filterset_class = filtersets.ASNFilterSet
#
# VRFs
#
class VRFViewSet(CustomFieldModelViewSet): class VRFViewSet(CustomFieldModelViewSet):
queryset = VRF.objects.prefetch_related('tenant').prefetch_related( queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
'import_targets', 'export_targets', 'tags' 'import_targets', 'export_targets', 'tags'
@ -42,20 +49,12 @@ class VRFViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VRFFilterSet filterset_class = filtersets.VRFFilterSet
#
# Route targets
#
class RouteTargetViewSet(CustomFieldModelViewSet): class RouteTargetViewSet(CustomFieldModelViewSet):
queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags') queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags')
serializer_class = serializers.RouteTargetSerializer serializer_class = serializers.RouteTargetSerializer
filterset_class = filtersets.RouteTargetFilterSet filterset_class = filtersets.RouteTargetFilterSet
#
# RIRs
#
class RIRViewSet(CustomFieldModelViewSet): class RIRViewSet(CustomFieldModelViewSet):
queryset = RIR.objects.annotate( queryset = RIR.objects.annotate(
aggregate_count=count_related(Aggregate, 'rir') aggregate_count=count_related(Aggregate, 'rir')
@ -64,20 +63,12 @@ class RIRViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.RIRFilterSet filterset_class = filtersets.RIRFilterSet
#
# Aggregates
#
class AggregateViewSet(CustomFieldModelViewSet): class AggregateViewSet(CustomFieldModelViewSet):
queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags') queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
serializer_class = serializers.AggregateSerializer serializer_class = serializers.AggregateSerializer
filterset_class = filtersets.AggregateFilterSet filterset_class = filtersets.AggregateFilterSet
#
# Roles
#
class RoleViewSet(CustomFieldModelViewSet): class RoleViewSet(CustomFieldModelViewSet):
queryset = Role.objects.annotate( queryset = Role.objects.annotate(
prefix_count=count_related(Prefix, 'role'), prefix_count=count_related(Prefix, 'role'),
@ -87,11 +78,7 @@ class RoleViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.RoleFilterSet filterset_class = filtersets.RoleFilterSet
# class PrefixViewSet(CustomFieldModelViewSet):
# Prefixes
#
class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, CustomFieldModelViewSet):
queryset = Prefix.objects.prefetch_related( queryset = Prefix.objects.prefetch_related(
'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags' 'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
) )
@ -106,11 +93,7 @@ class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, Cus
return super().get_serializer_class() return super().get_serializer_class()
# class IPRangeViewSet(CustomFieldModelViewSet):
# IP ranges
#
class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet):
queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags') queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags')
serializer_class = serializers.IPRangeSerializer serializer_class = serializers.IPRangeSerializer
filterset_class = filtersets.IPRangeFilterSet filterset_class = filtersets.IPRangeFilterSet
@ -118,10 +101,6 @@ class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet):
parent_model = IPRange # AvailableIPsMixin parent_model = IPRange # AvailableIPsMixin
#
# IP addresses
#
class IPAddressViewSet(CustomFieldModelViewSet): class IPAddressViewSet(CustomFieldModelViewSet):
queryset = IPAddress.objects.prefetch_related( queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object' 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
@ -130,10 +109,6 @@ class IPAddressViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.IPAddressFilterSet filterset_class = filtersets.IPAddressFilterSet
#
# FHRP groups
#
class FHRPGroupViewSet(CustomFieldModelViewSet): class FHRPGroupViewSet(CustomFieldModelViewSet):
queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags') queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags')
serializer_class = serializers.FHRPGroupSerializer serializer_class = serializers.FHRPGroupSerializer
@ -147,10 +122,6 @@ class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.FHRPGroupAssignmentFilterSet filterset_class = filtersets.FHRPGroupAssignmentFilterSet
#
# VLAN groups
#
class VLANGroupViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(CustomFieldModelViewSet):
queryset = VLANGroup.objects.annotate( queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group') vlan_count=count_related(VLAN, 'group')
@ -159,10 +130,6 @@ class VLANGroupViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VLANGroupFilterSet filterset_class = filtersets.VLANGroupFilterSet
#
# VLANs
#
class VLANViewSet(CustomFieldModelViewSet): class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.prefetch_related( queryset = VLAN.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'tags' 'site', 'group', 'tenant', 'role', 'tags'
@ -173,13 +140,190 @@ class VLANViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VLANFilterSet filterset_class = filtersets.VLANFilterSet
#
# Services
#
class ServiceViewSet(ModelViewSet): class ServiceViewSet(ModelViewSet):
queryset = Service.objects.prefetch_related( queryset = Service.objects.prefetch_related(
'device', 'virtual_machine', 'tags', 'ipaddresses' 'device', 'virtual_machine', 'tags', 'ipaddresses'
) )
serializer_class = serializers.ServiceSerializer serializer_class = serializers.ServiceSerializer
filterset_class = filtersets.ServiceFilterSet 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): class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = IPAddress 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 = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'], ['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'],

View File

@ -575,9 +575,9 @@ class FHRPGroupForm(CustomFieldModelForm):
vrf=self.cleaned_data['ip_vrf'], vrf=self.cleaned_data['ip_vrf'],
address=self.cleaned_data['ip_address'], address=self.cleaned_data['ip_address'],
status=self.cleaned_data['ip_status'], status=self.cleaned_data['ip_status'],
role=FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']],
assigned_object=instance assigned_object=instance
) )
ipaddress.role = FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']]
ipaddress.save() ipaddress.save()
# Check that the new IPAddress conforms with any assigned object-level permissions # Check that the new IPAddress conforms with any assigned object-level permissions
@ -587,13 +587,20 @@ class FHRPGroupForm(CustomFieldModelForm):
return instance return instance
def clean(self): def clean(self):
ip_vrf = self.cleaned_data.get('ip_vrf')
ip_address = self.cleaned_data.get('ip_address') ip_address = self.cleaned_data.get('ip_address')
ip_status = self.cleaned_data.get('ip_status') ip_status = self.cleaned_data.get('ip_status')
if ip_address and not ip_status: if ip_address:
raise forms.ValidationError({ ip_form = IPAddressForm({
'ip_status': "Status must be set when creating a new IP address." '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): class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):

View File

@ -289,7 +289,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
vrf = VRF.objects.create(name='VRF 1') vrf = VRF.objects.create(name='VRF 1')
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True) 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}) 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 # Create four available prefixes with individual requests
prefixes_to_be_created = [ prefixes_to_be_created = [
@ -311,7 +311,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
# Try to create one more prefix # Try to create one more prefix
response = self.client.post(url, {'prefix_length': 30}, format='json', **self.header) 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) self.assertIn('detail', response.data)
# Try to create invalid prefix type # Try to create invalid prefix type
@ -337,7 +337,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
{'prefix_length': 30, 'description': 'Prefix 5'}, {'prefix_length': 30, 'description': 'Prefix 5'},
] ]
response = self.client.post(url, data, format='json', **self.header) 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) self.assertIn('detail', response.data)
# Verify that no prefixes were created (the entire /28 is still available) # 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 # Try to create one more IP
response = self.client.post(url, {}, **self.header) 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) self.assertIn('detail', response.data)
def test_create_multiple_available_ips(self): def test_create_multiple_available_ips(self):
@ -406,7 +406,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
# Try to create nine IPs (only eight are available) # Try to create nine IPs (only eight are available)
data = [{'description': f'Test IP {i}'} for i in range(1, 10)] # 9 IPs data = [{'description': f'Test IP {i}'} for i in range(1, 10)] # 9 IPs
response = self.client.post(url, data, format='json', **self.header) 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) self.assertIn('detail', response.data)
# Create all eight available IPs in a single request # Create all eight available IPs in a single request
@ -488,7 +488,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
# Try to create one more IP # Try to create one more IP
response = self.client.post(url, {}, **self.header) 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) self.assertIn('detail', response.data)
def test_create_multiple_available_ips(self): def test_create_multiple_available_ips(self):
@ -505,7 +505,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
# Try to create nine IPs (only eight are available) # Try to create nine IPs (only eight are available)
data = [{'description': f'Test IP #{i}'} for i in range(1, 10)] # 9 IPs data = [{'description': f'Test IP #{i}'} for i in range(1, 10)] # 9 IPs
response = self.client.post(url, data, format='json', **self.header) 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) self.assertIn('detail', response.data)
# Create all eight available IPs in a single request # Create all eight available IPs in a single request

View File

@ -123,11 +123,28 @@ class BulkDestroyModelMixin:
self.perform_destroy(obj) 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 # Viewsets
# #
class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_): class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet_):
""" """
Extend DRF's ModelViewSet to support bulk update and delete functions. Extend DRF's ModelViewSet to support bulk update and delete functions.
""" """
@ -211,20 +228,6 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
**kwargs **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): def list(self, request, *args, **kwargs):
""" """
Overrides ListModelMixin to allow processing ExportTemplates. Overrides ListModelMixin to allow processing ExportTemplates.

View File

@ -19,7 +19,7 @@ from netbox.config import PARAMS
# Environment setup # Environment setup
# #
VERSION = '3.1.0' VERSION = '3.1.1'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -424,7 +424,7 @@ EXEMPT_PATHS = (
f'/{BASE_PATH}graphql/', f'/{BASE_PATH}graphql/',
f'/{BASE_PATH}login/', f'/{BASE_PATH}login/',
f'/{BASE_PATH}oauth/', 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) > * > * { &.table > :not(caption) > * > * {
padding-right: $table-cell-padding-x-sm !important; padding-right: $table-cell-padding-x-sm !important;
padding-left: $table-cell-padding-x-sm !important; padding-left: $table-cell-padding-x-sm !important;

View File

@ -4,7 +4,7 @@
{% block extra_controls %} {% block extra_controls %}
{% if perms.ipam.add_ipaddress and first_available_ip %} {% 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 <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add IP Address
</a> </a>
{% endif %} {% endif %}

View File

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

View File

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

View File

@ -1,42 +1,85 @@
{% extends 'users/base.html' %} {% extends 'users/base.html' %}
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}User Profile{% endblock %} {% block title %}User Profile{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row mb-3">
<div class="col-md-8 offset-md-2"> <div class="col-md-6">
<span class="text-muted">User Login</span> <div class="card">
<h5 class="mb-3">{{ request.user.username }}</h5> <h5 class="card-header">Account Details</h5>
<div class="card-body">
<span class="text-muted">Full Name</span> <table class="table table-hover attr-table">
<h5 class="mb-3"> <tr>
{% if request.user.first_name and request.user.last_name %} <th scope="row">Username</th>
{{ request.user.first_name }} {{ request.user.last_name }} <td>{{ request.user.username }}</td>
{% elif request.user.first_name and not request.user.last_name %} </tr>
{{ request.user.first_name }} <tr>
{% else %} <th scope="row">Full Name</th>
{{ request.user.last_name|placeholder }} <td>
{% endif %} {% if request.user.first_name or request.user.last_name %}
</h5> {{ request.user.first_name }} {{ request.user.last_name }}
{% else %}
<span class="text-muted">Email</span> <span class="text-muted">&mdash;</span>
<h5 class="mb-3">{{ request.user.email|placeholder }}</h5> {% endif %}
</td>
<span class="text-muted">Registered</span> </tr>
<h5 class="mb-3">{{ request.user.date_joined|annotated_date }}</h5> <tr>
<th scope="row">Email</th>
<span class="text-muted">Groups</span> <td>{{ request.user.email|placeholder }}</td>
<h5 class="mb-3"> </tr>
{% for group in request.user.groups.all %} <tr>
<span class="badge bg-secondary">{{ group }}</span> <th scope="row">Account Created</th>
{% empty %} <td>{{ request.user.date_joined|annotated_date }}</td>
<span class="text-muted">None</span> </tr>
{% endfor %} <tr>
</h5> <th scope="row">Superuser</th>
<td>
<span class="text-muted">Admin Access</span> {% if request.user.is_superuser %}
<h5 class="mb-3">{{ request.user.is_staff|yesno|capfirst }}</h5> <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>
</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 %} {% endblock %}

View File

@ -56,7 +56,7 @@ class TenantSerializer(PrimaryModelSerializer):
class ContactGroupSerializer(NestedGroupModelSerializer): class ContactGroupSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail') 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) contact_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:

View File

@ -1,4 +1,3 @@
from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from extras.forms import CustomFieldModelFilterForm 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 django.views.generic import View
from social_core.backends.utils import load_backends 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 netbox.config import get_config
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from .forms import LoginForm, PasswordChangeForm, TokenForm from .forms import LoginForm, PasswordChangeForm, TokenForm
@ -119,7 +121,14 @@ class ProfileView(LoginRequiredMixin, View):
def get(self, request): 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, { return render(request, self.template_name, {
'changelog_table': changelog_table,
'active_tab': 'profile', 'active_tab': 'profile',
}) })

View File

@ -17,9 +17,10 @@ def multivalue_field_factory(field_class):
def to_python(self, value): def to_python(self, value):
if not value: if not value:
return [] return []
field = field_class()
return [ return [
# Only append non-empty values (this avoids e.g. trying to cast '' as an integer) # 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()) return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict())
@ -50,15 +51,15 @@ class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
class MACAddressFilter(django_filters.CharFilter): class MACAddressFilter(django_filters.CharFilter):
field_class = MACAddressField pass
class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter): class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(MACAddressField) field_class = multivalue_field_factory(forms.CharField)
class MultiValueWWNFilter(django_filters.MultipleChoiceFilter): class MultiValueWWNFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(MACAddressField) field_class = multivalue_field_factory(forms.CharField)
class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):

View File

@ -415,7 +415,9 @@ class CustomFieldColumn(tables.Column):
elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL: elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
# Linkify custom URLs # Linkify custom URLs
return mark_safe(f'<a href="{value}">{value}</a>') 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): class MPTTColumn(tables.TemplateColumn):

View File

@ -32,7 +32,7 @@ def placeholder(value):
""" """
Render a muted placeholder if value equates to False. Render a muted placeholder if value equates to False.
""" """
if value: if value not in ('', None):
return value return value
placeholder = '<span class="text-muted">&mdash;</span>' placeholder = '<span class="text-muted">&mdash;</span>'
return mark_safe(placeholder) return mark_safe(placeholder)

View File

@ -31,9 +31,6 @@ class ClusterGroupFilterForm(CustomFieldModelFilterForm):
class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class ClusterFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Cluster model = Cluster
field_order = [
'q', 'type_id', 'region_id', 'site_id', 'group_id', 'tenant_group_id', 'tenant_id',
]
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['group_id', 'type_id'], ['group_id', 'type_id'],

View File

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

View File

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

View File

@ -1,6 +1,6 @@
Django==3.2.9 Django==3.2.10
django-cors-headers==3.10.1 django-cors-headers==3.10.1
django-debug-toolbar==3.2.2 django-debug-toolbar==3.2.3
django-filter==21.1 django-filter==21.1
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
django-mptt==0.13.4 django-mptt==0.13.4
@ -18,7 +18,7 @@ gunicorn==20.1.0
Jinja2==3.0.3 Jinja2==3.0.3
Markdown==3.3.6 Markdown==3.3.6
markdown-include==0.6.0 markdown-include==0.6.0
mkdocs-material==8.0.4 mkdocs-material==8.1.0
netaddr==0.8.0 netaddr==0.8.0
Pillow==8.4.0 Pillow==8.4.0
psycopg2-binary==2.9.2 psycopg2-binary==2.9.2