Merge pull request #2725 from digitalocean/develop

Release v2.5.2
This commit is contained in:
Jeremy Stretch 2018-12-21 11:46:31 -05:00 committed by GitHub
commit a1312cabe7
21 changed files with 149 additions and 21 deletions

View File

@ -1,3 +1,25 @@
v2.5.2 (2018-12-21)
## Enhancements
* [#2561](https://github.com/digitalocean/netbox/issues/2561) - Add 200G and 400G interface types
* [#2701](https://github.com/digitalocean/netbox/issues/2701) - Enable filtering of prefixes by exact prefix value
## Bug Fixes
* [#2673](https://github.com/digitalocean/netbox/issues/2673) - Fix exception on LLDP neighbors view for device with a circuit connected
* [#2691](https://github.com/digitalocean/netbox/issues/2691) - Cable trace should follow circuits
* [#2698](https://github.com/digitalocean/netbox/issues/2698) - Remove pagination restriction on bulk component creation for devices/VMs
* [#2704](https://github.com/digitalocean/netbox/issues/2704) - Fix form select widget population on parent with null value
* [#2707](https://github.com/digitalocean/netbox/issues/2707) - Correct permission evaluation for circuit termination cabling
* [#2712](https://github.com/digitalocean/netbox/issues/2712) - Preserve list filtering after editing objects in bulk
* [#2717](https://github.com/digitalocean/netbox/issues/2717) - Fix bulk deletion of tags
* [#2721](https://github.com/digitalocean/netbox/issues/2721) - Detect loops when tracing front/rear ports
* [#2723](https://github.com/digitalocean/netbox/issues/2723) - Correct permission evaluation when bulk deleting tags
* [#2724](https://github.com/digitalocean/netbox/issues/2724) - Limit rear port choices to current device when editing a front port
---
v2.5.1 (2018-12-13) v2.5.1 (2018-12-13)
## Enhancements ## Enhancements

View File

@ -44,7 +44,7 @@ class DeviceConnectionsReport(Report):
# Check that every console port for every active device has a connection defined. # Check that every console port for every active device has a connection defined.
for console_port in ConsolePort.objects.select_related('device').filter(device__status=DEVICE_STATUS_ACTIVE): for console_port in ConsolePort.objects.select_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
if console_port.cs_port is None: if console_port.connected_endpoint is None:
self.log_failure( self.log_failure(
console_port.device, console_port.device,
"No console connection defined for {}".format(console_port.name) "No console connection defined for {}".format(console_port.name)
@ -63,7 +63,7 @@ class DeviceConnectionsReport(Report):
for device in Device.objects.filter(status=DEVICE_STATUS_ACTIVE): for device in Device.objects.filter(status=DEVICE_STATUS_ACTIVE):
connected_ports = 0 connected_ports = 0
for power_port in PowerPort.objects.filter(device=device): for power_port in PowerPort.objects.filter(device=device):
if power_port.power_outlet is not None: if power_port.connected_endpoint is not None:
connected_ports += 1 connected_ports += 1
if power_port.connection_status == CONNECTION_STATUS_PLANNED: if power_port.connection_status == CONNECTION_STATUS_PLANNED:
self.log_warning( self.log_warning(

View File

@ -95,6 +95,9 @@ AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
# Define a group required to login. # Define a group required to login.
AUTH_LDAP_REQUIRE_GROUP = "CN=NETBOX_USERS,DC=example,DC=com" AUTH_LDAP_REQUIRE_GROUP = "CN=NETBOX_USERS,DC=example,DC=com"
# Mirror LDAP group assignments.
AUTH_LDAP_MIRROR_GROUPS = True
# Define special user types using groups. Exercise great caution when assigning superuser status. # Define special user types using groups. Exercise great caution when assigning superuser status.
AUTH_LDAP_USER_FLAGS_BY_GROUP = { AUTH_LDAP_USER_FLAGS_BY_GROUP = {
"is_active": "cn=active,ou=groups,dc=example,dc=com", "is_active": "cn=active,ou=groups,dc=example,dc=com",
@ -113,3 +116,21 @@ AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
* `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in. * `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in.
* `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions. * `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions.
* `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions. * `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions.
# Troubleshooting LDAP
`supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`.
For troubleshooting LDAP user/group queries, add the following lines to the start of `ldap_config.py` after `import ldap`.
```python
import logging, logging.handlers
logfile = "/opt/netbox/logs/django-ldap-debug.log"
my_logger = logging.getLogger('django_auth_ldap')
my_logger.setLevel(logging.DEBUG)
handler = logging.handlers.RotatingFileHandler(
logfile, maxBytes=1024 * 500, backupCount=5)
my_logger.addHandler(handler)
```
Ensure the file and path specified in logfile exist and are writable and executable by the application service account. Restart the netbox service and attempt to log into the site to trigger log entries to this file.

View File

@ -60,7 +60,7 @@ class CableTraceMixin(object):
# Initialize the path array # Initialize the path array
path = [] path = []
for near_end, cable, far_end in obj.trace(): for near_end, cable, far_end in obj.trace(follow_circuits=True):
# Serialize each object # Serialize each object
serializer_a = get_serializer_for_model(near_end, prefix='Nested') serializer_a = get_serializer_for_model(near_end, prefix='Nested')

View File

@ -82,6 +82,9 @@ IFACE_FF_100GE_CFP2 = 1510
IFACE_FF_100GE_CFP4 = 1520 IFACE_FF_100GE_CFP4 = 1520
IFACE_FF_100GE_CPAK = 1550 IFACE_FF_100GE_CPAK = 1550
IFACE_FF_100GE_QSFP28 = 1600 IFACE_FF_100GE_QSFP28 = 1600
IFACE_FF_200GE_CFP2 = 1650
IFACE_FF_200GE_QSFP56 = 1700
IFACE_FF_400GE_QSFP_DD = 1750
# Wireless # Wireless
IFACE_FF_80211A = 2600 IFACE_FF_80211A = 2600
IFACE_FF_80211G = 2610 IFACE_FF_80211G = 2610
@ -153,9 +156,12 @@ IFACE_FF_CHOICES = [
[IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
[IFACE_FF_100GE_CFP, 'CFP (100GE)'], [IFACE_FF_100GE_CFP, 'CFP (100GE)'],
[IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'], [IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'],
[IFACE_FF_200GE_CFP2, 'CFP2 (200GE)'],
[IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'], [IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'],
[IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'], [IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'],
[IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'], [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
[IFACE_FF_200GE_QSFP56, 'QSFP56 (200GE)'],
[IFACE_FF_400GE_QSFP_DD, 'QSFP-DD (400GE)'],
] ]
], ],
[ [

View File

@ -0,0 +1,5 @@
class LoopDetected(Exception):
"""
A loop has been detected while tracing a cable path.
"""
pass

View File

@ -2098,6 +2098,15 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm):
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
} }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit RearPort choices to the local device
if hasattr(self.instance, 'device'):
self.fields['rear_port'].queryset = self.fields['rear_port'].queryset.filter(
device=self.instance.device
)
# TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
class FrontPortCreateForm(ComponentForm): class FrontPortCreateForm(ComponentForm):
@ -2703,6 +2712,12 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
to_field_name='slug', to_field_name='slug',
null_label='-- None --' null_label='-- None --'
) )
discovered = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
# #

View File

@ -21,6 +21,7 @@ from utilities.managers import NaturalOrderingManager
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object, to_meters from utilities.utils import serialize_object, to_meters
from .constants import * from .constants import *
from .exceptions import LoopDetected
from .fields import ASNField, MACAddressField from .fields import ASNField, MACAddressField
from .managers import DeviceComponentManager, InterfaceManager from .managers import DeviceComponentManager, InterfaceManager
@ -88,7 +89,7 @@ class CableTermination(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def trace(self, position=1, follow_circuits=False): def trace(self, position=1, follow_circuits=False, cable_history=None):
""" """
Return a list representing a complete cable path, with each individual segment represented as a three-tuple: Return a list representing a complete cable path, with each individual segment represented as a three-tuple:
[ [
@ -133,6 +134,13 @@ class CableTermination(models.Model):
if not self.cable: if not self.cable:
return [(self, None, None)] return [(self, None, None)]
# Record cable history to detect loops
if cable_history is None:
cable_history = []
elif self.cable in cable_history:
raise LoopDetected()
cable_history.append(self.cable)
far_end = self.cable.termination_b if self.cable.termination_a == self else self.cable.termination_a far_end = self.cable.termination_b if self.cable.termination_a == self else self.cable.termination_a
path = [(self, self.cable, far_end)] path = [(self, self.cable, far_end)]
@ -140,7 +148,11 @@ class CableTermination(models.Model):
if peer_port is None: if peer_port is None:
return path return path
next_segment = peer_port.trace(position) try:
next_segment = peer_port.trace(position, follow_circuits, cable_history)
except LoopDetected:
return path
if next_segment is None: if next_segment is None:
return path + [(peer_port, None, None)] return path + [(peer_port, None, None)]

View File

@ -29,7 +29,8 @@ SITE_REGION_LINK = """
""" """
COLOR_LABEL = """ COLOR_LABEL = """
<label class="label" style="background-color: #{{ record.color }}">{{ record }}</label> {% load helpers %}
<label class="label" style="color: {{ record.color|fgcolor }}; background-color: #{{ record.color }}">{{ record }}</label>
""" """
DEVICE_LINK = """ DEVICE_LINK = """

View File

@ -1530,6 +1530,7 @@ class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateV
form = forms.DeviceBulkAddComponentForm form = forms.DeviceBulkAddComponentForm
model = ConsolePort model = ConsolePort
model_form = forms.ConsolePortForm model_form = forms.ConsolePortForm
filter = filters.DeviceFilter
table = tables.DeviceTable table = tables.DeviceTable
default_return_url = 'dcim:device_list' default_return_url = 'dcim:device_list'
@ -1541,6 +1542,7 @@ class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentC
form = forms.DeviceBulkAddComponentForm form = forms.DeviceBulkAddComponentForm
model = ConsoleServerPort model = ConsoleServerPort
model_form = forms.ConsoleServerPortForm model_form = forms.ConsoleServerPortForm
filter = filters.DeviceFilter
table = tables.DeviceTable table = tables.DeviceTable
default_return_url = 'dcim:device_list' default_return_url = 'dcim:device_list'
@ -1552,6 +1554,7 @@ class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateVie
form = forms.DeviceBulkAddComponentForm form = forms.DeviceBulkAddComponentForm
model = PowerPort model = PowerPort
model_form = forms.PowerPortForm model_form = forms.PowerPortForm
filter = filters.DeviceFilter
table = tables.DeviceTable table = tables.DeviceTable
default_return_url = 'dcim:device_list' default_return_url = 'dcim:device_list'
@ -1563,6 +1566,7 @@ class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateV
form = forms.DeviceBulkAddComponentForm form = forms.DeviceBulkAddComponentForm
model = PowerOutlet model = PowerOutlet
model_form = forms.PowerOutletForm model_form = forms.PowerOutletForm
filter = filters.DeviceFilter
table = tables.DeviceTable table = tables.DeviceTable
default_return_url = 'dcim:device_list' default_return_url = 'dcim:device_list'
@ -1574,6 +1578,7 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie
form = forms.DeviceBulkAddInterfaceForm form = forms.DeviceBulkAddInterfaceForm
model = Interface model = Interface
model_form = forms.InterfaceForm model_form = forms.InterfaceForm
filter = filters.DeviceFilter
table = tables.DeviceTable table = tables.DeviceTable
default_return_url = 'dcim:device_list' default_return_url = 'dcim:device_list'
@ -1585,6 +1590,7 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie
form = forms.DeviceBulkAddComponentForm form = forms.DeviceBulkAddComponentForm
model = DeviceBay model = DeviceBay
model_form = forms.DeviceBayForm model_form = forms.DeviceBayForm
filter = filters.DeviceFilter
table = tables.DeviceTable table = tables.DeviceTable
default_return_url = 'dcim:device_list' default_return_url = 'dcim:device_list'

View File

@ -7,19 +7,19 @@ urlpatterns = [
# Tags # Tags
url(r'^tags/$', views.TagListView.as_view(), name='tag_list'), url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
url(r'^tags/(?P<slug>[\w-]+)/$', views.TagView.as_view(), name='tag'), url(r'^tags/(?P<slug>[\w-]+)/$', views.TagView.as_view(), name='tag'),
url(r'^tags/(?P<slug>[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'), url(r'^tags/(?P<slug>[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'),
url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'), url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
# Config contexts # Config contexts
url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'), url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),
url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'), url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
url(r'^config-contexts/edit/$', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), url(r'^config-contexts/edit/$', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
url(r'^config-contexts/(?P<pk>\d+)/$', views.ConfigContextView.as_view(), name='configcontext'), url(r'^config-contexts/(?P<pk>\d+)/$', views.ConfigContextView.as_view(), name='configcontext'),
url(r'^config-contexts/(?P<pk>\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'), url(r'^config-contexts/(?P<pk>\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
url(r'^config-contexts/(?P<pk>\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), url(r'^config-contexts/(?P<pk>\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
# Image attachments # Image attachments
url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),

View File

@ -82,7 +82,7 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype' permission_required = 'taggit.delete_tag'
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
items=Count('taggit_taggeditem_items') items=Count('taggit_taggeditem_items')
).order_by( ).order_by(

View File

@ -112,6 +112,10 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='search', method='search',
label='Search', label='Search',
) )
prefix = django_filters.CharFilter(
method='filter_prefix',
label='Prefix',
)
within = django_filters.CharFilter( within = django_filters.CharFilter(
method='search_within', method='search_within',
label='Within prefix', label='Within prefix',
@ -197,6 +201,15 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
pass pass
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
def filter_prefix(self, queryset, name, value):
if not value.strip():
return queryset
try:
query = str(netaddr.IPNetwork(value).cidr)
return queryset.filter(prefix=query)
except ValidationError:
return queryset.none()
def search_within(self, queryset, name, value): def search_within(self, queryset, name, value):
value = value.strip() value = value.strip()
if not value: if not value:

View File

@ -22,7 +22,7 @@ except ImportError:
) )
VERSION = '2.5.1' VERSION = '2.5.2'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@ -100,7 +100,7 @@ $(document).ready(function() {
} else if (filter_field.val()) { } else if (filter_field.val()) {
rendered_url = rendered_url.replace(match[0], filter_field.val()); rendered_url = rendered_url.replace(match[0], filter_field.val());
} else if (filter_field.attr('nullable') == 'true') { } else if (filter_field.attr('nullable') == 'true') {
rendered_url = rendered_url.replace(match[0], '0'); rendered_url = rendered_url.replace(match[0], 'null');
} }
} }

View File

@ -53,7 +53,7 @@
<i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }} <i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }}
{% endif %} {% endif %}
{% else %} {% else %}
{% if perms.circuits.add_cable %} {% if perms.dcim.add_cable %}
<div class="pull-right"> <div class="pull-right">
<a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk %}?return_url={{ circuit.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect"> <a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk %}?return_url={{ circuit.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i> Connect <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i> Connect

View File

@ -22,13 +22,20 @@
{% for iface in interfaces %} {% for iface in interfaces %}
<tr id="{{ iface.name }}"> <tr id="{{ iface.name }}">
<td>{{ iface }}</td> <td>{{ iface }}</td>
{% if iface.connected_endpoint %} {% if iface.connected_endpoint.device %}
<td class="configured_device" data="{{ iface.connected_endpoint.device }}"> <td class="configured_device" data="{{ iface.connected_endpoint.device }}">
<a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a> <a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
</td> </td>
<td class="configured_interface" data="{{ iface.connected_endpoint }}"> <td class="configured_interface" data="{{ iface.connected_endpoint }}">
<span title="{{ iface.connected_endpoint.get_form_factor_display }}">{{ iface.connected_endpoint }}</span> <span title="{{ iface.connected_endpoint.get_form_factor_display }}">{{ iface.connected_endpoint }}</span>
</td> </td>
{% elif iface.connected_endpoint.circuit %}
{% with circuit=iface.connected_endpoint.circuit %}
<td colspan="2">
<i class="fa fa-fw fa-globe" title="Circuit"></i>
<a href="{{ circuit.get_absolute_url }}">{{ circuit.provider }} {{ circuit }}</a>
</td>
{% endwith %}
{% else %} {% else %}
<td colspan="2">None</td> <td colspan="2">None</td>
{% endif %} {% endif %}

View File

@ -2,7 +2,8 @@
{% load form_helpers %} {% load form_helpers %}
{% block content %} {% block content %}
<h1>Add {{ component_name|title }}</h1> <h1>{% block title %}Add {{ model_name|title }}{% endblock %}</h1>
<p>{{ table.rows|length }} {{ parent_model_name }} selected</p>
<form action="." method="post" class="form form-horizontal"> <form action="." method="post" class="form form-horizontal">
{% csrf_token %} {% csrf_token %}
{% if request.POST.return_url %} {% if request.POST.return_url %}
@ -27,7 +28,7 @@
</div> </div>
{% endif %} {% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>{{ component_name|title }} to Add</strong></div> <div class="panel-heading"><strong>{{ model_name|title }} to Add</strong></div>
<div class="panel-body"> <div class="panel-body">
{% for field in form.visible_fields %} {% for field in form.visible_fields %}
{% render_field field %} {% render_field field %}

View File

@ -1,11 +1,13 @@
import datetime import datetime
import json import json
import re
from django import template from django import template
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from markdown import markdown from markdown import markdown
from utilities.forms import unpack_grouped_choices from utilities.forms import unpack_grouped_choices
from utilities.utils import foreground_color
register = template.Library() register = template.Library()
@ -152,6 +154,17 @@ def tzoffset(value):
return datetime.datetime.now(value).strftime('%z') return datetime.datetime.now(value).strftime('%z')
@register.filter()
def fgcolor(value):
"""
Return black (#000000) or white (#ffffff) given an arbitrary background color in RRGGBB format.
"""
value = value.lower().strip('#')
if not re.match('^[0-9a-f]{6}$', value):
return ''
return '#{}'.format(foreground_color(value))
# #
# Tags # Tags
# #

View File

@ -55,8 +55,9 @@ class GetReturnURLMixin(object):
def get_return_url(self, request, obj=None): def get_return_url(self, request, obj=None):
# First, see if `return_url` was specified as a query parameter. Use it only if it's considered safe. # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's
query_param = request.GET.get('return_url') # considered safe.
query_param = request.GET.get('return_url') or request.POST.get('return_url')
if query_param and is_safe_url(url=query_param, allowed_hosts=request.get_host()): if query_param and is_safe_url(url=query_param, allowed_hosts=request.get_host()):
return query_param return query_param
@ -789,9 +790,12 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
def post(self, request): def post(self, request):
parent_model_name = self.parent_model._meta.verbose_name_plural
model_name = self.model._meta.verbose_name_plural
# Are we editing *all* objects in the queryset or just a selected subset? # Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all') and self.filter is not None: if request.POST.get('_all') and self.filter is not None:
pk_list = [obj.pk for obj in self.filter(request.GET, self.model.objects.only('pk')).qs] pk_list = [obj.pk for obj in self.filter(request.GET, self.parent_model.objects.only('pk')).qs]
else: else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')] pk_list = [int(pk) for pk in request.POST.getlist('pk')]
@ -829,9 +833,9 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
messages.success(request, "Added {} {} to {} {}.".format( messages.success(request, "Added {} {} to {} {}.".format(
len(new_components), len(new_components),
self.model._meta.verbose_name_plural, model_name,
len(form.cleaned_data['pk']), len(form.cleaned_data['pk']),
self.parent_model._meta.verbose_name_plural parent_model_name
)) ))
return redirect(self.get_return_url(request)) return redirect(self.get_return_url(request))
@ -840,7 +844,8 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
return render(request, self.template_name, { return render(request, self.template_name, {
'form': form, 'form': form,
'component_name': self.model._meta.verbose_name_plural, 'parent_model_name': parent_model_name,
'model_name': model_name,
'table': table, 'table': table,
'return_url': self.get_return_url(request), 'return_url': self.get_return_url(request),
}) })

View File

@ -369,5 +369,6 @@ class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentC
form = forms.VirtualMachineBulkAddInterfaceForm form = forms.VirtualMachineBulkAddInterfaceForm
model = Interface model = Interface
model_form = forms.InterfaceForm model_form = forms.InterfaceForm
filter = filters.VirtualMachineFilter
table = tables.VirtualMachineTable table = tables.VirtualMachineTable
default_return_url = 'virtualization:virtualmachine_list' default_return_url = 'virtualization:virtualmachine_list'