From 08c492f1f4bee4b1db806d0c10c41e70347e415b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 4 Sep 2020 16:09:05 -0400 Subject: [PATCH] Merge develop into develop-2.10 --- docs/installation/3-netbox.md | 2 +- docs/installation/upgrading.md | 11 ++- docs/models/dcim/interface.md | 2 +- docs/models/ipam/ipaddress.md | 1 + docs/plugins/development.md | 3 + docs/release-notes/version-2.9.md | 65 ++++++++++++++- mkdocs.yml | 1 + netbox/dcim/choices.py | 6 ++ netbox/dcim/elevations.py | 18 ++-- netbox/dcim/forms.py | 26 ++++-- .../migrations/0115_rackreservation_order.py | 17 ++++ netbox/dcim/models/device_components.py | 16 ++-- netbox/dcim/models/devices.py | 2 +- netbox/dcim/models/racks.py | 2 +- netbox/dcim/tables.py | 71 +++++++++++++--- netbox/dcim/views.py | 9 +- netbox/extras/api/customfields.py | 2 +- netbox/extras/api/views.py | 1 + netbox/extras/filters.py | 10 ++- netbox/extras/tests/test_filters.py | 82 ++++++++++++++++++- netbox/ipam/api/views.py | 4 +- netbox/ipam/choices.py | 2 + netbox/ipam/models.py | 9 +- netbox/ipam/tables.py | 28 ++++--- netbox/ipam/views.py | 6 +- netbox/netbox/settings.py | 3 +- netbox/templates/500.html | 5 +- .../templates/dcim/device_component_edit.html | 16 ++++ netbox/templates/dcim/inc/consoleport.html | 2 +- .../templates/dcim/inc/consoleserverport.html | 2 +- netbox/templates/dcim/inc/devicebay.html | 2 +- netbox/templates/dcim/inc/poweroutlet.html | 2 +- netbox/templates/dcim/inc/powerport.html | 2 +- netbox/templates/dcim/interface_edit.html | 15 ++++ netbox/templates/inc/plugin_menu_items.html | 28 ++++--- .../ipam/inc/ipadress_edit_header.html | 2 +- netbox/templates/utilities/obj_edit.html | 4 +- .../virtualization/inc/vminterface.html | 8 +- .../virtualmachine_component_add.html | 2 +- .../virtualization/vminterface_edit.html | 20 +++++ netbox/users/views.py | 26 ++++-- netbox/utilities/tables.py | 2 +- netbox/utilities/views.py | 4 + netbox/virtualization/forms.py | 29 +++---- netbox/virtualization/models.py | 6 +- netbox/virtualization/tables.py | 5 +- 46 files changed, 464 insertions(+), 117 deletions(-) create mode 100644 netbox/dcim/migrations/0115_rackreservation_order.py create mode 100644 netbox/templates/dcim/device_component_edit.html diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 05f6d825e4..235e39a8f2 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -25,7 +25,7 @@ Begin by installing all system packages required by NetBox and its dependencies. Before continuing with either platform, update pip (Python's package management tool) to its latest release: ```no-highlight -# pip install --upgrade pip +# pip3 install --upgrade pip ``` ## Download NetBox diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 807b9b1e65..34274342ce 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -4,8 +4,15 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect. -!!! note - Beginning with version 2.8, NetBox requires Python 3.6 or later. +## Update Dependencies to Required Versions + +NetBox v2.9.0 and later requires the following: + +| Dependency | Minimum Version | +|------------|-----------------| +| Python | 3.6 | +| PostgreSQL | 9.6 | +| Redis | 4.0 | ## Install the Latest Code diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index be43ac2a6a..756e320af1 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -4,7 +4,7 @@ Interfaces in NetBox represent network interfaces used to exchange data with con Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces. -Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. Like all virtual interfaces, LAG interfaces cannot be connected physically. +Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) diff --git a/docs/models/ipam/ipaddress.md b/docs/models/ipam/ipaddress.md index 04ac417dbf..1ea6139976 100644 --- a/docs/models/ipam/ipaddress.md +++ b/docs/models/ipam/ipaddress.md @@ -10,6 +10,7 @@ Each IP address can also be assigned an operational status and a functional role * Reserved * Deprecated * DHCP +* SLAAC (IPv6 Stateless Address Autoconfiguration) Roles are used to indicate some special attribute of an IP address; for example, use as a loopback or as the the virtual IP for a VRRP group. (Note that functional roles are conceptual in nature, and thus cannot be customized by the user.) Available roles include: diff --git a/docs/plugins/development.md b/docs/plugins/development.md index b704ad7fc0..f4db3c84d2 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -328,6 +328,9 @@ A `PluginMenuButton` has the following attributes: * `color` - One of the choices provided by `ButtonColorChoices` (optional) * `permissions` - A list of permissions required to display this button (optional) +!!! note + Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. + ## Extending Core Templates Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index cc38fdb5ec..a3d5000949 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -1,7 +1,69 @@ # NetBox v2.9 +## v2.9.3 (2020-09-04) + +### Enhancements + +* [#4977](https://github.com/netbox-community/netbox/issues/4977) - Redirect authenticated users from login view +* [#5048](https://github.com/netbox-community/netbox/issues/5048) - Show the device/VM name when editing a component +* [#5072](https://github.com/netbox-community/netbox/issues/5072) - Add REST API filters for image attachments +* [#5080](https://github.com/netbox-community/netbox/issues/5080) - Add 8P6C, 8P4C, 8P2C port types + +### Bug Fixes + +* [#5046](https://github.com/netbox-community/netbox/issues/5046) - Disabled plugin menu items are no longer clickable +* [#5063](https://github.com/netbox-community/netbox/issues/5063) - Fix "add device" link in rack elevations for opposite side of half-depth devices +* [#5074](https://github.com/netbox-community/netbox/issues/5074) - Fix inclusion of VC member interfaces when viewing VC master +* [#5078](https://github.com/netbox-community/netbox/issues/5078) - Fix assignment of existing IP addresses to interfaces via web UI +* [#5081](https://github.com/netbox-community/netbox/issues/5081) - Fix exception during webhook processing with custom select field +* [#5085](https://github.com/netbox-community/netbox/issues/5085) - Fix ordering by assignment in IP addresses table +* [#5087](https://github.com/netbox-community/netbox/issues/5087) - Restore label field when editing console server ports, power ports, and power outlets +* [#5089](https://github.com/netbox-community/netbox/issues/5089) - Redirect to device view after editing component +* [#5090](https://github.com/netbox-community/netbox/issues/5090) - Fix status display for console/power/interface connections +* [#5091](https://github.com/netbox-community/netbox/issues/5091) - Avoid KeyError when handling invalid table preferences +* [#5095](https://github.com/netbox-community/netbox/issues/5095) - Show assigned prefixes in VLANs list + +--- + +## 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 + +* [#4540](https://github.com/netbox-community/netbox/issues/4540) - Add IP address status type for SLAAC +* [#4814](https://github.com/netbox-community/netbox/issues/4814) - Allow nested LAG interfaces +* [#4991](https://github.com/netbox-community/netbox/issues/4991) - Add Python and NetBox versions to error page +* [#5033](https://github.com/netbox-community/netbox/issues/5033) - Support backward compatibility for `REMOTE_AUTH_BACKEND` configuration parameter + +--- + ## v2.9.0 (2020-08-21) +**Note:** Redis 4.0 or later is required for this release. + ### New Features #### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554)) @@ -56,7 +118,8 @@ Two new REST API endpoints have been added to facilitate the retrieval and manip ### Configuration Changes -* If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.) +* If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved. +* If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.) * `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`. ### REST API Changes diff --git a/mkdocs.yml b/mkdocs.yml index bd8fc780d4..b3f8ae1bcb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -75,6 +75,7 @@ nav: - User Preferences: 'development/user-preferences.md' - Release Checklist: 'development/release-checklist.md' - Release Notes: + - Version 2.9: 'release-notes/version-2.9.md' - Version 2.8: 'release-notes/version-2.8.md' - Version 2.7: 'release-notes/version-2.7.md' - Version 2.6: 'release-notes/version-2.6.md' diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index dc12e686ec..fa4f817929 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -814,6 +814,9 @@ class InterfaceModeChoices(ChoiceSet): class PortTypeChoices(ChoiceSet): TYPE_8P8C = '8p8c' + TYPE_8P6C = '8p6c' + TYPE_8P4C = '8p4c' + TYPE_8P2C = '8p2c' TYPE_110_PUNCH = '110-punch' TYPE_BNC = 'bnc' TYPE_MRJ21 = 'mrj21' @@ -833,6 +836,9 @@ class PortTypeChoices(ChoiceSet): 'Copper', ( (TYPE_8P8C, '8P8C'), + (TYPE_8P6C, '8P6C'), + (TYPE_8P4C, '8P4C'), + (TYPE_8P2C, '8P2C'), (TYPE_110_PUNCH, '110 Punch'), (TYPE_BNC, 'BNC'), (TYPE_MRJ21, 'MRJ21'), diff --git a/netbox/dcim/elevations.py b/netbox/dcim/elevations.py index cef95a7b6e..93c44f087e 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) @@ -141,7 +149,7 @@ class RackElevationSVG: unit_cursor = 0 for u in elevation: o = other[unit_cursor] - if not u['device'] and o['device']: + if not u['device'] and o['device'] and o['device'].device_type.is_full_depth: u['device'] = o['device'] u['height'] = 1 unit_cursor += u.get('height', 1) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6dd5cb6bf3..43f77de515 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 @@ -2317,7 +2317,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPort fields = [ - 'device', 'name', 'type', 'description', 'tags', + 'device', 'name', 'label', 'type', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2390,7 +2390,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPort fields = [ - 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags', + 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2479,7 +2479,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutlet fields = [ - 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags', + 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2686,7 +2686,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): device_query = Q(device=device) if device.virtual_chassis: device_query |= Q(device__virtual_chassis=device.virtual_chassis) - self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG) + self.fields['lag'].queryset = Interface.objects.filter( + device_query, + type=InterfaceTypeChoices.TYPE_LAG + ).exclude(pk=self.instance.pk) # Add current site to VLANs query params self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) @@ -2876,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 0000000000..594f6b9a43 --- /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/device_components.py b/netbox/dcim/models/device_components.py index 9bd7cdc8b7..4d79f34346 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -702,18 +702,12 @@ class Interface(CableTermination, ComponentModel, BaseInterface): }) # A virtual interface cannot have a parent LAG - if self.type in NONCONNECTABLE_IFACE_TYPES and self.lag is not None: - raise ValidationError({ - 'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_type_display()) - }) + if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None: + raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."}) - # Only a LAG can have LAG members - if self.type != InterfaceTypeChoices.TYPE_LAG and self.member_interfaces.exists(): - raise ValidationError({ - 'type': "Cannot change interface type; it has LAG members ({}).".format( - ", ".join([iface.name for iface in self.member_interfaces.all()]) - ) - }) + # A LAG interface cannot be its own parent + if self.pk and self.lag_id == self.pk: + raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]: diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 8bb56101e6..c152e7c04d 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 3169272b48..6c5ab08b99 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 e48eaedba2..371eff9db9 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -152,6 +152,10 @@ INTERFACE_TAGGED_VLANS = """ {% endfor %} """ +CONNECTION_STATUS = """ +{{ record.get_connection_status_display }} +""" + # # Regions @@ -706,34 +710,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 +771,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 +788,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 +815,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 +830,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') @@ -876,15 +912,20 @@ class ConsoleConnectionTable(BaseTable): verbose_name='Console Server' ) connected_endpoint = tables.Column( + linkify=True, verbose_name='Port' ) device = tables.Column( linkify=True ) name = tables.Column( + linkify=True, verbose_name='Console Port' ) - connection_status = BooleanColumn() + connection_status = tables.TemplateColumn( + template_code=CONNECTION_STATUS, + verbose_name='Status' + ) class Meta(BaseTable.Meta): model = ConsolePort @@ -901,14 +942,20 @@ class PowerConnectionTable(BaseTable): ) outlet = tables.Column( accessor=Accessor('_connected_poweroutlet'), + linkify=True, verbose_name='Outlet' ) device = tables.Column( linkify=True ) name = tables.Column( + linkify=True, verbose_name='Power Port' ) + connection_status = tables.TemplateColumn( + template_code=CONNECTION_STATUS, + verbose_name='Status' + ) class Meta(BaseTable.Meta): model = PowerPort @@ -940,6 +987,10 @@ class InterfaceConnectionTable(BaseTable): args=[Accessor('_connected_interface__pk')], verbose_name='Interface B' ) + connection_status = tables.TemplateColumn( + template_code=CONNECTION_STATUS, + verbose_name='Status' + ) class Meta(BaseTable.Meta): model = Interface diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c3cfa105fa..f9a04aede9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1030,7 +1030,7 @@ class DeviceView(ObjectView): ) # Interfaces - interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related( + interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related( Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)), Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)), 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable', @@ -1228,6 +1228,7 @@ class ConsolePortCreateView(ComponentCreateView): class ConsolePortEditView(ObjectEditView): queryset = ConsolePort.objects.all() model_form = forms.ConsolePortForm + template_name = 'dcim/device_component_edit.html' class ConsolePortDeleteView(ObjectDeleteView): @@ -1287,6 +1288,7 @@ class ConsoleServerPortCreateView(ComponentCreateView): class ConsoleServerPortEditView(ObjectEditView): queryset = ConsoleServerPort.objects.all() model_form = forms.ConsoleServerPortForm + template_name = 'dcim/device_component_edit.html' class ConsoleServerPortDeleteView(ObjectDeleteView): @@ -1346,6 +1348,7 @@ class PowerPortCreateView(ComponentCreateView): class PowerPortEditView(ObjectEditView): queryset = PowerPort.objects.all() model_form = forms.PowerPortForm + template_name = 'dcim/device_component_edit.html' class PowerPortDeleteView(ObjectDeleteView): @@ -1405,6 +1408,7 @@ class PowerOutletCreateView(ComponentCreateView): class PowerOutletEditView(ObjectEditView): queryset = PowerOutlet.objects.all() model_form = forms.PowerOutletForm + template_name = 'dcim/device_component_edit.html' class PowerOutletDeleteView(ObjectDeleteView): @@ -1556,6 +1560,7 @@ class FrontPortCreateView(ComponentCreateView): class FrontPortEditView(ObjectEditView): queryset = FrontPort.objects.all() model_form = forms.FrontPortForm + template_name = 'dcim/device_component_edit.html' class FrontPortDeleteView(ObjectDeleteView): @@ -1615,6 +1620,7 @@ class RearPortCreateView(ComponentCreateView): class RearPortEditView(ObjectEditView): queryset = RearPort.objects.all() model_form = forms.RearPortForm + template_name = 'dcim/device_component_edit.html' class RearPortDeleteView(ObjectDeleteView): @@ -1674,6 +1680,7 @@ class DeviceBayCreateView(ComponentCreateView): class DeviceBayEditView(ObjectEditView): queryset = DeviceBay.objects.all() model_form = forms.DeviceBayForm + template_name = 'dcim/device_component_edit.html' class DeviceBayDeleteView(ObjectDeleteView): diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 5ef983977d..f096fb4a6d 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -158,7 +158,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): instance.custom_fields = {} for field in custom_fields: value = instance.cf.get(field.name) - if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None: + if field.type == CustomFieldTypeChoices.TYPE_SELECT and value: instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data else: instance.custom_fields[field.name] = value diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 5fa26a0d71..5a7cfa482d 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -129,6 +129,7 @@ class ImageAttachmentViewSet(ModelViewSet): metadata_class = ContentTypeMetadata queryset = ImageAttachment.objects.all() serializer_class = serializers.ImageAttachmentSerializer + filterset_class = filters.ImageAttachmentFilterSet # diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 73811c0635..b37701d9d7 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -7,7 +7,7 @@ from tenancy.models import Tenant, TenantGroup from utilities.filters import BaseFilterSet from virtualization.models import Cluster, ClusterGroup from .choices import * -from .models import ConfigContext, CustomField, ExportTemplate, ObjectChange, JobResult, Tag +from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag __all__ = ( @@ -16,6 +16,7 @@ __all__ = ( 'CustomFieldFilter', 'CustomFieldFilterSet', 'ExportTemplateFilterSet', + 'ImageAttachmentFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', 'TagFilterSet', @@ -96,6 +97,13 @@ class ExportTemplateFilterSet(BaseFilterSet): fields = ['id', 'content_type', 'name'] +class ImageAttachmentFilterSet(BaseFilterSet): + + class Meta: + model = ImageAttachment + fields = ['id', 'content_type', 'object_id', 'name'] + + class TagFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index cc702f07bf..e96293b203 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -1,9 +1,9 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from dcim.models import DeviceRole, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Rack, Region, Site from extras.filters import * -from extras.models import ConfigContext, ExportTemplate, Tag +from extras.models import ConfigContext, ExportTemplate, ImageAttachment, Tag from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -37,6 +37,84 @@ class ExportTemplateTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) +class ImageAttachmentTestCase(TestCase): + queryset = ImageAttachment.objects.all() + filterset = ImageAttachmentFilterSet + + @classmethod + def setUpTestData(cls): + + site_ct = ContentType.objects.get(app_label='dcim', model='site') + rack_ct = ContentType.objects.get(app_label='dcim', model='rack') + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + racks = ( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[1]), + ) + Rack.objects.bulk_create(racks) + + image_attachments = ( + ImageAttachment( + content_type=site_ct, + object_id=sites[0].pk, + name='Image Attachment 1', + image='http://example.com/image1.png', + image_height=100, + image_width=100 + ), + ImageAttachment( + content_type=site_ct, + object_id=sites[1].pk, + name='Image Attachment 2', + image='http://example.com/image2.png', + image_height=100, + image_width=100 + ), + ImageAttachment( + content_type=rack_ct, + object_id=racks[0].pk, + name='Image Attachment 3', + image='http://example.com/image3.png', + image_height=100, + image_width=100 + ), + ImageAttachment( + content_type=rack_ct, + object_id=racks[1].pk, + name='Image Attachment 4', + image='http://example.com/image4.png', + image_height=100, + image_width=100 + ) + ) + ImageAttachment.objects.bulk_create(image_attachments) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Image Attachment 1', 'Image Attachment 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_type(self): + params = {'content_type': ContentType.objects.get(app_label='dcim', model='site').pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_type_and_object_id(self): + params = { + 'content_type': ContentType.objects.get(app_label='dcim', model='site').pk, + 'object_id': [Site.objects.first().pk], + } + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + class ConfigContextTestCase(TestCase): queryset = ConfigContext.objects.all() filterset = ConfigContextFilterSet diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 0d273e4d85..dd0731bb8f 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/choices.py b/netbox/ipam/choices.py index 68fdfd9dfb..f3ff19ddc2 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -41,12 +41,14 @@ class IPAddressStatusChoices(ChoiceSet): STATUS_RESERVED = 'reserved' STATUS_DEPRECATED = 'deprecated' STATUS_DHCP = 'dhcp' + STATUS_SLAAC = 'slaac' CHOICES = ( (STATUS_ACTIVE, 'Active'), (STATUS_RESERVED, 'Reserved'), (STATUS_DEPRECATED, 'Deprecated'), (STATUS_DHCP, 'DHCP'), + (STATUS_SLAAC, 'SLAAC'), ) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 58dd960896..832e09330e 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 5a4e2c1331..d7a64f7db9 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -67,11 +67,7 @@ IPADDRESS_LINK = """ """ IPADDRESS_ASSIGN_LINK = """ -{% if request.GET %} - {{ record }} -{% else %} - {{ record }} -{% endif %} +{{ record }} """ VRF_LINK = """ @@ -103,7 +99,7 @@ VLAN_LINK = """ """ VLAN_PREFIXES = """ -{% for prefix in record.prefixes.unrestricted %} +{% for prefix in record.prefixes.all %} {{ prefix }}{% if not forloop.last %}
{% endif %} {% empty %} — @@ -387,15 +383,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 '', @@ -411,6 +415,10 @@ class IPAddressDetailTable(IPAddressTable): tenant = tables.TemplateColumn( template_code=COL_TENANT ) + assigned = tables.BooleanColumn( + accessor='assigned_object_id', + verbose_name='Assigned' + ) tags = TagColumn( url_name='ipam:ipaddress_list' ) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 8ea33764c6..1f0e2607ed 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 @@ -582,7 +582,7 @@ class IPAddressAssignView(ObjectView): def dispatch(self, request, *args, **kwargs): # Redirect user if an interface has not been provided - if 'interface' not in request.GET: + if 'interface' not in request.GET and 'vminterface' not in request.GET: return redirect('ipam:ipaddress_add') return super().dispatch(request, *args, **kwargs) @@ -609,7 +609,7 @@ class IPAddressAssignView(ObjectView): return render(request, 'ipam/ipaddress_assign.html', { 'form': form, 'table': table, - 'return_url': request.GET.get('return_url', ''), + 'return_url': request.GET.get('return_url'), }) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index d49e41b768..bd247070fa 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-dev' +VERSION = '2.9.4-dev' # Hostname HOSTNAME = platform.node() @@ -132,7 +132,6 @@ if RELEASE_CHECK_URL: if RELEASE_CHECK_TIMEOUT < 3600: raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)") - # # Database # diff --git a/netbox/templates/500.html b/netbox/templates/500.html index bd59b72332..61115cbab3 100644 --- a/netbox/templates/500.html +++ b/netbox/templates/500.html @@ -31,7 +31,10 @@ The complete exception is provided below:

{{ exception }}
-{{ error }}
+{{ error }} + +Python version: {{ python_version }} +NetBox version: {{ netbox_version }}

If further assistance is required, please post to the NetBox mailing list.

diff --git a/netbox/templates/dcim/device_component_edit.html b/netbox/templates/dcim/device_component_edit.html new file mode 100644 index 0000000000..e0f1a23264 --- /dev/null +++ b/netbox/templates/dcim/device_component_edit.html @@ -0,0 +1,16 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form_fields %} + {% if form.instance.device %} +
+ + +
+ {% endif %} + {% render_form form %} +{% endblock %} diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index dc2111b8ab..6fa5e8b912 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -66,7 +66,7 @@ {% endif %} {% if perms.dcim.change_consoleport %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index dcf168ae77..fca1fa5f46 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -68,7 +68,7 @@ {% endif %} {% if perms.dcim.change_consoleserverport %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/devicebay.html b/netbox/templates/dcim/inc/devicebay.html index ee6a66d8fd..bde7b8641d 100644 --- a/netbox/templates/dcim/inc/devicebay.html +++ b/netbox/templates/dcim/inc/devicebay.html @@ -52,7 +52,7 @@ {% endif %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index d9a77d6474..5800f4b48b 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -81,7 +81,7 @@ {% endif %} {% if perms.dcim.change_poweroutlet %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index 58eed145a5..b30fc8456f 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -78,7 +78,7 @@ {% endif %} {% if perms.dcim.change_powerport %} - + {% endif %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index eaffe2bcaf..7a5c999053 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -5,6 +5,16 @@
Interface
+ {% if form.instance.device %} +
+ + +
+ {% endif %} {% render_field form.name %} {% render_field form.label %} {% render_field form.type %} @@ -14,6 +24,11 @@ {% render_field form.mtu %} {% render_field form.mgmt_only %} {% render_field form.description %} +
+
+
+
802.1Q Switching
+
{% render_field form.mode %} {% render_field form.untagged_vlan %} {% render_field form.tagged_vlans %} diff --git a/netbox/templates/inc/plugin_menu_items.html b/netbox/templates/inc/plugin_menu_items.html index 0df4a5e8a5..3d9a46a528 100644 --- a/netbox/templates/inc/plugin_menu_items.html +++ b/netbox/templates/inc/plugin_menu_items.html @@ -5,18 +5,22 @@ {% for section_name, menu_items in registry.plugin_menu_items.items %} {% for menu_item in menu_items %} - - {% if menu_item.buttons %} -
- {% for button in menu_item.buttons %} - {% if not button.permissions or request.user|has_perms:button.permissions %} - - {% endif %} - {% endfor %} -
- {% endif %} - {{ menu_item.link_text }} - + {% if not menu_item.permissions or request.user|has_perms:menu_item.permissions %} +
  • + {% if menu_item.buttons %} +
    + {% for button in menu_item.buttons %} + {% if not button.permissions or request.user|has_perms:button.permissions %} + + {% endif %} + {% endfor %} +
    + {% endif %} + {{ menu_item.link_text }} +
  • + {% else %} +
  • {{ menu_item.link_text }}
  • + {% endif %} {% endfor %} {% if not forloop.last %}
  • diff --git a/netbox/templates/ipam/inc/ipadress_edit_header.html b/netbox/templates/ipam/inc/ipadress_edit_header.html index b8ec3878a1..ed9692eea3 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/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html index 5230b25947..0bd051161c 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/netbox/templates/utilities/obj_edit.html @@ -31,7 +31,9 @@
    {{ obj_type|capfirst }}
    - {% render_form form %} + {% block form_fields %} + {% render_form form %} + {% endblock %}
    {% endblock %} diff --git a/netbox/templates/virtualization/inc/vminterface.html b/netbox/templates/virtualization/inc/vminterface.html index 0f672b7292..93efafb5ab 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 %} @@ -43,12 +43,12 @@ {% endif %} - {% if perms.virtualization.change_interface %} + {% if perms.virtualization.change_vminterface %} {% endif %} - {% if perms.virtualization.delete_interface %} + {% if perms.virtualization.delete_vminterface %} @@ -60,7 +60,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/templates/virtualization/virtualmachine_component_add.html b/netbox/templates/virtualization/virtualmachine_component_add.html index aafefffa15..11b120ee0b 100644 --- a/netbox/templates/virtualization/virtualmachine_component_add.html +++ b/netbox/templates/virtualization/virtualmachine_component_add.html @@ -2,7 +2,7 @@ {% load helpers %} {% load form_helpers %} -{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %} +{% block title %}Create {{ component_type }}{% endblock %} {% block content %}
    diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index 6b0313284a..12018ba5d6 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -5,14 +5,34 @@
    Interface
    + {% if form.instance.virtual_machine %} +
    + + +
    + {% endif %} {% render_field form.name %} {% render_field form.enabled %} {% render_field form.mac_address %} {% render_field form.mtu %} {% render_field form.description %} +
    +
    +
    +
    802.1Q Switching
    +
    {% render_field form.mode %} {% render_field form.untagged_vlan %} {% render_field form.tagged_vlans %} +
    +
    +
    +
    Tags
    +
    {% render_field form.tags %}
    diff --git a/netbox/users/views.py b/netbox/users/views.py index 7552324440..46221f6499 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -38,6 +38,10 @@ class LoginView(View): def get(self, request): form = LoginForm(request) + if request.user.is_authenticated: + logger = logging.getLogger('netbox.auth.login') + return self.redirect_to_next(request, logger) + return render(request, self.template_name, { 'form': form, }) @@ -49,12 +53,6 @@ class LoginView(View): if form.is_valid(): logger.debug("Login form validation was successful") - # Determine where to direct user after successful login - redirect_to = request.POST.get('next', reverse('home')) - if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): - logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}") - redirect_to = reverse('home') - # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's # last_login time upon authentication. if settings.MAINTENANCE_MODE: @@ -66,8 +64,7 @@ class LoginView(View): logger.info(f"User {request.user} successfully authenticated") messages.info(request, "Logged in as {}.".format(request.user)) - logger.debug(f"Redirecting user to {redirect_to}") - return HttpResponseRedirect(redirect_to) + return self.redirect_to_next(request, logger) else: logger.debug("Login form validation failed") @@ -76,6 +73,19 @@ class LoginView(View): 'form': form, }) + def redirect_to_next(self, request, logger): + if request.method == "POST": + redirect_to = request.POST.get('next', reverse('home')) + else: + redirect_to = request.GET.get('next', reverse('home')) + + if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): + logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}") + redirect_to = reverse('home') + + logger.debug(f"Redirecting user to {redirect_to}") + return HttpResponseRedirect(redirect_to) + class LogoutView(View): """ diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 0144ea2d1d..6df6b2e269 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -44,7 +44,7 @@ class BaseTable(tables.Table): self.columns.show(name) else: self.columns.hide(name) - self.sequence = columns + self.sequence = [c for c in columns if c in self.base_columns] # Always include PK and actions column, if defined on the table if pk: diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index c7db2f6494..0790686487 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,8 +1,10 @@ import logging +import platform import re import sys from copy import deepcopy +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.contenttypes.models import ContentType @@ -1421,6 +1423,8 @@ def server_error(request, template_name=ERROR_500_TEMPLATE_NAME): type_, error, traceback = sys.exc_info() return HttpResponseServerError(template.render({ + 'python_version': platform.python_version(), + 'netbox_version': settings.VERSION, 'exception': str(type_), 'error': error, })) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index a64a0a7d87..5d002deccf 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 fb61c5b9ee..0845fac7ed 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 039934d703..5f5b9326de 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')