Merge branch 'develop' into develop-2.9

This commit is contained in:
Jeremy Stretch 2020-07-21 12:57:02 -04:00
commit 1714902f88
20 changed files with 214 additions and 96 deletions

4
.gitattributes vendored
View File

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

View File

@ -1,11 +1,25 @@
# NetBox v2.8 # NetBox v2.8
## v2.8.8 (FUTURE) ## 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 ### 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 * [#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 * [#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
--- ---

View File

@ -1,3 +1,4 @@
import socket
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings from django.conf import settings
@ -388,6 +389,22 @@ class DeviceViewSet(CustomFieldModelViewSet):
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
@ -407,10 +424,8 @@ class DeviceViewSet(CustomFieldModelViewSet):
if not request.user.has_perm('dcim.napalm_read_device'): if not request.user.has_perm('dcim.napalm_read_device'):
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()
@ -430,8 +445,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,
@ -440,7 +456,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:

View File

@ -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'),
) )
@ -228,6 +232,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'
@ -243,6 +252,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
@ -304,6 +317,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'),
@ -320,6 +338,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'),
)), )),
@ -389,6 +411,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'
@ -404,6 +431,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
@ -466,6 +497,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'),
@ -482,6 +518,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'),
)), )),

View File

@ -804,7 +804,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

View File

@ -52,20 +52,12 @@ RACK_DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a> <a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
""" """
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>
""" """
STATUS_LABEL = """ STATUS_LABEL = """
@ -210,6 +202,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 = ButtonsColumn(RackRole) actions = ButtonsColumn(RackRole)
@ -502,15 +495,11 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
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__unrestricted__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__unrestricted__count'),
orderable=False,
verbose_name='VMs' verbose_name='VMs'
) )
color = tables.TemplateColumn( color = tables.TemplateColumn(
@ -533,15 +522,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__unrestricted__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__unrestricted__count'),
orderable=False,
verbose_name='VMs' verbose_name='VMs'
) )
actions = ButtonsColumn(Platform, pk_field='slug') actions = ButtonsColumn(Platform, pk_field='slug')

View File

@ -22,7 +22,7 @@ from secrets.models import Secret
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
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, BulkRenameView, ComponentCreateView, BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
GetReturnURLMixin, ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, GetReturnURLMixin, ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
@ -341,13 +341,14 @@ class RackView(ObjectView):
def get(self, request, pk): def get(self, request, pk):
rack = get_object_or_404(self.queryset, pk=pk) rack = get_object_or_404(self.queryset, pk=pk)
nonracked_devices = Device.objects.restrict(request.user, 'view').filter( # Get 0U and child devices located within the rack
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')
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=rack.site) peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=rack.site)
if rack.group: if rack.group:
peer_racks = peer_racks.filter(group=rack.group) peer_racks = peer_racks.filter(group=rack.group)
else: else:
@ -474,10 +475,10 @@ class RackReservationBulkDeleteView(BulkDeleteView):
class ManufacturerListView(ObjectListView): class ManufacturerListView(ObjectListView):
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')
).order_by(*Manufacturer._meta.ordering) )
table = tables.ManufacturerTable table = tables.ManufacturerTable
@ -919,7 +920,10 @@ class DeviceBayTemplateBulkDeleteView(BulkDeleteView):
# #
class DeviceRoleListView(ObjectListView): class DeviceRoleListView(ObjectListView):
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
@ -948,7 +952,10 @@ class DeviceRoleBulkDeleteView(BulkDeleteView):
# #
class PlatformListView(ObjectListView): class PlatformListView(ObjectListView):
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

View File

@ -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):

View File

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

View 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)

View File

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

View File

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

View File

@ -73,6 +73,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'])

View File

@ -31,11 +31,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>
""" """
PREFIX_LINK = """ PREFIX_LINK = """
@ -283,15 +283,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__unrestricted__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__unrestricted__count'),
template_code=ROLE_VLAN_COUNT, template_code=ROLE_VLAN_COUNT,
orderable=False,
verbose_name='VLANs' verbose_name='VLANs'
) )
actions = ButtonsColumn(Role, pk_field='slug') actions = ButtonsColumn(Role, pk_field='slug')
@ -474,7 +470,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')]

View File

@ -7,6 +7,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, ObjectView, ObjectDeleteView, ObjectEditView, BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
ObjectListView, ObjectListView,
@ -285,7 +286,10 @@ class AggregateBulkDeleteView(BulkDeleteView):
# #
class RoleListView(ObjectListView): class RoleListView(ObjectListView):
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

View File

@ -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 %}
{% endif %} <a href="{{ peer.device.get_absolute_url }}">{{ peer.device }}</a>
{% elif peer.circuit %}
<a href="{{ peer.circuit.get_absolute_url }}">{{ peer.circuit }}</a>
{% 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>

View File

@ -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 %} <td><a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay.device }}</a></td>
<a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay }}</a> <td>{{ device.parent_bay }}</td>
{% else %} {% else %}
<span class="text-muted">&mdash;</span> <td colspan="2" class="text-muted">&mdash;</td>
{% endif %} {% endif %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>

View File

@ -231,6 +231,7 @@ class VMInterfaceFilterSet(BaseFilterSet):
mac_address = MultiValueMACAddressFilter( mac_address = MultiValueMACAddressFilter(
label='MAC address', label='MAC address',
) )
tag = TagFilter()
class Meta: class Meta:
model = VMInterface model = VMInterface

View File

@ -16,6 +16,14 @@ VIRTUALMACHINE_PRIMARY_IP = """
{{ record.primary_ip4.address.ip|default:"" }} {{ record.primary_ip4.address.ip|default:"" }}
""" """
DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?cluster_id={{ record.pk }}">{{ value|default:0 }}</a>
"""
VM_COUNT = """
<a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ record.pk }}">{{ value|default:0 }}</a>
"""
# #
# Cluster types # Cluster types
@ -66,14 +74,12 @@ class ClusterTable(BaseTable):
site = tables.Column( site = tables.Column(
linkify=True linkify=True
) )
device_count = tables.Column( device_count = tables.TemplateColumn(
accessor=Accessor('devices__unrestricted__count'), template_code=DEVICE_COUNT,
orderable=False,
verbose_name='Devices' verbose_name='Devices'
) )
vm_count = tables.Column( vm_count = tables.TemplateColumn(
accessor=Accessor('virtual_machines__unrestricted__count'), template_code=VM_COUNT,
orderable=False,
verbose_name='VMs' verbose_name='VMs'
) )
tags = TagColumn( tags = TagColumn(

View File

@ -9,6 +9,7 @@ from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView from extras.views import ObjectConfigContextView
from ipam.models import IPAddress, Service from ipam.models import IPAddress, Service
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from utilities.utils import get_subquery
from utilities.views import ( from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView, BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
@ -80,7 +81,11 @@ class ClusterGroupBulkDeleteView(BulkDeleteView):
# #
class ClusterListView(ObjectListView): class ClusterListView(ObjectListView):
queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant') permission_required = 'virtualization.view_cluster'
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