Просмотр исходного кода

Merge pull request #12946 from netbox-community/develop

Release v3.5.4
Jeremy Stretch 2 лет назад
Родитель
Сommit
9dab3a0d79

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

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

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

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

+ 25 - 0
docs/release-notes/version-3.5.md

@@ -1,5 +1,30 @@
 # NetBox v3.5
 
+## v3.5.4 (2023-06-20)
+
+### Enhancements
+
+* [#12828](https://github.com/netbox-community/netbox/issues/12828) - Define colors for staged change action choices
+* [#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
+* [#12865](https://github.com/netbox-community/netbox/issues/12865) - Add "add" buttons for reports & scripts to navigation menu
+
+### Bug Fixes
+
+* [#12474](https://github.com/netbox-community/netbox/issues/12474) - Update cable terminations when assigning a location to a new site
+* [#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
+* [#12845](https://github.com/netbox-community/netbox/issues/12845) - Fix pagination of objects for related IP addresses table
+* [#12850](https://github.com/netbox-community/netbox/issues/12850) - Fix table configuration modal for the contact assignments list
+* [#12885](https://github.com/netbox-community/netbox/issues/12885) - Permit mounting of devices in rack unit 100
+* [#12914](https://github.com/netbox-community/netbox/issues/12914) - Clear stored ordering from user config when cleared by request
+
+---
+
 ## v3.5.3 (2023-06-02)
 
 ### Enhancements

+ 1 - 1
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)

+ 4 - 1
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)

+ 1 - 0
netbox/dcim/constants.py

@@ -11,6 +11,7 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
 #
 
 RACK_U_HEIGHT_DEFAULT = 42
+RACK_U_HEIGHT_MAX = 100
 
 RACK_ELEVATION_BORDER_WIDTH = 2
 RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30

+ 1 - 1
netbox/dcim/migrations/0154_half_height_rack_units.py

@@ -18,6 +18,6 @@ class Migration(migrations.Migration):
         migrations.AlterField(
             model_name='device',
             name='position',
-            field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]),
+            field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100.5)]),
         ),
     ]

+ 1 - 1
netbox/dcim/models/devices.py

@@ -568,7 +568,7 @@ class Device(PrimaryModel, ConfigContextModel):
         decimal_places=1,
         blank=True,
         null=True,
-        validators=[MinValueValidator(1), MaxValueValidator(99.5)],
+        validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)],
         verbose_name='Position (U)',
         help_text=_('The lowest-numbered unit occupied by the device')
     )

+ 2 - 2
netbox/dcim/models/racks.py

@@ -126,7 +126,7 @@ class Rack(PrimaryModel, WeightMixin):
     u_height = models.PositiveSmallIntegerField(
         default=RACK_U_HEIGHT_DEFAULT,
         verbose_name='Height (U)',
-        validators=[MinValueValidator(1), MaxValueValidator(100)],
+        validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
         help_text=_('Height in rack units')
     )
     desc_units = models.BooleanField(
@@ -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):

+ 1 - 0
netbox/dcim/signals.py

@@ -27,6 +27,7 @@ def handle_location_site_change(instance, created, **kwargs):
         Rack.objects.filter(location__in=locations).update(site=instance.site)
         Device.objects.filter(location__in=locations).update(site=instance.site)
         PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
+        CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
 
 
 @receiver(post_save, sender=Rack)

+ 0 - 10
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)

+ 3 - 3
netbox/extras/choices.py

@@ -210,7 +210,7 @@ class ChangeActionChoices(ChoiceSet):
     ACTION_DELETE = 'delete'
 
     CHOICES = (
-        (ACTION_CREATE, 'Create'),
-        (ACTION_UPDATE, 'Update'),
-        (ACTION_DELETE, 'Delete'),
+        (ACTION_CREATE, 'Create', 'green'),
+        (ACTION_UPDATE, 'Update', 'blue'),
+        (ACTION_DELETE, 'Delete', 'red'),
     )

+ 1 - 1
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)

+ 3 - 0
netbox/extras/models/staging.py

@@ -112,3 +112,6 @@ class StagedChange(ChangeLoggedModel):
             instance = self.model.objects.get(pk=self.object_id)
             logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
             instance.delete()
+
+    def get_action_color(self):
+        return ChangeActionChoices.colors.get(self.action)

+ 25 - 9
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
+
+        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
+            })
 
-            # 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)
+        queryset = self.fields['vlan'].queryset.filter(query)
+        self.fields['vlan'].queryset = queryset
 
 
 class IPRangeImportForm(NetBoxModelImportForm):

