mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Merge develop into develop-2.10
This commit is contained in:
@ -25,7 +25,7 @@ Begin by installing all system packages required by NetBox and its dependencies.
|
||||
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
|
||||
|
||||
```no-highlight
|
||||
# pip install --upgrade pip
|
||||
# pip3 install --upgrade pip
|
||||
```
|
||||
|
||||
## Download NetBox
|
||||
|
@ -4,8 +4,15 @@
|
||||
|
||||
Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect.
|
||||
|
||||
!!! note
|
||||
Beginning with version 2.8, NetBox requires Python 3.6 or later.
|
||||
## Update Dependencies to Required Versions
|
||||
|
||||
NetBox v2.9.0 and later requires the following:
|
||||
|
||||
| Dependency | Minimum Version |
|
||||
|------------|-----------------|
|
||||
| Python | 3.6 |
|
||||
| PostgreSQL | 9.6 |
|
||||
| Redis | 4.0 |
|
||||
|
||||
## Install the Latest Code
|
||||
|
||||
|
@ -4,7 +4,7 @@ Interfaces in NetBox represent network interfaces used to exchange data with con
|
||||
|
||||
Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces.
|
||||
|
||||
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. Like all virtual interfaces, LAG interfaces cannot be connected physically.
|
||||
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
|
||||
|
||||
IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.)
|
||||
|
||||
|
@ -10,6 +10,7 @@ Each IP address can also be assigned an operational status and a functional role
|
||||
* Reserved
|
||||
* Deprecated
|
||||
* DHCP
|
||||
* SLAAC (IPv6 Stateless Address Autoconfiguration)
|
||||
|
||||
Roles are used to indicate some special attribute of an IP address; for example, use as a loopback or as the the virtual IP for a VRRP group. (Note that functional roles are conceptual in nature, and thus cannot be customized by the user.) Available roles include:
|
||||
|
||||
|
@ -328,6 +328,9 @@ A `PluginMenuButton` has the following attributes:
|
||||
* `color` - One of the choices provided by `ButtonColorChoices` (optional)
|
||||
* `permissions` - A list of permissions required to display this button (optional)
|
||||
|
||||
!!! note
|
||||
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
|
||||
|
||||
## Extending Core Templates
|
||||
|
||||
Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:
|
||||
|
@ -1,7 +1,69 @@
|
||||
# NetBox v2.9
|
||||
|
||||
## v2.9.3 (2020-09-04)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4977](https://github.com/netbox-community/netbox/issues/4977) - Redirect authenticated users from login view
|
||||
* [#5048](https://github.com/netbox-community/netbox/issues/5048) - Show the device/VM name when editing a component
|
||||
* [#5072](https://github.com/netbox-community/netbox/issues/5072) - Add REST API filters for image attachments
|
||||
* [#5080](https://github.com/netbox-community/netbox/issues/5080) - Add 8P6C, 8P4C, 8P2C port types
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5046](https://github.com/netbox-community/netbox/issues/5046) - Disabled plugin menu items are no longer clickable
|
||||
* [#5063](https://github.com/netbox-community/netbox/issues/5063) - Fix "add device" link in rack elevations for opposite side of half-depth devices
|
||||
* [#5074](https://github.com/netbox-community/netbox/issues/5074) - Fix inclusion of VC member interfaces when viewing VC master
|
||||
* [#5078](https://github.com/netbox-community/netbox/issues/5078) - Fix assignment of existing IP addresses to interfaces via web UI
|
||||
* [#5081](https://github.com/netbox-community/netbox/issues/5081) - Fix exception during webhook processing with custom select field
|
||||
* [#5085](https://github.com/netbox-community/netbox/issues/5085) - Fix ordering by assignment in IP addresses table
|
||||
* [#5087](https://github.com/netbox-community/netbox/issues/5087) - Restore label field when editing console server ports, power ports, and power outlets
|
||||
* [#5089](https://github.com/netbox-community/netbox/issues/5089) - Redirect to device view after editing component
|
||||
* [#5090](https://github.com/netbox-community/netbox/issues/5090) - Fix status display for console/power/interface connections
|
||||
* [#5091](https://github.com/netbox-community/netbox/issues/5091) - Avoid KeyError when handling invalid table preferences
|
||||
* [#5095](https://github.com/netbox-community/netbox/issues/5095) - Show assigned prefixes in VLANs list
|
||||
|
||||
---
|
||||
|
||||
## v2.9.2 (2020-08-27)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5055](https://github.com/netbox-community/netbox/issues/5055) - Add tags column to device/VM component list tables
|
||||
* [#5056](https://github.com/netbox-community/netbox/issues/5056) - Add interface and parent columns to IP address list
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#4988](https://github.com/netbox-community/netbox/issues/4988) - Fix ordering of rack reservations with identical creation times
|
||||
* [#5002](https://github.com/netbox-community/netbox/issues/5002) - Correct OpenAPI definition for `available-prefixes` endpoint
|
||||
* [#5035](https://github.com/netbox-community/netbox/issues/5035) - Fix exception when modifying an IP address assigned to a VM
|
||||
* [#5038](https://github.com/netbox-community/netbox/issues/5038) - Fix validation of primary IPs assigned to virtual machines
|
||||
* [#5040](https://github.com/netbox-community/netbox/issues/5040) - Limit SLAAC status to IPv6 addresses
|
||||
* [#5041](https://github.com/netbox-community/netbox/issues/5041) - Fix form tabs when assigning an IP to a VM interface
|
||||
* [#5042](https://github.com/netbox-community/netbox/issues/5042) - Fix display of SLAAC label for IP addresses status
|
||||
* [#5045](https://github.com/netbox-community/netbox/issues/5045) - Allow assignment of interfaces to non-master VC peer LAG during import
|
||||
* [#5058](https://github.com/netbox-community/netbox/issues/5058) - Correct URL for front rack elevation images when using external storage
|
||||
* [#5059](https://github.com/netbox-community/netbox/issues/5059) - Fix inclusion of checkboxes for interfaces in virtual machine view
|
||||
* [#5060](https://github.com/netbox-community/netbox/issues/5060) - Fix validation when bulk-importing child devices
|
||||
* [#5061](https://github.com/netbox-community/netbox/issues/5061) - Allow adding/removing tags when bulk editing virtual machine interfaces
|
||||
|
||||
---
|
||||
|
||||
## v2.9.1 (2020-08-22)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4540](https://github.com/netbox-community/netbox/issues/4540) - Add IP address status type for SLAAC
|
||||
* [#4814](https://github.com/netbox-community/netbox/issues/4814) - Allow nested LAG interfaces
|
||||
* [#4991](https://github.com/netbox-community/netbox/issues/4991) - Add Python and NetBox versions to error page
|
||||
* [#5033](https://github.com/netbox-community/netbox/issues/5033) - Support backward compatibility for `REMOTE_AUTH_BACKEND` configuration parameter
|
||||
|
||||
---
|
||||
|
||||
## v2.9.0 (2020-08-21)
|
||||
|
||||
**Note:** Redis 4.0 or later is required for this release.
|
||||
|
||||
### New Features
|
||||
|
||||
#### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554))
|
||||
@ -56,7 +118,8 @@ Two new REST API endpoints have been added to facilitate the retrieval and manip
|
||||
|
||||
### Configuration Changes
|
||||
|
||||
* If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
|
||||
* If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved.
|
||||
* If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
|
||||
* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
|
||||
|
||||
### REST API Changes
|
||||
|
@ -75,6 +75,7 @@ nav:
|
||||
- User Preferences: 'development/user-preferences.md'
|
||||
- Release Checklist: 'development/release-checklist.md'
|
||||
- Release Notes:
|
||||
- Version 2.9: 'release-notes/version-2.9.md'
|
||||
- Version 2.8: 'release-notes/version-2.8.md'
|
||||
- Version 2.7: 'release-notes/version-2.7.md'
|
||||
- Version 2.6: 'release-notes/version-2.6.md'
|
||||
|
@ -814,6 +814,9 @@ class InterfaceModeChoices(ChoiceSet):
|
||||
class PortTypeChoices(ChoiceSet):
|
||||
|
||||
TYPE_8P8C = '8p8c'
|
||||
TYPE_8P6C = '8p6c'
|
||||
TYPE_8P4C = '8p4c'
|
||||
TYPE_8P2C = '8p2c'
|
||||
TYPE_110_PUNCH = '110-punch'
|
||||
TYPE_BNC = 'bnc'
|
||||
TYPE_MRJ21 = 'mrj21'
|
||||
@ -833,6 +836,9 @@ class PortTypeChoices(ChoiceSet):
|
||||
'Copper',
|
||||
(
|
||||
(TYPE_8P8C, '8P8C'),
|
||||
(TYPE_8P6C, '8P6C'),
|
||||
(TYPE_8P4C, '8P4C'),
|
||||
(TYPE_8P2C, '8P2C'),
|
||||
(TYPE_110_PUNCH, '110 Punch'),
|
||||
(TYPE_BNC, 'BNC'),
|
||||
(TYPE_MRJ21, 'MRJ21'),
|
||||
|
@ -94,8 +94,12 @@ class RackElevationSVG:
|
||||
|
||||
# Embed front device type image if one exists
|
||||
if self.include_images and device.device_type.front_image:
|
||||
url = '{}{}'.format(self.base_url, device.device_type.front_image.url)
|
||||
image = drawing.image(href=url, insert=start, size=end, class_='device-image')
|
||||
image = drawing.image(
|
||||
href=device.device_type.front_image.url,
|
||||
insert=start,
|
||||
size=end,
|
||||
class_='device-image'
|
||||
)
|
||||
image.fit(scale='slice')
|
||||
link.add(image)
|
||||
|
||||
@ -107,8 +111,12 @@ class RackElevationSVG:
|
||||
|
||||
# Embed rear device type image if one exists
|
||||
if self.include_images and device.device_type.rear_image:
|
||||
url = device.device_type.rear_image.url
|
||||
image = drawing.image(href=url, insert=start, size=end, class_='device-image')
|
||||
image = drawing.image(
|
||||
href=device.device_type.rear_image.url,
|
||||
insert=start,
|
||||
size=end,
|
||||
class_='device-image'
|
||||
)
|
||||
image.fit(scale='slice')
|
||||
drawing.add(image)
|
||||
|
||||
@ -141,7 +149,7 @@ class RackElevationSVG:
|
||||
unit_cursor = 0
|
||||
for u in elevation:
|
||||
o = other[unit_cursor]
|
||||
if not u['device'] and o['device']:
|
||||
if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
|
||||
u['device'] = o['device']
|
||||
u['height'] = 1
|
||||
unit_cursor += u.get('height', 1)
|
||||
|
@ -1811,7 +1811,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
nat_inside__assigned_object_id__in=interface_ids
|
||||
).prefetch_related('assigned_object')
|
||||
if nat_ips:
|
||||
ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in nat_ips]
|
||||
ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
|
||||
ip_choices.append(('NAT IPs', ip_list))
|
||||
self.fields['primary_ip{}'.format(family)].choices = ip_choices
|
||||
|
||||
@ -2317,7 +2317,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = [
|
||||
'device', 'name', 'type', 'description', 'tags',
|
||||
'device', 'name', 'label', 'type', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
@ -2390,7 +2390,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = [
|
||||
'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
|
||||
'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
@ -2479,7 +2479,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = [
|
||||
'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags',
|
||||
'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
@ -2686,7 +2686,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
|
||||
device_query = Q(device=device)
|
||||
if device.virtual_chassis:
|
||||
device_query |= Q(device__virtual_chassis=device.virtual_chassis)
|
||||
self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG)
|
||||
self.fields['lag'].queryset = Interface.objects.filter(
|
||||
device_query,
|
||||
type=InterfaceTypeChoices.TYPE_LAG
|
||||
).exclude(pk=self.instance.pk)
|
||||
|
||||
# Add current site to VLANs query params
|
||||
self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
|
||||
@ -2876,17 +2879,22 @@ class InterfaceCSVForm(CSVModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces belonging to this device (or VC master)
|
||||
# Limit LAG choices to interfaces belonging to this device (or virtual chassis)
|
||||
device = None
|
||||
if self.is_bound and 'device' in self.data:
|
||||
try:
|
||||
device = self.fields['device'].to_python(self.data['device'])
|
||||
except forms.ValidationError:
|
||||
pass
|
||||
|
||||
if device:
|
||||
if device and device.virtual_chassis:
|
||||
self.fields['lag'].queryset = Interface.objects.filter(
|
||||
device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG
|
||||
Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis),
|
||||
type=InterfaceTypeChoices.TYPE_LAG
|
||||
)
|
||||
elif device:
|
||||
self.fields['lag'].queryset = Interface.objects.filter(
|
||||
device=device,
|
||||
type=InterfaceTypeChoices.TYPE_LAG
|
||||
)
|
||||
else:
|
||||
self.fields['lag'].queryset = Interface.objects.none()
|
||||
|
17
netbox/dcim/migrations/0115_rackreservation_order.py
Normal file
17
netbox/dcim/migrations/0115_rackreservation_order.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.1 on 2020-08-24 16:03
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0114_update_jsonfield'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='rackreservation',
|
||||
options={'ordering': ['created', 'pk']},
|
||||
),
|
||||
]
|
@ -702,18 +702,12 @@ class Interface(CableTermination, ComponentModel, BaseInterface):
|
||||
})
|
||||
|
||||
# A virtual interface cannot have a parent LAG
|
||||
if self.type in NONCONNECTABLE_IFACE_TYPES and self.lag is not None:
|
||||
raise ValidationError({
|
||||
'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_type_display())
|
||||
})
|
||||
if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
|
||||
raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."})
|
||||
|
||||
# Only a LAG can have LAG members
|
||||
if self.type != InterfaceTypeChoices.TYPE_LAG and self.member_interfaces.exists():
|
||||
raise ValidationError({
|
||||
'type': "Cannot change interface type; it has LAG members ({}).".format(
|
||||
", ".join([iface.name for iface in self.member_interfaces.all()])
|
||||
)
|
||||
})
|
||||
# A LAG interface cannot be its own parent
|
||||
if self.pk and self.lag_id == self.pk:
|
||||
raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
|
||||
|
||||
# Validate untagged VLAN
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
|
||||
|
@ -633,7 +633,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
# Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
|
||||
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
|
||||
# of the uniqueness constraint without manual intervention.
|
||||
if self.name and self.tenant is None:
|
||||
if self.name and hasattr(self, 'site') and self.tenant is None:
|
||||
if Device.objects.exclude(pk=self.pk).filter(
|
||||
name=self.name,
|
||||
site=self.site,
|
||||
|
@ -600,7 +600,7 @@ class RackReservation(ChangeLoggedModel):
|
||||
csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ['created']
|
||||
ordering = ['created', 'pk']
|
||||
|
||||
def __str__(self):
|
||||
return "Reservation for rack {}".format(self.rack)
|
||||
|
@ -152,6 +152,10 @@ INTERFACE_TAGGED_VLANS = """
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
CONNECTION_STATUS = """
|
||||
<span class="label label-{% if record.connection_status %}success{% else %}danger{% endif %}">{{ record.get_connection_status_display }}</span>
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Regions
|
||||
@ -706,34 +710,48 @@ class DeviceComponentTable(BaseTable):
|
||||
|
||||
|
||||
class ConsolePortTable(DeviceComponentTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:consoleport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsolePort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
class ConsoleServerPortTable(DeviceComponentTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:consoleserverport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsoleServerPort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
class PowerPortTable(DeviceComponentTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:powerport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerPort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable')
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||
|
||||
|
||||
class PowerOutletTable(DeviceComponentTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:poweroutlet_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerOutlet
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable')
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
|
||||
|
||||
|
||||
@ -753,12 +771,15 @@ class BaseInterfaceTable(BaseTable):
|
||||
|
||||
|
||||
class InterfaceTable(DeviceComponentTable, BaseInterfaceTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:interface_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = Interface
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
|
||||
'description', 'cable', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
|
||||
'description', 'cable', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
@ -767,18 +788,26 @@ class FrontPortTable(DeviceComponentTable):
|
||||
rear_port_position = tables.Column(
|
||||
verbose_name='Position'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:frontport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = FrontPort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable')
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
|
||||
|
||||
|
||||
class RearPortTable(DeviceComponentTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:rearport_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = RearPort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable')
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
@ -786,10 +815,13 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
installed_device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='dcim:devicebay_list'
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = DeviceBay
|
||||
fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
|
||||
fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description', 'tags')
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
|
||||
|
||||
|
||||
@ -798,12 +830,16 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
linkify=True
|
||||
)
|
||||
discovered = BooleanColumn()
|
||||
tags = TagColumn(
|
||||
url_name='dcim:inventoryitem_list'
|
||||
)
|
||||
cable = None # Override DeviceComponentTable
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = InventoryItem
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
|
||||
'discovered',
|
||||
'discovered', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
|
||||
|
||||
@ -876,15 +912,20 @@ class ConsoleConnectionTable(BaseTable):
|
||||
verbose_name='Console Server'
|
||||
)
|
||||
connected_endpoint = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Port'
|
||||
)
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
name = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Console Port'
|
||||
)
|
||||
connection_status = BooleanColumn()
|
||||
connection_status = tables.TemplateColumn(
|
||||
template_code=CONNECTION_STATUS,
|
||||
verbose_name='Status'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ConsolePort
|
||||
@ -901,14 +942,20 @@ class PowerConnectionTable(BaseTable):
|
||||
)
|
||||
outlet = tables.Column(
|
||||
accessor=Accessor('_connected_poweroutlet'),
|
||||
linkify=True,
|
||||
verbose_name='Outlet'
|
||||
)
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
name = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Power Port'
|
||||
)
|
||||
connection_status = tables.TemplateColumn(
|
||||
template_code=CONNECTION_STATUS,
|
||||
verbose_name='Status'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPort
|
||||
@ -940,6 +987,10 @@ class InterfaceConnectionTable(BaseTable):
|
||||
args=[Accessor('_connected_interface__pk')],
|
||||
verbose_name='Interface B'
|
||||
)
|
||||
connection_status = tables.TemplateColumn(
|
||||
template_code=CONNECTION_STATUS,
|
||||
verbose_name='Status'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
|
@ -1030,7 +1030,7 @@ class DeviceView(ObjectView):
|
||||
)
|
||||
|
||||
# Interfaces
|
||||
interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related(
|
||||
interfaces = device.vc_interfaces.restrict(request.user, 'view').prefetch_related(
|
||||
Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
|
||||
Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
|
||||
'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
|
||||
@ -1228,6 +1228,7 @@ class ConsolePortCreateView(ComponentCreateView):
|
||||
class ConsolePortEditView(ObjectEditView):
|
||||
queryset = ConsolePort.objects.all()
|
||||
model_form = forms.ConsolePortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class ConsolePortDeleteView(ObjectDeleteView):
|
||||
@ -1287,6 +1288,7 @@ class ConsoleServerPortCreateView(ComponentCreateView):
|
||||
class ConsoleServerPortEditView(ObjectEditView):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
model_form = forms.ConsoleServerPortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class ConsoleServerPortDeleteView(ObjectDeleteView):
|
||||
@ -1346,6 +1348,7 @@ class PowerPortCreateView(ComponentCreateView):
|
||||
class PowerPortEditView(ObjectEditView):
|
||||
queryset = PowerPort.objects.all()
|
||||
model_form = forms.PowerPortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class PowerPortDeleteView(ObjectDeleteView):
|
||||
@ -1405,6 +1408,7 @@ class PowerOutletCreateView(ComponentCreateView):
|
||||
class PowerOutletEditView(ObjectEditView):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
model_form = forms.PowerOutletForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class PowerOutletDeleteView(ObjectDeleteView):
|
||||
@ -1556,6 +1560,7 @@ class FrontPortCreateView(ComponentCreateView):
|
||||
class FrontPortEditView(ObjectEditView):
|
||||
queryset = FrontPort.objects.all()
|
||||
model_form = forms.FrontPortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class FrontPortDeleteView(ObjectDeleteView):
|
||||
@ -1615,6 +1620,7 @@ class RearPortCreateView(ComponentCreateView):
|
||||
class RearPortEditView(ObjectEditView):
|
||||
queryset = RearPort.objects.all()
|
||||
model_form = forms.RearPortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class RearPortDeleteView(ObjectDeleteView):
|
||||
@ -1674,6 +1680,7 @@ class DeviceBayCreateView(ComponentCreateView):
|
||||
class DeviceBayEditView(ObjectEditView):
|
||||
queryset = DeviceBay.objects.all()
|
||||
model_form = forms.DeviceBayForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class DeviceBayDeleteView(ObjectDeleteView):
|
||||
|
@ -158,7 +158,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
||||
instance.custom_fields = {}
|
||||
for field in custom_fields:
|
||||
value = instance.cf.get(field.name)
|
||||
if field.type == CustomFieldTypeChoices.TYPE_SELECT and value is not None:
|
||||
if field.type == CustomFieldTypeChoices.TYPE_SELECT and value:
|
||||
instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
|
||||
else:
|
||||
instance.custom_fields[field.name] = value
|
||||
|
@ -129,6 +129,7 @@ class ImageAttachmentViewSet(ModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = ImageAttachment.objects.all()
|
||||
serializer_class = serializers.ImageAttachmentSerializer
|
||||
filterset_class = filters.ImageAttachmentFilterSet
|
||||
|
||||
|
||||
#
|
||||
|
@ -7,7 +7,7 @@ from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.filters import BaseFilterSet
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from .choices import *
|
||||
from .models import ConfigContext, CustomField, ExportTemplate, ObjectChange, JobResult, Tag
|
||||
from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag
|
||||
|
||||
|
||||
__all__ = (
|
||||
@ -16,6 +16,7 @@ __all__ = (
|
||||
'CustomFieldFilter',
|
||||
'CustomFieldFilterSet',
|
||||
'ExportTemplateFilterSet',
|
||||
'ImageAttachmentFilterSet',
|
||||
'LocalConfigContextFilterSet',
|
||||
'ObjectChangeFilterSet',
|
||||
'TagFilterSet',
|
||||
@ -96,6 +97,13 @@ class ExportTemplateFilterSet(BaseFilterSet):
|
||||
fields = ['id', 'content_type', 'name']
|
||||
|
||||
|
||||
class ImageAttachmentFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ImageAttachment
|
||||
fields = ['id', 'content_type', 'object_id', 'name']
|
||||
|
||||
|
||||
class TagFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
@ -1,9 +1,9 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from dcim.models import DeviceRole, Platform, Rack, Region, Site
|
||||
from extras.filters import *
|
||||
from extras.models import ConfigContext, ExportTemplate, Tag
|
||||
from extras.models import ConfigContext, ExportTemplate, ImageAttachment, Tag
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
@ -37,6 +37,84 @@ class ExportTemplateTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class ImageAttachmentTestCase(TestCase):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
filterset = ImageAttachmentFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site_ct = ContentType.objects.get(app_label='dcim', model='site')
|
||||
rack_ct = ContentType.objects.get(app_label='dcim', model='rack')
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
racks = (
|
||||
Rack(name='Rack 1', site=sites[0]),
|
||||
Rack(name='Rack 2', site=sites[1]),
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
image_attachments = (
|
||||
ImageAttachment(
|
||||
content_type=site_ct,
|
||||
object_id=sites[0].pk,
|
||||
name='Image Attachment 1',
|
||||
image='http://example.com/image1.png',
|
||||
image_height=100,
|
||||
image_width=100
|
||||
),
|
||||
ImageAttachment(
|
||||
content_type=site_ct,
|
||||
object_id=sites[1].pk,
|
||||
name='Image Attachment 2',
|
||||
image='http://example.com/image2.png',
|
||||
image_height=100,
|
||||
image_width=100
|
||||
),
|
||||
ImageAttachment(
|
||||
content_type=rack_ct,
|
||||
object_id=racks[0].pk,
|
||||
name='Image Attachment 3',
|
||||
image='http://example.com/image3.png',
|
||||
image_height=100,
|
||||
image_width=100
|
||||
),
|
||||
ImageAttachment(
|
||||
content_type=rack_ct,
|
||||
object_id=racks[1].pk,
|
||||
name='Image Attachment 4',
|
||||
image='http://example.com/image4.png',
|
||||
image_height=100,
|
||||
image_width=100
|
||||
)
|
||||
)
|
||||
ImageAttachment.objects.bulk_create(image_attachments)
|
||||
|
||||
def test_id(self):
|
||||
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Image Attachment 1', 'Image Attachment 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_content_type(self):
|
||||
params = {'content_type': ContentType.objects.get(app_label='dcim', model='site').pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_content_type_and_object_id(self):
|
||||
params = {
|
||||
'content_type': ContentType.objects.get(app_label='dcim', model='site').pk,
|
||||
'object_id': [Site.objects.first().pk],
|
||||
}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class ConfigContextTestCase(TestCase):
|
||||
queryset = ConfigContext.objects.all()
|
||||
filterset = ConfigContextFilterSet
|
||||
|
@ -88,7 +88,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
return super().get_serializer_class()
|
||||
|
||||
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
||||
@swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
|
||||
@swagger_auto_schema(method='post', responses={201: serializers.PrefixSerializer(many=False)})
|
||||
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
|
||||
def available_prefixes(self, request, pk=None):
|
||||
@ -247,7 +247,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags',
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
|
||||
)
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
filterset_class = filters.IPAddressFilterSet
|
||||
|
@ -41,12 +41,14 @@ class IPAddressStatusChoices(ChoiceSet):
|
||||
STATUS_RESERVED = 'reserved'
|
||||
STATUS_DEPRECATED = 'deprecated'
|
||||
STATUS_DHCP = 'dhcp'
|
||||
STATUS_SLAAC = 'slaac'
|
||||
|
||||
CHOICES = (
|
||||
(STATUS_ACTIVE, 'Active'),
|
||||
(STATUS_RESERVED, 'Reserved'),
|
||||
(STATUS_DEPRECATED, 'Deprecated'),
|
||||
(STATUS_DHCP, 'DHCP'),
|
||||
(STATUS_SLAAC, 'SLAAC'),
|
||||
)
|
||||
|
||||
|
||||
|
@ -669,6 +669,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
'reserved': 'info',
|
||||
'deprecated': 'danger',
|
||||
'dhcp': 'success',
|
||||
'slaac': 'success',
|
||||
}
|
||||
|
||||
ROLE_CLASS_MAP = {
|
||||
@ -745,12 +746,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
|
||||
f"interface"
|
||||
})
|
||||
elif self.interface.virtual_machine != vm:
|
||||
elif self.assigned_object.virtual_machine != vm:
|
||||
raise ValidationError({
|
||||
'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
|
||||
f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
|
||||
})
|
||||
|
||||
# Validate IP status selection
|
||||
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
|
||||
raise ValidationError({
|
||||
'status': "Only IPv6 addresses can be assigned SLAAC status"
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Force dns_name to lowercase
|
||||
|
@ -67,11 +67,7 @@ IPADDRESS_LINK = """
|
||||
"""
|
||||
|
||||
IPADDRESS_ASSIGN_LINK = """
|
||||
{% if request.GET %}
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
|
||||
"""
|
||||
|
||||
VRF_LINK = """
|
||||
@ -103,7 +99,7 @@ VLAN_LINK = """
|
||||
"""
|
||||
|
||||
VLAN_PREFIXES = """
|
||||
{% for prefix in record.prefixes.unrestricted %}
|
||||
{% for prefix in record.prefixes.all %}
|
||||
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% empty %}
|
||||
—
|
||||
@ -387,15 +383,23 @@ class IPAddressTable(BaseTable):
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=TENANT_LINK
|
||||
)
|
||||
assigned = tables.BooleanColumn(
|
||||
accessor='assigned_object_id',
|
||||
verbose_name='Assigned'
|
||||
assigned_object = tables.Column(
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
verbose_name='Interface'
|
||||
)
|
||||
assigned_object_parent = tables.Column(
|
||||
accessor='assigned_object__parent',
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
verbose_name='Interface Parent'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = (
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'assigned_object_parent', 'dns_name',
|
||||
'description',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
|
||||
@ -411,6 +415,10 @@ class IPAddressDetailTable(IPAddressTable):
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
assigned = tables.BooleanColumn(
|
||||
accessor='assigned_object_id',
|
||||
verbose_name='Assigned'
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:ipaddress_list'
|
||||
)
|
||||
|
@ -493,7 +493,7 @@ class PrefixBulkDeleteView(BulkDeleteView):
|
||||
|
||||
class IPAddressListView(ObjectListView):
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside'
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'assigned_object'
|
||||
)
|
||||
filterset = filters.IPAddressFilterSet
|
||||
filterset_form = forms.IPAddressFilterForm
|
||||
@ -582,7 +582,7 @@ class IPAddressAssignView(ObjectView):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
# Redirect user if an interface has not been provided
|
||||
if 'interface' not in request.GET:
|
||||
if 'interface' not in request.GET and 'vminterface' not in request.GET:
|
||||
return redirect('ipam:ipaddress_add')
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
@ -609,7 +609,7 @@ class IPAddressAssignView(ObjectView):
|
||||
return render(request, 'ipam/ipaddress_assign.html', {
|
||||
'form': form,
|
||||
'table': table,
|
||||
'return_url': request.GET.get('return_url', ''),
|
||||
'return_url': request.GET.get('return_url'),
|
||||
})
|
||||
|
||||
|
||||
|
@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.9.1-dev'
|
||||
VERSION = '2.9.4-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@ -132,7 +132,6 @@ if RELEASE_CHECK_URL:
|
||||
if RELEASE_CHECK_TIMEOUT < 3600:
|
||||
raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)")
|
||||
|
||||
|
||||
#
|
||||
# Database
|
||||
#
|
||||
|
@ -31,7 +31,10 @@
|
||||
The complete exception is provided below:
|
||||
</p>
|
||||
<pre><strong>{{ exception }}</strong><br />
|
||||
{{ error }}</pre>
|
||||
{{ error }}
|
||||
|
||||
Python version: {{ python_version }}
|
||||
NetBox version: {{ netbox_version }}</pre>
|
||||
<p>
|
||||
If further assistance is required, please post to the <a href="https://groups.google.com/forum/#!forum/netbox-discuss">NetBox mailing list</a>.
|
||||
</p>
|
||||
|
16
netbox/templates/dcim/device_component_edit.html
Normal file
16
netbox/templates/dcim/device_component_edit.html
Normal file
@ -0,0 +1,16 @@
|
||||
{% extends 'utilities/obj_edit.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form_fields %}
|
||||
{% if form.instance.device %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required" for="id_device">Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">
|
||||
<a href="{{ form.instance.device.get_absolute_url }}">{{ form.instance.device }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% render_form form %}
|
||||
{% endblock %}
|
@ -66,7 +66,7 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if perms.dcim.change_consoleport %}
|
||||
<a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}" title="Edit port" class="btn btn-info btn-xs">
|
||||
<a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -68,7 +68,7 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if perms.dcim.change_consoleserverport %}
|
||||
<a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}" title="Edit port" class="btn btn-info btn-xs">
|
||||
<a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -52,7 +52,7 @@
|
||||
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Install device"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}" class="btn btn-info btn-xs">
|
||||
<a href="{% url 'dcim:devicebay_edit' pk=devicebay.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit device bay"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -81,7 +81,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.change_poweroutlet %}
|
||||
<a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}" title="Edit outlet" class="btn btn-info btn-xs">
|
||||
<a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}?return_url={{ device.get_absolute_url }}" title="Edit outlet" class="btn btn-info btn-xs">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -78,7 +78,7 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if perms.dcim.change_powerport %}
|
||||
<a href="{% url 'dcim:powerport_edit' pk=pp.pk %}" title="Edit port" class="btn btn-info btn-xs">
|
||||
<a href="{% url 'dcim:powerport_edit' pk=pp.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -5,6 +5,16 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Interface</strong></div>
|
||||
<div class="panel-body">
|
||||
{% if form.instance.device %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required" for="id_device">Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">
|
||||
<a href="{{ form.instance.device.get_absolute_url }}">{{ form.instance.device }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.label %}
|
||||
{% render_field form.type %}
|
||||
@ -14,6 +24,11 @@
|
||||
{% render_field form.mtu %}
|
||||
{% render_field form.mgmt_only %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>802.1Q Switching</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.mode %}
|
||||
{% render_field form.untagged_vlan %}
|
||||
{% render_field form.tagged_vlans %}
|
||||
|
@ -5,7 +5,8 @@
|
||||
{% for section_name, menu_items in registry.plugin_menu_items.items %}
|
||||
<li class="dropdown-header">{{ section_name }}</li>
|
||||
{% for menu_item in menu_items %}
|
||||
<li{% if menu_item.permissions and not request.user|has_perms:menu_item.permissions %} class="disabled"{% endif %}>
|
||||
{% if not menu_item.permissions or request.user|has_perms:menu_item.permissions %}
|
||||
<li>
|
||||
{% if menu_item.buttons %}
|
||||
<div class="buttons pull-right">
|
||||
{% for button in menu_item.buttons %}
|
||||
@ -17,6 +18,9 @@
|
||||
{% endif %}
|
||||
<a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="disabled"><a href="#">{{ menu_item.link_text }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not forloop.last %}
|
||||
<li class="divider"></li>
|
||||
|
@ -4,7 +4,7 @@
|
||||
<li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:ipaddress_add' %}{% querystring request %}">New IP</a>
|
||||
</li>
|
||||
{% if 'interface' in request.GET %}
|
||||
{% if 'interface' in request.GET or 'vminterface' in request.GET %}
|
||||
<li role="presentation"{% if active_tab == 'assign' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}">Assign IP</a>
|
||||
</li>
|
||||
|
@ -31,7 +31,9 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
|
||||
<div class="panel-body">
|
||||
{% block form_fields %}
|
||||
{% render_form form %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -2,7 +2,7 @@
|
||||
<tr class="interface{% if not iface.enabled %} danger{% endif %}" id="interface_{{ iface.name }}">
|
||||
|
||||
{# Checkbox #}
|
||||
{% if perms.virtualization.change_interface or perms.virtualization.delete_interface %}
|
||||
{% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
|
||||
<td class="pk">
|
||||
<input name="pk" type="checkbox" value="{{ iface.pk }}" />
|
||||
</td>
|
||||
@ -43,12 +43,12 @@
|
||||
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.virtualization.change_interface %}
|
||||
{% if perms.virtualization.change_vminterface %}
|
||||
<a href="{% url 'virtualization:vminterface_edit' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.virtualization.delete_interface %}
|
||||
{% if perms.virtualization.delete_vminterface %}
|
||||
<a href="{% url 'virtualization:vminterface_delete' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</a>
|
||||
@ -60,7 +60,7 @@
|
||||
{% if ipaddresses %}
|
||||
<tr class="ipaddresses">
|
||||
{# Placeholder #}
|
||||
{% if perms.virtualization.change_interface or perms.virtualization.delete_interface %}
|
||||
{% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
|
||||
{% block title %}Create {{ component_type }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="" method="post" class="form form-horizontal">
|
||||
|
@ -5,14 +5,34 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Interface</strong></div>
|
||||
<div class="panel-body">
|
||||
{% if form.instance.virtual_machine %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required" for="id_device">Virtual Machine</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">
|
||||
<a href="{{ form.instance.virtual_machine.get_absolute_url }}">{{ form.instance.virtual_machine }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.enabled %}
|
||||
{% render_field form.mac_address %}
|
||||
{% render_field form.mtu %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>802.1Q Switching</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.mode %}
|
||||
{% render_field form.untagged_vlan %}
|
||||
{% render_field form.tagged_vlans %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Tags</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -38,6 +38,10 @@ class LoginView(View):
|
||||
def get(self, request):
|
||||
form = LoginForm(request)
|
||||
|
||||
if request.user.is_authenticated:
|
||||
logger = logging.getLogger('netbox.auth.login')
|
||||
return self.redirect_to_next(request, logger)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
})
|
||||
@ -49,12 +53,6 @@ class LoginView(View):
|
||||
if form.is_valid():
|
||||
logger.debug("Login form validation was successful")
|
||||
|
||||
# Determine where to direct user after successful login
|
||||
redirect_to = request.POST.get('next', reverse('home'))
|
||||
if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
|
||||
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
|
||||
redirect_to = reverse('home')
|
||||
|
||||
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
|
||||
# last_login time upon authentication.
|
||||
if settings.MAINTENANCE_MODE:
|
||||
@ -66,8 +64,7 @@ class LoginView(View):
|
||||
logger.info(f"User {request.user} successfully authenticated")
|
||||
messages.info(request, "Logged in as {}.".format(request.user))
|
||||
|
||||
logger.debug(f"Redirecting user to {redirect_to}")
|
||||
return HttpResponseRedirect(redirect_to)
|
||||
return self.redirect_to_next(request, logger)
|
||||
|
||||
else:
|
||||
logger.debug("Login form validation failed")
|
||||
@ -76,6 +73,19 @@ class LoginView(View):
|
||||
'form': form,
|
||||
})
|
||||
|
||||
def redirect_to_next(self, request, logger):
|
||||
if request.method == "POST":
|
||||
redirect_to = request.POST.get('next', reverse('home'))
|
||||
else:
|
||||
redirect_to = request.GET.get('next', reverse('home'))
|
||||
|
||||
if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
|
||||
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
|
||||
redirect_to = reverse('home')
|
||||
|
||||
logger.debug(f"Redirecting user to {redirect_to}")
|
||||
return HttpResponseRedirect(redirect_to)
|
||||
|
||||
|
||||
class LogoutView(View):
|
||||
"""
|
||||
|
@ -44,7 +44,7 @@ class BaseTable(tables.Table):
|
||||
self.columns.show(name)
|
||||
else:
|
||||
self.columns.hide(name)
|
||||
self.sequence = columns
|
||||
self.sequence = [c for c in columns if c in self.base_columns]
|
||||
|
||||
# Always include PK and actions column, if defined on the table
|
||||
if pk:
|
||||
|
@ -1,8 +1,10 @@
|
||||
import logging
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@ -1421,6 +1423,8 @@ def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
|
||||
type_, error, traceback = sys.exc_info()
|
||||
|
||||
return HttpResponseServerError(template.render({
|
||||
'python_version': platform.python_version(),
|
||||
'netbox_version': settings.VERSION,
|
||||
'exception': str(type_),
|
||||
'error': error,
|
||||
}))
|
||||
|
@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
@ -325,28 +326,28 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
# Compile list of choices for primary IPv4 and IPv6 addresses
|
||||
for family in [4, 6]:
|
||||
ip_choices = [(None, '---------')]
|
||||
|
||||
# Gather PKs of all interfaces belonging to this VM
|
||||
interface_ids = self.instance.interfaces.values_list('pk', flat=True)
|
||||
|
||||
# Collect interface IPs
|
||||
interface_ips = IPAddress.objects.prefetch_related('interface').filter(
|
||||
interface_ips = IPAddress.objects.filter(
|
||||
address__family=family,
|
||||
vminterface__in=self.instance.interfaces.values_list('id', flat=True)
|
||||
assigned_object_type=ContentType.objects.get_for_model(VMInterface),
|
||||
assigned_object_id__in=interface_ids
|
||||
)
|
||||
if interface_ips:
|
||||
ip_choices.append(
|
||||
('Interface IPs', [
|
||||
(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
|
||||
])
|
||||
)
|
||||
ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
|
||||
ip_choices.append(('Interface IPs', ip_list))
|
||||
# Collect NAT IPs
|
||||
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
|
||||
address__family=family,
|
||||
nat_inside__vminterface__in=self.instance.interfaces.values_list('id', flat=True)
|
||||
nat_inside__assigned_object_type=ContentType.objects.get_for_model(VMInterface),
|
||||
nat_inside__assigned_object_id__in=interface_ids
|
||||
)
|
||||
if nat_ips:
|
||||
ip_choices.append(
|
||||
('NAT IPs', [
|
||||
(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
|
||||
])
|
||||
)
|
||||
ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
|
||||
ip_choices.append(('NAT IPs', ip_list))
|
||||
self.fields['primary_ip{}'.format(family)].choices = ip_choices
|
||||
|
||||
else:
|
||||
@ -683,7 +684,7 @@ class VMInterfaceCSVForm(CSVModelForm):
|
||||
return self.cleaned_data['enabled']
|
||||
|
||||
|
||||
class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=VMInterface.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
|
@ -335,13 +335,13 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
for field in ['primary_ip4', 'primary_ip6']:
|
||||
ip = getattr(self, field)
|
||||
if ip is not None:
|
||||
if ip.interface in interfaces:
|
||||
if ip.assigned_object in interfaces:
|
||||
pass
|
||||
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in interfaces:
|
||||
elif ip.nat_inside is not None and ip.nat_inside.assigned_object in interfaces:
|
||||
pass
|
||||
else:
|
||||
raise ValidationError({
|
||||
field: "The specified IP address ({}) is not assigned to this VM.".format(ip),
|
||||
field: f"The specified IP address ({ip}) is not assigned to this VM.",
|
||||
})
|
||||
|
||||
def to_csv(self):
|
||||
|
@ -154,11 +154,14 @@ class VMInterfaceTable(BaseInterfaceTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='virtualization:vminterface_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VMInterface
|
||||
fields = (
|
||||
'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'ip_addresses',
|
||||
'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'tags', 'ip_addresses',
|
||||
'untagged_vlan', 'tagged_vlans',
|
||||
)
|
||||
default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description')
|
||||
|
Reference in New Issue
Block a user