diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 549067b13..e853a06e4 100644 --- a/docs/release-notes/version-2.9.md +++ b/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 diff --git a/netbox/dcim/elevations.py b/netbox/dcim/elevations.py index cef95a7b6..5a22188b8 100644 --- a/netbox/dcim/elevations.py +++ b/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) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 2b8bf4e9e..b6ba55d6d 100644 --- a/netbox/dcim/forms.py +++ b/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() diff --git a/netbox/dcim/migrations/0115_rackreservation_order.py b/netbox/dcim/migrations/0115_rackreservation_order.py new file mode 100644 index 000000000..594f6b9a4 --- /dev/null +++ b/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']}, + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 4189e0446..e96becadf 100644 --- a/netbox/dcim/models/devices.py +++ b/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, diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 3169272b4..6c5ab08b9 100644 --- a/netbox/dcim/models/racks.py +++ b/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) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index e48eaedba..78fa1dea6 100644 --- a/netbox/dcim/tables.py +++ b/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') diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 0d273e4d8..dd0731bb8 100644 --- a/netbox/ipam/api/views.py +++ b/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 diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 58dd96089..832e09330 100644 --- a/netbox/ipam/models.py +++ b/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 diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 5a4e2c133..7b4aa44ba 100644 --- a/netbox/ipam/tables.py +++ b/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 '', diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 8ea33764c..68f7da8ad 100644 --- a/netbox/ipam/views.py +++ b/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 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 74cfcbd83..61dc9cd72 100644 --- a/netbox/netbox/settings.py +++ b/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() diff --git a/netbox/templates/ipam/inc/ipadress_edit_header.html b/netbox/templates/ipam/inc/ipadress_edit_header.html index b8ec3878a..ed9692eea 100644 --- a/netbox/templates/ipam/inc/ipadress_edit_header.html +++ b/netbox/templates/ipam/inc/ipadress_edit_header.html @@ -4,7 +4,7 @@ - {% if 'interface' in request.GET %} + {% if 'interface' in request.GET or 'vminterface' in request.GET %} diff --git a/netbox/templates/virtualization/inc/vminterface.html b/netbox/templates/virtualization/inc/vminterface.html index 5410fba7a..9ee1fa396 100644 --- a/netbox/templates/virtualization/inc/vminterface.html +++ b/netbox/templates/virtualization/inc/vminterface.html @@ -2,7 +2,7 @@ {# Checkbox #} - {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %} + {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} @@ -48,12 +48,12 @@ {% endif %} - {% if perms.virtualization.change_interface %} + {% if perms.virtualization.change_vminterface %} {% endif %} - {% if perms.virtualization.delete_interface %} + {% if perms.virtualization.delete_vminterface %} @@ -65,7 +65,7 @@ {% if ipaddresses %} {# Placeholder #} - {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %} + {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} {% endif %} diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index a64a0a7d8..5d002decc 100644 --- a/netbox/virtualization/forms.py +++ b/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() diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index f787aef0e..7d0b99872 100644 --- a/netbox/virtualization/models.py +++ b/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): diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 039934d70..5f5b9326d 100644 --- a/netbox/virtualization/tables.py +++ b/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')