diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 7ed635bc2..1421bb2c7 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -5,8 +5,13 @@ ### Enhancements * [#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 * [#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 @@ -16,9 +21,11 @@ * [#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 * [#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 * [#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 +* [#10134](https://github.com/netbox-community/netbox/issues/10134) - Custom fields data serializer should return a 400 response for invalid data --- diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 79049384a..019ae09a4 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -790,7 +790,9 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_80211AC = 'ieee802.11ac' TYPE_80211AD = 'ieee802.11ad' TYPE_80211AX = 'ieee802.11ax' + TYPE_80211AY = 'ieee802.11ay' TYPE_802151 = 'ieee802.15.1' + TYPE_OTHER_WIRELESS = 'other-wireless' # Cellular TYPE_GSM = 'gsm' @@ -918,7 +920,9 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_80211AC, 'IEEE 802.11ac'), (TYPE_80211AD, 'IEEE 802.11ad'), (TYPE_80211AX, 'IEEE 802.11ax'), + (TYPE_80211AY, 'IEEE 802.11ay'), (TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'), + (TYPE_OTHER_WIRELESS, 'Other (Wireless)'), ) ), ( diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 9e41ed113..80d7558c9 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -45,6 +45,9 @@ WIRELESS_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_80211AC, InterfaceTypeChoices.TYPE_80211AD, InterfaceTypeChoices.TYPE_80211AX, + InterfaceTypeChoices.TYPE_80211AY, + InterfaceTypeChoices.TYPE_802151, + InterfaceTypeChoices.TYPE_OTHER_WIRELESS, ] NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 8f765ae9b..396f7e59b 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -480,7 +480,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')), ) nullable_fields = ( - 'tenant', 'platform', 'serial', 'airflow', + 'location', 'tenant', 'platform', 'serial', 'airflow', ) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index b7fd1e129..cb35b4e73 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.models import ContentType from rest_framework.fields import Field +from rest_framework.serializers import ValidationError from extras.choices import CustomFieldTypeChoices from extras.models import CustomField @@ -62,6 +63,12 @@ class CustomFieldsDataField(Field): return 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 self.parent.instance: data = {**self.parent.instance.custom_field_data, **data} diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index 809007033..9718e9cab 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from django.utils.functional import cached_property from ipam.choices import L2VPNTypeChoices from ipam.constants import L2VPN_ASSIGNMENT_MODELS @@ -68,6 +69,13 @@ class L2VPN(NetBoxModel): def get_absolute_url(self): 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): l2vpn = models.ForeignKey( diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 3fef04194..5dc708cd0 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -390,7 +390,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): self.assertEqual(response.data['description'], data['description']) # 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.assertIn('detail', response.data) @@ -487,7 +487,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase): self.assertEqual(response.data['description'], data['description']) # 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.assertIn('detail', response.data) @@ -973,9 +973,9 @@ class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): VLAN.objects.bulk_create(vlans) l2vpns = ( - L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', type='vpls'), # No RD + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD ) L2VPN.objects.bulk_create(l2vpns) diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 081f6e11d..5c4113786 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1485,9 +1485,9 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): RouteTarget.objects.bulk_create(route_targets) l2vpns = ( - L2VPN(name='L2VPN 1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001), - L2VPN(name='L2VPN 2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002), - L2VPN(name='L2VPN 3', type=L2VPNTypeChoices.TYPE_VPLS), + L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VPLS), ) L2VPN.objects.bulk_create(l2vpns) l2vpns[0].import_targets.add(route_targets[0]) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 3bd7e8ccb..94a315be5 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -581,9 +581,9 @@ class TestL2VPNTermination(TestCase): VLAN.objects.bulk_create(vlans) l2vpns = ( - L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', type='vpls'), # No RD + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD ) L2VPN.objects.bulk_create(l2vpns) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0edce8f69..4438d338b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -533,6 +533,9 @@ REST_FRAMEWORK = { ), 'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata', 'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination', + 'DEFAULT_PARSER_CLASSES': ( + 'rest_framework.parsers.JSONParser', + ), 'DEFAULT_PERMISSION_CLASSES': ( 'netbox.api.authentication.TokenPermissions', ), @@ -542,7 +545,6 @@ REST_FRAMEWORK = { ), 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', - # 'PAGE_SIZE': PAGINATE_COUNT, 'SCHEMA_COERCE_METHOD_NAMES': { # Default mappings 'retrieve': 'read', diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 19401f79a..7617e0402 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -328,6 +328,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ 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 # @@ -403,6 +409,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): # If cloning is supported, pre-populate a new instance of the form params = prepare_cloned_fields(obj) + params.update(self.get_extra_addanother_params(request)) if params: if 'return_url' in request.GET: params['return_url'] = request.GET.get('return_url') diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html index 44a1da818..c19363d33 100644 --- a/netbox/templates/ipam/l2vpn.html +++ b/netbox/templates/ipam/l2vpn.html @@ -59,7 +59,7 @@ {% if perms.ipam.add_l2vpntermination %} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 9a2fe6ab9..e582c15d1 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.http import QueryDict from django.shortcuts import get_object_or_404 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')) 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): queryset = ContactAssignment.objects.all() diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index bcfc9cf14..a0bf8a49e 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -124,7 +124,7 @@ class TokenTest( user = User.objects.create_user(**data) 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.assertIn('key', response.data) self.assertEqual(len(response.data['key']), 40) @@ -141,7 +141,7 @@ class TokenTest( } 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) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 1dece76c8..69ab615fc 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -285,7 +285,7 @@ def prepare_cloned_fields(instance): """ # Generate the clone attributes from the instance if not hasattr(instance, 'clone'): - return QueryDict() + return QueryDict(mutable=True) attrs = instance.clone() # Prepare querydict parameters diff --git a/netbox/wireless/migrations/0005_wirelesslink_interface_types.py b/netbox/wireless/migrations/0005_wirelesslink_interface_types.py new file mode 100644 index 000000000..0b3f88c5b --- /dev/null +++ b/netbox/wireless/migrations/0005_wirelesslink_interface_types.py @@ -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'), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 36410b83b..c383ad642 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -24,7 +24,8 @@ class WirelessAuthenticationBase(models.Model): auth_type = models.CharField( max_length=50, choices=WirelessAuthTypeChoices, - blank=True + blank=True, + verbose_name="Auth Type", ) auth_cipher = models.CharField( max_length=50, @@ -127,21 +128,29 @@ class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): 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): """ A point-to-point connection between two wireless Interfaces. """ interface_a = models.ForeignKey( to='dcim.Interface', - limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, + limit_choices_to=get_wireless_interface_types, on_delete=models.PROTECT, - related_name='+' + related_name='+', + verbose_name="Interface A", ) interface_b = models.ForeignKey( to='dcim.Interface', - limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, + limit_choices_to=get_wireless_interface_types, on_delete=models.PROTECT, - related_name='+' + related_name='+', + verbose_name="Interface B", ) ssid = models.CharField( max_length=SSID_MAX_LENGTH,