Merge pull request #6143 from netbox-community/develop

Release v2.10.9
This commit is contained in:
Jeremy Stretch 2021-04-12 13:28:28 -04:00 committed by GitHub
commit b493d739bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 230 additions and 169 deletions

View File

@ -39,6 +39,12 @@ Each custom selection field must have at least two choices. These are specified
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.
## 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 ## Custom Fields and the REST API
When retrieving an object via the REST API, all of its custom data will be included within the `custom_fields` attribute. For example, below is the partial output of a site with two custom fields defined: When retrieving an object via the REST API, all of its custom data will be included within the `custom_fields` attribute. For example, below is the partial output of a site with two custom fields defined:

View File

@ -18,6 +18,14 @@ Height: {{ rack.u_height }}U
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example:
```
{% for server in queryset %}
{% set data = server.get_config_context() %}
{{ data.syslog }}
{% endfor %}
```
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
## Example ## Example

View File

@ -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. 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 !!! note
To enable this integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm) for more information. To enable this integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm) for more information.

View File

@ -10,7 +10,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replace's
| ----------- | ----------- | | ----------- | ----------- |
| `{"status": "active"}` | Status is active | | `{"status": "active"}` | Status is active |
| `{"status__in": ["planned", "reserved"]}` | Status is active **OR** reserved | | `{"status__in": ["planned", "reserved"]}` | Status is active **OR** reserved |
| `{"status": "active", "role": "testing"}` | Status is active **OR** role is testing | | `{"status": "active", "role": "testing"}` | Status is active **AND** role is testing |
| `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) | | `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) |
| `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) | | `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) |
| `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 | | `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 |

View File

