Merge pull request #4873 from netbox-community/develop

Release v2.8.8
This commit is contained in:
Jeremy Stretch 2020-07-21 12:21:04 -04:00 committed by GitHub
commit f1e82a3647
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 273 additions and 121 deletions

4
.gitattributes vendored
View File

@ -1 +1,5 @@
*.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,5 +1,28 @@
# 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)
### Enhancements

View File

@ -1,3 +1,4 @@
import socket
from collections import OrderedDict
from django.conf import settings
@ -371,15 +372,29 @@ class DeviceViewSet(CustomFieldModelViewSet):
Execute a NAPALM method on a Device
"""
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:
raise ServiceUnavailable("No platform is configured for this device.")
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
))
# 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
try:
import napalm
@ -399,10 +414,8 @@ class DeviceViewSet(CustomFieldModelViewSet):
if not request.user.has_perm('dcim.napalm_read'):
return HttpResponseForbidden()
# Connect to the device
napalm_methods = request.GET.getlist('method')
response = OrderedDict([(m, None) for m in napalm_methods])
ip_address = str(device.primary_ip.address.ip)
username = settings.NAPALM_USERNAME
password = settings.NAPALM_PASSWORD
optional_args = settings.NAPALM_ARGS.copy()
@ -422,8 +435,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
elif key:
optional_args[key.lower()] = request.headers[header]
# Connect to the device
d = driver(
hostname=ip_address,
hostname=host,
username=username,
password=password,
timeout=settings.NAPALM_TIMEOUT,
@ -432,7 +446,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
try:
d.open()
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
for method in napalm_methods:

View File

@ -7,13 +7,17 @@ from utilities.choices import ChoiceSet
class SiteStatusChoices(ChoiceSet):
STATUS_ACTIVE = 'active'
STATUS_PLANNED = 'planned'
STATUS_STAGING = 'staging'
STATUS_ACTIVE = 'active'
STATUS_DECOMMISSIONING = 'decommissioning'
STATUS_RETIRED = 'retired'
CHOICES = (
(STATUS_ACTIVE, 'Active'),
(STATUS_PLANNED, 'Planned'),
(STATUS_STAGING, 'Staging'),
(STATUS_ACTIVE, 'Active'),
(STATUS_DECOMMISSIONING, 'Decommissioning'),
(STATUS_RETIRED, 'Retired'),
)
@ -275,6 +279,11 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_NEMA_1430P = 'nema-14-30p'
TYPE_NEMA_1450P = 'nema-14-50p'
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
TYPE_NEMA_L115P = 'nema-l1-15p'
TYPE_NEMA_L515P = 'nema-l5-15p'
@ -290,6 +299,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_NEMA_L1430P = 'nema-l14-30p'
TYPE_NEMA_L1450P = 'nema-l14-50p'
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_L2130P = 'nema-l21-30p'
# California style
@ -351,6 +364,11 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_1430P, 'NEMA 14-30P'),
(TYPE_NEMA_1450P, 'NEMA 14-50P'),
(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)', (
(TYPE_NEMA_L115P, 'NEMA L1-15P'),
@ -367,6 +385,10 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_L1430P, 'NEMA L14-30P'),
(TYPE_NEMA_L1450P, 'NEMA L14-50P'),
(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_L2130P, 'NEMA L21-30P'),
)),
@ -436,6 +458,11 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEMA_1430R = 'nema-14-30r'
TYPE_NEMA_1450R = 'nema-14-50r'
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
TYPE_NEMA_L115R = 'nema-l1-15r'
TYPE_NEMA_L515R = 'nema-l5-15r'
@ -451,6 +478,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEMA_L1430R = 'nema-l14-30r'
TYPE_NEMA_L1450R = 'nema-l14-50r'
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_L2130R = 'nema-l21-30r'
# California style
@ -513,6 +544,11 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_1430R, 'NEMA 14-30R'),
(TYPE_NEMA_1450R, 'NEMA 14-50R'),
(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)', (
(TYPE_NEMA_L115R, 'NEMA L1-15R'),
@ -529,6 +565,10 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_L1430R, 'NEMA L14-30R'),
(TYPE_NEMA_L1450R, 'NEMA L14-50R'),
(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_L2130R, 'NEMA L21-30R'),
)),

View File

@ -787,7 +787,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
)
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)
return int(allocated_draw_total / available_power_total * 100) or 0
return 0

View File

@ -103,20 +103,12 @@ DEVICEROLE_ACTIONS = """
{% endif %}
"""
DEVICEROLE_DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
"""
DEVICEROLE_VM_COUNT = """
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value }}</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>
VM_COUNT = """
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
"""
PLATFORM_ACTIONS = """
@ -278,6 +270,7 @@ class RackGroupTable(BaseTable):
class RackRoleTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(linkify=True)
rack_count = tables.Column(verbose_name='Racks')
color = tables.TemplateColumn(COLOR_LABEL)
actions = tables.TemplateColumn(
@ -704,21 +697,18 @@ class DeviceBayTemplateTable(BaseTable):
class DeviceRoleTable(BaseTable):
pk = ToggleColumn()
device_count = tables.TemplateColumn(
template_code=DEVICEROLE_DEVICE_COUNT,
accessor=Accessor('devices.count'),
orderable=False,
template_code=DEVICE_COUNT,
verbose_name='Devices'
)
vm_count = tables.TemplateColumn(
template_code=DEVICEROLE_VM_COUNT,
accessor=Accessor('virtual_machines.count'),
orderable=False,
template_code=VM_COUNT,
verbose_name='VMs'
)
color = tables.TemplateColumn(
template_code=COLOR_LABEL,
verbose_name='Label'
)
vm_role = BooleanColumn()
actions = tables.TemplateColumn(
template_code=DEVICEROLE_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
@ -738,15 +728,11 @@ class DeviceRoleTable(BaseTable):
class PlatformTable(BaseTable):
pk = ToggleColumn()
device_count = tables.TemplateColumn(
template_code=PLATFORM_DEVICE_COUNT,
accessor=Accessor('devices.count'),
orderable=False,
template_code=DEVICE_COUNT,
verbose_name='Devices'
)
vm_count = tables.TemplateColumn(
template_code=PLATFORM_VM_COUNT,
accessor=Accessor('virtual_machines.count'),
orderable=False,
template_code=VM_COUNT,
verbose_name='VMs'
)
actions = tables.TemplateColumn(

View File

@ -23,7 +23,7 @@ from ipam.models import Prefix, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.utils import csv_format
from utilities.utils import csv_format, get_subquery
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
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)
# Get 0U and child devices located within the rack
nonracked_devices = Device.objects.filter(
rack=rack,
position__isnull=True,
parent_bay__isnull=True
position__isnull=True
).prefetch_related('device_type__manufacturer')
if rack.group:
peer_racks = Rack.objects.filter(site=rack.site, group=rack.group)
else:
@ -557,9 +558,9 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_manufacturer'
queryset = Manufacturer.objects.annotate(
devicetype_count=Count('device_types', distinct=True),
inventoryitem_count=Count('inventory_items', distinct=True),
platform_count=Count('platforms', distinct=True),
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
platform_count=get_subquery(Platform, 'manufacturer')
)
table = tables.ManufacturerTable
@ -1020,7 +1021,10 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
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
@ -1055,7 +1059,10 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class PlatformListView(PermissionRequiredMixin, ObjectListView):
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

View File

@ -16,6 +16,7 @@ from extras.models import (
from extras.reports import get_report, get_reports
from extras.scripts import get_script, get_scripts, run_script
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
from utilities.metadata import ContentTypeMetadata
from . import serializers
@ -88,6 +89,7 @@ class CustomFieldModelViewSet(ModelViewSet):
#
class GraphViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Graph.objects.all()
serializer_class = serializers.GraphSerializer
filterset_class = filters.GraphFilterSet
@ -98,6 +100,7 @@ class GraphViewSet(ModelViewSet):
#
class ExportTemplateViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = ExportTemplate.objects.all()
serializer_class = serializers.ExportTemplateSerializer
filterset_class = filters.ExportTemplateFilterSet
@ -120,6 +123,7 @@ class TagViewSet(ModelViewSet):
#
class ImageAttachmentViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer
@ -271,6 +275,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
"""
Retrieve a list of recent changes.
"""
metadata_class = ContentTypeMetadata
queryset = ObjectChange.objects.prefetch_related('user')
serializer_class = serializers.ObjectChangeSerializer
filterset_class = filters.ObjectChangeFilterSet

View File

@ -6,11 +6,12 @@ from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.template.loader import get_template
from django.utils.module_loading import import_string
from extras.registry import registry
from utilities.choices import ButtonColorChoices
from extras.plugins.utils import import_object
# Initialize plugin registry stores
registry['plugin_template_extensions'] = collections.defaultdict(list)
@ -60,18 +61,14 @@ class PluginConfig(AppConfig):
def ready(self):
# Register template content
try:
template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
template_extensions = import_object(f"{self.__module__}.{self.template_extensions}")
if template_extensions is not None:
register_template_extensions(template_extensions)
except ImportError:
pass
# Register navigation menu items (if defined)
try:
menu_items = import_string(f"{self.__module__}.{self.menu_items}")
menu_items = import_object(f"{self.__module__}.{self.menu_items}")
if menu_items is not None:
register_menu_items(self.verbose_name, menu_items)
except ImportError:
pass
@classmethod
def validate(cls, user_config):

View File

@ -3,7 +3,8 @@ from django.conf import settings
from django.conf.urls import include
from django.contrib.admin.views.decorators import staff_member_required
from django.urls import path
from django.utils.module_loading import import_string
from extras.plugins.utils import import_object
from . import views
@ -24,19 +25,15 @@ for plugin_path in settings.PLUGINS:
base_url = getattr(app, 'base_url') or app.label
# Check if the plugin specifies any base URLs
try:
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
urlpatterns = import_object(f"{plugin_path}.urls.urlpatterns")
if urlpatterns is not None:
plugin_patterns.append(
path(f"{base_url}/", include((urlpatterns, app.label)))
)
except ImportError:
pass
# Check if the plugin specifies any API URLs
try:
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
urlpatterns = import_object(f"{plugin_path}.api.urls.urlpatterns")
if urlpatterns is not None:
plugin_api_patterns.append(
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.shortcuts import render
from django.urls.exceptions import NoReverseMatch
from django.utils.module_loading import import_string
from django.views.generic import View
from rest_framework import permissions
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView
from extras.plugins.utils import import_object
class InstalledPluginsAdminView(View):
"""
@ -60,9 +61,9 @@ class PluginsAPIRootView(APIView):
@staticmethod
def _get_plugin_entry(plugin, app_config, request, format):
try:
api_app_name = import_string(f"{plugin}.api.urls.app_name")
except (ImportError, ModuleNotFoundError):
# Check if the plugin specifies any API URLs
api_app_name = import_object(f"{plugin}.api.urls.app_name")
if api_app_name is None:
# Plugin does not expose an API
return None
@ -73,7 +74,7 @@ class PluginsAPIRootView(APIView):
format=format
))
except NoReverseMatch:
# The plugin does not include an api-root
# The plugin does not include an api-root url
entry = None
return entry

View File

@ -44,6 +44,7 @@ class NestedRIRSerializer(WritableNestedSerializer):
class NestedAggregateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
family = serializers.IntegerField(read_only=True)
class Meta:
model = models.Aggregate
@ -87,6 +88,7 @@ class NestedVLANSerializer(WritableNestedSerializer):
class NestedPrefixSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
family = serializers.IntegerField(read_only=True)
class Meta:
model = models.Prefix
@ -99,6 +101,7 @@ class NestedPrefixSerializer(WritableNestedSerializer):
class NestedIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
family = serializers.IntegerField(read_only=True)
class Meta:
model = models.IPAddress

View File

@ -74,6 +74,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
serializer_class = serializers.PrefixSerializer
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='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])

View File

@ -1068,7 +1068,12 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False
required=False,
widget=APISelect(
filter_for={
'group': 'site_id'
}
)
)
group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),

View File

@ -40,11 +40,11 @@ UTILIZATION_GRAPH = """
"""
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 = """
<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 = """
@ -319,15 +319,11 @@ class AggregateDetailTable(AggregateTable):
class RoleTable(BaseTable):
pk = ToggleColumn()
prefix_count = tables.TemplateColumn(
accessor=Accessor('prefixes.count'),
template_code=ROLE_PREFIX_COUNT,
orderable=False,
verbose_name='Prefixes'
)
vlan_count = tables.TemplateColumn(
accessor=Accessor('vlans.count'),
template_code=ROLE_VLAN_COUNT,
orderable=False,
verbose_name='VLANs'
)
actions = tables.TemplateColumn(
@ -524,7 +520,7 @@ class InterfaceIPAddressTable(BaseTable):
class VLANGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
name = tables.Column(linkify=True)
site = tables.LinkColumn(
viewname='dcim:site',
args=[Accessor('site.slug')]

View File

@ -9,6 +9,7 @@ from django_tables2 import RequestConfig
from dcim.models import Device, Interface
from utilities.paginator import EnhancedPaginator
from utilities.utils import get_subquery
from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
@ -407,7 +408,10 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class RoleListView(PermissionRequiredMixin, ObjectListView):
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

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.8.7'
VERSION = '2.8.8'
# Hostname
HOSTNAME = platform.node()

View File

@ -51,10 +51,15 @@
<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>
</a>
{% if termination.connected_endpoint %}
to <a href="{% url 'dcim:device' pk=termination.connected_endpoint.device.pk %}">{{ termination.connected_endpoint.device }}</a>
<i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }}
{% endif %}
{% with peer=termination.get_cable_peer %}
to
{% 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 %}
({{ peer }})
{% endwith %}
{% else %}
{% if perms.dcim.add_cable %}
<div class="pull-right">
@ -63,10 +68,10 @@
<span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span> Connect
</button>
<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='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='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='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='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' %}?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' %}?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' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Circuit Termination</a></li>
</ul>
</span>
</div>

View File

@ -337,7 +337,7 @@
<th>Name</th>
<th>Role</th>
<th>Type</th>
<th>Parent</th>
<th colspan="2">Parent Device</th>
</tr>
{% for device in nonracked_devices %}
<tr{% if device.device_type.u_height %} class="warning"{% endif %}>
@ -346,13 +346,12 @@
</td>
<td>{{ device.device_role }}</td>
<td>{{ device.device_type.display_name }}</td>
<td>
{% if device.parent_bay %}
<a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
{% if device.parent_bay %}
<td><a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay.device }}</a></td>
<td>{{ device.parent_bay }}</td>
{% else %}
<td colspan="2" class="text-muted">&mdash;</td>
{% endif %}
</tr>
{% endfor %}
</table>

View File

@ -594,21 +594,20 @@ class DynamicModelChoiceMixin:
filter = django_filters.ModelChoiceFilter
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):
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
# will be populated on-demand via the APISelect widget.
data = bound_field.value()
if data:
filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset)
self.queryset = filter.filter(self.queryset, data)
field_name = getattr(self, 'to_field_name') or 'pk'
filter = self.filter(field_name=field_name)
try:
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:
self.queryset = self.queryset.none()
@ -638,12 +637,6 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
filter = django_filters.ModelMultipleChoiceFilter
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):
"""

View File

@ -1,15 +1,13 @@
from django.http import QueryDict
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):
"""
Validate the operation of dict_to_filter_params().
"""
def setUp(self):
return
def test_dict_to_filter_params(self):
input = {
@ -39,13 +37,21 @@ class DictToFilterParamsTest(TestCase):
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):
"""
Validate the behavior of the deepmerge() utility.
"""
def setUp(self):
return
def test_deepmerge(self):
dict1 = {

View File

@ -150,6 +150,24 @@ def dict_to_filter_params(d, prefix=''):
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):
"""
Deep merge two dictionaries (new into original) and return a new dict

View File

@ -27,7 +27,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate
from extras.querysets import CustomFieldQueryset
from utilities.exceptions import AbortTransaction
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 .forms import ConfirmationForm, ImportForm
from .paginator import EnhancedPaginator, get_paginate_count
@ -250,7 +250,7 @@ class ObjectEditView(GetReturnURLMixin, View):
def get(self, request, *args, **kwargs):
# 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)
return render(request, self.template_name, {

View File

@ -220,6 +220,7 @@ class InterfaceFilterSet(BaseFilterSet):
mac_address = MultiValueMACAddressFilter(
label='MAC address',
)
tag = TagFilter()
class Meta:
model = Interface

View File

@ -34,6 +34,14 @@ VIRTUALMACHINE_PRIMARY_IP = """
{{ 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
@ -94,14 +102,12 @@ class ClusterTable(BaseTable):
viewname='dcim:site',
args=[Accessor('site.slug')]
)
device_count = tables.Column(
accessor=Accessor('devices.count'),
orderable=False,
device_count = tables.TemplateColumn(
template_code=CLUSTER_DEVICE_COUNT,
verbose_name='Devices'
)
vm_count = tables.Column(
accessor=Accessor('virtual_machines.count'),
orderable=False,
vm_count = tables.TemplateColumn(
template_code=CLUSTER_VM_COUNT,
verbose_name='VMs'
)
tags = TagColumn(

View File

@ -10,6 +10,7 @@ from dcim.models import Device, Interface
from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView
from ipam.models import Service
from utilities.utils import get_subquery
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
ObjectEditView, ObjectListView,
@ -94,7 +95,10 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ClusterListView(PermissionRequiredMixin, ObjectListView):
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
filterset = filters.ClusterFilterSet
filterset_form = forms.ClusterFilterForm