+ 2 - 4
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)
 

+ 59 - 0
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

+ 5 - 3
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'),
@@ -301,12 +301,14 @@ CUSTOMIZATION_MENU = Menu(
                 MenuItem(
                     link='extras:report_list',
                     link_text=_('Reports'),
-                    permissions=['extras.view_report']
+                    permissions=['extras.view_report'],
+                    buttons=get_model_buttons('extras', "reportmodule", actions=['add'])
                 ),
                 MenuItem(
                     link='extras:script_list',
                     link_text=_('Scripts'),
-                    permissions=['extras.view_script']
+                    permissions=['extras.view_script'],
+                    buttons=get_model_buttons('extras', "scriptmodule", actions=['add'])
                 ),
             ),
         ),

+ 1 - 1
netbox/netbox/settings.py

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 # Environment setup
 #
 
-VERSION = '3.5.3'
+VERSION = '3.5.4'
 
 # Hostname
 HOSTNAME = platform.node()

+ 8 - 4
netbox/netbox/tables/tables.py

@@ -140,10 +140,14 @@ class BaseTable(tables.Table):
         if request.user.is_authenticated:
             table_name = self.__class__.__name__
             if self.prefixed_order_by_field in request.GET:
-                # If an ordering has been specified as a query parameter, save it as the
-                # user's preferred ordering for this table.
-                ordering = request.GET.getlist(self.prefixed_order_by_field)
-                request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
+                if request.GET[self.prefixed_order_by_field]:
+                    # If an ordering has been specified as a query parameter, save it as the
+                    # user's preferred ordering for this table.
+                    ordering = request.GET.getlist(self.prefixed_order_by_field)
+                    request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
+                else:
+                    # If the ordering has been set to none (empty), clear any existing preference.
+                    request.user.config.clear(f'tables.{table_name}.ordering', commit=True)
             elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
                 # If no ordering has been specified, set the preferred ordering (if any).
                 self.order_by = ordering

+ 12 - 12
netbox/templates/ipam/ipaddress/ip_addresses.html

@@ -2,18 +2,18 @@
 {% load helpers %}
 
 {% block content %}
-    {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
-    <form method="post">
-        {% csrf_token %}
-        <div class="card">
-            <div class="card-body" id="object_list">
-                {% include 'htmx/table.html' %}
-            </div>
-        </div>
-    </form>
-{% endblock content %}
+  {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
+  <form method="post">
+    {% csrf_token %}
+    <div class="card">
+      <div class="card-body htmx-container table-responsive" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
+    </div>
+  </form>
+{% endblock %}
 
 {% block modals %}
-    {{ block.super }}
-    {% table_config_form table %}
+  {{ block.super }}
+  {% table_config_form table %}
 {% endblock modals %}

+ 1 - 1
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" %}
     <form method="post">
         {% csrf_token %}
         <div class="card">

+ 1 - 1
netbox/utilities/views.py

@@ -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):
             ...
 

+ 0 - 1
netbox/virtualization/views.py

@@ -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)

+ 8 - 8
requirements.txt

@@ -1,19 +1,19 @@
 bleach==6.0.0
-boto3==1.26.145
+boto3==1.26.156
 Django==4.1.9
-django-cors-headers==4.0.0
+django-cors-headers==4.1.0
 django-debug-toolbar==4.1.0
 django-filter==23.2
 django-graphiql-debug-toolbar==0.2.0
 django-mptt==0.14
 django-pglocks==1.0.4
 django-prometheus==2.3.1
-django-redis==5.2.0
-django-rich==1.5.0
+django-redis==5.3.0
+django-rich==1.6.0
 django-rq==2.8.1
 django-tables2==2.5.3
 django-taggit==4.0.0
-django-timezone-field==5.0
+django-timezone-field==5.1
 djangorestframework==3.14.0
 drf-spectacular==0.26.2
 drf-spectacular-sidecar==2023.6.1
@@ -23,15 +23,15 @@ graphene-django==3.0.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Markdown==3.3.7
-mkdocs-material==9.1.15
+mkdocs-material==9.1.16
 mkdocstrings[python-legacy]==0.22.0
 netaddr==0.8.0
 Pillow==9.5.0
 psycopg2-binary==2.9.6
 PyYAML==6.0
-sentry-sdk==1.25.0
+sentry-sdk==1.25.1
 social-auth-app-django==5.2.0
 social-auth-core[openidconnect]==4.4.2
 svgwrite==1.4.3
-tablib==3.4.0
+tablib==3.5.0
 tzdata==2023.3