diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index e0c34bb40..f2a3de0e8 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -2,6 +2,21 @@ ## v3.5.4 (FUTURE) +### Enhancements + +* [#12847](https://github.com/netbox-community/netbox/issues/12847) - Include "add" button on all device & virtual machine component list views +* [#12862](https://github.com/netbox-community/netbox/issues/12862) - Add menu navigation button to add wireless links directly + +### Bug Fixes + +* [#12622](https://github.com/netbox-community/netbox/issues/12622) - Permit the assignment of non-site VLANs to prefixes assigned to a site +* [#12682](https://github.com/netbox-community/netbox/issues/12682) - Correct OpenAPI schema for connected device API endpoint +* [#12687](https://github.com/netbox-community/netbox/issues/12687) - Allow the assignment of all /31 IP addresses to interfaces +* [#12818](https://github.com/netbox-community/netbox/issues/12818) - Fix permissions evaluation when queuing a data sync job +* [#12822](https://github.com/netbox-community/netbox/issues/12822) - Fix encoding of whitespace in custom link URLs +* [#12838](https://github.com/netbox-community/netbox/issues/12838) - Correct rounding of rack power utilization values +* [#12850](https://github.com/netbox-community/netbox/issues/12850) - Fix table configuration modal for the contact assignments list + --- ## v3.5.3 (2023-06-02) diff --git a/netbox/core/api/views.py b/netbox/core/api/views.py index fc4ef2927..7bf2f87a6 100644 --- a/netbox/core/api/views.py +++ b/netbox/core/api/views.py @@ -33,7 +33,7 @@ class DataSourceViewSet(NetBoxModelViewSet): """ Enqueue a job to synchronize the DataSource. """ - if not request.user.has_perm('extras.sync_datasource'): + if not request.user.has_perm('core.sync_datasource'): raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.") datasource = get_object_or_404(DataSource, pk=pk) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 5b87c4e5d..e8a2eabbf 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -646,7 +646,10 @@ class ConnectedDeviceViewSet(ViewSet): def get_view_name(self): return "Connected Device Locator" - @extend_schema(responses={200: OpenApiTypes.OBJECT}) + @extend_schema( + parameters=[_device_param, _interface_param], + responses={200: serializers.DeviceSerializer} + ) def list(self, request): peer_device_name = request.query_params.get(self._device_param.name) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index e5412a3ab..54de5c434 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -466,7 +466,7 @@ class Rack(PrimaryModel, WeightMixin): powerport.get_power_draw()['allocated'] for powerport in powerports ]) - return int(allocated_draw / available_power_total * 100) + return round(allocated_draw / available_power_total * 100, 1) @cached_property def total_weight(self): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0def4f4a8..b52e0afa5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2193,7 +2193,6 @@ class ConsolePortListView(generic.ObjectListView): filterset = filtersets.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm table = tables.ConsolePortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(ConsolePort) @@ -2257,7 +2256,6 @@ class ConsoleServerPortListView(generic.ObjectListView): filterset = filtersets.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm table = tables.ConsoleServerPortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(ConsoleServerPort) @@ -2321,7 +2319,6 @@ class PowerPortListView(generic.ObjectListView): filterset = filtersets.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm table = tables.PowerPortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(PowerPort) @@ -2385,7 +2382,6 @@ class PowerOutletListView(generic.ObjectListView): filterset = filtersets.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm table = tables.PowerOutletTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(PowerOutlet) @@ -2449,7 +2445,6 @@ class InterfaceListView(generic.ObjectListView): filterset = filtersets.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(Interface) @@ -2559,7 +2554,6 @@ class FrontPortListView(generic.ObjectListView): filterset = filtersets.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm table = tables.FrontPortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(FrontPort) @@ -2623,7 +2617,6 @@ class RearPortListView(generic.ObjectListView): filterset = filtersets.RearPortFilterSet filterset_form = forms.RearPortFilterForm table = tables.RearPortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(RearPort) @@ -2687,7 +2680,6 @@ class ModuleBayListView(generic.ObjectListView): filterset = filtersets.ModuleBayFilterSet filterset_form = forms.ModuleBayFilterForm table = tables.ModuleBayTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(ModuleBay) @@ -2743,7 +2735,6 @@ class DeviceBayListView(generic.ObjectListView): filterset = filtersets.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm table = tables.DeviceBayTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(DeviceBay) @@ -2868,7 +2859,6 @@ class InventoryItemListView(generic.ObjectListView): filterset = filtersets.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm table = tables.InventoryItemTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(InventoryItem) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 969fd22e0..e95c0aff3 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): text = clean_html(text, allowed_schemes) # Sanitize link - link = urllib.parse.quote_plus(link, safe='/:?&=%+[]@#') + link = urllib.parse.quote(link, safe='/:?&=%+[]@#') # Verify link scheme is allowed result = urllib.parse.urlparse(link) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index fd0b315a0..683d40f49 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.db.models import Q from django.utils.translation import gettext as _ from dcim.models import Device, Interface, Site @@ -181,16 +182,31 @@ class PrefixImportForm(NetBoxModelImportForm): def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) - if data: + if not data: + return - # Limit VLAN queryset by assigned site and/or group (if specified) - params = {} - if data.get('site'): - params[f"site__{self.fields['site'].to_field_name}"] = data.get('site') - if data.get('vlan_group'): - params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group') - if params: - self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) + site = data.get('site') + vlan_group = data.get('vlan_group') + + # Limit VLAN queryset by assigned site and/or group (if specified) + query = Q() + + if site: + query |= Q(**{ + f"site__{self.fields['site'].to_field_name}": site + }) + # Don't Forget to include VLANs without a site in the filter + query |= Q(**{ + f"site__{self.fields['site'].to_field_name}__isnull": True + }) + + if vlan_group: + query &= Q(**{ + f"group__{self.fields['vlan_group'].to_field_name}": vlan_group + }) + + queryset = self.fields['vlan'].queryset.filter(query) + self.fields['vlan'].queryset = queryset class IPRangeImportForm(NetBoxModelImportForm): diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index eb6dbe598..b0b08e4e0 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -211,10 +211,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm): vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, + selector=True, label=_('VLAN'), - query_params={ - 'site_id': '$site', - } ) role = DynamicModelChoiceField( queryset=Role.objects.all(), @@ -370,7 +368,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): raise ValidationError(msg) if address.version == 6 and address.prefixlen not in (127, 128): raise ValidationError(msg) - if address.ip == address.broadcast: + if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32): msg = f"{address} is a broadcast address, which may not be assigned to an interface." raise ValidationError(msg) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 44af9eae2..c9128c0f6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -495,6 +495,65 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk}) self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_import(self): + """ + Custom import test for YAML-based imports (versus CSV) + """ + IMPORT_DATA = """ +prefix: 10.1.1.0/24 +status: active +vlan: 101 +site: Site 1 +""" + # Note, a site is not tied to the VLAN to verify the fix for #12622 + VLAN.objects.create(vid=101, name='VLAN101') + + # Add all required permissions to the test user + self.add_permissions('ipam.view_prefix', 'ipam.add_prefix') + + form_data = { + 'data': IMPORT_DATA, + 'format': 'yaml' + } + response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True) + self.assertHttpStatus(response, 200) + + prefix = Prefix.objects.get(prefix='10.1.1.0/24') + self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE) + self.assertEqual(prefix.vlan.vid, 101) + self.assertEqual(prefix.site.name, "Site 1") + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_import_with_vlan_group(self): + """ + This test covers a unique import edge case where VLAN group is specified during the import. + """ + IMPORT_DATA = """ +prefix: 10.1.2.0/24 +status: active +vlan: 102 +site: Site 1 +vlan_group: Group 1 +""" + vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1")) + VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group) + + # Add all required permissions to the test user + self.add_permissions('ipam.view_prefix', 'ipam.add_prefix') + + form_data = { + 'data': IMPORT_DATA, + 'format': 'yaml' + } + response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True) + self.assertHttpStatus(response, 200) + + prefix = Prefix.objects.get(prefix='10.1.2.0/24') + self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE) + self.assertEqual(prefix.vlan.vid, 102) + self.assertEqual(prefix.site.name, "Site 1") + class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = IPRange diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 6e5bcfc23..d139546d9 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -102,7 +102,7 @@ CONNECTIONS_MENU = Menu( label=_('Connections'), items=( get_model_item('dcim', 'cable', _('Cables'), actions=['import']), - get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']), + get_model_item('wireless', 'wirelesslink', _('Wireless Links')), MenuItem( link='dcim:interface_connections_list', link_text=_('Interface Connections'), diff --git a/netbox/templates/tenancy/object_contacts.html b/netbox/templates/tenancy/object_contacts.html index aca63a379..bbe2d9bc1 100644 --- a/netbox/templates/tenancy/object_contacts.html +++ b/netbox/templates/tenancy/object_contacts.html @@ -10,7 +10,7 @@ {% endblock %} {% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal="ContactTable_config" %} + {% include 'inc/table_controls_htmx.html' with table_modal="ContactAssignmentTable_config" %}