mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-25 01:48:38 -06:00
commit
f1e82a3647
4
.gitattributes
vendored
4
.gitattributes
vendored
@ -1 +1,5 @@
|
|||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
# Treat minified or packed JS/CSS files as binary, as they're not meant to be human-readable
|
||||||
|
*.min.* binary
|
||||||
|
*.map binary
|
||||||
|
*.pack.js binary
|
||||||
|
@ -1,5 +1,28 @@
|
|||||||
# NetBox v2.8
|
# NetBox v2.8
|
||||||
|
|
||||||
|
## v2.8.8 (2020-07-21)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#4805](https://github.com/netbox-community/netbox/issues/4805) - Improve handling of plugin loading errors
|
||||||
|
* [#4829](https://github.com/netbox-community/netbox/issues/4829) - Add NEMA 15 power port and outlet types
|
||||||
|
* [#4831](https://github.com/netbox-community/netbox/issues/4831) - Allow NAPALM to resolve device name when primary IP is not set
|
||||||
|
* [#4854](https://github.com/netbox-community/netbox/issues/4854) - Add staging and decommissioning statuses for sites
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#3240](https://github.com/netbox-community/netbox/issues/3240) - Correct OpenAPI definition for available-prefixes endpoint
|
||||||
|
* [#4595](https://github.com/netbox-community/netbox/issues/4595) - Ensure consistent display of non-racked and child devices on rack view
|
||||||
|
* [#4803](https://github.com/netbox-community/netbox/issues/4803) - Return IP family (4 or 6) as integer rather than string
|
||||||
|
* [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs
|
||||||
|
* [#4835](https://github.com/netbox-community/netbox/issues/4835) - Support passing multiple initial values for multiple choice fields
|
||||||
|
* [#4838](https://github.com/netbox-community/netbox/issues/4838) - Fix rack power utilization display for racks without devices
|
||||||
|
* [#4851](https://github.com/netbox-community/netbox/issues/4851) - Show locally connected peer on circuit terminations
|
||||||
|
* [#4856](https://github.com/netbox-community/netbox/issues/4856) - Redirect user back to circuit after connecting a termination
|
||||||
|
* [#4872](https://github.com/netbox-community/netbox/issues/4872) - Enable filtering virtual machine interfaces by tag
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v2.8.7 (2020-07-02)
|
## v2.8.7 (2020-07-02)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import socket
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -371,15 +372,29 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
|||||||
Execute a NAPALM method on a Device
|
Execute a NAPALM method on a Device
|
||||||
"""
|
"""
|
||||||
device = get_object_or_404(Device, pk=pk)
|
device = get_object_or_404(Device, pk=pk)
|
||||||
if not device.primary_ip:
|
|
||||||
raise ServiceUnavailable("This device does not have a primary IP address configured.")
|
|
||||||
if device.platform is None:
|
if device.platform is None:
|
||||||
raise ServiceUnavailable("No platform is configured for this device.")
|
raise ServiceUnavailable("No platform is configured for this device.")
|
||||||
if not device.platform.napalm_driver:
|
if not device.platform.napalm_driver:
|
||||||
raise ServiceUnavailable("No NAPALM driver is configured for this device's platform ().".format(
|
raise ServiceUnavailable("No NAPALM driver is configured for this device's platform {}.".format(
|
||||||
device.platform
|
device.platform
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Check for primary IP address from NetBox object
|
||||||
|
if device.primary_ip:
|
||||||
|
host = str(device.primary_ip.address.ip)
|
||||||
|
else:
|
||||||
|
# Raise exception for no IP address and no Name if device.name does not exist
|
||||||
|
if not device.name:
|
||||||
|
raise ServiceUnavailable(
|
||||||
|
"This device does not have a primary IP address or device name to lookup configured.")
|
||||||
|
try:
|
||||||
|
# Attempt to complete a DNS name resolution if no primary_ip is set
|
||||||
|
host = socket.gethostbyname(device.name)
|
||||||
|
except socket.gaierror:
|
||||||
|
# Name lookup failure
|
||||||
|
raise ServiceUnavailable(
|
||||||
|
f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or setup name resolution.")
|
||||||
|
|
||||||
# Check that NAPALM is installed
|
# Check that NAPALM is installed
|
||||||
try:
|
try:
|
||||||
import napalm
|
import napalm
|
||||||
@ -399,10 +414,8 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
|||||||
if not request.user.has_perm('dcim.napalm_read'):
|
if not request.user.has_perm('dcim.napalm_read'):
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
# Connect to the device
|
|
||||||
napalm_methods = request.GET.getlist('method')
|
napalm_methods = request.GET.getlist('method')
|
||||||
response = OrderedDict([(m, None) for m in napalm_methods])
|
response = OrderedDict([(m, None) for m in napalm_methods])
|
||||||
ip_address = str(device.primary_ip.address.ip)
|
|
||||||
username = settings.NAPALM_USERNAME
|
username = settings.NAPALM_USERNAME
|
||||||
password = settings.NAPALM_PASSWORD
|
password = settings.NAPALM_PASSWORD
|
||||||
optional_args = settings.NAPALM_ARGS.copy()
|
optional_args = settings.NAPALM_ARGS.copy()
|
||||||
@ -422,8 +435,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
|||||||
elif key:
|
elif key:
|
||||||
optional_args[key.lower()] = request.headers[header]
|
optional_args[key.lower()] = request.headers[header]
|
||||||
|
|
||||||
|
# Connect to the device
|
||||||
d = driver(
|
d = driver(
|
||||||
hostname=ip_address,
|
hostname=host,
|
||||||
username=username,
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
timeout=settings.NAPALM_TIMEOUT,
|
timeout=settings.NAPALM_TIMEOUT,
|
||||||
@ -432,7 +446,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
|||||||
try:
|
try:
|
||||||
d.open()
|
d.open()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ServiceUnavailable("Error connecting to the device at {}: {}".format(ip_address, e))
|
raise ServiceUnavailable("Error connecting to the device at {}: {}".format(host, e))
|
||||||
|
|
||||||
# Validate and execute each specified NAPALM method
|
# Validate and execute each specified NAPALM method
|
||||||
for method in napalm_methods:
|
for method in napalm_methods:
|
||||||
|
@ -7,13 +7,17 @@ from utilities.choices import ChoiceSet
|
|||||||
|
|
||||||
class SiteStatusChoices(ChoiceSet):
|
class SiteStatusChoices(ChoiceSet):
|
||||||
|
|
||||||
STATUS_ACTIVE = 'active'
|
|
||||||
STATUS_PLANNED = 'planned'
|
STATUS_PLANNED = 'planned'
|
||||||
|
STATUS_STAGING = 'staging'
|
||||||
|
STATUS_ACTIVE = 'active'
|
||||||
|
STATUS_DECOMMISSIONING = 'decommissioning'
|
||||||
STATUS_RETIRED = 'retired'
|
STATUS_RETIRED = 'retired'
|
||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
(STATUS_ACTIVE, 'Active'),
|
|
||||||
(STATUS_PLANNED, 'Planned'),
|
(STATUS_PLANNED, 'Planned'),
|
||||||
|
(STATUS_STAGING, 'Staging'),
|
||||||
|
(STATUS_ACTIVE, 'Active'),
|
||||||
|
(STATUS_DECOMMISSIONING, 'Decommissioning'),
|
||||||
(STATUS_RETIRED, 'Retired'),
|
(STATUS_RETIRED, 'Retired'),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -275,6 +279,11 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
TYPE_NEMA_1430P = 'nema-14-30p'
|
TYPE_NEMA_1430P = 'nema-14-30p'
|
||||||
TYPE_NEMA_1450P = 'nema-14-50p'
|
TYPE_NEMA_1450P = 'nema-14-50p'
|
||||||
TYPE_NEMA_1460P = 'nema-14-60p'
|
TYPE_NEMA_1460P = 'nema-14-60p'
|
||||||
|
TYPE_NEMA_1515P = 'nema-15-15p'
|
||||||
|
TYPE_NEMA_1520P = 'nema-15-20p'
|
||||||
|
TYPE_NEMA_1530P = 'nema-15-30p'
|
||||||
|
TYPE_NEMA_1550P = 'nema-15-50p'
|
||||||
|
TYPE_NEMA_1560P = 'nema-15-60p'
|
||||||
# NEMA locking
|
# NEMA locking
|
||||||
TYPE_NEMA_L115P = 'nema-l1-15p'
|
TYPE_NEMA_L115P = 'nema-l1-15p'
|
||||||
TYPE_NEMA_L515P = 'nema-l5-15p'
|
TYPE_NEMA_L515P = 'nema-l5-15p'
|
||||||
@ -290,6 +299,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
TYPE_NEMA_L1430P = 'nema-l14-30p'
|
TYPE_NEMA_L1430P = 'nema-l14-30p'
|
||||||
TYPE_NEMA_L1450P = 'nema-l14-50p'
|
TYPE_NEMA_L1450P = 'nema-l14-50p'
|
||||||
TYPE_NEMA_L1460P = 'nema-l14-60p'
|
TYPE_NEMA_L1460P = 'nema-l14-60p'
|
||||||
|
TYPE_NEMA_L1520P = 'nema-l15-20p'
|
||||||
|
TYPE_NEMA_L1530P = 'nema-l15-30p'
|
||||||
|
TYPE_NEMA_L1550P = 'nema-l15-50p'
|
||||||
|
TYPE_NEMA_L1560P = 'nema-l15-60p'
|
||||||
TYPE_NEMA_L2120P = 'nema-l21-20p'
|
TYPE_NEMA_L2120P = 'nema-l21-20p'
|
||||||
TYPE_NEMA_L2130P = 'nema-l21-30p'
|
TYPE_NEMA_L2130P = 'nema-l21-30p'
|
||||||
# California style
|
# California style
|
||||||
@ -351,6 +364,11 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
(TYPE_NEMA_1430P, 'NEMA 14-30P'),
|
(TYPE_NEMA_1430P, 'NEMA 14-30P'),
|
||||||
(TYPE_NEMA_1450P, 'NEMA 14-50P'),
|
(TYPE_NEMA_1450P, 'NEMA 14-50P'),
|
||||||
(TYPE_NEMA_1460P, 'NEMA 14-60P'),
|
(TYPE_NEMA_1460P, 'NEMA 14-60P'),
|
||||||
|
(TYPE_NEMA_1515P, 'NEMA 15-15P'),
|
||||||
|
(TYPE_NEMA_1520P, 'NEMA 15-20P'),
|
||||||
|
(TYPE_NEMA_1530P, 'NEMA 15-30P'),
|
||||||
|
(TYPE_NEMA_1550P, 'NEMA 15-50P'),
|
||||||
|
(TYPE_NEMA_1560P, 'NEMA 15-60P'),
|
||||||
)),
|
)),
|
||||||
('NEMA (Locking)', (
|
('NEMA (Locking)', (
|
||||||
(TYPE_NEMA_L115P, 'NEMA L1-15P'),
|
(TYPE_NEMA_L115P, 'NEMA L1-15P'),
|
||||||
@ -367,6 +385,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
(TYPE_NEMA_L1430P, 'NEMA L14-30P'),
|
(TYPE_NEMA_L1430P, 'NEMA L14-30P'),
|
||||||
(TYPE_NEMA_L1450P, 'NEMA L14-50P'),
|
(TYPE_NEMA_L1450P, 'NEMA L14-50P'),
|
||||||
(TYPE_NEMA_L1460P, 'NEMA L14-60P'),
|
(TYPE_NEMA_L1460P, 'NEMA L14-60P'),
|
||||||
|
(TYPE_NEMA_L1520P, 'NEMA L15-20P'),
|
||||||
|
(TYPE_NEMA_L1530P, 'NEMA L15-30P'),
|
||||||
|
(TYPE_NEMA_L1550P, 'NEMA L15-50P'),
|
||||||
|
(TYPE_NEMA_L1560P, 'NEMA L15-60P'),
|
||||||
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
|
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
|
||||||
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
|
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
|
||||||
)),
|
)),
|
||||||
@ -436,6 +458,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
TYPE_NEMA_1430R = 'nema-14-30r'
|
TYPE_NEMA_1430R = 'nema-14-30r'
|
||||||
TYPE_NEMA_1450R = 'nema-14-50r'
|
TYPE_NEMA_1450R = 'nema-14-50r'
|
||||||
TYPE_NEMA_1460R = 'nema-14-60r'
|
TYPE_NEMA_1460R = 'nema-14-60r'
|
||||||
|
TYPE_NEMA_1515R = 'nema-15-15r'
|
||||||
|
TYPE_NEMA_1520R = 'nema-15-20r'
|
||||||
|
TYPE_NEMA_1530R = 'nema-15-30r'
|
||||||
|
TYPE_NEMA_1550R = 'nema-15-50r'
|
||||||
|
TYPE_NEMA_1560R = 'nema-15-60r'
|
||||||
# NEMA locking
|
# NEMA locking
|
||||||
TYPE_NEMA_L115R = 'nema-l1-15r'
|
TYPE_NEMA_L115R = 'nema-l1-15r'
|
||||||
TYPE_NEMA_L515R = 'nema-l5-15r'
|
TYPE_NEMA_L515R = 'nema-l5-15r'
|
||||||
@ -451,6 +478,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
TYPE_NEMA_L1430R = 'nema-l14-30r'
|
TYPE_NEMA_L1430R = 'nema-l14-30r'
|
||||||
TYPE_NEMA_L1450R = 'nema-l14-50r'
|
TYPE_NEMA_L1450R = 'nema-l14-50r'
|
||||||
TYPE_NEMA_L1460R = 'nema-l14-60r'
|
TYPE_NEMA_L1460R = 'nema-l14-60r'
|
||||||
|
TYPE_NEMA_L1520R = 'nema-l15-20r'
|
||||||
|
TYPE_NEMA_L1530R = 'nema-l15-30r'
|
||||||
|
TYPE_NEMA_L1550R = 'nema-l15-50r'
|
||||||
|
TYPE_NEMA_L1560R = 'nema-l15-60r'
|
||||||
TYPE_NEMA_L2120R = 'nema-l21-20r'
|
TYPE_NEMA_L2120R = 'nema-l21-20r'
|
||||||
TYPE_NEMA_L2130R = 'nema-l21-30r'
|
TYPE_NEMA_L2130R = 'nema-l21-30r'
|
||||||
# California style
|
# California style
|
||||||
@ -513,6 +544,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
(TYPE_NEMA_1430R, 'NEMA 14-30R'),
|
(TYPE_NEMA_1430R, 'NEMA 14-30R'),
|
||||||
(TYPE_NEMA_1450R, 'NEMA 14-50R'),
|
(TYPE_NEMA_1450R, 'NEMA 14-50R'),
|
||||||
(TYPE_NEMA_1460R, 'NEMA 14-60R'),
|
(TYPE_NEMA_1460R, 'NEMA 14-60R'),
|
||||||
|
(TYPE_NEMA_1515R, 'NEMA 15-15R'),
|
||||||
|
(TYPE_NEMA_1520R, 'NEMA 15-20R'),
|
||||||
|
(TYPE_NEMA_1530R, 'NEMA 15-30R'),
|
||||||
|
(TYPE_NEMA_1550R, 'NEMA 15-50R'),
|
||||||
|
(TYPE_NEMA_1560R, 'NEMA 15-60R'),
|
||||||
)),
|
)),
|
||||||
('NEMA (Locking)', (
|
('NEMA (Locking)', (
|
||||||
(TYPE_NEMA_L115R, 'NEMA L1-15R'),
|
(TYPE_NEMA_L115R, 'NEMA L1-15R'),
|
||||||
@ -529,6 +565,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
(TYPE_NEMA_L1430R, 'NEMA L14-30R'),
|
(TYPE_NEMA_L1430R, 'NEMA L14-30R'),
|
||||||
(TYPE_NEMA_L1450R, 'NEMA L14-50R'),
|
(TYPE_NEMA_L1450R, 'NEMA L14-50R'),
|
||||||
(TYPE_NEMA_L1460R, 'NEMA L14-60R'),
|
(TYPE_NEMA_L1460R, 'NEMA L14-60R'),
|
||||||
|
(TYPE_NEMA_L1520R, 'NEMA L15-20R'),
|
||||||
|
(TYPE_NEMA_L1530R, 'NEMA L15-30R'),
|
||||||
|
(TYPE_NEMA_L1550R, 'NEMA L15-50R'),
|
||||||
|
(TYPE_NEMA_L1560R, 'NEMA L15-60R'),
|
||||||
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
|
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
|
||||||
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
|
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
|
||||||
)),
|
)),
|
||||||
|
@ -787,7 +787,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if power_stats:
|
if power_stats:
|
||||||
allocated_draw_total = sum(x['allocated_draw_total'] for x in power_stats)
|
allocated_draw_total = sum(x['allocated_draw_total'] or 0 for x in power_stats)
|
||||||
available_power_total = sum(x['available_power'] for x in power_stats)
|
available_power_total = sum(x['available_power'] for x in power_stats)
|
||||||
return int(allocated_draw_total / available_power_total * 100) or 0
|
return int(allocated_draw_total / available_power_total * 100) or 0
|
||||||
return 0
|
return 0
|
||||||
|
@ -103,20 +103,12 @@ DEVICEROLE_ACTIONS = """
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEVICEROLE_DEVICE_COUNT = """
|
DEVICE_COUNT = """
|
||||||
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
|
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEVICEROLE_VM_COUNT = """
|
VM_COUNT = """
|
||||||
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value }}</a>
|
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||||
"""
|
|
||||||
|
|
||||||
PLATFORM_DEVICE_COUNT = """
|
|
||||||
<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value }}</a>
|
|
||||||
"""
|
|
||||||
|
|
||||||
PLATFORM_VM_COUNT = """
|
|
||||||
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value }}</a>
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PLATFORM_ACTIONS = """
|
PLATFORM_ACTIONS = """
|
||||||
@ -278,6 +270,7 @@ class RackGroupTable(BaseTable):
|
|||||||
|
|
||||||
class RackRoleTable(BaseTable):
|
class RackRoleTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
|
name = tables.Column(linkify=True)
|
||||||
rack_count = tables.Column(verbose_name='Racks')
|
rack_count = tables.Column(verbose_name='Racks')
|
||||||
color = tables.TemplateColumn(COLOR_LABEL)
|
color = tables.TemplateColumn(COLOR_LABEL)
|
||||||
actions = tables.TemplateColumn(
|
actions = tables.TemplateColumn(
|
||||||
@ -704,21 +697,18 @@ class DeviceBayTemplateTable(BaseTable):
|
|||||||
class DeviceRoleTable(BaseTable):
|
class DeviceRoleTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
device_count = tables.TemplateColumn(
|
device_count = tables.TemplateColumn(
|
||||||
template_code=DEVICEROLE_DEVICE_COUNT,
|
template_code=DEVICE_COUNT,
|
||||||
accessor=Accessor('devices.count'),
|
|
||||||
orderable=False,
|
|
||||||
verbose_name='Devices'
|
verbose_name='Devices'
|
||||||
)
|
)
|
||||||
vm_count = tables.TemplateColumn(
|
vm_count = tables.TemplateColumn(
|
||||||
template_code=DEVICEROLE_VM_COUNT,
|
template_code=VM_COUNT,
|
||||||
accessor=Accessor('virtual_machines.count'),
|
|
||||||
orderable=False,
|
|
||||||
verbose_name='VMs'
|
verbose_name='VMs'
|
||||||
)
|
)
|
||||||
color = tables.TemplateColumn(
|
color = tables.TemplateColumn(
|
||||||
template_code=COLOR_LABEL,
|
template_code=COLOR_LABEL,
|
||||||
verbose_name='Label'
|
verbose_name='Label'
|
||||||
)
|
)
|
||||||
|
vm_role = BooleanColumn()
|
||||||
actions = tables.TemplateColumn(
|
actions = tables.TemplateColumn(
|
||||||
template_code=DEVICEROLE_ACTIONS,
|
template_code=DEVICEROLE_ACTIONS,
|
||||||
attrs={'td': {'class': 'text-right noprint'}},
|
attrs={'td': {'class': 'text-right noprint'}},
|
||||||
@ -738,15 +728,11 @@ class DeviceRoleTable(BaseTable):
|
|||||||
class PlatformTable(BaseTable):
|
class PlatformTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
device_count = tables.TemplateColumn(
|
device_count = tables.TemplateColumn(
|
||||||
template_code=PLATFORM_DEVICE_COUNT,
|
template_code=DEVICE_COUNT,
|
||||||
accessor=Accessor('devices.count'),
|
|
||||||
orderable=False,
|
|
||||||
verbose_name='Devices'
|
verbose_name='Devices'
|
||||||
)
|
)
|
||||||
vm_count = tables.TemplateColumn(
|
vm_count = tables.TemplateColumn(
|
||||||
template_code=PLATFORM_VM_COUNT,
|
template_code=VM_COUNT,
|
||||||
accessor=Accessor('virtual_machines.count'),
|
|
||||||
orderable=False,
|
|
||||||
verbose_name='VMs'
|
verbose_name='VMs'
|
||||||
)
|
)
|
||||||
actions = tables.TemplateColumn(
|
actions = tables.TemplateColumn(
|
||||||
|
@ -23,7 +23,7 @@ from ipam.models import Prefix, VLAN
|
|||||||
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
|
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
|
||||||
from utilities.forms import ConfirmationForm
|
from utilities.forms import ConfirmationForm
|
||||||
from utilities.paginator import EnhancedPaginator
|
from utilities.paginator import EnhancedPaginator
|
||||||
from utilities.utils import csv_format
|
from utilities.utils import csv_format, get_subquery
|
||||||
from utilities.views import (
|
from utilities.views import (
|
||||||
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
|
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
|
||||||
ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||||
@ -399,11 +399,12 @@ class RackView(PermissionRequiredMixin, View):
|
|||||||
|
|
||||||
rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
|
rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
|
||||||
|
|
||||||
|
# Get 0U and child devices located within the rack
|
||||||
nonracked_devices = Device.objects.filter(
|
nonracked_devices = Device.objects.filter(
|
||||||
rack=rack,
|
rack=rack,
|
||||||
position__isnull=True,
|
position__isnull=True
|
||||||
parent_bay__isnull=True
|
|
||||||
).prefetch_related('device_type__manufacturer')
|
).prefetch_related('device_type__manufacturer')
|
||||||
|
|
||||||
if rack.group:
|
if rack.group:
|
||||||
peer_racks = Rack.objects.filter(site=rack.site, group=rack.group)
|
peer_racks = Rack.objects.filter(site=rack.site, group=rack.group)
|
||||||
else:
|
else:
|
||||||
@ -557,9 +558,9 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
|||||||
class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
|
class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
|
||||||
permission_required = 'dcim.view_manufacturer'
|
permission_required = 'dcim.view_manufacturer'
|
||||||
queryset = Manufacturer.objects.annotate(
|
queryset = Manufacturer.objects.annotate(
|
||||||
devicetype_count=Count('device_types', distinct=True),
|
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
|
||||||
inventoryitem_count=Count('inventory_items', distinct=True),
|
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
|
||||||
platform_count=Count('platforms', distinct=True),
|
platform_count=get_subquery(Platform, 'manufacturer')
|
||||||
)
|
)
|
||||||
table = tables.ManufacturerTable
|
table = tables.ManufacturerTable
|
||||||
|
|
||||||
@ -1020,7 +1021,10 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
|||||||
|
|
||||||
class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
|
class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
|
||||||
permission_required = 'dcim.view_devicerole'
|
permission_required = 'dcim.view_devicerole'
|
||||||
queryset = DeviceRole.objects.all()
|
queryset = DeviceRole.objects.annotate(
|
||||||
|
device_count=get_subquery(Device, 'device_role'),
|
||||||
|
vm_count=get_subquery(VirtualMachine, 'role')
|
||||||
|
)
|
||||||
table = tables.DeviceRoleTable
|
table = tables.DeviceRoleTable
|
||||||
|
|
||||||
|
|
||||||
@ -1055,7 +1059,10 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
|||||||
|
|
||||||
class PlatformListView(PermissionRequiredMixin, ObjectListView):
|
class PlatformListView(PermissionRequiredMixin, ObjectListView):
|
||||||
permission_required = 'dcim.view_platform'
|
permission_required = 'dcim.view_platform'
|
||||||
queryset = Platform.objects.all()
|
queryset = Platform.objects.annotate(
|
||||||
|
device_count=get_subquery(Device, 'device_role'),
|
||||||
|
vm_count=get_subquery(VirtualMachine, 'role')
|
||||||
|
)
|
||||||
table = tables.PlatformTable
|
table = tables.PlatformTable
|
||||||
|
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ from extras.models import (
|
|||||||
from extras.reports import get_report, get_reports
|
from extras.reports import get_report, get_reports
|
||||||
from extras.scripts import get_script, get_scripts, run_script
|
from extras.scripts import get_script, get_scripts, run_script
|
||||||
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
||||||
|
from utilities.metadata import ContentTypeMetadata
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
@ -88,6 +89,7 @@ class CustomFieldModelViewSet(ModelViewSet):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class GraphViewSet(ModelViewSet):
|
class GraphViewSet(ModelViewSet):
|
||||||
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = Graph.objects.all()
|
queryset = Graph.objects.all()
|
||||||
serializer_class = serializers.GraphSerializer
|
serializer_class = serializers.GraphSerializer
|
||||||
filterset_class = filters.GraphFilterSet
|
filterset_class = filters.GraphFilterSet
|
||||||
@ -98,6 +100,7 @@ class GraphViewSet(ModelViewSet):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class ExportTemplateViewSet(ModelViewSet):
|
class ExportTemplateViewSet(ModelViewSet):
|
||||||
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = ExportTemplate.objects.all()
|
queryset = ExportTemplate.objects.all()
|
||||||
serializer_class = serializers.ExportTemplateSerializer
|
serializer_class = serializers.ExportTemplateSerializer
|
||||||
filterset_class = filters.ExportTemplateFilterSet
|
filterset_class = filters.ExportTemplateFilterSet
|
||||||
@ -120,6 +123,7 @@ class TagViewSet(ModelViewSet):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class ImageAttachmentViewSet(ModelViewSet):
|
class ImageAttachmentViewSet(ModelViewSet):
|
||||||
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = ImageAttachment.objects.all()
|
queryset = ImageAttachment.objects.all()
|
||||||
serializer_class = serializers.ImageAttachmentSerializer
|
serializer_class = serializers.ImageAttachmentSerializer
|
||||||
|
|
||||||
@ -271,6 +275,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
|||||||
"""
|
"""
|
||||||
Retrieve a list of recent changes.
|
Retrieve a list of recent changes.
|
||||||
"""
|
"""
|
||||||
|
metadata_class = ContentTypeMetadata
|
||||||
queryset = ObjectChange.objects.prefetch_related('user')
|
queryset = ObjectChange.objects.prefetch_related('user')
|
||||||
serializer_class = serializers.ObjectChangeSerializer
|
serializer_class = serializers.ObjectChangeSerializer
|
||||||
filterset_class = filters.ObjectChangeFilterSet
|
filterset_class = filters.ObjectChangeFilterSet
|
||||||
|
@ -6,11 +6,12 @@ from django.apps import AppConfig
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
from django.utils.module_loading import import_string
|
|
||||||
|
|
||||||
from extras.registry import registry
|
from extras.registry import registry
|
||||||
from utilities.choices import ButtonColorChoices
|
from utilities.choices import ButtonColorChoices
|
||||||
|
|
||||||
|
from extras.plugins.utils import import_object
|
||||||
|
|
||||||
|
|
||||||
# Initialize plugin registry stores
|
# Initialize plugin registry stores
|
||||||
registry['plugin_template_extensions'] = collections.defaultdict(list)
|
registry['plugin_template_extensions'] = collections.defaultdict(list)
|
||||||
@ -60,18 +61,14 @@ class PluginConfig(AppConfig):
|
|||||||
def ready(self):
|
def ready(self):
|
||||||
|
|
||||||
# Register template content
|
# Register template content
|
||||||
try:
|
template_extensions = import_object(f"{self.__module__}.{self.template_extensions}")
|
||||||
template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
|
if template_extensions is not None:
|
||||||
register_template_extensions(template_extensions)
|
register_template_extensions(template_extensions)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Register navigation menu items (if defined)
|
# Register navigation menu items (if defined)
|
||||||
try:
|
menu_items = import_object(f"{self.__module__}.{self.menu_items}")
|
||||||
menu_items = import_string(f"{self.__module__}.{self.menu_items}")
|
if menu_items is not None:
|
||||||
register_menu_items(self.verbose_name, menu_items)
|
register_menu_items(self.verbose_name, menu_items)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls, user_config):
|
def validate(cls, user_config):
|
||||||
|
@ -3,7 +3,8 @@ from django.conf import settings
|
|||||||
from django.conf.urls import include
|
from django.conf.urls import include
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.utils.module_loading import import_string
|
|
||||||
|
from extras.plugins.utils import import_object
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
@ -24,19 +25,15 @@ for plugin_path in settings.PLUGINS:
|
|||||||
base_url = getattr(app, 'base_url') or app.label
|
base_url = getattr(app, 'base_url') or app.label
|
||||||
|
|
||||||
# Check if the plugin specifies any base URLs
|
# Check if the plugin specifies any base URLs
|
||||||
try:
|
urlpatterns = import_object(f"{plugin_path}.urls.urlpatterns")
|
||||||
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
|
if urlpatterns is not None:
|
||||||
plugin_patterns.append(
|
plugin_patterns.append(
|
||||||
path(f"{base_url}/", include((urlpatterns, app.label)))
|
path(f"{base_url}/", include((urlpatterns, app.label)))
|
||||||
)
|
)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check if the plugin specifies any API URLs
|
# Check if the plugin specifies any API URLs
|
||||||
try:
|
urlpatterns = import_object(f"{plugin_path}.api.urls.urlpatterns")
|
||||||
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
|
if urlpatterns is not None:
|
||||||
plugin_api_patterns.append(
|
plugin_api_patterns.append(
|
||||||
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
|
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
|
||||||
)
|
)
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
33
netbox/extras/plugins/utils.py
Normal file
33
netbox/extras/plugins/utils.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def import_object(module_and_object):
|
||||||
|
"""
|
||||||
|
Import a specific object from a specific module by name, such as "extras.plugins.utils.import_object".
|
||||||
|
|
||||||
|
Returns the imported object, or None if it doesn't exist.
|
||||||
|
"""
|
||||||
|
target_module_name, object_name = module_and_object.rsplit('.', 1)
|
||||||
|
module_hierarchy = target_module_name.split('.')
|
||||||
|
|
||||||
|
# Iterate through the module hierarchy, checking for the existence of each successive submodule.
|
||||||
|
# We have to do this rather than jumping directly to calling find_spec(target_module_name)
|
||||||
|
# because find_spec will raise a ModuleNotFoundError if any parent module of target_module_name does not exist.
|
||||||
|
module_name = ""
|
||||||
|
for module_component in module_hierarchy:
|
||||||
|
module_name = f"{module_name}.{module_component}" if module_name else module_component
|
||||||
|
spec = importlib.util.find_spec(module_name)
|
||||||
|
if spec is None:
|
||||||
|
# No such module
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Okay, target_module_name exists. Load it if not already loaded
|
||||||
|
if target_module_name in sys.modules:
|
||||||
|
module = sys.modules[target_module_name]
|
||||||
|
else:
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[target_module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
return getattr(module, object_name, None)
|
@ -4,13 +4,14 @@ from django.apps import apps
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls.exceptions import NoReverseMatch
|
from django.urls.exceptions import NoReverseMatch
|
||||||
from django.utils.module_loading import import_string
|
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.reverse import reverse
|
from rest_framework.reverse import reverse
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from extras.plugins.utils import import_object
|
||||||
|
|
||||||
|
|
||||||
class InstalledPluginsAdminView(View):
|
class InstalledPluginsAdminView(View):
|
||||||
"""
|
"""
|
||||||
@ -60,9 +61,9 @@ class PluginsAPIRootView(APIView):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_plugin_entry(plugin, app_config, request, format):
|
def _get_plugin_entry(plugin, app_config, request, format):
|
||||||
try:
|
# Check if the plugin specifies any API URLs
|
||||||
api_app_name = import_string(f"{plugin}.api.urls.app_name")
|
api_app_name = import_object(f"{plugin}.api.urls.app_name")
|
||||||
except (ImportError, ModuleNotFoundError):
|
if api_app_name is None:
|
||||||
# Plugin does not expose an API
|
# Plugin does not expose an API
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -73,7 +74,7 @@ class PluginsAPIRootView(APIView):
|
|||||||
format=format
|
format=format
|
||||||
))
|
))
|
||||||
except NoReverseMatch:
|
except NoReverseMatch:
|
||||||
# The plugin does not include an api-root
|
# The plugin does not include an api-root url
|
||||||
entry = None
|
entry = None
|
||||||
|
|
||||||
return entry
|
return entry
|
||||||
|
@ -44,6 +44,7 @@ class NestedRIRSerializer(WritableNestedSerializer):
|
|||||||
|
|
||||||
class NestedAggregateSerializer(WritableNestedSerializer):
|
class NestedAggregateSerializer(WritableNestedSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
|
||||||
|
family = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Aggregate
|
model = models.Aggregate
|
||||||
@ -87,6 +88,7 @@ class NestedVLANSerializer(WritableNestedSerializer):
|
|||||||
|
|
||||||
class NestedPrefixSerializer(WritableNestedSerializer):
|
class NestedPrefixSerializer(WritableNestedSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
|
||||||
|
family = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Prefix
|
model = models.Prefix
|
||||||
@ -99,6 +101,7 @@ class NestedPrefixSerializer(WritableNestedSerializer):
|
|||||||
|
|
||||||
class NestedIPAddressSerializer(WritableNestedSerializer):
|
class NestedIPAddressSerializer(WritableNestedSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
|
||||||
|
family = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.IPAddress
|
model = models.IPAddress
|
||||||
|
@ -74,6 +74,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
|||||||
serializer_class = serializers.PrefixSerializer
|
serializer_class = serializers.PrefixSerializer
|
||||||
filterset_class = filters.PrefixFilterSet
|
filterset_class = filters.PrefixFilterSet
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "available_prefixes" and self.request.method == "POST":
|
||||||
|
return serializers.PrefixLengthSerializer
|
||||||
|
return super().get_serializer_class()
|
||||||
|
|
||||||
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
@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.AvailablePrefixSerializer(many=True)})
|
||||||
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
|
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
|
||||||
|
@ -1068,7 +1068,12 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
|
|||||||
)
|
)
|
||||||
site = DynamicModelChoiceField(
|
site = DynamicModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
widget=APISelect(
|
||||||
|
filter_for={
|
||||||
|
'group': 'site_id'
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
group = DynamicModelChoiceField(
|
group = DynamicModelChoiceField(
|
||||||
queryset=VLANGroup.objects.all(),
|
queryset=VLANGroup.objects.all(),
|
||||||
|
@ -40,11 +40,11 @@ UTILIZATION_GRAPH = """
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
ROLE_PREFIX_COUNT = """
|
ROLE_PREFIX_COUNT = """
|
||||||
<a href="{% url 'ipam:prefix_list' %}?role={{ record.slug }}">{{ value }}</a>
|
<a href="{% url 'ipam:prefix_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ROLE_VLAN_COUNT = """
|
ROLE_VLAN_COUNT = """
|
||||||
<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value }}</a>
|
<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ROLE_ACTIONS = """
|
ROLE_ACTIONS = """
|
||||||
@ -319,15 +319,11 @@ class AggregateDetailTable(AggregateTable):
|
|||||||
class RoleTable(BaseTable):
|
class RoleTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
prefix_count = tables.TemplateColumn(
|
prefix_count = tables.TemplateColumn(
|
||||||
accessor=Accessor('prefixes.count'),
|
|
||||||
template_code=ROLE_PREFIX_COUNT,
|
template_code=ROLE_PREFIX_COUNT,
|
||||||
orderable=False,
|
|
||||||
verbose_name='Prefixes'
|
verbose_name='Prefixes'
|
||||||
)
|
)
|
||||||
vlan_count = tables.TemplateColumn(
|
vlan_count = tables.TemplateColumn(
|
||||||
accessor=Accessor('vlans.count'),
|
|
||||||
template_code=ROLE_VLAN_COUNT,
|
template_code=ROLE_VLAN_COUNT,
|
||||||
orderable=False,
|
|
||||||
verbose_name='VLANs'
|
verbose_name='VLANs'
|
||||||
)
|
)
|
||||||
actions = tables.TemplateColumn(
|
actions = tables.TemplateColumn(
|
||||||
@ -524,7 +520,7 @@ class InterfaceIPAddressTable(BaseTable):
|
|||||||
|
|
||||||
class VLANGroupTable(BaseTable):
|
class VLANGroupTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
name = tables.LinkColumn()
|
name = tables.Column(linkify=True)
|
||||||
site = tables.LinkColumn(
|
site = tables.LinkColumn(
|
||||||
viewname='dcim:site',
|
viewname='dcim:site',
|
||||||
args=[Accessor('site.slug')]
|
args=[Accessor('site.slug')]
|
||||||
|
@ -9,6 +9,7 @@ from django_tables2 import RequestConfig
|
|||||||
|
|
||||||
from dcim.models import Device, Interface
|
from dcim.models import Device, Interface
|
||||||
from utilities.paginator import EnhancedPaginator
|
from utilities.paginator import EnhancedPaginator
|
||||||
|
from utilities.utils import get_subquery
|
||||||
from utilities.views import (
|
from utilities.views import (
|
||||||
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||||
)
|
)
|
||||||
@ -407,7 +408,10 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
|||||||
|
|
||||||
class RoleListView(PermissionRequiredMixin, ObjectListView):
|
class RoleListView(PermissionRequiredMixin, ObjectListView):
|
||||||
permission_required = 'ipam.view_role'
|
permission_required = 'ipam.view_role'
|
||||||
queryset = Role.objects.all()
|
queryset = Role.objects.annotate(
|
||||||
|
prefix_count=get_subquery(Prefix, 'role'),
|
||||||
|
vlan_count=get_subquery(VLAN, 'role')
|
||||||
|
)
|
||||||
table = tables.RoleTable
|
table = tables.RoleTable
|
||||||
|
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '2.8.7'
|
VERSION = '2.8.8'
|
||||||
|
|
||||||
# Hostname
|
# Hostname
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
|
@ -51,10 +51,15 @@
|
|||||||
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
||||||
<i class="fa fa-share-alt" aria-hidden="true"></i>
|
<i class="fa fa-share-alt" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if termination.connected_endpoint %}
|
{% with peer=termination.get_cable_peer %}
|
||||||
to <a href="{% url 'dcim:device' pk=termination.connected_endpoint.device.pk %}">{{ termination.connected_endpoint.device }}</a>
|
to
|
||||||
<i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }}
|
{% if peer.device %}
|
||||||
|
<a href="{{ peer.device.get_absolute_url }}">{{ peer.device }}</a>
|
||||||
|
{% elif peer.circuit %}
|
||||||
|
<a href="{{ peer.circuit.get_absolute_url }}">{{ peer.circuit }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
({{ peer }})
|
||||||
|
{% endwith %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if perms.dcim.add_cable %}
|
{% if perms.dcim.add_cable %}
|
||||||
<div class="pull-right">
|
<div class="pull-right">
|
||||||
@ -63,10 +68,10 @@
|
|||||||
<span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span> Connect
|
<span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span> Connect
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-right">
|
<ul class="dropdown-menu dropdown-menu-right">
|
||||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='interface' %}?return_url={{ device.get_absolute_url }}">Interface</a></li>
|
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='interface' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Interface</a></li>
|
||||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='front-port' %}?return_url={{ device.get_absolute_url }}">Front Port</a></li>
|
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='front-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Front Port</a></li>
|
||||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='rear-port' %}?return_url={{ device.get_absolute_url }}">Rear Port</a></li>
|
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='rear-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Rear Port</a></li>
|
||||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='circuit-termination' %}?return_url={{ device.get_absolute_url }}">Circuit Termination</a></li>
|
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='circuit-termination' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Circuit Termination</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -337,7 +337,7 @@
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Parent</th>
|
<th colspan="2">Parent Device</th>
|
||||||
</tr>
|
</tr>
|
||||||
{% for device in nonracked_devices %}
|
{% for device in nonracked_devices %}
|
||||||
<tr{% if device.device_type.u_height %} class="warning"{% endif %}>
|
<tr{% if device.device_type.u_height %} class="warning"{% endif %}>
|
||||||
@ -346,13 +346,12 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ device.device_role }}</td>
|
<td>{{ device.device_role }}</td>
|
||||||
<td>{{ device.device_type.display_name }}</td>
|
<td>{{ device.device_type.display_name }}</td>
|
||||||
<td>
|
|
||||||
{% if device.parent_bay %}
|
{% if device.parent_bay %}
|
||||||
<a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay }}</a>
|
<td><a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay.device }}</a></td>
|
||||||
|
<td>{{ device.parent_bay }}</td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
<td colspan="2" class="text-muted">—</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
|
@ -594,21 +594,20 @@ class DynamicModelChoiceMixin:
|
|||||||
filter = django_filters.ModelChoiceFilter
|
filter = django_filters.ModelChoiceFilter
|
||||||
widget = APISelect
|
widget = APISelect
|
||||||
|
|
||||||
def _get_initial_value(self, initial_data, field_name):
|
|
||||||
return initial_data.get(field_name)
|
|
||||||
|
|
||||||
def get_bound_field(self, form, field_name):
|
def get_bound_field(self, form, field_name):
|
||||||
bound_field = BoundField(form, self, field_name)
|
bound_field = BoundField(form, self, field_name)
|
||||||
|
|
||||||
# Override initial() to allow passing multiple values
|
|
||||||
bound_field.initial = self._get_initial_value(form.initial, field_name)
|
|
||||||
|
|
||||||
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
|
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
|
||||||
# will be populated on-demand via the APISelect widget.
|
# will be populated on-demand via the APISelect widget.
|
||||||
data = bound_field.value()
|
data = bound_field.value()
|
||||||
if data:
|
if data:
|
||||||
filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset)
|
field_name = getattr(self, 'to_field_name') or 'pk'
|
||||||
|
filter = self.filter(field_name=field_name)
|
||||||
|
try:
|
||||||
self.queryset = filter.filter(self.queryset, data)
|
self.queryset = filter.filter(self.queryset, data)
|
||||||
|
except TypeError:
|
||||||
|
# Catch any error caused by invalid initial data passed from the user
|
||||||
|
self.queryset = self.queryset.none()
|
||||||
else:
|
else:
|
||||||
self.queryset = self.queryset.none()
|
self.queryset = self.queryset.none()
|
||||||
|
|
||||||
@ -638,12 +637,6 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
|
|||||||
filter = django_filters.ModelMultipleChoiceFilter
|
filter = django_filters.ModelMultipleChoiceFilter
|
||||||
widget = APISelectMultiple
|
widget = APISelectMultiple
|
||||||
|
|
||||||
def _get_initial_value(self, initial_data, field_name):
|
|
||||||
# If a QueryDict has been passed as initial form data, get *all* listed values
|
|
||||||
if hasattr(initial_data, 'getlist'):
|
|
||||||
return initial_data.getlist(field_name)
|
|
||||||
return initial_data.get(field_name)
|
|
||||||
|
|
||||||
|
|
||||||
class LaxURLField(forms.URLField):
|
class LaxURLField(forms.URLField):
|
||||||
"""
|
"""
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
|
from django.http import QueryDict
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from utilities.utils import deepmerge, dict_to_filter_params
|
from utilities.utils import deepmerge, dict_to_filter_params, normalize_querydict
|
||||||
|
|
||||||
|
|
||||||
class DictToFilterParamsTest(TestCase):
|
class DictToFilterParamsTest(TestCase):
|
||||||
"""
|
"""
|
||||||
Validate the operation of dict_to_filter_params().
|
Validate the operation of dict_to_filter_params().
|
||||||
"""
|
"""
|
||||||
def setUp(self):
|
|
||||||
return
|
|
||||||
|
|
||||||
def test_dict_to_filter_params(self):
|
def test_dict_to_filter_params(self):
|
||||||
|
|
||||||
input = {
|
input = {
|
||||||
@ -39,13 +37,21 @@ class DictToFilterParamsTest(TestCase):
|
|||||||
self.assertNotEqual(dict_to_filter_params(input), output)
|
self.assertNotEqual(dict_to_filter_params(input), output)
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizeQueryDictTest(TestCase):
|
||||||
|
"""
|
||||||
|
Validate normalize_querydict() utility function.
|
||||||
|
"""
|
||||||
|
def test_normalize_querydict(self):
|
||||||
|
self.assertDictEqual(
|
||||||
|
normalize_querydict(QueryDict('foo=1&bar=2&bar=3&baz=')),
|
||||||
|
{'foo': '1', 'bar': ['2', '3'], 'baz': ''}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeepMergeTest(TestCase):
|
class DeepMergeTest(TestCase):
|
||||||
"""
|
"""
|
||||||
Validate the behavior of the deepmerge() utility.
|
Validate the behavior of the deepmerge() utility.
|
||||||
"""
|
"""
|
||||||
def setUp(self):
|
|
||||||
return
|
|
||||||
|
|
||||||
def test_deepmerge(self):
|
def test_deepmerge(self):
|
||||||
|
|
||||||
dict1 = {
|
dict1 = {
|
||||||
|
@ -150,6 +150,24 @@ def dict_to_filter_params(d, prefix=''):
|
|||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_querydict(querydict):
|
||||||
|
"""
|
||||||
|
Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,
|
||||||
|
|
||||||
|
QueryDict('foo=1&bar=2&bar=3&baz=')
|
||||||
|
|
||||||
|
becomes:
|
||||||
|
|
||||||
|
{'foo': '1', 'bar': ['2', '3'], 'baz': ''}
|
||||||
|
|
||||||
|
This function is necessary because QueryDict does not provide any built-in mechanism which preserves multiple
|
||||||
|
values.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
k: v if len(v) > 1 else v[0] for k, v in querydict.lists()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def deepmerge(original, new):
|
def deepmerge(original, new):
|
||||||
"""
|
"""
|
||||||
Deep merge two dictionaries (new into original) and return a new dict
|
Deep merge two dictionaries (new into original) and return a new dict
|
||||||
|
@ -27,7 +27,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate
|
|||||||
from extras.querysets import CustomFieldQueryset
|
from extras.querysets import CustomFieldQueryset
|
||||||
from utilities.exceptions import AbortTransaction
|
from utilities.exceptions import AbortTransaction
|
||||||
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
|
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
|
||||||
from utilities.utils import csv_format, prepare_cloned_fields
|
from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields
|
||||||
from .error_handlers import handle_protectederror
|
from .error_handlers import handle_protectederror
|
||||||
from .forms import ConfirmationForm, ImportForm
|
from .forms import ConfirmationForm, ImportForm
|
||||||
from .paginator import EnhancedPaginator, get_paginate_count
|
from .paginator import EnhancedPaginator, get_paginate_count
|
||||||
@ -250,7 +250,7 @@ class ObjectEditView(GetReturnURLMixin, View):
|
|||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
# Parse initial data manually to avoid setting field values as lists
|
# Parse initial data manually to avoid setting field values as lists
|
||||||
initial_data = {k: request.GET[k] for k in request.GET}
|
initial_data = normalize_querydict(request.GET)
|
||||||
form = self.model_form(instance=self.obj, initial=initial_data)
|
form = self.model_form(instance=self.obj, initial=initial_data)
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
return render(request, self.template_name, {
|
||||||
|
@ -220,6 +220,7 @@ class InterfaceFilterSet(BaseFilterSet):
|
|||||||
mac_address = MultiValueMACAddressFilter(
|
mac_address = MultiValueMACAddressFilter(
|
||||||
label='MAC address',
|
label='MAC address',
|
||||||
)
|
)
|
||||||
|
tag = TagFilter()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Interface
|
model = Interface
|
||||||
|
@ -34,6 +34,14 @@ VIRTUALMACHINE_PRIMARY_IP = """
|
|||||||
{{ record.primary_ip4.address.ip|default:"" }}
|
{{ record.primary_ip4.address.ip|default:"" }}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
CLUSTER_DEVICE_COUNT = """
|
||||||
|
<a href="{% url 'dcim:device_list' %}?cluster_id={{ record.pk }}">{{ value|default:0 }}</a>
|
||||||
|
"""
|
||||||
|
|
||||||
|
CLUSTER_VM_COUNT = """
|
||||||
|
<a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ record.pk }}">{{ value|default:0 }}</a>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Cluster types
|
# Cluster types
|
||||||
@ -94,14 +102,12 @@ class ClusterTable(BaseTable):
|
|||||||
viewname='dcim:site',
|
viewname='dcim:site',
|
||||||
args=[Accessor('site.slug')]
|
args=[Accessor('site.slug')]
|
||||||
)
|
)
|
||||||
device_count = tables.Column(
|
device_count = tables.TemplateColumn(
|
||||||
accessor=Accessor('devices.count'),
|
template_code=CLUSTER_DEVICE_COUNT,
|
||||||
orderable=False,
|
|
||||||
verbose_name='Devices'
|
verbose_name='Devices'
|
||||||
)
|
)
|
||||||
vm_count = tables.Column(
|
vm_count = tables.TemplateColumn(
|
||||||
accessor=Accessor('virtual_machines.count'),
|
template_code=CLUSTER_VM_COUNT,
|
||||||
orderable=False,
|
|
||||||
verbose_name='VMs'
|
verbose_name='VMs'
|
||||||
)
|
)
|
||||||
tags = TagColumn(
|
tags = TagColumn(
|
||||||
|
@ -10,6 +10,7 @@ from dcim.models import Device, Interface
|
|||||||
from dcim.tables import DeviceTable
|
from dcim.tables import DeviceTable
|
||||||
from extras.views import ObjectConfigContextView
|
from extras.views import ObjectConfigContextView
|
||||||
from ipam.models import Service
|
from ipam.models import Service
|
||||||
|
from utilities.utils import get_subquery
|
||||||
from utilities.views import (
|
from utilities.views import (
|
||||||
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
|
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
|
||||||
ObjectEditView, ObjectListView,
|
ObjectEditView, ObjectListView,
|
||||||
@ -94,7 +95,10 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
|||||||
|
|
||||||
class ClusterListView(PermissionRequiredMixin, ObjectListView):
|
class ClusterListView(PermissionRequiredMixin, ObjectListView):
|
||||||
permission_required = 'virtualization.view_cluster'
|
permission_required = 'virtualization.view_cluster'
|
||||||
queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant')
|
queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant').annotate(
|
||||||
|
device_count=get_subquery(Device, 'cluster'),
|
||||||
|
vm_count=get_subquery(VirtualMachine, 'cluster')
|
||||||
|
)
|
||||||
table = tables.ClusterTable
|
table = tables.ClusterTable
|
||||||
filterset = filters.ClusterFilterSet
|
filterset = filters.ClusterFilterSet
|
||||||
filterset_form = forms.ClusterFilterForm
|
filterset_form = forms.ClusterFilterForm
|
||||||
|
Loading…
Reference in New Issue
Block a user