diff --git a/docs/additional-features/custom-fields.md b/docs/additional-features/custom-fields.md index 5c74c6744..618d07052 100644 --- a/docs/additional-features/custom-fields.md +++ b/docs/additional-features/custom-fields.md @@ -38,9 +38,13 @@ NetBox supports limited custom validation for custom field values. Following are Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. -If a default value is specified for a selection field, it must exactly match one of the provided choices. +If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. -The value of a multiple selection field will always return a list, even if only one value is selected. +## Custom Fields in Templates + +Several features within NetBox, such as export templates and webhooks, utilize Jinja2 templating. For convenience, objects which support custom field assignment expose custom field data through the `cf` property. This is a bit cleaner than accessing custom field data through the actual field (`custom_field_data`). + +For example, a custom field named `foo123` on the Site model is accessible on an instance as `{{ site.cf.foo123 }}`. ## Custom Fields and the REST API diff --git a/docs/additional-features/napalm.md b/docs/additional-features/napalm.md index d2d69fc3c..957a5a214 100644 --- a/docs/additional-features/napalm.md +++ b/docs/additional-features/napalm.md @@ -2,6 +2,13 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to serve a proxy for operational data, fetching live data from network devices and returning it to a requester via its REST API. Note that NetBox does not store any NAPALM data locally. +The NetBox UI will display tabs for status, LLDP neighbors, and configuration under the device view if the following conditions are met: + +* Device status is "Active" +* A primary IP has been assigned to the device +* A platform with a NAPALM driver has been assigned +* The authenticated user has the `dcim.napalm_read_device` permission + !!! note To enable this integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm) for more information. diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 7031f7fb8..95f8bd8ab 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -1,5 +1,21 @@ # NetBox v2.10 +## v2.10.9 (FUTURE) + +### Enhancements + +* [#5526](https://github.com/netbox-community/netbox/issues/5526) - Add MAC address search field to VM interfaces list +* [#5756](https://github.com/netbox-community/netbox/issues/5756) - Omit child devices from non-racked devices list under rack view +* [#5840](https://github.com/netbox-community/netbox/issues/5840) - Add column to cable termination objects to display cable color +* [#6054](https://github.com/netbox-community/netbox/issues/6054) - Display NAPALM-enabled device tabs only when relevant + +### Bug Fixes + +* [#5805](https://github.com/netbox-community/netbox/issues/5805) - Fix missing custom field filters for cables, rack reservations +* [#6073](https://github.com/netbox-community/netbox/issues/6073) - Permit users to manage their own REST API tokens without needing explicit permission + +--- + ## v2.10.8 (2021-03-26) ### Bug Fixes diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 667605b5c..e8b7f6de4 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1054,7 +1054,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField nullable_fields = [] -class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): +class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = RackReservation field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( @@ -4319,7 +4319,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFo }) -class CableFilterForm(BootstrapMixin, forms.Form): +class CableFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Cable q = forms.CharField( required=False, diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index ed29c72e7..122eb7489 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -244,6 +244,11 @@ class CableTerminationTable(BaseTable): cable = tables.Column( linkify=True ) + cable_color = ColorColumn( + accessor='cable.color', + orderable=False, + verbose_name='Cable Color' + ) cable_peer = tables.TemplateColumn( accessor='_cable_peer', template_code=CABLETERMINATION, @@ -276,8 +281,8 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer', - 'connection', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description') @@ -296,8 +301,8 @@ class DeviceConsolePortTable(ConsolePortTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer', - 'connection', 'tags', 'actions' + 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'connection', 'tags', 'actions' ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { @@ -319,8 +324,8 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer', - 'connection', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description') @@ -340,8 +345,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer', - 'connection', 'tags', 'actions', + 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'connection', 'tags', 'actions', ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { @@ -364,7 +369,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable): model = PowerPort fields = ( 'pk', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', - 'cable', 'cable_peer', 'connection', 'tags', + 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -385,7 +390,7 @@ class DevicePowerPortTable(PowerPortTable): model = PowerPort fields = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', - 'cable_peer', 'connection', 'tags', 'actions', + 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', @@ -414,7 +419,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): model = PowerOutlet fields = ( 'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', - 'cable_peer', 'connection', 'tags', + 'cable_color', 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -434,7 +439,7 @@ class DevicePowerOutletTable(PowerOutletTable): model = PowerOutlet fields = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', - 'cable_peer', 'connection', 'tags', 'actions', + 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', @@ -475,7 +480,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', + 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') @@ -506,7 +511,7 @@ class DeviceInterfaceTable(InterfaceTable): model = Interface fields = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', + 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ( @@ -540,7 +545,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable): model = FrontPort fields = ( 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', - 'cable', 'cable_peer', 'tags', + 'cable', 'cable_color', 'cable_peer', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') @@ -561,7 +566,7 @@ class DeviceFrontPortTable(FrontPortTable): model = FrontPort fields = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', - 'cable_peer', 'tags', 'actions', + 'cable_color', 'cable_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer', @@ -587,7 +592,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable): model = RearPort fields = ( 'pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', - 'cable_peer', 'tags', + 'cable_color', 'cable_peer', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') @@ -607,8 +612,8 @@ class DeviceRearPortTable(RearPortTable): class Meta(DeviceComponentTable.Meta): model = RearPort fields = ( - 'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_peer', 'tags', - 'actions', + 'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', + 'cable_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index ce69b0ede..9f71e24fc 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -1,10 +1,8 @@ import django_tables2 as tables -from django_tables2.utils import Accessor from dcim.models import PowerFeed, PowerPanel from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn from .devices import CableTerminationTable -from .template_code import POWERFEED_CABLE, POWERFEED_CABLETERMINATION __all__ = ( 'PowerFeedTable', @@ -68,7 +66,8 @@ class PowerFeedTable(CableTerminationTable): model = PowerFeed fields = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'mark_connected', 'cable', 'cable_peer', 'connection', 'available_power', 'tags', + 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power', + 'tags', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a51fb9deb..8d253f825 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -502,10 +502,11 @@ class RackView(generic.ObjectView): queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') def get_extra_context(self, request, instance): - # Get 0U and child devices located within the rack + # Get 0U devices located within the rack nonracked_devices = Device.objects.filter( rack=instance, - position__isnull=True + position__isnull=True, + parent_bay__isnull=True ).prefetch_related('device_type__manufacturer') peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 27cc3e992..3ffef01ac 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -131,16 +131,17 @@ {% endif %} {% endwith %} - {% if perms.dcim.napalm_read_device %} - {% if object.status != 'active' %} - {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %} - {% elif not object.platform %} - {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %} - {% elif not object.platform.napalm_driver %} - {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %} - {% else %} - {% include 'dcim/inc/device_napalm_tabs.html' %} - {% endif %} + {% if perms.dcim.napalm_read_device and object.status == 'active' and object.primary_ip and object.platform.napalm_driver %} + {# NAPALM-enabled tabs #} + + + {% endif %} {% if perms.extras.view_configcontext %} - - -{% else %} - - - -{% endif %} diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 04e7cb23d..f14773293 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -11,12 +11,8 @@
Copy - {% if perms.users.change_token %} - Edit - {% endif %} - {% if perms.users.delete_token %} - Delete - {% endif %} + Edit + Delete
{{ token.key }} @@ -55,16 +51,10 @@ {% empty %}

