mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-18 21:16:27 -06:00
commit
a77d1e502c
@ -344,7 +344,7 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
|
||||
|
||||
Default: `False`
|
||||
|
||||
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authenitcation will still take effect as a fallback.)
|
||||
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.)
|
||||
|
||||
---
|
||||
|
||||
|
@ -1,13 +1,15 @@
|
||||
# NetBox Installation
|
||||
|
||||
This section of the documentation discusses installing and configuring the NetBox application. Begin by installing all system packages required by NetBox and its dependencies:
|
||||
This section of the documentation discusses installing and configuring the NetBox application itself.
|
||||
|
||||
## Install System Packages
|
||||
|
||||
Begin by installing all system packages required by NetBox and its dependencies. Note that beginning with NetBox v2.8, Python 3.6 or later is required.
|
||||
|
||||
### Ubuntu
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
|
||||
# apt-get install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
|
||||
```
|
||||
|
||||
### CentOS
|
||||
|
@ -4,6 +4,9 @@
|
||||
|
||||
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.
|
||||
|
||||
## Install the Latest Code
|
||||
|
||||
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository.
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@ -2,6 +2,6 @@
|
||||
|
||||
Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room.
|
||||
|
||||
Each rack group must be assigned to a parent site. Hierarchical recursion of rack groups is not currently supported.
|
||||
Each rack group must be assigned to a parent site, and rack groups may optionally be nested to achieve a multi-level hierarchy.
|
||||
|
||||
The name and facility ID of each rack within a group must be unique. (Racks not assigned to the same rack group may have identical names and/or facility IDs.)
|
||||
|
@ -1,3 +1,5 @@
|
||||
# Tenant Groups
|
||||
|
||||
Tenants can be organized by custom groups. For instance, you might create one group called "Customers" and one called "Acquisitions." The assignment of tenants to groups is optional.
|
||||
|
||||
Tenant groups may be nested to achieve a multi-level hierarchy. For example, you might have a group called "Customers" containing subgroups of individual tenants grouped by product or account team.
|
||||
|
@ -1,7 +1,45 @@
|
||||
# NetBox v2.8
|
||||
|
||||
## v2.8.1 (2020-04-23)
|
||||
|
||||
### Notes
|
||||
|
||||
In accordance with the fix in [#4459](https://github.com/netbox-community/netbox/issues/4459), users that are experiencing invalid nested data with
|
||||
regions, rack groups, or tenant groups can perform a one-time operation using the NetBox shell to rebuild the correct nested relationships after upgrading:
|
||||
|
||||
```text
|
||||
$ python netbox/manage.py nbshell
|
||||
### NetBox interactive shell (localhost)
|
||||
### Python 3.6.4 | Django 3.0.5 | NetBox 2.8.1
|
||||
### lsmodels() will show available models. Use help(<model>) for more info.
|
||||
>>> Region.objects.rebuild()
|
||||
>>> RackGroup.objects.rebuild()
|
||||
>>> TenantGroup.objects.rebuild()
|
||||
```
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4464](https://github.com/netbox-community/netbox/issues/4464) - Add 21-inch rack width (ETSI)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#2994](https://github.com/netbox-community/netbox/issues/2994) - Prevent modifying termination points of existing cable to ensure end-to-end path integrity
|
||||
* [#3356](https://github.com/netbox-community/netbox/issues/3356) - Correct Swagger schema specification for the available prefixes/IPs API endpoints
|
||||
* [#4139](https://github.com/netbox-community/netbox/issues/4139) - Enable assigning all relevant attributes during bulk device/VM component creation
|
||||
* [#4336](https://github.com/netbox-community/netbox/issues/4336) - Ensure interfaces without a subinterface ID are ordered before subinterface zero
|
||||
* [#4361](https://github.com/netbox-community/netbox/issues/4361) - Fix Type of `connection_state` in Swagger schema
|
||||
* [#4388](https://github.com/netbox-community/netbox/issues/4388) - Fix detection of connected endpoints when connecting rear ports
|
||||
* [#4459](https://github.com/netbox-community/netbox/issues/4459) - Fix caching issue resulting in erroneous nested data for regions, rack groups, and tenant groups
|
||||
* [#4489](https://github.com/netbox-community/netbox/issues/4489) - Fix display of parent/child role on device type view
|
||||
* [#4496](https://github.com/netbox-community/netbox/issues/4496) - Fix exception when validating certain models via REST API
|
||||
* [#4510](https://github.com/netbox-community/netbox/issues/4510) - Enforce address family for device primary IPv4/v6 addresses
|
||||
|
||||
---
|
||||
|
||||
## v2.8.0 (2020-04-13)
|
||||
|
||||
**NOTE:** Beginning with release 2.8.0, NetBox requires Python 3.6 or later.
|
||||
|
||||
### New Features (Beta)
|
||||
|
||||
This releases introduces two new features in beta status. While they are expected to be functional, their precise implementation is subject to change during the v2.8 release cycle. It is recommended to wait until NetBox v2.9 to deploy them in production.
|
||||
@ -35,7 +73,7 @@ For NetBox plugins to be recognized, they must be installed and added by name to
|
||||
* [#1754](https://github.com/netbox-community/netbox/issues/1754) - Added support for nested rack groups
|
||||
* [#3939](https://github.com/netbox-community/netbox/issues/3939) - Added support for nested tenant groups
|
||||
* [#4078](https://github.com/netbox-community/netbox/issues/4078) - Standardized description fields across all models
|
||||
* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](../configuration/optional-settings.md#logging))
|
||||
* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#logging))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
|
@ -143,8 +143,7 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
# Validate uniqueness of (group, facility_id) since we omitted the automatically-created validator from Meta.
|
||||
if data.get('facility_id', None):
|
||||
validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'facility_id'))
|
||||
validator.set_context(self)
|
||||
validator(data)
|
||||
validator(data, self)
|
||||
|
||||
# Enforce model validation
|
||||
super().validate(data)
|
||||
@ -395,8 +394,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
# Validate uniqueness of (rack, position, face) since we omitted the automatically-created validator from Meta.
|
||||
if data.get('rack') and data.get('position') and data.get('face'):
|
||||
validator = UniqueTogetherValidator(queryset=Device.objects.all(), fields=('rack', 'position', 'face'))
|
||||
validator.set_context(self)
|
||||
validator(data)
|
||||
validator(data, self)
|
||||
|
||||
# Enforce model validation
|
||||
super().validate(data)
|
||||
|
@ -48,7 +48,7 @@ class CableTraceMixin(object):
|
||||
# Initialize the path array
|
||||
path = []
|
||||
|
||||
for near_end, cable, far_end in obj.trace():
|
||||
for near_end, cable, far_end in obj.trace()[0]:
|
||||
|
||||
# Serialize each object
|
||||
serializer_a = get_serializer_for_model(near_end, prefix='Nested')
|
||||
|
@ -57,11 +57,13 @@ class RackWidthChoices(ChoiceSet):
|
||||
|
||||
WIDTH_10IN = 10
|
||||
WIDTH_19IN = 19
|
||||
WIDTH_21IN = 21
|
||||
WIDTH_23IN = 23
|
||||
|
||||
CHOICES = (
|
||||
(WIDTH_10IN, '10 inches'),
|
||||
(WIDTH_19IN, '19 inches'),
|
||||
(WIDTH_21IN, '21 inches'),
|
||||
(WIDTH_23IN, '23 inches'),
|
||||
)
|
||||
|
||||
|
@ -3,3 +3,12 @@ class LoopDetected(Exception):
|
||||
A loop has been detected while tracing a cable path.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CableTraceSplit(Exception):
|
||||
"""
|
||||
A cable trace cannot be completed because a RearPort maps to multiple FrontPorts and
|
||||
we don't know which one to follow.
|
||||
"""
|
||||
def __init__(self, termination, *args, **kwargs):
|
||||
self.termination = termination
|
||||
|
@ -23,8 +23,9 @@ from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
|
||||
BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, JSONField, SelectWithPK,
|
||||
SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, form_from_model, JSONField,
|
||||
SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
||||
BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
|
||||
from .choices import *
|
||||
@ -2298,30 +2299,10 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
|
||||
label='Name'
|
||||
)
|
||||
|
||||
|
||||
class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
|
||||
type = forms.ChoiceField(
|
||||
choices=InterfaceTypeChoices,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
enabled = forms.BooleanField(
|
||||
required=False,
|
||||
initial=True
|
||||
)
|
||||
mtu = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=INTERFACE_MTU_MIN,
|
||||
max_value=INTERFACE_MTU_MAX,
|
||||
label='MTU'
|
||||
)
|
||||
mgmt_only = forms.BooleanField(
|
||||
required=False,
|
||||
label='Management only'
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
def clean_tags(self):
|
||||
# Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we
|
||||
# must first convert the list of tags to a string.
|
||||
return ','.join(self.cleaned_data.get('tags'))
|
||||
|
||||
|
||||
#
|
||||
@ -2375,20 +2356,23 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class ConsolePortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class ConsolePortBulkCreateForm(
|
||||
form_from_model(ConsolePort, ['type', 'description', 'tags']),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class ConsolePortBulkEditForm(
|
||||
form_from_model(ConsolePort, ['type', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
BulkEditForm
|
||||
):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ConsolePort.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
type = forms.ChoiceField(
|
||||
choices=add_blank_choice(ConsolePortTypeChoices),
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = (
|
||||
@ -2462,20 +2446,23 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class ConsoleServerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class ConsoleServerPortBulkCreateForm(
|
||||
form_from_model(ConsoleServerPort, ['type', 'description', 'tags']),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class ConsoleServerPortBulkEditForm(
|
||||
form_from_model(ConsoleServerPort, ['type', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
BulkEditForm
|
||||
):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ConsoleServerPort.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
type = forms.ChoiceField(
|
||||
choices=add_blank_choice(ConsolePortTypeChoices),
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = [
|
||||
@ -2573,30 +2560,23 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class PowerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class PowerPortBulkCreateForm(
|
||||
form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description', 'tags']),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class PowerPortBulkEditForm(
|
||||
form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
BulkEditForm
|
||||
):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerPort.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
type = forms.ChoiceField(
|
||||
choices=add_blank_choice(PowerPortTypeChoices),
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
maximum_draw = forms.IntegerField(
|
||||
min_value=1,
|
||||
required=False,
|
||||
help_text="Maximum draw in watts"
|
||||
)
|
||||
allocated_draw = forms.IntegerField(
|
||||
min_value=1,
|
||||
required=False,
|
||||
help_text="Allocated draw in watts"
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = (
|
||||
@ -2700,6 +2680,61 @@ class PowerOutletCreateForm(BootstrapMixin, forms.Form):
|
||||
self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
|
||||
|
||||
|
||||
class PowerOutletBulkCreateForm(
|
||||
form_from_model(PowerOutlet, ['type', 'feed_leg', 'description', 'tags']),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class PowerOutletBulkEditForm(
|
||||
form_from_model(PowerOutlet, ['type', 'feed_leg', 'power_port', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
BulkEditForm
|
||||
):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerOutlet.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
disabled=True,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = [
|
||||
'type', 'feed_leg', 'power_port', 'description',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit power_port queryset to PowerPorts which belong to the parent Device
|
||||
if 'device' in self.initial:
|
||||
device = Device.objects.filter(pk=self.initial['device']).first()
|
||||
self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
|
||||
else:
|
||||
self.fields['power_port'].choices = ()
|
||||
self.fields['power_port'].widget.attrs['disabled'] = True
|
||||
|
||||
|
||||
class PowerOutletBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerOutlet.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerOutlet.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletCSVForm(forms.ModelForm):
|
||||
device = FlexibleModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@ -2750,65 +2785,6 @@ class PowerOutletCSVForm(forms.ModelForm):
|
||||
self.fields['power_port'].queryset = PowerPort.objects.none()
|
||||
|
||||
|
||||
class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerOutlet.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
disabled=True,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
type = forms.ChoiceField(
|
||||
choices=add_blank_choice(PowerOutletTypeChoices),
|
||||
required=False
|
||||
)
|
||||
feed_leg = forms.ChoiceField(
|
||||
choices=add_blank_choice(PowerOutletFeedLegChoices),
|
||||
required=False,
|
||||
)
|
||||
power_port = forms.ModelChoiceField(
|
||||
queryset=PowerPort.objects.all(),
|
||||
required=False
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = [
|
||||
'type', 'feed_leg', 'power_port', 'description',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit power_port queryset to PowerPorts which belong to the parent Device
|
||||
if 'device' in self.initial:
|
||||
device = Device.objects.filter(pk=self.initial['device']).first()
|
||||
self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
|
||||
else:
|
||||
self.fields['power_port'].choices = ()
|
||||
self.fields['power_port'].widget.attrs['disabled'] = True
|
||||
|
||||
|
||||
class PowerOutletBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerOutlet.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerOutlet.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Interfaces
|
||||
#
|
||||
@ -2985,74 +2961,19 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
|
||||
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
|
||||
|
||||
|
||||
class InterfaceCSVForm(forms.ModelForm):
|
||||
device = FlexibleModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Name or ID of device',
|
||||
error_messages={
|
||||
'invalid_choice': 'Device not found.',
|
||||
}
|
||||
)
|
||||
virtual_machine = FlexibleModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Name or ID of virtual machine',
|
||||
error_messages={
|
||||
'invalid_choice': 'Virtual machine not found.',
|
||||
}
|
||||
)
|
||||
lag = FlexibleModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Name or ID of LAG interface',
|
||||
error_messages={
|
||||
'invalid_choice': 'LAG interface not found.',
|
||||
}
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=InterfaceTypeChoices,
|
||||
)
|
||||
mode = CSVChoiceField(
|
||||
choices=InterfaceModeChoices,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = Interface.csv_headers
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces belonging to this device (or VC master)
|
||||
if self.is_bound and 'device' in self.data:
|
||||
try:
|
||||
device = self.fields['device'].to_python(self.data['device'])
|
||||
except forms.ValidationError:
|
||||
device = None
|
||||
else:
|
||||
device = self.instance.device
|
||||
|
||||
if device:
|
||||
self.fields['lag'].queryset = Interface.objects.filter(
|
||||
device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG
|
||||
)
|
||||
else:
|
||||
self.fields['lag'].queryset = Interface.objects.none()
|
||||
|
||||
def clean_enabled(self):
|
||||
# Make sure enabled is True when it's not included in the uploaded data
|
||||
if 'enabled' not in self.data:
|
||||
return True
|
||||
else:
|
||||
return self.cleaned_data['enabled']
|
||||
class InterfaceBulkCreateForm(
|
||||
form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags']),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class InterfaceBulkEditForm(
|
||||
form_from_model(Interface, ['type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
BulkEditForm
|
||||
):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@ -3063,45 +2984,6 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
disabled=True,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
type = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfaceTypeChoices),
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
lag = forms.ModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
label='Parent LAG',
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
mac_address = forms.CharField(
|
||||
required=False,
|
||||
label='MAC Address'
|
||||
)
|
||||
mtu = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=INTERFACE_MTU_MIN,
|
||||
max_value=INTERFACE_MTU_MAX,
|
||||
label='MTU'
|
||||
)
|
||||
mgmt_only = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label='Management only'
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
mode = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfaceModeChoices),
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
@ -3175,6 +3057,73 @@ class InterfaceBulkDisconnectForm(ConfirmationForm):
|
||||
)
|
||||
|
||||
|
||||
class InterfaceCSVForm(forms.ModelForm):
|
||||
device = FlexibleModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Name or ID of device',
|
||||
error_messages={
|
||||
'invalid_choice': 'Device not found.',
|
||||
}
|
||||
)
|
||||
virtual_machine = FlexibleModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Name or ID of virtual machine',
|
||||
error_messages={
|
||||
'invalid_choice': 'Virtual machine not found.',
|
||||
}
|
||||
)
|
||||
lag = FlexibleModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Name or ID of LAG interface',
|
||||
error_messages={
|
||||
'invalid_choice': 'LAG interface not found.',
|
||||
}
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=InterfaceTypeChoices,
|
||||
)
|
||||
mode = CSVChoiceField(
|
||||
choices=InterfaceModeChoices,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = Interface.csv_headers
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces belonging to this device (or VC master)
|
||||
if self.is_bound and 'device' in self.data:
|
||||
try:
|
||||
device = self.fields['device'].to_python(self.data['device'])
|
||||
except forms.ValidationError:
|
||||
device = None
|
||||
else:
|
||||
device = self.instance.device
|
||||
|
||||
if device:
|
||||
self.fields['lag'].queryset = Interface.objects.filter(
|
||||
device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG
|
||||
)
|
||||
else:
|
||||
self.fields['lag'].queryset = Interface.objects.none()
|
||||
|
||||
def clean_enabled(self):
|
||||
# Make sure enabled is True when it's not included in the uploaded data
|
||||
if 'enabled' not in self.data:
|
||||
return True
|
||||
else:
|
||||
return self.cleaned_data['enabled']
|
||||
|
||||
|
||||
#
|
||||
# Front pass-through ports
|
||||
#
|
||||
@ -3283,6 +3232,44 @@ class FrontPortCreateForm(BootstrapMixin, forms.Form):
|
||||
}
|
||||
|
||||
|
||||
# class FrontPortBulkCreateForm(
|
||||
# form_from_model(FrontPort, ['type', 'description', 'tags']),
|
||||
# DeviceBulkAddComponentForm
|
||||
# ):
|
||||
# pass
|
||||
|
||||
|
||||
class FrontPortBulkEditForm(
|
||||
form_from_model(FrontPort, ['type', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
BulkEditForm
|
||||
):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=FrontPort.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = [
|
||||
'description',
|
||||
]
|
||||
|
||||
|
||||
class FrontPortBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=FrontPort.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
|
||||
|
||||
class FrontPortBulkDisconnectForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=FrontPort.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
|
||||
|
||||
class FrontPortCSVForm(forms.ModelForm):
|
||||
device = FlexibleModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@ -3331,41 +3318,6 @@ class FrontPortCSVForm(forms.ModelForm):
|
||||
self.fields['rear_port'].queryset = RearPort.objects.none()
|
||||
|
||||
|
||||
class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=FrontPort.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
type = forms.ChoiceField(
|
||||
choices=add_blank_choice(PortTypeChoices),
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = [
|
||||
'description',
|
||||
]
|
||||
|
||||
|
||||
class FrontPortBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=FrontPort.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
|
||||
|
||||
class FrontPortBulkDisconnectForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=FrontPort.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Rear pass-through ports
|
||||
#
|
||||
@ -3418,38 +3370,23 @@ class RearPortCreateForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class RearPortCSVForm(forms.ModelForm):
|
||||
device = FlexibleModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Name or ID of device',
|
||||
error_messages={
|
||||
'invalid_choice': 'Device not found.',
|
||||
}
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=PortTypeChoices,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = RearPort.csv_headers
|
||||
class RearPortBulkCreateForm(
|
||||
form_from_model(RearPort, ['type', 'positions', 'description', 'tags']),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
class RearPortBulkEditForm(
|
||||
form_from_model(RearPort, ['type', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
BulkEditForm
|
||||
):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=RearPort.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
type = forms.ChoiceField(
|
||||
choices=add_blank_choice(PortTypeChoices),
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = [
|
||||
@ -3471,6 +3408,164 @@ class RearPortBulkDisconnectForm(ConfirmationForm):
|
||||
)
|
||||
|
||||
|
||||
class RearPortCSVForm(forms.ModelForm):
|
||||
device = FlexibleModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Name or ID of device',
|
||||
error_messages={
|
||||
'invalid_choice': 'Device not found.',
|
||||
}
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
choices=PortTypeChoices,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = RearPort.csv_headers
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
|
||||
class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
model = DeviceBay
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class DeviceBayForm(BootstrapMixin, forms.ModelForm):
|
||||
tags = TagField(
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = [
|
||||
'device', 'name', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayCreateForm(BootstrapMixin, forms.Form):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.prefetch_related('device_type__manufacturer')
|
||||
)
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name'
|
||||
)
|
||||
tags = TagField(
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
||||
installed_device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
label='Child Device',
|
||||
help_text="Child devices must first be created and assigned to the site/rack of the parent device.",
|
||||
widget=StaticSelect2(),
|
||||
)
|
||||
|
||||
def __init__(self, device_bay, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['installed_device'].queryset = Device.objects.filter(
|
||||
site=device_bay.device.site,
|
||||
rack=device_bay.device.rack,
|
||||
parent_bay__isnull=True,
|
||||
device_type__u_height=0,
|
||||
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
|
||||
).exclude(pk=device_bay.device.pk)
|
||||
|
||||
|
||||
class DeviceBayBulkCreateForm(
|
||||
form_from_model(DeviceBay, ['description', 'tags']),
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
tags = TagField(
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class DeviceBayBulkEditForm(
|
||||
form_from_model(DeviceBay, ['description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
BulkEditForm
|
||||
):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=DeviceBay.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = (
|
||||
'description',
|
||||
)
|
||||
|
||||
|
||||
class DeviceBayBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=DeviceBay.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
|
||||
|
||||
class DeviceBayCSVForm(forms.ModelForm):
|
||||
device = FlexibleModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Name or ID of device',
|
||||
error_messages={
|
||||
'invalid_choice': 'Device not found.',
|
||||
}
|
||||
)
|
||||
installed_device = FlexibleModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Name or ID of device',
|
||||
error_messages={
|
||||
'invalid_choice': 'Child device not found.',
|
||||
}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = DeviceBay.csv_headers
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit installed device choices to devices of the correct type and location
|
||||
if self.is_bound:
|
||||
try:
|
||||
device = self.fields['device'].to_python(self.data['device'])
|
||||
except forms.ValidationError:
|
||||
device = None
|
||||
else:
|
||||
try:
|
||||
device = self.instance.device
|
||||
except Device.DoesNotExist:
|
||||
device = None
|
||||
|
||||
if device:
|
||||
self.fields['installed_device'].queryset = Device.objects.filter(
|
||||
site=device.site,
|
||||
rack=device.rack,
|
||||
parent_bay__isnull=True,
|
||||
device_type__u_height=0,
|
||||
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
|
||||
).exclude(pk=device.pk)
|
||||
else:
|
||||
self.fields['installed_device'].queryset = Interface.objects.none()
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
@ -3954,136 +4049,6 @@ class CableFilterForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
|
||||
class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
model = DeviceBay
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class DeviceBayForm(BootstrapMixin, forms.ModelForm):
|
||||
tags = TagField(
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = [
|
||||
'device', 'name', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayCreateForm(BootstrapMixin, forms.Form):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.prefetch_related('device_type__manufacturer')
|
||||
)
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name'
|
||||
)
|
||||
tags = TagField(
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
||||
installed_device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
label='Child Device',
|
||||
help_text="Child devices must first be created and assigned to the site/rack of the parent device.",
|
||||
widget=StaticSelect2(),
|
||||
)
|
||||
|
||||
def __init__(self, device_bay, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['installed_device'].queryset = Device.objects.filter(
|
||||
site=device_bay.device.site,
|
||||
rack=device_bay.device.rack,
|
||||
parent_bay__isnull=True,
|
||||
device_type__u_height=0,
|
||||
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
|
||||
).exclude(pk=device_bay.device.pk)
|
||||
|
||||
|
||||
class DeviceBayBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=DeviceBay.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = (
|
||||
'description',
|
||||
)
|
||||
|
||||
|
||||
class DeviceBayCSVForm(forms.ModelForm):
|
||||
device = FlexibleModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Name or ID of device',
|
||||
error_messages={
|
||||
'invalid_choice': 'Device not found.',
|
||||
}
|
||||
)
|
||||
installed_device = FlexibleModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Name or ID of device',
|
||||
error_messages={
|
||||
'invalid_choice': 'Child device not found.',
|
||||
}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = DeviceBay.csv_headers
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit installed device choices to devices of the correct type and location
|
||||
if self.is_bound:
|
||||
try:
|
||||
device = self.fields['device'].to_python(self.data['device'])
|
||||
except forms.ValidationError:
|
||||
device = None
|
||||
else:
|
||||
try:
|
||||
device = self.instance.device
|
||||
except Device.DoesNotExist:
|
||||
device = None
|
||||
|
||||
if device:
|
||||
self.fields['installed_device'].queryset = Device.objects.filter(
|
||||
site=device.site,
|
||||
rack=device.rack,
|
||||
parent_bay__isnull=True,
|
||||
device_type__u_height=0,
|
||||
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
|
||||
).exclude(pk=device.pk)
|
||||
else:
|
||||
self.fields['installed_device'].queryset = Interface.objects.none()
|
||||
|
||||
|
||||
class DeviceBayBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=DeviceBay.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Connections
|
||||
#
|
||||
|
18
netbox/dcim/migrations/0105_interface_name_collation.py
Normal file
18
netbox/dcim/migrations/0105_interface_name_collation.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.5 on 2020-04-21 20:13
|
||||
|
||||
from django.db import migrations
|
||||
import utilities.query_functions
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0104_correct_infiniband_types'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='interface',
|
||||
options={'ordering': ('device', utilities.query_functions.CollateAsChar('_name'))},
|
||||
),
|
||||
]
|
@ -1514,24 +1514,30 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
# Validate primary IP addresses
|
||||
vc_interfaces = self.vc_interfaces.all()
|
||||
if self.primary_ip4:
|
||||
if self.primary_ip4.family != 4:
|
||||
raise ValidationError({
|
||||
'primary_ip4': f"{self.primary_ip4} is not an IPv4 address."
|
||||
})
|
||||
if self.primary_ip4.interface in vc_interfaces:
|
||||
pass
|
||||
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
|
||||
pass
|
||||
else:
|
||||
raise ValidationError({
|
||||
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(
|
||||
self.primary_ip4),
|
||||
'primary_ip4': f"The specified IP address ({self.primary_ip4}) is not assigned to this device."
|
||||
})
|
||||
if self.primary_ip6:
|
||||
if self.primary_ip6.family != 6:
|
||||
raise ValidationError({
|
||||
'primary_ip6': f"{self.primary_ip6} is not an IPv6 address."
|
||||
})
|
||||
if self.primary_ip6.interface in vc_interfaces:
|
||||
pass
|
||||
elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
|
||||
pass
|
||||
else:
|
||||
raise ValidationError({
|
||||
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(
|
||||
self.primary_ip6),
|
||||
'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
|
||||
})
|
||||
|
||||
# Validate manufacturer/platform
|
||||
@ -2070,6 +2076,20 @@ class Cable(ChangeLoggedModel):
|
||||
# A copy of the PK to be used by __str__ in case the object is deleted
|
||||
self._pk = self.pk
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, db, field_names, values):
|
||||
"""
|
||||
Cache the original A and B terminations of existing Cable instances for later reference inside clean().
|
||||
"""
|
||||
instance = super().from_db(db, field_names, values)
|
||||
|
||||
instance._orig_termination_a_type = instance.termination_a_type
|
||||
instance._orig_termination_a_id = instance.termination_a_id
|
||||
instance._orig_termination_b_type = instance.termination_b_type
|
||||
instance._orig_termination_b_id = instance.termination_b_id
|
||||
|
||||
return instance
|
||||
|
||||
def __str__(self):
|
||||
return self.label or '#{}'.format(self._pk)
|
||||
|
||||
@ -2098,6 +2118,24 @@ class Cable(ChangeLoggedModel):
|
||||
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
|
||||
})
|
||||
|
||||
# If editing an existing Cable instance, check that neither termination has been modified.
|
||||
if self.pk:
|
||||
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
|
||||
if (
|
||||
self.termination_a_type != self._orig_termination_a_type or
|
||||
self.termination_a_id != self._orig_termination_a_id
|
||||
):
|
||||
raise ValidationError({
|
||||
'termination_a': err_msg
|
||||
})
|
||||
if (
|
||||
self.termination_b_type != self._orig_termination_b_type or
|
||||
self.termination_b_id != self._orig_termination_b_id
|
||||
):
|
||||
raise ValidationError({
|
||||
'termination_b': err_msg
|
||||
})
|
||||
|
||||
type_a = self.termination_a_type.model
|
||||
type_b = self.termination_b_type.model
|
||||
|
||||
@ -2205,26 +2243,3 @@ class Cable(ChangeLoggedModel):
|
||||
if self.termination_a is None:
|
||||
return
|
||||
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
|
||||
|
||||
def get_path_endpoints(self):
|
||||
"""
|
||||
Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be
|
||||
None.
|
||||
"""
|
||||
a_path = self.termination_b.trace()
|
||||
b_path = self.termination_a.trace()
|
||||
|
||||
# Determine overall path status (connected or planned)
|
||||
if self.status == CableStatusChoices.STATUS_CONNECTED:
|
||||
path_status = True
|
||||
for segment in a_path[1:] + b_path[1:]:
|
||||
if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED:
|
||||
path_status = False
|
||||
break
|
||||
else:
|
||||
path_status = False
|
||||
|
||||
a_endpoint = a_path[-1][2]
|
||||
b_endpoint = b_path[-1][2]
|
||||
|
||||
return a_endpoint, b_endpoint, path_status
|
||||
|
@ -10,11 +10,13 @@ from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.exceptions import CableTraceSplit
|
||||
from dcim.fields import MACAddressField
|
||||
from extras.models import ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.fields import NaturalOrderingField
|
||||
from utilities.ordering import naturalize_interface
|
||||
from utilities.query_functions import CollateAsChar
|
||||
from utilities.utils import serialize_object
|
||||
from virtualization.choices import VMInterfaceTypeChoices
|
||||
|
||||
@ -91,7 +93,13 @@ class CableTermination(models.Model):
|
||||
|
||||
def trace(self):
|
||||
"""
|
||||
Return a list representing a complete cable path, with each individual segment represented as a three-tuple:
|
||||
Return two items: the traceable portion of a cable path, and the termination points where it splits (if any).
|
||||
This occurs when the trace is initiated from a midpoint along a path which traverses a RearPort. In cases where
|
||||
the originating endpoint is unknown, it is not possible to know which corresponding FrontPort to follow.
|
||||
|
||||
The path is a list representing a complete cable path, with each individual segment represented as a
|
||||
three-tuple:
|
||||
|
||||
[
|
||||
(termination A, cable, termination B),
|
||||
(termination C, cable, termination D),
|
||||
@ -117,10 +125,7 @@ class CableTermination(models.Model):
|
||||
|
||||
# Can't map to a FrontPort without a position
|
||||
if not position_stack:
|
||||
# TODO: This behavior is broken. We need a mechanism by which to return all FrontPorts mapped
|
||||
# to a given RearPort so that we can update end-to-end paths when a cable is created/deleted.
|
||||
# For now, we're maintaining the current behavior of tracing only to the first FrontPort.
|
||||
position_stack.append(1)
|
||||
raise CableTraceSplit(termination)
|
||||
|
||||
position = position_stack.pop()
|
||||
|
||||
@ -159,12 +164,12 @@ class CableTermination(models.Model):
|
||||
if not endpoint.cable:
|
||||
path.append((endpoint, None, None))
|
||||
logger.debug("No cable connected")
|
||||
return path
|
||||
return path, None
|
||||
|
||||
# Check for loops
|
||||
if endpoint.cable in [segment[1] for segment in path]:
|
||||
logger.debug("Loop detected!")
|
||||
return path
|
||||
return path, None
|
||||
|
||||
# Record the current segment in the path
|
||||
far_end = endpoint.get_cable_peer()
|
||||
@ -174,9 +179,13 @@ class CableTermination(models.Model):
|
||||
))
|
||||
|
||||
# Get the peer port of the far end termination
|
||||
endpoint = get_peer_port(far_end)
|
||||
try:
|
||||
endpoint = get_peer_port(far_end)
|
||||
except CableTraceSplit as e:
|
||||
return path, e.termination.frontports.all()
|
||||
|
||||
if endpoint is None:
|
||||
return path
|
||||
return path, None
|
||||
|
||||
def get_cable_peer(self):
|
||||
if self.cable is None:
|
||||
@ -186,6 +195,23 @@ class CableTermination(models.Model):
|
||||
if self._cabled_as_b.exists():
|
||||
return self.cable.termination_a
|
||||
|
||||
def get_path_endpoints(self):
|
||||
"""
|
||||
Return all endpoints of paths which traverse this object.
|
||||
"""
|
||||
endpoints = []
|
||||
|
||||
# Get the far end of the last path segment
|
||||
path, split_ends = self.trace()
|
||||
endpoint = path[-1][2]
|
||||
if split_ends is not None:
|
||||
for termination in split_ends:
|
||||
endpoints.extend(termination.get_path_endpoints())
|
||||
elif endpoint is not None:
|
||||
endpoints.append(endpoint)
|
||||
|
||||
return endpoints
|
||||
|
||||
|
||||
#
|
||||
# Console ports
|
||||
@ -651,7 +677,7 @@ class Interface(CableTermination, ComponentModel):
|
||||
|
||||
class Meta:
|
||||
# TODO: ordering and unique_together should include virtual_machine
|
||||
ordering = ('device', '_name')
|
||||
ordering = ('device', CollateAsChar('_name'))
|
||||
unique_together = ('device', 'name')
|
||||
|
||||
def __str__(self):
|
||||
|
@ -3,6 +3,7 @@ import logging
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .choices import CableStatusChoices
|
||||
from .models import Cable, Device, VirtualChassis
|
||||
|
||||
|
||||
@ -48,16 +49,28 @@ def update_connected_endpoints(instance, **kwargs):
|
||||
instance.termination_b.cable = instance
|
||||
instance.termination_b.save()
|
||||
|
||||
# Check if this Cable has formed a complete path. If so, update both endpoints.
|
||||
endpoint_a, endpoint_b, path_status = instance.get_path_endpoints()
|
||||
if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
|
||||
logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
|
||||
endpoint_a.connected_endpoint = endpoint_b
|
||||
endpoint_a.connection_status = path_status
|
||||
endpoint_a.save()
|
||||
endpoint_b.connected_endpoint = endpoint_a
|
||||
endpoint_b.connection_status = path_status
|
||||
endpoint_b.save()
|
||||
# Update any endpoints for this Cable.
|
||||
endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints()
|
||||
for endpoint in endpoints:
|
||||
path, split_ends = endpoint.trace()
|
||||
# Determine overall path status (connected or planned)
|
||||
path_status = True
|
||||
for segment in path:
|
||||
if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED:
|
||||
path_status = False
|
||||
break
|
||||
|
||||
endpoint_a = path[0][0]
|
||||
endpoint_b = path[-1][2]
|
||||
|
||||
if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
|
||||
logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
|
||||
endpoint_a.connected_endpoint = endpoint_b
|
||||
endpoint_a.connection_status = path_status
|
||||
endpoint_a.save()
|
||||
endpoint_b.connected_endpoint = endpoint_a
|
||||
endpoint_b.connection_status = path_status
|
||||
endpoint_b.save()
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Cable)
|
||||
@ -67,7 +80,7 @@ def nullify_connected_endpoints(instance, **kwargs):
|
||||
"""
|
||||
logger = logging.getLogger('netbox.dcim.cable')
|
||||
|
||||
endpoint_a, endpoint_b, _ = instance.get_path_endpoints()
|
||||
endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints()
|
||||
|
||||
# Disassociate the Cable from its termination points
|
||||
if instance.termination_a is not None:
|
||||
@ -79,12 +92,10 @@ def nullify_connected_endpoints(instance, **kwargs):
|
||||
instance.termination_b.cable = None
|
||||
instance.termination_b.save()
|
||||
|
||||
# If this Cable was part of a complete path, tear it down
|
||||
if hasattr(endpoint_a, 'connected_endpoint') and hasattr(endpoint_b, 'connected_endpoint'):
|
||||
logger.debug("Tearing down path ({} <---> {})".format(endpoint_a, endpoint_b))
|
||||
endpoint_a.connected_endpoint = None
|
||||
endpoint_a.connection_status = None
|
||||
endpoint_a.save()
|
||||
endpoint_b.connected_endpoint = None
|
||||
endpoint_b.connection_status = None
|
||||
endpoint_b.save()
|
||||
# If this Cable was part of any complete end-to-end paths, tear them down.
|
||||
for endpoint in endpoints:
|
||||
logger.debug(f"Removing path information for {endpoint}")
|
||||
if hasattr(endpoint, 'connected_endpoint'):
|
||||
endpoint.connected_endpoint = None
|
||||
endpoint.connection_status = None
|
||||
endpoint.save()
|
||||
|
@ -582,6 +582,7 @@ class RackTest(APITestCase):
|
||||
|
||||
data = {
|
||||
'name': 'Test Rack 4',
|
||||
'facility_id': '1234',
|
||||
'site': self.site1.pk,
|
||||
'group': self.rackgroup1.pk,
|
||||
'role': self.rackrole1.pk,
|
||||
@ -1815,6 +1816,7 @@ class DeviceTest(APITestCase):
|
||||
|
||||
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
|
||||
self.rack1 = Rack.objects.create(name='Test Rack 1', site=self.site1, u_height=48)
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
self.devicetype1 = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
|
||||
@ -1920,6 +1922,9 @@ class DeviceTest(APITestCase):
|
||||
'device_role': self.devicerole1.pk,
|
||||
'name': 'Test Device 4',
|
||||
'site': self.site1.pk,
|
||||
'rack': self.rack1.pk,
|
||||
'face': DeviceFaceChoices.FACE_FRONT,
|
||||
'position': 1,
|
||||
'cluster': self.cluster1.pk,
|
||||
}
|
||||
|
||||
|
@ -549,12 +549,21 @@ class CablePathTestCase(TestCase):
|
||||
self.assertIsNone(endpoint_a.connection_status)
|
||||
self.assertIsNone(endpoint_b.connection_status)
|
||||
|
||||
def test_connection_via_patch(self):
|
||||
def test_connections_via_patch(self):
|
||||
"""
|
||||
1 2 3
|
||||
[Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Device 2]
|
||||
Iface1 FP1 RP1 RP1 FP1 Iface1
|
||||
Test two connections via patched rear ports:
|
||||
Device 1 <---> Device 2
|
||||
Device 3 <---> Device 4
|
||||
|
||||
1 2
|
||||
[Device 1] -----------+ +----------- [Device 2]
|
||||
Iface1 | | Iface1
|
||||
FP1 | 3 | FP1
|
||||
[Panel 1] ----- [Panel 2]
|
||||
FP2 | RP1 RP1 | FP2
|
||||
Iface1 | | Iface1
|
||||
[Device 3] -----------+ +----------- [Device 4]
|
||||
4 5
|
||||
"""
|
||||
# Create cables
|
||||
cable1 = Cable(
|
||||
@ -563,139 +572,43 @@ class CablePathTestCase(TestCase):
|
||||
)
|
||||
cable1.save()
|
||||
cable2 = Cable(
|
||||
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
|
||||
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
|
||||
)
|
||||
cable2.save()
|
||||
cable3 = Cable(
|
||||
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
|
||||
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
|
||||
)
|
||||
cable3.save()
|
||||
|
||||
# Retrieve endpoints
|
||||
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
|
||||
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
|
||||
|
||||
# Validate connections
|
||||
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
|
||||
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
|
||||
self.assertTrue(endpoint_a.connection_status)
|
||||
self.assertTrue(endpoint_b.connection_status)
|
||||
|
||||
# Delete cable 2
|
||||
cable2.delete()
|
||||
|
||||
# Refresh endpoints
|
||||
endpoint_a.refresh_from_db()
|
||||
endpoint_b.refresh_from_db()
|
||||
|
||||
# Check that connections have been nullified
|
||||
self.assertIsNone(endpoint_a.connected_endpoint)
|
||||
self.assertIsNone(endpoint_b.connected_endpoint)
|
||||
self.assertIsNone(endpoint_a.connection_status)
|
||||
self.assertIsNone(endpoint_b.connection_status)
|
||||
|
||||
def test_connection_via_multiple_patches(self):
|
||||
"""
|
||||
1 2 3 4 5
|
||||
[Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4] ----- [Device 2]
|
||||
Iface1 FP1 RP1 RP1 FP1 FP1 RP1 RP1 FP1 Iface1
|
||||
|
||||
"""
|
||||
# Create cables
|
||||
cable1 = Cable(
|
||||
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
|
||||
)
|
||||
cable1.save()
|
||||
cable2 = Cable(
|
||||
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
|
||||
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
|
||||
)
|
||||
cable2.save()
|
||||
cable3 = Cable(
|
||||
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1')
|
||||
)
|
||||
cable3.save()
|
||||
cable4 = Cable(
|
||||
termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'),
|
||||
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
|
||||
)
|
||||
cable4.save()
|
||||
cable5 = Cable(
|
||||
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
|
||||
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
|
||||
)
|
||||
cable5.save()
|
||||
|
||||
# Retrieve endpoints
|
||||
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
|
||||
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
|
||||
|
||||
# Validate connections
|
||||
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
|
||||
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
|
||||
self.assertTrue(endpoint_a.connection_status)
|
||||
self.assertTrue(endpoint_b.connection_status)
|
||||
|
||||
# Delete cable 3
|
||||
cable3.delete()
|
||||
|
||||
# Refresh endpoints
|
||||
endpoint_a.refresh_from_db()
|
||||
endpoint_b.refresh_from_db()
|
||||
|
||||
# Check that connections have been nullified
|
||||
self.assertIsNone(endpoint_a.connected_endpoint)
|
||||
self.assertIsNone(endpoint_b.connected_endpoint)
|
||||
self.assertIsNone(endpoint_a.connection_status)
|
||||
self.assertIsNone(endpoint_b.connection_status)
|
||||
|
||||
def test_connection_via_stacked_rear_ports(self):
|
||||
"""
|
||||
1 2 3 4 5
|
||||
[Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4] ----- [Device 2]
|
||||
Iface1 FP1 RP1 FP1 RP1 RP1 FP1 RP1 FP1 Iface1
|
||||
|
||||
"""
|
||||
# Create cables
|
||||
cable1 = Cable(
|
||||
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
|
||||
)
|
||||
cable1.save()
|
||||
cable2 = Cable(
|
||||
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
|
||||
termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
|
||||
)
|
||||
cable2.save()
|
||||
|
||||
cable3 = Cable(
|
||||
termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'),
|
||||
termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1')
|
||||
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
|
||||
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
|
||||
)
|
||||
cable3.save()
|
||||
|
||||
cable4 = Cable(
|
||||
termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'),
|
||||
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
|
||||
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
|
||||
)
|
||||
cable4.save()
|
||||
cable5 = Cable(
|
||||
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
|
||||
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
|
||||
termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2')
|
||||
)
|
||||
cable5.save()
|
||||
|
||||
# Retrieve endpoints
|
||||
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
|
||||
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
|
||||
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
|
||||
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
|
||||
|
||||
# Validate connections
|
||||
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
|
||||
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
|
||||
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
|
||||
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
|
||||
self.assertTrue(endpoint_a.connection_status)
|
||||
self.assertTrue(endpoint_b.connection_status)
|
||||
self.assertTrue(endpoint_c.connection_status)
|
||||
self.assertTrue(endpoint_d.connection_status)
|
||||
|
||||
# Delete cable 3
|
||||
cable3.delete()
|
||||
@ -703,12 +616,204 @@ class CablePathTestCase(TestCase):
|
||||
# Refresh endpoints
|
||||
endpoint_a.refresh_from_db()
|
||||
endpoint_b.refresh_from_db()
|
||||
endpoint_c.refresh_from_db()
|
||||
endpoint_d.refresh_from_db()
|
||||
|
||||
# Check that connections have been nullified
|
||||
self.assertIsNone(endpoint_a.connected_endpoint)
|
||||
self.assertIsNone(endpoint_b.connected_endpoint)
|
||||
self.assertIsNone(endpoint_c.connected_endpoint)
|
||||
self.assertIsNone(endpoint_d.connected_endpoint)
|
||||
self.assertIsNone(endpoint_a.connection_status)
|
||||
self.assertIsNone(endpoint_b.connection_status)
|
||||
self.assertIsNone(endpoint_c.connection_status)
|
||||
self.assertIsNone(endpoint_d.connection_status)
|
||||
|
||||
def test_connections_via_multiple_patches(self):
|
||||
"""
|
||||
Test two connections via patched rear ports:
|
||||
Device 1 <---> Device 2
|
||||
Device 3 <---> Device 4
|
||||
|
||||
1 2 3
|
||||
[Device 1] -----------+ +---------------+ +----------- [Device 2]
|
||||
Iface1 | | | | Iface1
|
||||
FP1 | 4 | FP1 FP1 | 5 | FP1
|
||||
[Panel 1] ----- [Panel 2] [Panel 3] ----- [Panel 4]
|
||||
FP2 | RP1 RP1 | FP2 FP2 | RP1 RP1 | FP2
|
||||
Iface1 | | | | Iface1
|
||||
[Device 3] -----------+ +---------------+ +----------- [Device 4]
|
||||
6 7 8
|
||||
"""
|
||||
# Create cables
|
||||
cable1 = Cable(
|
||||
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
|
||||
)
|
||||
cable1.save()
|
||||
cable2 = Cable(
|
||||
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1')
|
||||
)
|
||||
cable2.save()
|
||||
cable3 = Cable(
|
||||
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
|
||||
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
|
||||
)
|
||||
cable3.save()
|
||||
|
||||
cable4 = Cable(
|
||||
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
|
||||
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
|
||||
)
|
||||
cable4.save()
|
||||
cable5 = Cable(
|
||||
termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'),
|
||||
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
|
||||
)
|
||||
cable5.save()
|
||||
|
||||
cable6 = Cable(
|
||||
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
|
||||
)
|
||||
cable6.save()
|
||||
cable7 = Cable(
|
||||
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 2')
|
||||
)
|
||||
cable7.save()
|
||||
cable8 = Cable(
|
||||
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
|
||||
termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
|
||||
)
|
||||
cable8.save()
|
||||
|
||||
# Retrieve endpoints
|
||||
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
|
||||
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
|
||||
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
|
||||
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
|
||||
|
||||
# Validate connections
|
||||
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
|
||||
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
|
||||
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
|
||||
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
|
||||
self.assertTrue(endpoint_a.connection_status)
|
||||
self.assertTrue(endpoint_b.connection_status)
|
||||
self.assertTrue(endpoint_c.connection_status)
|
||||
self.assertTrue(endpoint_d.connection_status)
|
||||
|
||||
# Delete cables 4 and 5
|
||||
cable4.delete()
|
||||
cable5.delete()
|
||||
|
||||
# Refresh endpoints
|
||||
endpoint_a.refresh_from_db()
|
||||
endpoint_b.refresh_from_db()
|
||||
endpoint_c.refresh_from_db()
|
||||
endpoint_d.refresh_from_db()
|
||||
|
||||
# Check that connections have been nullified
|
||||
self.assertIsNone(endpoint_a.connected_endpoint)
|
||||
self.assertIsNone(endpoint_b.connected_endpoint)
|
||||
self.assertIsNone(endpoint_c.connected_endpoint)
|
||||
self.assertIsNone(endpoint_d.connected_endpoint)
|
||||
self.assertIsNone(endpoint_a.connection_status)
|
||||
self.assertIsNone(endpoint_b.connection_status)
|
||||
self.assertIsNone(endpoint_c.connection_status)
|
||||
self.assertIsNone(endpoint_d.connection_status)
|
||||
|
||||
def test_connections_via_nested_rear_ports(self):
|
||||
"""
|
||||
Test two connections via nested rear ports:
|
||||
Device 1 <---> Device 2
|
||||
Device 3 <---> Device 4
|
||||
|
||||
1 2
|
||||
[Device 1] -----------+ +----------- [Device 2]
|
||||
Iface1 | | Iface1
|
||||
FP1 | 3 4 5 | FP1
|
||||
[Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4]
|
||||
FP2 | RP1 FP1 RP1 RP1 FP1 RP1 | FP2
|
||||
Iface1 | | Iface1
|
||||
[Device 3] -----------+ +----------- [Device 4]
|
||||
6 7
|
||||
"""
|
||||
# Create cables
|
||||
cable1 = Cable(
|
||||
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
|
||||
)
|
||||
cable1.save()
|
||||
cable2 = Cable(
|
||||
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
|
||||
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
|
||||
)
|
||||
cable2.save()
|
||||
|
||||
cable3 = Cable(
|
||||
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
|
||||
)
|
||||
cable3.save()
|
||||
cable4 = Cable(
|
||||
termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'),
|
||||
termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1')
|
||||
)
|
||||
cable4.save()
|
||||
cable5 = Cable(
|
||||
termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'),
|
||||
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
|
||||
)
|
||||
cable5.save()
|
||||
|
||||
cable6 = Cable(
|
||||
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
|
||||
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
|
||||
)
|
||||
cable6.save()
|
||||
cable7 = Cable(
|
||||
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
|
||||
termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
|
||||
)
|
||||
cable7.save()
|
||||
|
||||
# Retrieve endpoints
|
||||
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
|
||||
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
|
||||
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
|
||||
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
|
||||
|
||||
# Validate connections
|
||||
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
|
||||
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
|
||||
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
|
||||
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
|
||||
self.assertTrue(endpoint_a.connection_status)
|
||||
self.assertTrue(endpoint_b.connection_status)
|
||||
self.assertTrue(endpoint_c.connection_status)
|
||||
self.assertTrue(endpoint_d.connection_status)
|
||||
|
||||
# Delete cable 4
|
||||
cable4.delete()
|
||||
|
||||
# Refresh endpoints
|
||||
endpoint_a.refresh_from_db()
|
||||
endpoint_b.refresh_from_db()
|
||||
endpoint_c.refresh_from_db()
|
||||
endpoint_d.refresh_from_db()
|
||||
|
||||
# Check that connections have been nullified
|
||||
self.assertIsNone(endpoint_a.connected_endpoint)
|
||||
self.assertIsNone(endpoint_b.connected_endpoint)
|
||||
self.assertIsNone(endpoint_c.connected_endpoint)
|
||||
self.assertIsNone(endpoint_d.connected_endpoint)
|
||||
self.assertIsNone(endpoint_a.connection_status)
|
||||
self.assertIsNone(endpoint_b.connection_status)
|
||||
self.assertIsNone(endpoint_c.connection_status)
|
||||
self.assertIsNone(endpoint_d.connection_status)
|
||||
|
||||
def test_connection_via_circuit(self):
|
||||
"""
|
||||
|
@ -23,28 +23,34 @@ class NaturalOrderingTestCase(TestCase):
|
||||
|
||||
INTERFACES = [
|
||||
'0',
|
||||
'0.0',
|
||||
'0.1',
|
||||
'0.2',
|
||||
'0.10',
|
||||
'0.100',
|
||||
'0:1',
|
||||
'0:1.0',
|
||||
'0:1.1',
|
||||
'0:1.2',
|
||||
'0:1.10',
|
||||
'0:2',
|
||||
'0:2.0',
|
||||
'0:2.1',
|
||||
'0:2.2',
|
||||
'0:2.10',
|
||||
'1',
|
||||
'1.0',
|
||||
'1.1',
|
||||
'1.2',
|
||||
'1.10',
|
||||
'1.100',
|
||||
'1:1',
|
||||
'1:1.0',
|
||||
'1:1.1',
|
||||
'1:1.2',
|
||||
'1:1.10',
|
||||
'1:2',
|
||||
'1:2.0',
|
||||
'1:2.1',
|
||||
'1:2.2',
|
||||
'1:2.10',
|
||||
|
@ -278,7 +278,7 @@ urlpatterns = [
|
||||
path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
|
||||
path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
|
||||
path('rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
|
||||
# path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
|
||||
path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
|
||||
|
||||
# Device bays
|
||||
path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
|
||||
|
@ -32,6 +32,7 @@ from virtualization.models import VirtualMachine
|
||||
from . import filters, forms, tables
|
||||
from .choices import DeviceFaceChoices
|
||||
from .constants import NONCONNECTABLE_IFACE_TYPES
|
||||
from .exceptions import CableTraceSplit
|
||||
from .models import (
|
||||
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
||||
@ -1929,7 +1930,7 @@ class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateV
|
||||
permission_required = 'dcim.add_consoleport'
|
||||
parent_model = Device
|
||||
parent_field = 'device'
|
||||
form = forms.DeviceBulkAddComponentForm
|
||||
form = forms.ConsolePortBulkCreateForm
|
||||
model = ConsolePort
|
||||
model_form = forms.ConsolePortForm
|
||||
filterset = filters.DeviceFilterSet
|
||||
@ -1941,7 +1942,7 @@ class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentC
|
||||
permission_required = 'dcim.add_consoleserverport'
|
||||
parent_model = Device
|
||||
parent_field = 'device'
|
||||
form = forms.DeviceBulkAddComponentForm
|
||||
form = forms.ConsoleServerPortBulkCreateForm
|
||||
model = ConsoleServerPort
|
||||
model_form = forms.ConsoleServerPortForm
|
||||
filterset = filters.DeviceFilterSet
|
||||
@ -1953,7 +1954,7 @@ class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateVie
|
||||
permission_required = 'dcim.add_powerport'
|
||||
parent_model = Device
|
||||
parent_field = 'device'
|
||||
form = forms.DeviceBulkAddComponentForm
|
||||
form = forms.PowerPortBulkCreateForm
|
||||
model = PowerPort
|
||||
model_form = forms.PowerPortForm
|
||||
filterset = filters.DeviceFilterSet
|
||||
@ -1965,7 +1966,7 @@ class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateV
|
||||
permission_required = 'dcim.add_poweroutlet'
|
||||
parent_model = Device
|
||||
parent_field = 'device'
|
||||
form = forms.DeviceBulkAddComponentForm
|
||||
form = forms.PowerOutletBulkCreateForm
|
||||
model = PowerOutlet
|
||||
model_form = forms.PowerOutletForm
|
||||
filterset = filters.DeviceFilterSet
|
||||
@ -1977,7 +1978,7 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie
|
||||
permission_required = 'dcim.add_interface'
|
||||
parent_model = Device
|
||||
parent_field = 'device'
|
||||
form = forms.DeviceBulkAddInterfaceForm
|
||||
form = forms.InterfaceBulkCreateForm
|
||||
model = Interface
|
||||
model_form = forms.InterfaceForm
|
||||
filterset = filters.DeviceFilterSet
|
||||
@ -1985,11 +1986,35 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie
|
||||
default_return_url = 'dcim:device_list'
|
||||
|
||||
|
||||
# class DeviceBulkAddFrontPortView(PermissionRequiredMixin, BulkComponentCreateView):
|
||||
# permission_required = 'dcim.add_frontport'
|
||||
# parent_model = Device
|
||||
# parent_field = 'device'
|
||||
# form = forms.FrontPortBulkCreateForm
|
||||
# model = FrontPort
|
||||
# model_form = forms.FrontPortForm
|
||||
# filterset = filters.DeviceFilterSet
|
||||
# table = tables.DeviceTable
|
||||
# default_return_url = 'dcim:device_list'
|
||||
|
||||
|
||||
class DeviceBulkAddRearPortView(PermissionRequiredMixin, BulkComponentCreateView):
|
||||
permission_required = 'dcim.add_rearport'
|
||||
parent_model = Device
|
||||
parent_field = 'device'
|
||||
form = forms.RearPortBulkCreateForm
|
||||
model = RearPort
|
||||
model_form = forms.RearPortForm
|
||||
filterset = filters.DeviceFilterSet
|
||||
table = tables.DeviceTable
|
||||
default_return_url = 'dcim:device_list'
|
||||
|
||||
|
||||
class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView):
|
||||
permission_required = 'dcim.add_devicebay'
|
||||
parent_model = Device
|
||||
parent_field = 'device'
|
||||
form = forms.DeviceBulkAddComponentForm
|
||||
form = forms.DeviceBayBulkCreateForm
|
||||
model = DeviceBay
|
||||
model_form = forms.DeviceBayForm
|
||||
filterset = filters.DeviceFilterSet
|
||||
@ -2033,12 +2058,15 @@ class CableTraceView(PermissionRequiredMixin, View):
|
||||
def get(self, request, model, pk):
|
||||
|
||||
obj = get_object_or_404(model, pk=pk)
|
||||
trace = obj.trace()
|
||||
total_length = sum([entry[1]._abs_length for entry in trace if entry[1] and entry[1]._abs_length])
|
||||
path, split_ends = obj.trace()
|
||||
total_length = sum(
|
||||
[entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length]
|
||||
)
|
||||
|
||||
return render(request, 'dcim/cable_trace.html', {
|
||||
'obj': obj,
|
||||
'trace': trace,
|
||||
'trace': path,
|
||||
'split_ends': split_ends,
|
||||
'total_length': total_length,
|
||||
})
|
||||
|
||||
|
@ -90,8 +90,7 @@ class VLANGroupSerializer(ValidatedModelSerializer):
|
||||
if data.get('site', None):
|
||||
for field in ['name', 'slug']:
|
||||
validator = UniqueTogetherValidator(queryset=VLANGroup.objects.all(), fields=('site', field))
|
||||
validator.set_context(self)
|
||||
validator(data)
|
||||
validator(data, self)
|
||||
|
||||
# Enforce model validation
|
||||
super().validate(data)
|
||||
@ -122,8 +121,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
if data.get('group', None):
|
||||
for field in ['vid', 'name']:
|
||||
validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('group', field))
|
||||
validator.set_context(self)
|
||||
validator(data)
|
||||
validator(data, self)
|
||||
|
||||
# Enforce model validation
|
||||
super().validate(data)
|
||||
@ -185,6 +183,10 @@ class AvailablePrefixSerializer(serializers.Serializer):
|
||||
"""
|
||||
Representation of a prefix which does not exist in the database.
|
||||
"""
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
prefix = serializers.CharField(read_only=True)
|
||||
vrf = NestedVRFSerializer(read_only=True)
|
||||
|
||||
def to_representation(self, instance):
|
||||
if self.context.get('vrf'):
|
||||
vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
|
||||
@ -248,6 +250,10 @@ class AvailableIPSerializer(serializers.Serializer):
|
||||
"""
|
||||
Representation of an IP address which does not exist in the database.
|
||||
"""
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
address = serializers.CharField(read_only=True)
|
||||
vrf = NestedVRFSerializer(read_only=True)
|
||||
|
||||
def to_representation(self, instance):
|
||||
if self.context.get('vrf'):
|
||||
vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
|
||||
|
@ -2,6 +2,7 @@ from django.conf import settings
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
@ -73,6 +74,12 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
filterset_class = filters.PrefixFilterSet
|
||||
|
||||
@swagger_auto_schema(
|
||||
methods=['get', 'post'],
|
||||
responses={
|
||||
200: serializers.AvailablePrefixSerializer(many=True),
|
||||
}
|
||||
)
|
||||
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
|
||||
def available_prefixes(self, request, pk=None):
|
||||
@ -151,6 +158,12 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
methods=['get', 'post'],
|
||||
responses={
|
||||
200: serializers.AvailableIPSerializer(many=True),
|
||||
}
|
||||
)
|
||||
@action(detail=True, url_path='available-ips', methods=['get', 'post'])
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
|
||||
def available_ips(self, request, pk=None):
|
||||
|
@ -785,6 +785,7 @@ class VLANGroupTest(APITestCase):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
self.vlangroup1 = VLANGroup.objects.create(name='Test VLAN Group 1', slug='test-vlan-group-1')
|
||||
self.vlangroup2 = VLANGroup.objects.create(name='Test VLAN Group 2', slug='test-vlan-group-2')
|
||||
self.vlangroup3 = VLANGroup.objects.create(name='Test VLAN Group 3', slug='test-vlan-group-3')
|
||||
@ -818,6 +819,7 @@ class VLANGroupTest(APITestCase):
|
||||
data = {
|
||||
'name': 'Test VLAN Group 4',
|
||||
'slug': 'test-vlan-group-4',
|
||||
'site': self.site1.pk,
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlangroup-list')
|
||||
@ -886,10 +888,10 @@ class VLANTest(APITestCase):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.group1 = VLANGroup.objects.create(name='Test VLAN Group 1', slug='test-vlan-group-1')
|
||||
self.vlan1 = VLAN.objects.create(vid=1, name='Test VLAN 1')
|
||||
self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2')
|
||||
self.vlan3 = VLAN.objects.create(vid=3, name='Test VLAN 3')
|
||||
|
||||
self.prefix1 = Prefix.objects.create(prefix=IPNetwork('192.168.1.0/24'))
|
||||
|
||||
def test_get_vlan(self):
|
||||
@ -921,6 +923,7 @@ class VLANTest(APITestCase):
|
||||
data = {
|
||||
'vid': 4,
|
||||
'name': 'Test VLAN 4',
|
||||
'group': self.group1.pk,
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlan-list')
|
||||
|
@ -178,8 +178,14 @@ PAGINATE_COUNT = 50
|
||||
# Enable installed plugins. Add the name of each plugin to the list.
|
||||
PLUGINS = []
|
||||
|
||||
# Configure enabled plugins. This should be a dictionary of dictionaries, mapping each plugin by name to its configuration parameters.
|
||||
PLUGINS_CONFIG = {}
|
||||
# Plugins configuration settings. These settings are used by various plugins that the user may have installed.
|
||||
# Each key in the dictionary is the name of an installed plugin and its value is a dictionary of settings.
|
||||
# PLUGINS_CONFIG = {
|
||||
# 'my_plugin': {
|
||||
# 'foo': 'bar',
|
||||
# 'buzz': 'bazz'
|
||||
# }
|
||||
# }
|
||||
|
||||
# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
|
||||
# prefer IPv4 instead.
|
||||
@ -209,18 +215,6 @@ RELEASE_CHECK_URL = None
|
||||
# this setting is derived from the installed location.
|
||||
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
|
||||
|
||||
# Enable plugin support in netbox. This setting must be enabled for any installed plugins to function.
|
||||
PLUGINS_ENABLED = False
|
||||
|
||||
# Plugins configuration settings. These settings are used by various plugins that the user may have installed.
|
||||
# Each key in the dictionary is the name of an installed plugin and its value is a dictionary of settings.
|
||||
# PLUGINS_CONFIG = {
|
||||
# 'my_plugin': {
|
||||
# 'foo': 'bar',
|
||||
# 'buzz': 'bazz'
|
||||
# }
|
||||
# }
|
||||
|
||||
# By default, NetBox will store session data in the database. Alternatively, a file path can be specified here to use
|
||||
# local file storage instead. (This can be useful for enabling authentication on a standby instance with read-only
|
||||
# database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
|
||||
|
@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.8.0'
|
||||
VERSION = '2.8.1'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@ -479,11 +479,14 @@ CACHEOPS = {
|
||||
'auth.*': {'ops': ('fetch', 'get')},
|
||||
'auth.permission': {'ops': 'all'},
|
||||
'circuits.*': {'ops': 'all'},
|
||||
'dcim.region': None, # MPTT models are exempt due to raw sql
|
||||
'dcim.rackgroup': None, # MPTT models are exempt due to raw sql
|
||||
'dcim.*': {'ops': 'all'},
|
||||
'ipam.*': {'ops': 'all'},
|
||||
'extras.*': {'ops': 'all'},
|
||||
'secrets.*': {'ops': 'all'},
|
||||
'users.*': {'ops': 'all'},
|
||||
'tenancy.tenantgroup': None, # MPTT models are exempt due to raw sql
|
||||
'tenancy.*': {'ops': 'all'},
|
||||
'virtualization.*': {'ops': 'all'},
|
||||
}
|
||||
@ -644,18 +647,18 @@ for plugin_name in PLUGINS:
|
||||
plugin = importlib.import_module(plugin_name)
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured(
|
||||
f"Unable to import plugin {plugin_name}: Module not found. Check that the plugin module has been "
|
||||
f"installed within the correct Python environment."
|
||||
"Unable to import plugin {}: Module not found. Check that the plugin module has been installed within the "
|
||||
"correct Python environment.".format(plugin_name)
|
||||
)
|
||||
|
||||
# Determine plugin config and add to INSTALLED_APPS.
|
||||
try:
|
||||
plugin_config = plugin.config
|
||||
INSTALLED_APPS.append(f"{plugin_config.__module__}.{plugin_config.__name__}")
|
||||
INSTALLED_APPS.append("{}.{}".format(plugin_config.__module__, plugin_config.__name__))
|
||||
except AttributeError:
|
||||
raise ImproperlyConfigured(
|
||||
f"Plugin {plugin_name} does not provide a 'config' variable. This should be defined in the plugin's "
|
||||
f"__init__.py file and point to the PluginConfig subclass."
|
||||
"Plugin {} does not provide a 'config' variable. This should be defined in the plugin's __init__.py file "
|
||||
"and point to the PluginConfig subclass.".format(plugin_name)
|
||||
)
|
||||
|
||||
# Validate user-provided configuration settings and assign defaults
|
||||
@ -670,7 +673,9 @@ for plugin_name in PLUGINS:
|
||||
|
||||
# Apply cacheops config
|
||||
if type(plugin_config.caching_config) is not dict:
|
||||
raise ImproperlyConfigured(f"Plugin {plugin_name} caching_config must be a dictionary.")
|
||||
raise ImproperlyConfigured(
|
||||
"Plugin {} caching_config must be a dictionary.".format(plugin_name)
|
||||
)
|
||||
CACHEOPS.update({
|
||||
f"{plugin_name}.{key}": value for key, value in plugin_config.caching_config.items()
|
||||
"{}.{}".format(plugin_name, key): value for key, value in plugin_config.caching_config.items()
|
||||
})
|
||||
|
@ -48,6 +48,50 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not forloop.last %}<hr />{% endif %}
|
||||
<hr />
|
||||
{% endfor %}
|
||||
<div class="row">
|
||||
{% if split_ends %}
|
||||
<div class="col-md-7 col-md-offset-3">
|
||||
<div class="panel panel-warning">
|
||||
<div class="panel-heading">
|
||||
<strong><i class="fa fa-warning"></i> Trace Split</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
There are multiple possible paths from this point. Select a port to continue.
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<table class="panel-body table">
|
||||
<thead>
|
||||
<tr class="table-headings">
|
||||
<th>Port</th>
|
||||
<th>Connected</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{% for termination in split_ends %}
|
||||
<tr>
|
||||
<td><a href="{% url 'dcim:frontport_trace' pk=termination.pk %}">{{ termination }}</a></td>
|
||||
<td>
|
||||
{% if termination.cable %}
|
||||
<i class="fa fa-check text-success" title="Yes"></i>
|
||||
{% else %}
|
||||
<i class="fa fa-times text-danger" title="No"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ termination.get_type_display }}</td>
|
||||
<td>{{ termination.description|placeholder }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-md-11 col-md-offset-1">
|
||||
<h3 class="text-success text-center">Trace completed!</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -12,6 +12,7 @@
|
||||
{% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
|
||||
{% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
|
||||
{% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
|
||||
{% if perms.dcim.add_rearport %}<li><a href="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Rear Ports</a></li>{% endif %}
|
||||
{% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -102,13 +102,7 @@
|
||||
<tr>
|
||||
<td>Parent/Child</td>
|
||||
<td>
|
||||
{% if devicetype.subdevice_role == True %}
|
||||
<label class="label label-primary">Parent</label>
|
||||
{% elif devicetype.subdevice_role == False %}
|
||||
<label class="label label-info">Child</label>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
{{ devicetype.get_subdevice_role_display|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -48,7 +48,7 @@ class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_):
|
||||
try:
|
||||
group_list.append(Group.objects.get(name=name))
|
||||
except Group.DoesNotExist:
|
||||
logging.error("Could not assign group {name} to remotely-authenticated user {user}: Group not found")
|
||||
logging.error(f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
|
||||
if group_list:
|
||||
user.groups.add(*group_list)
|
||||
logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}")
|
||||
|
@ -92,7 +92,7 @@ class CustomChoiceFieldInspector(FieldInspector):
|
||||
value_schema = openapi.Schema(type=schema_type, enum=choice_value)
|
||||
value_schema['x-nullable'] = True
|
||||
|
||||
if isinstance(choice_value[0], int):
|
||||
if all(type(x) == int for x in [c for c in choice_value if c is not None]):
|
||||
# Change value_schema for IPAddressFamilyChoices, RackWidthChoices
|
||||
value_schema = openapi.Schema(type=openapi.TYPE_INTEGER, enum=choice_value)
|
||||
|
||||
|
@ -10,6 +10,7 @@ from django.conf import settings
|
||||
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
|
||||
from django.db.models import Count
|
||||
from django.forms import BoundField
|
||||
from django.forms.models import fields_for_model
|
||||
from django.urls import reverse
|
||||
|
||||
from .choices import unpack_grouped_choices
|
||||
@ -123,6 +124,19 @@ def add_blank_choice(choices):
|
||||
return ((None, '---------'),) + tuple(choices)
|
||||
|
||||
|
||||
def form_from_model(model, fields):
|
||||
"""
|
||||
Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used
|
||||
for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields
|
||||
are marked as not required.
|
||||
"""
|
||||
form_fields = fields_for_model(model, fields=fields)
|
||||
for field in form_fields.values():
|
||||
field.required = False
|
||||
|
||||
return type('FormFromModel', (forms.Form,), form_fields)
|
||||
|
||||
|
||||
#
|
||||
# Widgets
|
||||
#
|
||||
|
@ -75,7 +75,7 @@ def naturalize_interface(value, max_length):
|
||||
if part is not None:
|
||||
output += part.rjust(6, '0')
|
||||
else:
|
||||
output += '000000'
|
||||
output += '......'
|
||||
|
||||
# Finally, naturalize any remaining text and append it
|
||||
if match.group('remainder') is not None and len(output) < max_length:
|
||||
|
9
netbox/utilities/query_functions.py
Normal file
9
netbox/utilities/query_functions.py
Normal file
@ -0,0 +1,9 @@
|
||||
from django.db.models import F, Func
|
||||
|
||||
|
||||
class CollateAsChar(Func):
|
||||
"""
|
||||
Disregard localization by collating a field as a plain character string. Helpful for ensuring predictable ordering.
|
||||
"""
|
||||
function = 'C'
|
||||
template = '(%(expressions)s) COLLATE "%(function)s"'
|
@ -30,29 +30,32 @@ class NaturalizationTestCase(TestCase):
|
||||
|
||||
# Original, naturalized
|
||||
data = (
|
||||
|
||||
# IOS/JunOS-style
|
||||
('Gi', '9999999999999999Gi000000000000000000'),
|
||||
('Gi1', '9999999999999999Gi000001000000000000'),
|
||||
('Gi1.0', '9999999999999999Gi000001000000000000'),
|
||||
('Gi1.1', '9999999999999999Gi000001000000000001'),
|
||||
('Gi1:0', '9999999999999999Gi000001000000000000'),
|
||||
('Gi', '9999999999999999Gi..................'),
|
||||
('Gi1', '9999999999999999Gi000001............'),
|
||||
('Gi1.0', '9999999999999999Gi000001......000000'),
|
||||
('Gi1.1', '9999999999999999Gi000001......000001'),
|
||||
('Gi1:0', '9999999999999999Gi000001000000......'),
|
||||
('Gi1:0.0', '9999999999999999Gi000001000000000000'),
|
||||
('Gi1:0.1', '9999999999999999Gi000001000000000001'),
|
||||
('Gi1:1', '9999999999999999Gi000001000001000000'),
|
||||
('Gi1:1', '9999999999999999Gi000001000001......'),
|
||||
('Gi1:1.0', '9999999999999999Gi000001000001000000'),
|
||||
('Gi1:1.1', '9999999999999999Gi000001000001000001'),
|
||||
('Gi1/2', '0001999999999999Gi000002000000000000'),
|
||||
('Gi1/2/3', '0001000299999999Gi000003000000000000'),
|
||||
('Gi1/2/3/4', '0001000200039999Gi000004000000000000'),
|
||||
('Gi1/2/3/4/5', '0001000200030004Gi000005000000000000'),
|
||||
('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006000000'),
|
||||
('Gi1/2', '0001999999999999Gi000002............'),
|
||||
('Gi1/2/3', '0001000299999999Gi000003............'),
|
||||
('Gi1/2/3/4', '0001000200039999Gi000004............'),
|
||||
('Gi1/2/3/4/5', '0001000200030004Gi000005............'),
|
||||
('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006......'),
|
||||
('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'),
|
||||
|
||||
# Generic
|
||||
('Interface 1', '9999999999999999Interface 000001000000000000'),
|
||||
('Interface 1 (other)', '9999999999999999Interface 000001000000000000 (other)'),
|
||||
('Interface 99', '9999999999999999Interface 000099000000000000'),
|
||||
('PCIe1-p1', '9999999999999999PCIe000001000000000000-p00000001'),
|
||||
('PCIe1-p99', '9999999999999999PCIe000001000000000000-p00000099'),
|
||||
('Interface 1', '9999999999999999Interface 000001............'),
|
||||
('Interface 1 (other)', '9999999999999999Interface 000001............ (other)'),
|
||||
('Interface 99', '9999999999999999Interface 000099............'),
|
||||
('PCIe1-p1', '9999999999999999PCIe000001............-p00000001'),
|
||||
('PCIe1-p99', '9999999999999999PCIe000001............-p00000099'),
|
||||
|
||||
)
|
||||
|
||||
for origin, naturalized in data:
|
||||
|
@ -972,25 +972,32 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
|
||||
new_components = []
|
||||
data = deepcopy(form.cleaned_data)
|
||||
|
||||
for obj in data['pk']:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
|
||||
names = data['name_pattern']
|
||||
for name in names:
|
||||
component_data = {
|
||||
self.parent_field: obj.pk,
|
||||
'name': name,
|
||||
}
|
||||
component_data.update(data)
|
||||
component_form = self.model_form(component_data)
|
||||
if component_form.is_valid():
|
||||
new_components.append(component_form.save(commit=False))
|
||||
else:
|
||||
for field, errors in component_form.errors.as_data().items():
|
||||
for e in errors:
|
||||
form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
|
||||
for obj in data['pk']:
|
||||
|
||||
names = data['name_pattern']
|
||||
for name in names:
|
||||
component_data = {
|
||||
self.parent_field: obj.pk,
|
||||
'name': name,
|
||||
}
|
||||
component_data.update(data)
|
||||
component_form = self.model_form(component_data)
|
||||
if component_form.is_valid():
|
||||
instance = component_form.save()
|
||||
logger.debug(f"Created {instance} on {instance.parent}")
|
||||
new_components.append(instance)
|
||||
else:
|
||||
for field, errors in component_form.errors.as_data().items():
|
||||
for e in errors:
|
||||
form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
|
||||
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if not form.errors:
|
||||
self.model.objects.bulk_create(new_components)
|
||||
msg = "Added {} {} to {} {}.".format(
|
||||
len(new_components),
|
||||
model_name,
|
||||
|
@ -15,7 +15,8 @@ from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
ExpandableNameField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
||||
ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple,
|
||||
TagFilterField,
|
||||
)
|
||||
from .choices import *
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
@ -827,24 +828,18 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form):
|
||||
label='Name'
|
||||
)
|
||||
|
||||
def clean_tags(self):
|
||||
# Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we
|
||||
# must first convert the list of tags to a string.
|
||||
return ','.join(self.cleaned_data.get('tags'))
|
||||
|
||||
class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm):
|
||||
|
||||
class InterfaceBulkCreateForm(
|
||||
form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']),
|
||||
VirtualMachineBulkAddComponentForm
|
||||
):
|
||||
type = forms.ChoiceField(
|
||||
choices=VMInterfaceTypeChoices,
|
||||
initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
enabled = forms.BooleanField(
|
||||
required=False,
|
||||
initial=True
|
||||
)
|
||||
mtu = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=INTERFACE_MTU_MIN,
|
||||
max_value=INTERFACE_MTU_MAX,
|
||||
label='MTU'
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
|
@ -366,7 +366,7 @@ class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentC
|
||||
permission_required = 'dcim.add_interface'
|
||||
parent_model = VirtualMachine
|
||||
parent_field = 'virtual_machine'
|
||||
form = forms.VirtualMachineBulkAddInterfaceForm
|
||||
form = forms.InterfaceBulkCreateForm
|
||||
model = Interface
|
||||
model_form = forms.InterfaceForm
|
||||
filterset = filters.VirtualMachineFilterSet
|
||||
|
Loading…
Reference in New Issue
Block a user