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

Merge pull request #5062 from netbox-community/develop

Release v2.9.2
Jeremy Stretch 5 лет назад
Родитель
Сommit
8e5aa69321

+ 24 - 0
docs/release-notes/version-2.9.md

@@ -1,5 +1,29 @@
 # NetBox v2.9
 
+## v2.9.2 (2020-08-27)
+
+### Enhancements
+
+* [#5055](https://github.com/netbox-community/netbox/issues/5055) - Add tags column to device/VM component list tables
+* [#5056](https://github.com/netbox-community/netbox/issues/5056) - Add interface and parent columns to IP address list
+
+### Bug Fixes
+
+* [#4988](https://github.com/netbox-community/netbox/issues/4988) - Fix ordering of rack reservations with identical creation times
+* [#5002](https://github.com/netbox-community/netbox/issues/5002) - Correct OpenAPI definition for `available-prefixes` endpoint
+* [#5035](https://github.com/netbox-community/netbox/issues/5035) - Fix exception when modifying an IP address assigned to a VM
+* [#5038](https://github.com/netbox-community/netbox/issues/5038) - Fix validation of primary IPs assigned to virtual machines
+* [#5040](https://github.com/netbox-community/netbox/issues/5040) - Limit SLAAC status to IPv6 addresses
+* [#5041](https://github.com/netbox-community/netbox/issues/5041) - Fix form tabs when assigning an IP to a VM interface
+* [#5042](https://github.com/netbox-community/netbox/issues/5042) - Fix display of SLAAC label for IP addresses status
+* [#5045](https://github.com/netbox-community/netbox/issues/5045) - Allow assignment of interfaces to non-master VC peer LAG during import
+* [#5058](https://github.com/netbox-community/netbox/issues/5058) - Correct URL for front rack elevation images when using external storage
+* [#5059](https://github.com/netbox-community/netbox/issues/5059) - Fix inclusion of checkboxes for interfaces in virtual machine view
+* [#5060](https://github.com/netbox-community/netbox/issues/5060) - Fix validation when bulk-importing child devices
+* [#5061](https://github.com/netbox-community/netbox/issues/5061) - Allow adding/removing tags when bulk editing virtual machine interfaces
+
+---
+
 ## v2.9.1 (2020-08-22)
 
 ### Enhancements

+ 12 - 4
netbox/dcim/elevations.py

@@ -94,8 +94,12 @@ class RackElevationSVG:
 
         # Embed front device type image if one exists
         if self.include_images and device.device_type.front_image:
-            url = '{}{}'.format(self.base_url, device.device_type.front_image.url)
-            image = drawing.image(href=url, insert=start, size=end, class_='device-image')
+            image = drawing.image(
+                href=device.device_type.front_image.url,
+                insert=start,
+                size=end,
+                class_='device-image'
+            )
             image.fit(scale='slice')
             link.add(image)
 
@@ -107,8 +111,12 @@ class RackElevationSVG:
 
         # Embed rear device type image if one exists
         if self.include_images and device.device_type.rear_image:
-            url = device.device_type.rear_image.url
-            image = drawing.image(href=url, insert=start, size=end, class_='device-image')
+            image = drawing.image(
+                href=device.device_type.rear_image.url,
+                insert=start,
+                size=end,
+                class_='device-image'
+            )
             image.fit(scale='slice')
             drawing.add(image)
 

+ 10 - 5
netbox/dcim/forms.py

@@ -1811,7 +1811,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
                     nat_inside__assigned_object_id__in=interface_ids
                 ).prefetch_related('assigned_object')
                 if nat_ips:
-                    ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in nat_ips]
+                    ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
                     ip_choices.append(('NAT IPs', ip_list))
                 self.fields['primary_ip{}'.format(family)].choices = ip_choices
 
@@ -2879,17 +2879,22 @@ class InterfaceCSVForm(CSVModelForm):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        # Limit LAG choices to interfaces belonging to this device (or VC master)
+        # Limit LAG choices to interfaces belonging to this device (or virtual chassis)
         device = None
         if self.is_bound and 'device' in self.data:
             try:
                 device = self.fields['device'].to_python(self.data['device'])
             except forms.ValidationError:
                 pass
-
-        if device:
+        if device and device.virtual_chassis:
             self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG
+                Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis),
+                type=InterfaceTypeChoices.TYPE_LAG
+            )
+        elif device:
+            self.fields['lag'].queryset = Interface.objects.filter(
+                device=device,
+                type=InterfaceTypeChoices.TYPE_LAG
             )
         else:
             self.fields['lag'].queryset = Interface.objects.none()

+ 17 - 0
netbox/dcim/migrations/0115_rackreservation_order.py

@@ -0,0 +1,17 @@
+# Generated by Django 3.1 on 2020-08-24 16:03
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0114_update_jsonfield'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='rackreservation',
+            options={'ordering': ['created', 'pk']},
+        ),
+    ]

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

@@ -633,7 +633,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
         # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
         # of the uniqueness constraint without manual intervention.
-        if self.name and self.tenant is None:
+        if self.name and hasattr(self, 'site') and self.tenant is None:
             if Device.objects.exclude(pk=self.pk).filter(
                     name=self.name,
                     site=self.site,

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

@@ -600,7 +600,7 @@ class RackReservation(ChangeLoggedModel):
     csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
 
     class Meta:
-        ordering = ['created']
+        ordering = ['created', 'pk']
 
     def __str__(self):
         return "Reservation for rack {}".format(self.rack)

+ 41 - 9
netbox/dcim/tables.py

@@ -706,34 +706,48 @@ class DeviceComponentTable(BaseTable):
 
 
 class ConsolePortTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:consoleport_list'
+    )
 
     class Meta(DeviceComponentTable.Meta):
         model = ConsolePort
-        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
+        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
 
 
 class ConsoleServerPortTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:consoleserverport_list'
+    )
 
     class Meta(DeviceComponentTable.Meta):
         model = ConsoleServerPort
-        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
+        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
 
 
 class PowerPortTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:powerport_list'
+    )
 
     class Meta(DeviceComponentTable.Meta):
         model = PowerPort
-        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable')
+        fields = (
+            'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'tags',
+        )
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
 
 
 class PowerOutletTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:poweroutlet_list'
+    )
 
     class Meta(DeviceComponentTable.Meta):
         model = PowerOutlet
-        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable')
+        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'tags')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
 
 
@@ -753,12 +767,15 @@ class BaseInterfaceTable(BaseTable):
 
 
 class InterfaceTable(DeviceComponentTable, BaseInterfaceTable):
+    tags = TagColumn(
+        url_name='dcim:interface_list'
+    )
 
     class Meta(DeviceComponentTable.Meta):
         model = Interface
         fields = (
             'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
-            'description', 'cable', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
+            'description', 'cable', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
         )
         default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
 
@@ -767,18 +784,26 @@ class FrontPortTable(DeviceComponentTable):
     rear_port_position = tables.Column(
         verbose_name='Position'
     )
+    tags = TagColumn(
+        url_name='dcim:frontport_list'
+    )
 
     class Meta(DeviceComponentTable.Meta):
         model = FrontPort
-        fields = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable')
+        fields = (
+            'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags',
+        )
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
 
 
 class RearPortTable(DeviceComponentTable):
+    tags = TagColumn(
+        url_name='dcim:rearport_list'
+    )
 
     class Meta(DeviceComponentTable.Meta):
         model = RearPort
-        fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable')
+        fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
 
 
@@ -786,10 +811,13 @@ class DeviceBayTable(DeviceComponentTable):
     installed_device = tables.Column(
         linkify=True
     )
+    tags = TagColumn(
+        url_name='dcim:devicebay_list'
+    )
 
     class Meta(DeviceComponentTable.Meta):
         model = DeviceBay
-        fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
+        fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description', 'tags')
         default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
 
 
@@ -798,12 +826,16 @@ class InventoryItemTable(DeviceComponentTable):
         linkify=True
     )
     discovered = BooleanColumn()
+    tags = TagColumn(
+        url_name='dcim:inventoryitem_list'
+    )
+    cable = None  # Override DeviceComponentTable
 
     class Meta(DeviceComponentTable.Meta):
         model = InventoryItem
         fields = (
             'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
-            'discovered',
+            'discovered', 'tags',
         )
         default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
 

+ 2 - 2
netbox/ipam/api/views.py

@@ -88,7 +88,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
         return super().get_serializer_class()
 
     @swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
-    @swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
+    @swagger_auto_schema(method='post', responses={201: serializers.PrefixSerializer(many=False)})
     @action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
     @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
     def available_prefixes(self, request, pk=None):
@@ -247,7 +247,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
 
 class IPAddressViewSet(CustomFieldModelViewSet):
     queryset = IPAddress.objects.prefetch_related(
-        'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags',
+        'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
     )
     serializer_class = serializers.IPAddressSerializer
     filterset_class = filters.IPAddressFilterSet

+ 8 - 1
netbox/ipam/models.py

@@ -669,6 +669,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
         'reserved': 'info',
         'deprecated': 'danger',
         'dhcp': 'success',
+        'slaac': 'success',
     }
 
     ROLE_CLASS_MAP = {
@@ -745,12 +746,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
                         'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
                                        f"interface"
                     })
-                elif self.interface.virtual_machine != vm:
+                elif self.assigned_object.virtual_machine != vm:
                     raise ValidationError({
                         'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
                                        f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
                     })
 
+        # Validate IP status selection
+        if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
+            raise ValidationError({
+                'status': "Only IPv6 addresses can be assigned SLAAC status"
+            })
+
     def save(self, *args, **kwargs):
 
         # Force dns_name to lowercase

+ 12 - 4
netbox/ipam/tables.py

@@ -387,15 +387,23 @@ class IPAddressTable(BaseTable):
     tenant = tables.TemplateColumn(
         template_code=TENANT_LINK
     )
-    assigned = tables.BooleanColumn(
-        accessor='assigned_object_id',
-        verbose_name='Assigned'
+    assigned_object = tables.Column(
+        linkify=True,
+        orderable=False,
+        verbose_name='Interface'
+    )
+    assigned_object_parent = tables.Column(
+        accessor='assigned_object__parent',
+        linkify=True,
+        orderable=False,
+        verbose_name='Interface Parent'
     )
 
     class Meta(BaseTable.Meta):
         model = IPAddress
         fields = (
-            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'assigned_object_parent', 'dns_name',
+            'description',
         )
         row_attrs = {
             'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',

+ 1 - 1
netbox/ipam/views.py

@@ -493,7 +493,7 @@ class PrefixBulkDeleteView(BulkDeleteView):
 
 class IPAddressListView(ObjectListView):
     queryset = IPAddress.objects.prefetch_related(
-        'vrf__tenant', 'tenant', 'nat_inside'
+        'vrf__tenant', 'tenant', 'nat_inside', 'assigned_object'
     )
     filterset = filters.IPAddressFilterSet
     filterset_form = forms.IPAddressFilterForm

+ 1 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '2.9.1'
+VERSION = '2.9.2'
 
 # Hostname
 HOSTNAME = platform.node()

+ 1 - 1
netbox/templates/ipam/inc/ipadress_edit_header.html

@@ -4,7 +4,7 @@
     <li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}>
         <a href="{% url 'ipam:ipaddress_add' %}{% querystring request %}">New IP</a>
     </li>
-    {% if 'interface' in request.GET %}
+    {% if 'interface' in request.GET or 'vminterface' in request.GET %}
         <li role="presentation"{% if active_tab == 'assign' %} class="active"{% endif %}>
             <a href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}">Assign IP</a>
         </li>

+ 4 - 4
netbox/templates/virtualization/inc/vminterface.html

@@ -2,7 +2,7 @@
 <tr class="interface{% if not iface.enabled %} danger{% endif %}" id="interface_{{ iface.name }}">
 
     {# Checkbox #}
-    {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %}
+    {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ iface.pk }}" />
         </td>
@@ -48,12 +48,12 @@
                 <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
             </a>
         {% endif %}
-        {% if perms.virtualization.change_interface %}
+        {% if perms.virtualization.change_vminterface %}
             <a href="{% url 'virtualization:vminterface_edit' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
         {% endif %}
-        {% if perms.virtualization.delete_interface %}
+        {% if perms.virtualization.delete_vminterface %}
             <a href="{% url 'virtualization:vminterface_delete' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
                 <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
             </a>
@@ -65,7 +65,7 @@
     {% if ipaddresses %}
         <tr class="ipaddresses">
             {# Placeholder #}
-            {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %}
+            {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
                 <td></td>
             {% endif %}
 

+ 15 - 14
netbox/virtualization/forms.py

@@ -1,4 +1,5 @@
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 
 from dcim.choices import InterfaceModeChoices
@@ -325,28 +326,28 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             # Compile list of choices for primary IPv4 and IPv6 addresses
             for family in [4, 6]:
                 ip_choices = [(None, '---------')]
+
+                # Gather PKs of all interfaces belonging to this VM
+                interface_ids = self.instance.interfaces.values_list('pk', flat=True)
+
                 # Collect interface IPs
-                interface_ips = IPAddress.objects.prefetch_related('interface').filter(
+                interface_ips = IPAddress.objects.filter(
                     address__family=family,
-                    vminterface__in=self.instance.interfaces.values_list('id', flat=True)
+                    assigned_object_type=ContentType.objects.get_for_model(VMInterface),
+                    assigned_object_id__in=interface_ids
                 )
                 if interface_ips:
-                    ip_choices.append(
-                        ('Interface IPs', [
-                            (ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
-                        ])
-                    )
+                    ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
+                    ip_choices.append(('Interface IPs', ip_list))
                 # Collect NAT IPs
                 nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
                     address__family=family,
-                    nat_inside__vminterface__in=self.instance.interfaces.values_list('id', flat=True)
+                    nat_inside__assigned_object_type=ContentType.objects.get_for_model(VMInterface),
+                    nat_inside__assigned_object_id__in=interface_ids
                 )
                 if nat_ips:
-                    ip_choices.append(
-                        ('NAT IPs', [
-                            (ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
-                        ])
-                    )
+                    ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
+                    ip_choices.append(('NAT IPs', ip_list))
                 self.fields['primary_ip{}'.format(family)].choices = ip_choices
 
         else:
@@ -683,7 +684,7 @@ class VMInterfaceCSVForm(CSVModelForm):
             return self.cleaned_data['enabled']
 
 
-class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
+class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=VMInterface.objects.all(),
         widget=forms.MultipleHiddenInput()

+ 3 - 3
netbox/virtualization/models.py

@@ -335,13 +335,13 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         for field in ['primary_ip4', 'primary_ip6']:
             ip = getattr(self, field)
             if ip is not None:
-                if ip.interface in interfaces:
+                if ip.assigned_object in interfaces:
                     pass
-                elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in interfaces:
+                elif ip.nat_inside is not None and ip.nat_inside.assigned_object in interfaces:
                     pass
                 else:
                     raise ValidationError({
-                        field: "The specified IP address ({}) is not assigned to this VM.".format(ip),
+                        field: f"The specified IP address ({ip}) is not assigned to this VM.",
                     })
 
     def to_csv(self):

+ 4 - 1
netbox/virtualization/tables.py

@@ -154,11 +154,14 @@ class VMInterfaceTable(BaseInterfaceTable):
     name = tables.Column(
         linkify=True
     )
+    tags = TagColumn(
+        url_name='virtualization:vminterface_list'
+    )
 
     class Meta(BaseTable.Meta):
         model = VMInterface
         fields = (
-            'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'ip_addresses',
+            'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'tags', 'ip_addresses',
             'untagged_vlan', 'tagged_vlans',
         )
         default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description')