mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Merge branch 'develop' into feature
This commit is contained in:
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
|
@ -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" %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="card">
|
||||
|
@ -178,7 +178,7 @@ def register_model_view(model, name='', path=None, kwargs=None):
|
||||
This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject
|
||||
additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model:
|
||||
|
||||
@netbox_model_view(Site, 'myview', path='my-custom-view')
|
||||
@register_model_view(Site, 'myview', path='my-custom-view')
|
||||
class MyView(ObjectView):
|
||||
...
|
||||
|
||||
|
@ -415,7 +415,6 @@ class VMInterfaceListView(generic.ObjectListView):
|
||||
filterset = filtersets.VMInterfaceFilterSet
|
||||
filterset_form = forms.VMInterfaceFilterForm
|
||||
table = tables.VMInterfaceTable
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(VMInterface)
|
||||
|
Reference in New Issue
Block a user