You do not have any API tokens.

{% endfor %} - {% if perms.users.add_token %} - - - Add a token - - {% else %} - - {% endif %} + + + Add a token +
{% endblock %} diff --git a/netbox/users/views.py b/netbox/users/views.py index a6d28ecd2..cf7ed6430 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -6,7 +6,7 @@ from django.contrib.auth import login as auth_login, logout as auth_logout, upda from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import update_last_login from django.contrib.auth.signals import user_logged_in -from django.http import HttpResponseForbidden, HttpResponseRedirect +from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.decorators import method_decorator @@ -282,13 +282,9 @@ class TokenEditView(LoginRequiredMixin, View): def get(self, request, pk=None): - if pk is not None: - if not request.user.has_perm('users.change_token'): - return HttpResponseForbidden() + if pk: token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) else: - if not request.user.has_perm('users.add_token'): - return HttpResponseForbidden() token = Token(user=request.user) form = TokenForm(instance=token) @@ -302,11 +298,11 @@ class TokenEditView(LoginRequiredMixin, View): def post(self, request, pk=None): - if pk is not None: + if pk: token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) form = TokenForm(request.POST, instance=token) else: - token = Token() + token = Token(user=request.user) form = TokenForm(request.POST) if form.is_valid(): @@ -314,7 +310,7 @@ class TokenEditView(LoginRequiredMixin, View): token.user = request.user token.save() - msg = "Modified token {}".format(token) if pk else "Created token {}".format(token) + msg = f"Modified token {token}" if pk else f"Created token {token}" messages.success(request, msg) if '_addanother' in request.POST: diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 2b39e75d3..014b73542 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -800,6 +800,10 @@ class VMInterfaceFilterForm(forms.Form): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + mac_address = forms.CharField( + required=False, + label='MAC address' + ) tag = TagFilterField(model)