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

Merge branch 'develop' into feature

Jeremy Stretch 4 лет назад
Родитель
Сommit
83c4577f6d

+ 6 - 2
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
 

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

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

+ 2 - 2
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,

+ 24 - 19
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',

+ 2 - 3
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',

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

+ 11 - 10
netbox/templates/dcim/device/base.html

@@ -131,16 +131,17 @@
                 </li>
             {% 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 #}
+            <li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:device_status' pk=object.pk %}">Status</a>
+            </li>
+            <li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:device_lldp_neighbors' pk=object.pk %}">LLDP Neighbors</a>
+            </li>
+            <li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:device_config' pk=object.pk %}">Configuration</a>
+            </li>
         {% endif %}
         {% if perms.extras.view_configcontext %}
             <li role="presentation"{% if active_tab == 'config-context' %} class="active"{% endif %}>

+ 0 - 15
netbox/templates/dcim/inc/device_napalm_tabs.html

@@ -1,15 +0,0 @@
-{% if not disabled_message %}
-    <li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
-        <a href="{% url 'dcim:device_status' pk=object.pk %}">Status</a>
-    </li>
-    <li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
-        <a href="{% url 'dcim:device_lldp_neighbors' pk=object.pk %}">LLDP Neighbors</a>
-    </li>
-    <li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
-        <a href="{% url 'dcim:device_config' pk=object.pk %}">Configuration</a>
-    </li>
-{% else %}
-    <li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Status</a></li>
-    <li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">LLDP Neighbors</a></li>
-    <li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Configuration</a></li>
-{% endif %}

+ 6 - 16
netbox/templates/users/api_tokens.html

@@ -11,12 +11,8 @@
                     <div class="panel-heading">
                         <div class="pull-right noprint">
                             <a class="btn btn-xs btn-success copy-token" data-clipboard-target="#token_{{ token.pk }}">Copy</a>
-                            {% if perms.users.change_token %}
-                                <a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
-                            {% endif %}
-                            {% if perms.users.delete_token %}
-                                <a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
-                            {% endif %}
+                            <a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
+                            <a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
                         </div>
                         <i class="mdi mdi-key"></i>
                         <samp><span id="token_{{ token.pk }}">{{ token.key }}</span></samp>
@@ -55,16 +51,10 @@
             {% empty %}
                 <p>You do not have any API tokens.</p>
             {% endfor %}
-            {% if perms.users.add_token %}
-                <a href="{% url 'user:token_add' %}" class="btn btn-primary">
-                    <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
-                    Add a token
-                </a>
-            {% else %}
-                <div class="alert alert-info text-center" role="alert">
-                    You do not have permission to create new API tokens. If needed, ask an administrator to enable token creation for your account or an assigned group.
-                </div>
-            {% endif %}
+            <a href="{% url 'user:token_add' %}" class="btn btn-primary">
+                <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
+                Add a token
+            </a>
         </div>
     </div>
 {% endblock %}

+ 5 - 9
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:

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