Merge branch 'develop' into art-10038

This commit is contained in:
Arthur 2022-08-24 14:34:26 -07:00
commit ddba785217
17 changed files with 100 additions and 22 deletions

View File

@ -5,8 +5,13 @@
### Enhancements ### Enhancements
* [#6454](https://github.com/netbox-community/netbox/issues/6454) - Include contextual help when creating first objects in UI * [#6454](https://github.com/netbox-community/netbox/issues/6454) - Include contextual help when creating first objects in UI
* [#9935](https://github.com/netbox-community/netbox/issues/9935) - Add 802.11ay and "other" wireless interface types
* [#10031](https://github.com/netbox-community/netbox/issues/10031) - Enforce `application/json` content type for REST API requests
* [#10033](https://github.com/netbox-community/netbox/issues/10033) - Disable "add termination" button for point-to-point L2VPNs with two terminations
* [#10037](https://github.com/netbox-community/netbox/issues/10037) - Add link to create child interface to interface context menu
* [#10061](https://github.com/netbox-community/netbox/issues/10061) - Replicate type when cloning L2VPN instances * [#10061](https://github.com/netbox-community/netbox/issues/10061) - Replicate type when cloning L2VPN instances
* [#10066](https://github.com/netbox-community/netbox/issues/10066) - Use fixed column widths for custom field values in UI * [#10066](https://github.com/netbox-community/netbox/issues/10066) - Use fixed column widths for custom field values in UI
* [#10133](https://github.com/netbox-community/netbox/issues/10133) - Enable nullifying device location during bulk edit
### Bug Fixes ### Bug Fixes
@ -16,9 +21,11 @@
* [#10057](https://github.com/netbox-community/netbox/issues/10057) - Fix AttributeError exception when global search results include rack reservations * [#10057](https://github.com/netbox-community/netbox/issues/10057) - Fix AttributeError exception when global search results include rack reservations
* [#10059](https://github.com/netbox-community/netbox/issues/10059) - Add identifier column to L2VPN table * [#10059](https://github.com/netbox-community/netbox/issues/10059) - Add identifier column to L2VPN table
* [#10089](https://github.com/netbox-community/netbox/issues/10089) - `linkify` template filter should escape object representation * [#10089](https://github.com/netbox-community/netbox/issues/10089) - `linkify` template filter should escape object representation
* [#10094](https://github.com/netbox-community/netbox/issues/10094) - Fix 404 when using "create and add another" to add contact assignments
* [#10108](https://github.com/netbox-community/netbox/issues/10108) - Linkify inside NAT IPs for primary device IPs in UI * [#10108](https://github.com/netbox-community/netbox/issues/10108) - Linkify inside NAT IPs for primary device IPs in UI
* [#10109](https://github.com/netbox-community/netbox/issues/10109) - Fix available prefixes calculation for container prefixes in the global table * [#10109](https://github.com/netbox-community/netbox/issues/10109) - Fix available prefixes calculation for container prefixes in the global table
* [#10111](https://github.com/netbox-community/netbox/issues/10111) - Wrap search QS to catch ValueError on identifier field * [#10111](https://github.com/netbox-community/netbox/issues/10111) - Wrap search QS to catch ValueError on identifier field
* [#10134](https://github.com/netbox-community/netbox/issues/10134) - Custom fields data serializer should return a 400 response for invalid data
--- ---

View File

@ -790,7 +790,9 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_80211AC = 'ieee802.11ac' TYPE_80211AC = 'ieee802.11ac'
TYPE_80211AD = 'ieee802.11ad' TYPE_80211AD = 'ieee802.11ad'
TYPE_80211AX = 'ieee802.11ax' TYPE_80211AX = 'ieee802.11ax'
TYPE_80211AY = 'ieee802.11ay'
TYPE_802151 = 'ieee802.15.1' TYPE_802151 = 'ieee802.15.1'
TYPE_OTHER_WIRELESS = 'other-wireless'
# Cellular # Cellular
TYPE_GSM = 'gsm' TYPE_GSM = 'gsm'
@ -918,7 +920,9 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_80211AC, 'IEEE 802.11ac'), (TYPE_80211AC, 'IEEE 802.11ac'),
(TYPE_80211AD, 'IEEE 802.11ad'), (TYPE_80211AD, 'IEEE 802.11ad'),
(TYPE_80211AX, 'IEEE 802.11ax'), (TYPE_80211AX, 'IEEE 802.11ax'),
(TYPE_80211AY, 'IEEE 802.11ay'),
(TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'), (TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
(TYPE_OTHER_WIRELESS, 'Other (Wireless)'),
) )
), ),
( (

View File

@ -45,6 +45,9 @@ WIRELESS_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_80211AC, InterfaceTypeChoices.TYPE_80211AC,
InterfaceTypeChoices.TYPE_80211AD, InterfaceTypeChoices.TYPE_80211AD,
InterfaceTypeChoices.TYPE_80211AX, InterfaceTypeChoices.TYPE_80211AX,
InterfaceTypeChoices.TYPE_80211AY,
InterfaceTypeChoices.TYPE_802151,
InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
] ]
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES

View File

@ -480,7 +480,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')), ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')),
) )
nullable_fields = ( nullable_fields = (
'tenant', 'platform', 'serial', 'airflow', 'location', 'tenant', 'platform', 'serial', 'airflow',
) )

View File

@ -1,5 +1,6 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from rest_framework.fields import Field from rest_framework.fields import Field
from rest_framework.serializers import ValidationError
from extras.choices import CustomFieldTypeChoices from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField from extras.models import CustomField
@ -62,6 +63,12 @@ class CustomFieldsDataField(Field):
return data return data
def to_internal_value(self, data): def to_internal_value(self, data):
if type(data) is not dict:
raise ValidationError(
"Invalid data format. Custom field data must be passed as a dictionary mapping field names to their "
"values."
)
# If updating an existing instance, start with existing custom_field_data # If updating an existing instance, start with existing custom_field_data
if self.parent.instance: if self.parent.instance:
data = {**self.parent.instance.custom_field_data, **data} data = {**self.parent.instance.custom_field_data, **data}

View File

@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property
from ipam.choices import L2VPNTypeChoices from ipam.choices import L2VPNTypeChoices
from ipam.constants import L2VPN_ASSIGNMENT_MODELS from ipam.constants import L2VPN_ASSIGNMENT_MODELS
@ -68,6 +69,13 @@ class L2VPN(NetBoxModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:l2vpn', args=[self.pk]) return reverse('ipam:l2vpn', args=[self.pk])
@cached_property
def can_add_termination(self):
if self.type in L2VPNTypeChoices.P2P and self.terminations.count() >= 2:
return False
else:
return True
class L2VPNTermination(NetBoxModel): class L2VPNTermination(NetBoxModel):
l2vpn = models.ForeignKey( l2vpn = models.ForeignKey(

View File

@ -390,7 +390,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
self.assertEqual(response.data['description'], data['description']) self.assertEqual(response.data['description'], data['description'])
# Try to create one more IP # Try to create one more IP
response = self.client.post(url, {}, **self.header) response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT) self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data) self.assertIn('detail', response.data)
@ -487,7 +487,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
self.assertEqual(response.data['description'], data['description']) self.assertEqual(response.data['description'], data['description'])
# Try to create one more IP # Try to create one more IP
response = self.client.post(url, {}, **self.header) response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT) self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
self.assertIn('detail', response.data) self.assertIn('detail', response.data)
@ -973,9 +973,9 @@ class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase):
VLAN.objects.bulk_create(vlans) VLAN.objects.bulk_create(vlans)
l2vpns = ( l2vpns = (
L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
L2VPN(name='L2VPN 2', type='vpws', identifier=650002), L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
L2VPN(name='L2VPN 3', type='vpls'), # No RD L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD
) )
L2VPN.objects.bulk_create(l2vpns) L2VPN.objects.bulk_create(l2vpns)

View File

@ -1485,9 +1485,9 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
RouteTarget.objects.bulk_create(route_targets) RouteTarget.objects.bulk_create(route_targets)
l2vpns = ( l2vpns = (
L2VPN(name='L2VPN 1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001), L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001),
L2VPN(name='L2VPN 2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002), L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002),
L2VPN(name='L2VPN 3', type=L2VPNTypeChoices.TYPE_VPLS), L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VPLS),
) )
L2VPN.objects.bulk_create(l2vpns) L2VPN.objects.bulk_create(l2vpns)
l2vpns[0].import_targets.add(route_targets[0]) l2vpns[0].import_targets.add(route_targets[0])

View File

@ -581,9 +581,9 @@ class TestL2VPNTermination(TestCase):
VLAN.objects.bulk_create(vlans) VLAN.objects.bulk_create(vlans)
l2vpns = ( l2vpns = (
L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
L2VPN(name='L2VPN 2', type='vpws', identifier=650002), L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
L2VPN(name='L2VPN 3', type='vpls'), # No RD L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD
) )
L2VPN.objects.bulk_create(l2vpns) L2VPN.objects.bulk_create(l2vpns)

View File

@ -533,6 +533,9 @@ REST_FRAMEWORK = {
), ),
'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata', 'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata',
'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination', 'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination',
'DEFAULT_PARSER_CLASSES': (
'rest_framework.parsers.JSONParser',
),
'DEFAULT_PERMISSION_CLASSES': ( 'DEFAULT_PERMISSION_CLASSES': (
'netbox.api.authentication.TokenPermissions', 'netbox.api.authentication.TokenPermissions',
), ),
@ -542,7 +545,6 @@ REST_FRAMEWORK = {
), ),
'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
# 'PAGE_SIZE': PAGINATE_COUNT,
'SCHEMA_COERCE_METHOD_NAMES': { 'SCHEMA_COERCE_METHOD_NAMES': {
# Default mappings # Default mappings
'retrieve': 'read', 'retrieve': 'read',

View File

@ -328,6 +328,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
""" """
return obj return obj
def get_extra_addanother_params(self, request):
"""
Return a dictionary of extra parameters to use on the Add Another button.
"""
return {}
# #
# Request handlers # Request handlers
# #
@ -403,6 +409,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
# If cloning is supported, pre-populate a new instance of the form # If cloning is supported, pre-populate a new instance of the form
params = prepare_cloned_fields(obj) params = prepare_cloned_fields(obj)
params.update(self.get_extra_addanother_params(request))
if params: if params:
if 'return_url' in request.GET: if 'return_url' in request.GET:
params['return_url'] = request.GET.get('return_url') params['return_url'] = request.GET.get('return_url')

View File

@ -59,7 +59,7 @@
</div> </div>
{% if perms.ipam.add_l2vpntermination %} {% if perms.ipam.add_l2vpntermination %}
<div class="card-footer text-end noprint"> <div class="card-footer text-end noprint">
<a href="{% url 'ipam:l2vpntermination_add' %}?l2vpn={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm"> <a href="{% url 'ipam:l2vpntermination_add' %}?l2vpn={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm{% if not object.can_add_termination %} disabled" aria-disabled="true{% endif %}">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a Termination <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a Termination
</a> </a>
</div> </div>

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.http import QueryDict
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from circuits.models import Circuit from circuits.models import Circuit
@ -365,6 +366,12 @@ class ContactAssignmentEditView(generic.ObjectEditView):
instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
return instance return instance
def get_extra_addanother_params(self, request):
return {
'content_type': request.GET.get('content_type'),
'object_id': request.GET.get('object_id'),
}
class ContactAssignmentDeleteView(generic.ObjectDeleteView): class ContactAssignmentDeleteView(generic.ObjectDeleteView):
queryset = ContactAssignment.objects.all() queryset = ContactAssignment.objects.all()

View File

@ -124,7 +124,7 @@ class TokenTest(
user = User.objects.create_user(**data) user = User.objects.create_user(**data)
url = reverse('users-api:token_provision') url = reverse('users-api:token_provision')
response = self.client.post(url, **self.header, data=data) response = self.client.post(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
self.assertIn('key', response.data) self.assertIn('key', response.data)
self.assertEqual(len(response.data['key']), 40) self.assertEqual(len(response.data['key']), 40)
@ -141,7 +141,7 @@ class TokenTest(
} }
url = reverse('users-api:token_provision') url = reverse('users-api:token_provision')
response = self.client.post(url, **self.header, data=data) response = self.client.post(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)

View File

@ -285,7 +285,7 @@ def prepare_cloned_fields(instance):
""" """
# Generate the clone attributes from the instance # Generate the clone attributes from the instance
if not hasattr(instance, 'clone'): if not hasattr(instance, 'clone'):
return QueryDict() return QueryDict(mutable=True)
attrs = instance.clone() attrs = instance.clone()
# Prepare querydict parameters # Prepare querydict parameters

View File

@ -0,0 +1,24 @@
from django.db import migrations, models
import django.db.models.deletion
import wireless.models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0161_cabling_cleanup'),
('wireless', '0004_wireless_tenancy'),
]
operations = [
migrations.AlterField(
model_name='wirelesslink',
name='interface_a',
field=models.ForeignKey(limit_choices_to=wireless.models.get_wireless_interface_types, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface'),
),
migrations.AlterField(
model_name='wirelesslink',
name='interface_b',
field=models.ForeignKey(limit_choices_to=wireless.models.get_wireless_interface_types, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface'),
),
]

View File

@ -24,7 +24,8 @@ class WirelessAuthenticationBase(models.Model):
auth_type = models.CharField( auth_type = models.CharField(
max_length=50, max_length=50,
choices=WirelessAuthTypeChoices, choices=WirelessAuthTypeChoices,
blank=True blank=True,
verbose_name="Auth Type",
) )
auth_cipher = models.CharField( auth_cipher = models.CharField(
max_length=50, max_length=50,
@ -127,21 +128,29 @@ class WirelessLAN(WirelessAuthenticationBase, NetBoxModel):
return reverse('wireless:wirelesslan', args=[self.pk]) return reverse('wireless:wirelesslan', args=[self.pk])
def get_wireless_interface_types():
# Wrap choices in a callable to avoid generating dummy migrations
# when the choices are updated.
return {'type__in': WIRELESS_IFACE_TYPES}
class WirelessLink(WirelessAuthenticationBase, NetBoxModel): class WirelessLink(WirelessAuthenticationBase, NetBoxModel):
""" """
A point-to-point connection between two wireless Interfaces. A point-to-point connection between two wireless Interfaces.
""" """
interface_a = models.ForeignKey( interface_a = models.ForeignKey(
to='dcim.Interface', to='dcim.Interface',
limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, limit_choices_to=get_wireless_interface_types,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+' related_name='+',
verbose_name="Interface A",
) )
interface_b = models.ForeignKey( interface_b = models.ForeignKey(
to='dcim.Interface', to='dcim.Interface',
limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, limit_choices_to=get_wireless_interface_types,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+' related_name='+',
verbose_name="Interface B",
) )
ssid = models.CharField( ssid = models.CharField(
max_length=SSID_MAX_LENGTH, max_length=SSID_MAX_LENGTH,