@ -66,6 +66,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
* `PASSWORD` - Redis password (if set) * `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID * `DATABASE` - Numeric database ID
* `SSL` - Use SSL connection to Redis * `SSL` - Use SSL connection to Redis
* `INSECURE_SKIP_TLS_VERIFY` - Set to `True` to **disable** TLS certificate verification (not recommended)
An example configuration is provided below: An example configuration is provided below:

View File

@ -1,5 +1,28 @@
# NetBox v2.10 # NetBox v2.10
## v2.10.9 (2021-04-12)
### 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
* [#6083](https://github.com/netbox-community/netbox/issues/6083) - Support disabling TLS certificate validation for Redis
### Bug Fixes
* [#5805](https://github.com/netbox-community/netbox/issues/5805) - Fix missing custom field filters for cables, rack reservations
* [#6070](https://github.com/netbox-community/netbox/issues/6070) - Add missing `count_ipaddresses` attribute to VMInterface serializer
* [#6073](https://github.com/netbox-community/netbox/issues/6073) - Permit users to manage their own REST API tokens without needing explicit permission
* [#6081](https://github.com/netbox-community/netbox/issues/6081) - Fix interface connections REST API endpoint
* [#6082](https://github.com/netbox-community/netbox/issues/6082) - Support colons in webhook header values
* [#6108](https://github.com/netbox-community/netbox/issues/6108) - Do not infer tenant assignment from parent objects for prefixes, IP addresses
* [#6117](https://github.com/netbox-community/netbox/issues/6117) - Handle exception when attempting to assign an MPTT-enabled model as its own parent
* [#6131](https://github.com/netbox-community/netbox/issues/6131) - Correct handling of boolean fields when cloning objects
---
## v2.10.8 (2021-03-26) ## v2.10.8 (2021-03-26)
### Bug Fixes ### Bug Fixes

View File

@ -779,7 +779,7 @@ class CablePathSerializer(serializers.ModelSerializer):
class InterfaceConnectionSerializer(ValidatedModelSerializer): class InterfaceConnectionSerializer(ValidatedModelSerializer):
interface_a = serializers.SerializerMethodField() interface_a = serializers.SerializerMethodField()
interface_b = NestedInterfaceSerializer(source='connected_endpoint') interface_b = NestedInterfaceSerializer(source='_path.destination')
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True) connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:

View File

@ -2,6 +2,7 @@ import socket
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import F from django.db.models import F
from django.http import HttpResponseForbidden, HttpResponse from django.http import HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@ -580,6 +581,8 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = Interface.objects.prefetch_related('device', '_path').filter( queryset = Interface.objects.prefetch_related('device', '_path').filter(
# Avoid duplicate connections by only selecting the lower PK in a connected pair # Avoid duplicate connections by only selecting the lower PK in a connected pair
_path__destination_type__app_label='dcim',
_path__destination_type__model='interface',
_path__destination_id__isnull=False, _path__destination_id__isnull=False,
pk__lt=F('_path__destination_id') pk__lt=F('_path__destination_id')
) )

View File

@ -868,7 +868,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
nullable_fields = [] nullable_fields = []
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = RackReservation model = RackReservation
field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant'] field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant']
q = forms.CharField( q = forms.CharField(
@ -3966,7 +3966,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFo
}) })
class CableFilterForm(BootstrapMixin, forms.Form): class CableFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Cable model = Cable
q = forms.CharField( q = forms.CharField(
required=False, required=False,

View File

@ -478,6 +478,10 @@ class BaseInterface(models.Model):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@property
def count_ipaddresses(self):
return self.ip_addresses.count()
@extras_features('export_templates', 'webhooks', 'custom_links') @extras_features('export_templates', 'webhooks', 'custom_links')
class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
@ -615,10 +619,6 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
def is_lag(self): def is_lag(self):
return self.type == InterfaceTypeChoices.TYPE_LAG return self.type == InterfaceTypeChoices.TYPE_LAG
@property
def count_ipaddresses(self):
return self.ip_addresses.count()
# #
# Pass-through ports # Pass-through ports

View File

@ -111,6 +111,12 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
def clean(self): def clean(self):
super().clean() super().clean()
# An MPTT model cannot be its own parent
if self.pk and self.parent_id == self.pk:
raise ValidationError({
"parent": "Cannot assign self as parent."
})
# Parent RackGroup (if any) must belong to the same Site # Parent RackGroup (if any) must belong to the same Site
if self.parent and self.parent.site != self.site: if self.parent and self.parent.site != self.site:
raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})") raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")

View File

@ -7,6 +7,7 @@ from timezone_field import TimeZoneField
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from django.core.exceptions import ValidationError
from dcim.fields import ASNField from dcim.fields import ASNField
from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
@ -87,6 +88,15 @@ class Region(MPTTModel, ChangeLoggedModel):
object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
) )
def clean(self):
super().clean()
# An MPTT model cannot be its own parent
if self.pk and self.parent_id == self.pk:
raise ValidationError({
"parent": "Cannot assign self as parent."
})
# #
# Sites # Sites

View File

@ -230,6 +230,11 @@ class CableTerminationTable(BaseTable):
cable = tables.Column( cable = tables.Column(
linkify=True linkify=True
) )
cable_color = ColorColumn(
accessor='cable.color',
orderable=False,
verbose_name='Cable Color'
)
cable_peer = tables.TemplateColumn( cable_peer = tables.TemplateColumn(
accessor='_cable_peer', accessor='_cable_peer',
template_code=CABLETERMINATION, template_code=CABLETERMINATION,
@ -255,7 +260,8 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = ConsolePort model = ConsolePort
fields = ( fields = (
'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_color', 'cable_peer', 'connection',
'tags',
) )
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
@ -274,7 +280,8 @@ class DeviceConsolePortTable(ConsolePortTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = ConsolePort model = ConsolePort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions' 'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags',
'actions'
) )
default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions') default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
row_attrs = { row_attrs = {
@ -289,7 +296,10 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort model = ConsoleServerPort
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags') fields = (
'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_color', 'cable_peer', 'connection',
'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
@ -308,7 +318,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort model = ConsoleServerPort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions' 'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags',
'actions'
) )
default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions') default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
row_attrs = { row_attrs = {
@ -325,7 +336,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
model = PowerPort model = PowerPort
fields = ( fields = (
'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable',
'cable_peer', 'connection', 'tags', 'cable_color', 'cable_peer', 'connection', 'tags',
) )
default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@ -345,8 +356,8 @@ class DevicePowerPortTable(PowerPortTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = PowerPort model = PowerPort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_peer', 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_color',
'connection', 'tags', 'actions', 'cable_peer', 'connection', 'tags', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
@ -368,8 +379,8 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = PowerOutlet model = PowerOutlet
fields = ( fields = (
'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'cable_peer', 'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'cable_color',
'connection', 'tags', 'cable_peer', 'connection', 'tags',
) )
default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
@ -388,8 +399,8 @@ class DevicePowerOutletTable(PowerOutletTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = PowerOutlet model = PowerOutlet
fields = ( fields = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'cable_peer', 'connection', 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'cable_color',
'tags', 'actions', 'cable_peer', 'connection', 'tags', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
@ -424,7 +435,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
model = Interface model = Interface
fields = ( fields = (
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'description', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'description', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan',
'tagged_vlans',
) )
default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
@ -450,7 +462,8 @@ class DeviceInterfaceTable(InterfaceTable):
model = Interface model = Interface
fields = ( fields = (
'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description', 'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description',
'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable', 'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable',
@ -477,7 +490,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
model = FrontPort model = FrontPort
fields = ( fields = (
'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable',
'cable_peer', 'tags', 'cable_color', 'cable_peer', 'tags',
) )
default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
@ -497,8 +510,8 @@ class DeviceFrontPortTable(FrontPortTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = FrontPort model = FrontPort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer', 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_color',
'tags', 'actions', 'cable_peer', 'tags', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer', 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer',
@ -516,7 +529,10 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = RearPort model = RearPort
fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags') fields = (
'pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_color', 'cable_peer',
'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
@ -535,7 +551,8 @@ class DeviceRearPortTable(RearPortTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = RearPort model = RearPort
fields = ( fields = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags', 'actions', 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_color', 'cable_peer', 'tags',
'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions', 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions',

View File

@ -4,7 +4,6 @@ from django_tables2.utils import Accessor
from dcim.models import PowerFeed, PowerPanel from dcim.models import PowerFeed, PowerPanel
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn
from .devices import CableTerminationTable from .devices import CableTerminationTable
from .template_code import POWERFEED_CABLE, POWERFEED_CABLETERMINATION
__all__ = ( __all__ = (
'PowerFeedTable', 'PowerFeedTable',
@ -69,7 +68,7 @@ class PowerFeedTable(CableTerminationTable):
model = PowerFeed model = PowerFeed
fields = ( fields = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'cable', 'cable_peer', 'connection', 'available_power', 'tags', 'max_utilization', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

View File

@ -342,10 +342,11 @@ class RackView(generic.ObjectView):
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role') queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role')
def get_extra_context(self, request, instance): 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( nonracked_devices = Device.objects.filter(
rack=instance, rack=instance,
position__isnull=True position__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer') ).prefetch_related('device_type__manufacturer')
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)

View File

@ -140,7 +140,7 @@ class Webhook(models.Model):
ret = {} ret = {}
data = render_jinja2(self.additional_headers, context) data = render_jinja2(self.additional_headers, context)
for line in data.splitlines(): for line in data.splitlines():
header, value = line.split(':') header, value = line.split(':', 1)
ret[header.strip()] = value.strip() ret[header.strip()] = value.strip()
return ret return ret

View File

@ -109,18 +109,6 @@ VLAN_MEMBER_TAGGED = """
{% endif %} {% endif %}
""" """
TENANT_LINK = """
{% if record.tenant %}
<a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
{% elif record.vrf.tenant %}
<a href="{% url 'tenancy:tenant' slug=record.vrf.tenant.slug %}" title="{{ record.vrf.tenant.description }}">{{ record.vrf.tenant }}</a>*
{% elif object.tenant %}
<a href="{% url 'tenancy:tenant' slug=object.tenant.slug %}" title="{{ object.tenant.description }}">{{ object.tenant }}</a>
{% else %}
&mdash;
{% endif %}
"""
# #
# VRFs # VRFs
@ -210,8 +198,8 @@ class AggregateTable(BaseTable):
prefix = tables.LinkColumn( prefix = tables.LinkColumn(
verbose_name='Aggregate' verbose_name='Aggregate'
) )
tenant = tables.TemplateColumn( tenant = tables.Column(
template_code=TENANT_LINK linkify=True
) )
date_added = tables.DateColumn( date_added = tables.DateColumn(
format="Y-m-d", format="Y-m-d",
@ -281,8 +269,8 @@ class PrefixTable(BaseTable):
template_code=VRF_LINK, template_code=VRF_LINK,
verbose_name='VRF' verbose_name='VRF'
) )
tenant = tables.TemplateColumn( tenant = tables.Column(
template_code=TENANT_LINK linkify=True
) )
site = tables.Column( site = tables.Column(
linkify=True linkify=True
@ -349,8 +337,8 @@ class IPAddressTable(BaseTable):
default=AVAILABLE_LABEL default=AVAILABLE_LABEL
) )
role = ChoiceFieldColumn() role = ChoiceFieldColumn()
tenant = tables.TemplateColumn( tenant = tables.Column(
template_code=TENANT_LINK linkify=True
) )
assigned_object = tables.Column( assigned_object = tables.Column(
linkify=True, linkify=True,
@ -430,8 +418,8 @@ class InterfaceIPAddressTable(BaseTable):
verbose_name='VRF' verbose_name='VRF'
) )
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
tenant = tables.TemplateColumn( tenant = tables.Column(
template_code=TENANT_LINK linkify=True
) )
actions = ButtonsColumn( actions = ButtonsColumn(
model=IPAddress model=IPAddress

View File

@ -34,6 +34,9 @@ REDIS = {
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 0, 'DATABASE': 0,
'SSL': False, 'SSL': False,
# Set this to True to skip TLS certificate verification
# This can expose the connection to attacks, be careful
# 'INSECURE_SKIP_TLS_VERIFY': False,
}, },
'caching': { 'caching': {
'HOST': 'localhost', 'HOST': 'localhost',
@ -44,6 +47,9 @@ REDIS = {
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'SSL': False, 'SSL': False,
# Set this to True to skip TLS certificate verification
# This can expose the connection to attacks, be careful
# 'INSECURE_SKIP_TLS_VERIFY': False,
} }
} }

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '2.10.8' VERSION = '2.10.9'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -215,6 +215,7 @@ TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10)
TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '') TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0) TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False) TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
TASKS_REDIS_SKIP_TLS_VERIFY = TASKS_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False)
# Caching # Caching
if 'caching' not in REDIS: if 'caching' not in REDIS:
@ -233,6 +234,7 @@ CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default'
CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '') CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0) CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False) CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
CACHING_REDIS_SKIP_TLS_VERIFY = CACHING_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False)
# #
@ -398,21 +400,14 @@ if CACHING_REDIS_USING_SENTINEL:
'password': CACHING_REDIS_PASSWORD, 'password': CACHING_REDIS_PASSWORD,
} }
else: else:
if CACHING_REDIS_SSL: CACHEOPS_REDIS = {
REDIS_CACHE_CON_STRING = 'rediss://' 'host': CACHING_REDIS_HOST,
else: 'port': CACHING_REDIS_PORT,
REDIS_CACHE_CON_STRING = 'redis://' 'db': CACHING_REDIS_DATABASE,
'password': CACHING_REDIS_PASSWORD,
if CACHING_REDIS_PASSWORD: 'ssl': CACHING_REDIS_SSL,
REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD) 'ssl_cert_reqs': None if CACHING_REDIS_SKIP_TLS_VERIFY else 'required',
}
REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(
REDIS_CACHE_CON_STRING,
CACHING_REDIS_HOST,
CACHING_REDIS_PORT,
CACHING_REDIS_DATABASE
)
CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
if not CACHE_TIMEOUT: if not CACHE_TIMEOUT:
CACHEOPS_ENABLED = False CACHEOPS_ENABLED = False
@ -560,6 +555,7 @@ else:
'DB': TASKS_REDIS_DATABASE, 'DB': TASKS_REDIS_DATABASE,
'PASSWORD': TASKS_REDIS_PASSWORD, 'PASSWORD': TASKS_REDIS_PASSWORD,
'SSL': TASKS_REDIS_SSL, 'SSL': TASKS_REDIS_SSL,
'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT, 'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
} }

View File

@ -153,16 +153,17 @@
</li> </li>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% if perms.dcim.napalm_read_device %} {% if perms.dcim.napalm_read_device and object.status == 'active' and object.primary_ip and object.platform.napalm_driver %}
{% if object.status != 'active' %} {# NAPALM-enabled tabs #}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %} <li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
{% elif not object.platform %} <a href="{% url 'dcim:device_status' pk=object.pk %}">Status</a>
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %} </li>
{% elif not object.platform.napalm_driver %} <li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %} <a href="{% url 'dcim:device_lldp_neighbors' pk=object.pk %}">LLDP Neighbors</a>
{% else %} </li>
{% include 'dcim/inc/device_napalm_tabs.html' %} <li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
{% endif %} <a href="{% url 'dcim:device_config' pk=object.pk %}">Configuration</a>
</li>
{% endif %} {% endif %}
{% if perms.extras.view_configcontext %} {% if perms.extras.view_configcontext %}
<li role="presentation"{% if active_tab == 'config-context' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'config-context' %} class="active"{% endif %}>

View File

@ -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 %}

View File

@ -1,6 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %} {% load helpers %}
{% load render_table from django_tables2 %}
{% load static %} {% load static %}
{% block content %} {% block content %}
@ -28,6 +29,7 @@
{% block sidebar %}{% endblock %} {% block sidebar %}{% endblock %}
</div> </div>
{% endif %} {% endif %}
<div class="table-responsive">
{% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %} {% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %}
{% if permissions.change or permissions.delete %} {% if permissions.change or permissions.delete %}
<form method="post" class="form form-horizontal"> <form method="post" class="form form-horizontal">
@ -57,7 +59,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% include table_template|default:'responsive_table.html' %} {% render_table table 'inc/table.html' %}
<div class="pull-left noprint"> <div class="pull-left noprint">
{% block bulk_buttons %}{% endblock %} {% block bulk_buttons %}{% endblock %}
{% if bulk_edit_url and permissions.change %} {% if bulk_edit_url and permissions.change %}
@ -73,9 +75,10 @@
</div> </div>
</form> </form>
{% else %} {% else %}
{% include table_template|default:'responsive_table.html' %} {% render_table table 'inc/table.html' %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</div>
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>

View File

@ -11,12 +11,8 @@
<div class="panel-heading"> <div class="panel-heading">
<div class="pull-right noprint"> <div class="pull-right noprint">
<a class="btn btn-xs btn-success copy-token" data-clipboard-target="#token_{{ token.pk }}">Copy</a> <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> <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> <a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
{% endif %}
</div> </div>
<i class="mdi mdi-key"></i> <i class="mdi mdi-key"></i>
<samp><span id="token_{{ token.pk }}">{{ token.key }}</span></samp> <samp><span id="token_{{ token.pk }}">{{ token.key }}</span></samp>
@ -55,16 +51,10 @@
{% empty %} {% empty %}
<p>You do not have any API tokens.</p> <p>You do not have any API tokens.</p>
{% endfor %} {% endfor %}
{% if perms.users.add_token %}
<a href="{% url 'user:token_add' %}" class="btn btn-primary"> <a href="{% url 'user:token_add' %}" class="btn btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
Add a token Add a token
</a> </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 %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,3 +1,4 @@
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
@ -74,6 +75,15 @@ class TenantGroup(MPTTModel, ChangeLoggedModel):
object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
) )
def clean(self):
super().clean()
# An MPTT model cannot be its own parent
if self.pk and self.parent_id == self.pk:
raise ValidationError({
"parent": "Cannot assign self as parent."
})
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Tenant(ChangeLoggedModel, CustomFieldModel): class Tenant(ChangeLoggedModel, CustomFieldModel):

View File

@ -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.mixins import LoginRequiredMixin
from django.contrib.auth.models import update_last_login from django.contrib.auth.models import update_last_login
from django.contrib.auth.signals import user_logged_in 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.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -282,13 +282,9 @@ class TokenEditView(LoginRequiredMixin, View):
def get(self, request, pk=None): def get(self, request, pk=None):
if pk is not None: if pk:
if not request.user.has_perm('users.change_token'):
return HttpResponseForbidden()
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
else: else:
if not request.user.has_perm('users.add_token'):
return HttpResponseForbidden()
token = Token(user=request.user) token = Token(user=request.user)
form = TokenForm(instance=token) form = TokenForm(instance=token)
@ -302,11 +298,11 @@ class TokenEditView(LoginRequiredMixin, View):
def post(self, request, pk=None): 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) token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
form = TokenForm(request.POST, instance=token) form = TokenForm(request.POST, instance=token)
else: else:
token = Token() token = Token(user=request.user)
form = TokenForm(request.POST) form = TokenForm(request.POST)
if form.is_valid(): if form.is_valid():
@ -314,7 +310,7 @@ class TokenEditView(LoginRequiredMixin, View):
token.user = request.user token.user = request.user
token.save() 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) messages.success(request, msg)
if '_addanother' in request.POST: if '_addanother' in request.POST:

View File

@ -224,12 +224,12 @@ def prepare_cloned_fields(instance):
field = instance._meta.get_field(field_name) field = instance._meta.get_field(field_name)
field_value = field.value_from_object(instance) field_value = field.value_from_object(instance)
# Swap out False with URL-friendly value # Pass False as null for boolean fields
if field_value is False: if field_value is False:
field_value = '' params.append((field_name, ''))
# Omit empty values # Omit empty values
if field_value not in (None, ''): elif field_value not in (None, ''):
params.append((field_name, field_value)) params.append((field_name, field_value))
# Copy tags # Copy tags

View File

@ -109,12 +109,13 @@ class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
required=False, required=False,
many=True many=True
) )
count_ipaddresses = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = VMInterface model = VMInterface
fields = [ fields = [
'id', 'url', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'id', 'url', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
'untagged_vlan', 'tagged_vlans', 'tags', 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
] ]
def validate(self, data): def validate(self, data):

View File

@ -80,7 +80,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet)
class VMInterfaceViewSet(ModelViewSet): class VMInterfaceViewSet(ModelViewSet):
queryset = VMInterface.objects.prefetch_related( queryset = VMInterface.objects.prefetch_related(
'virtual_machine', 'tags', 'tagged_vlans' 'virtual_machine', 'tags', 'tagged_vlans', 'ip_addresses',
) )
serializer_class = serializers.VMInterfaceSerializer serializer_class = serializers.VMInterfaceSerializer
filterset_class = filters.VMInterfaceFilterSet filterset_class = filters.VMInterfaceFilterSet

View File

@ -786,6 +786,10 @@ class VMInterfaceFilterForm(forms.Form):
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
mac_address = forms.CharField(
required=False,
label='MAC address'
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -1,4 +1,4 @@
Django==3.1.7 Django==3.1.8
django-cacheops==5.1 django-cacheops==5.1
django-cors-headers==3.7.0 django-cors-headers==3.7.0
django-debug-toolbar==3.2 django-debug-toolbar==3.2
@ -6,17 +6,17 @@ django-filter==2.4.0
django-mptt==0.12.0 django-mptt==0.12.0
django-pglocks==1.0.4 django-pglocks==1.0.4
django-prometheus==2.1.0 django-prometheus==2.1.0
django-rq==2.4.0 django-rq==2.4.1
django-tables2==2.3.4 django-tables2==2.3.4
django-taggit==1.3.0 django-taggit==1.3.0
django-timezone-field==4.1.2 django-timezone-field==4.1.2
djangorestframework==3.12.4 djangorestframework==3.12.4
drf-yasg[validation]==1.20.0 drf-yasg[validation]==1.20.0
gunicorn==20.0.4 gunicorn==20.1.0
Jinja2==2.11.3 Jinja2==2.11.3
Markdown==3.3.4 Markdown==3.3.4
netaddr==0.8.0 netaddr==0.8.0
Pillow==8.1.2 Pillow==8.2.0
psycopg2-binary==2.8.6 psycopg2-binary==2.8.6
pycryptodome==3.10.1 pycryptodome==3.10.1
PyYAML==5.4.1 PyYAML==5.4.1