Merge pull request #625 from digitalocean/develop

Release v1.6.3
This commit is contained in:
Jeremy Stretch 2016-10-19 16:25:50 -04:00 committed by GitHub
commit c171547037
25 changed files with 337 additions and 204 deletions

View File

@ -3,7 +3,6 @@ from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea, APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea,
@ -57,6 +56,9 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact') admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact')
comments = CommentField() comments = CommentField()
class Meta:
nullable_fields = ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Provider model = Provider
@ -86,7 +88,7 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
attrs={'filter-for': 'device'})) attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device', device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
attrs={'filter-for': 'interface'})) display_field='display_name', attrs={'filter-for': 'interface'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device') query_key='q', query_url='dcim-api:device_list', field_to_update='device')
) )
@ -178,11 +180,14 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False) type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False) provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)') port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)') commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
comments = CommentField() comments = CommentField()
class Meta:
nullable_fields = ['tenant', 'port_speed', 'commit_rate', 'comments']
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit model = Circuit

View File

@ -1,23 +1,24 @@
import re import re
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Count, Q from django.db.models import Count, Q
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from ipam.models import IPAddress from ipam.models import IPAddress
from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField, APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField,
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
SlugField,
) )
from .models import ( from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
Interface, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module,
PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES,
Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
) )
@ -42,37 +43,12 @@ def get_device_by_name_or_pk(name):
return device return device
def bulkedit_platform_choices(): def validate_connection_status(value):
choices = [
(None, '---------'),
(0, 'None'),
]
choices += [(p.pk, p.name) for p in Platform.objects.all()]
return choices
def bulkedit_rackgroup_choices():
""" """
Include an option to remove the currently assigned group from a rack. Custom validator for connection statuses. value must be either "planned" or "connected" (case-insensitive).
""" """
choices = [ if value.lower() not in ['planned', 'connected']:
(None, '---------'), raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value))
(0, 'None'),
]
choices += [(r.pk, r) for r in RackGroup.objects.all()]
return choices
def bulkedit_rackrole_choices():
"""
Include an option to remove the currently assigned role from a rack.
"""
choices = [
(None, '---------'),
(0, 'None'),
]
choices += [(r.pk, r.name) for r in RackRole.objects.all()]
return choices
# #
@ -114,7 +90,10 @@ class SiteImportForm(BulkImportForm, BootstrapMixin):
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
class Meta:
nullable_fields = ['tenant']
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
@ -234,14 +213,17 @@ class RackImportForm(BulkImportForm, BootstrapMixin):
class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site') site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site')
group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group') group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
role = forms.TypedChoiceField(choices=bulkedit_rackrole_choices, coerce=int, required=False, label='Role') role = forms.ModelChoiceField(queryset=RackRole.objects.all(), required=False)
type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type') type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type')
width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width') width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width')
u_height = forms.IntegerField(required=False, label='Height (U)') u_height = forms.IntegerField(required=False, label='Height (U)')
comments = CommentField() comments = CommentField()
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'comments']
class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Rack model = Rack
@ -279,7 +261,7 @@ class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
'is_pdu', 'is_network_device', 'subdevice_role'] 'is_pdu', 'is_network_device', 'subdevice_role']
class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin): class DeviceTypeBulkEditForm(BulkEditForm, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
u_height = forms.IntegerField(min_value=1, required=False) u_height = forms.IntegerField(min_value=1, required=False)
@ -334,6 +316,14 @@ class InterfaceTemplateForm(forms.ModelForm, BootstrapMixin):
fields = ['name_pattern', 'form_factor', 'mgmt_only'] fields = ['name_pattern', 'form_factor', 'mgmt_only']
class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
class Meta:
nullable_fields = []
class DeviceBayTemplateForm(forms.ModelForm, BootstrapMixin): class DeviceBayTemplateForm(forms.ModelForm, BootstrapMixin):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
@ -583,12 +573,14 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type') device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role') device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
platform = forms.TypedChoiceField(choices=bulkedit_platform_choices, coerce=int, required=False, platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
label='Platform')
status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status') status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status')
serial = forms.CharField(max_length=50, required=False, label='Serial Number') serial = forms.CharField(max_length=50, required=False, label='Serial Number')
class Meta:
nullable_fields = ['tenant', 'platform']
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device model = Device
@ -631,7 +623,7 @@ class ConsoleConnectionCSVForm(forms.Form):
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'}) error_messages={'invalid_choice': 'Device not found'})
console_port = forms.CharField() console_port = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')]) status = forms.CharField(validators=[validate_connection_status])
def clean(self): def clean(self):
@ -695,6 +687,7 @@ class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin):
widget=forms.Select(attrs={'filter-for': 'console_server'})) widget=forms.Select(attrs={'filter-for': 'console_server'}))
console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False, console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_console_server=True', widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_console_server=True',
display_field='display_name',
attrs={'filter-for': 'cs_port'})) attrs={'filter-for': 'cs_port'}))
livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch( livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='console_server') query_key='q', query_url='dcim-api:device_list', field_to_update='console_server')
@ -762,7 +755,7 @@ class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin):
widget=forms.Select(attrs={'filter-for': 'device'})) widget=forms.Select(attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
attrs={'filter-for': 'port'})) display_field='display_name', attrs={'filter-for': 'port'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device') query_key='q', query_url='dcim-api:device_list', field_to_update='device')
) )
@ -826,7 +819,7 @@ class PowerConnectionCSVForm(forms.Form):
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device not found'}) error_messages={'invalid_choice': 'Device not found'})
power_port = forms.CharField() power_port = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')]) status = forms.CharField(validators=[validate_connection_status])
def clean(self): def clean(self):
@ -891,7 +884,7 @@ class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin):
widget=forms.Select(attrs={'filter-for': 'pdu'})) widget=forms.Select(attrs={'filter-for': 'pdu'}))
pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False, pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_pdu=True', widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_pdu=True',
attrs={'filter-for': 'power_outlet'})) display_field='display_name', attrs={'filter-for': 'power_outlet'}))
livesearch = forms.CharField(required=False, label='PDU', widget=Livesearch( livesearch = forms.CharField(required=False, label='PDU', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='pdu') query_key='q', query_url='dcim-api:device_list', field_to_update='pdu')
) )
@ -958,7 +951,7 @@ class PowerOutletConnectionForm(forms.Form, BootstrapMixin):
widget=forms.Select(attrs={'filter-for': 'device'})) widget=forms.Select(attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
attrs={'filter-for': 'port'})) display_field='display_name', attrs={'filter-for': 'port'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device') query_key='q', query_url='dcim-api:device_list', field_to_update='device')
) )
@ -1023,6 +1016,15 @@ class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['description']
# #
# Interface connections # Interface connections
# #
@ -1033,6 +1035,7 @@ class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
widget=forms.Select(attrs={'filter-for': 'device_b'})) widget=forms.Select(attrs={'filter-for': 'device_b'}))
device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}', widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}',
display_field='display_name',
attrs={'filter-for': 'interface_b'})) attrs={'filter-for': 'interface_b'}))
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
query_key='q', query_url='dcim-api:device_list', field_to_update='device_b') query_key='q', query_url='dcim-api:device_list', field_to_update='device_b')
@ -1087,7 +1090,7 @@ class InterfaceConnectionCSVForm(forms.Form):
device_b = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', device_b = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Device B not found.'}) error_messages={'invalid_choice': 'Device B not found.'})
interface_b = forms.CharField() interface_b = forms.CharField()
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')]) status = forms.CharField(validators=[validate_connection_status])
def clean(self): def clean(self):

View File

@ -3,10 +3,6 @@ from django.conf.urls import url
from secrets.views import secret_add from secrets.views import secret_add
from . import views from . import views
from .models import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, PowerPortTemplate, PowerOutletTemplate,
InterfaceTemplate,
)
urlpatterns = [ urlpatterns = [
@ -75,6 +71,7 @@ urlpatterns = [
# Interface templates # Interface templates
url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateAddView.as_view(), name='devicetype_add_interface'), url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateAddView.as_view(), name='devicetype_add_interface'),
url(r'^device-types/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'), url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
# Device bay templates # Device bay templates
@ -159,6 +156,7 @@ urlpatterns = [
# Interfaces # Interfaces
url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_add_multi'), url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_add_multi'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'), url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'),
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'), url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'), url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),

View File

@ -457,6 +457,14 @@ class InterfaceTemplateAddView(ComponentTemplateCreateView):
form = forms.InterfaceTemplateForm form = forms.InterfaceTemplateForm
class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interfacetemplate'
cls = InterfaceTemplate
parent_cls = DeviceType
form = forms.InterfaceTemplateBulkEditForm
template_name = 'dcim/interfacetemplate_bulk_edit.html'
class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interfacetemplate' permission_required = 'dcim.delete_interfacetemplate'
cls = InterfaceTemplate cls = InterfaceTemplate
@ -1425,6 +1433,14 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
len(selected_devices))) len(selected_devices)))
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interface'
cls = Interface
parent_cls = Device
form = forms.InterfaceBulkEditForm
template_name = 'dcim/interface_bulk_edit.html'
class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interface' permission_required = 'dcim.delete_interface'
cls = Interface cls = Interface

View File

@ -3,7 +3,7 @@ from collections import OrderedDict
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from utilities.forms import LaxURLField from utilities.forms import BulkEditForm, LaxURLField
from .models import ( from .models import (
CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue
) )
@ -49,8 +49,6 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
# Select # Select
elif cf.type == CF_TYPE_SELECT: elif cf.type == CF_TYPE_SELECT:
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
if not cf.required:
choices = [(0, 'None')] + choices
if bulk_edit or filterable_only: if bulk_edit or filterable_only:
choices = [(None, '---------')] + choices choices = [(None, '---------')] + choices
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required) field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
@ -73,10 +71,10 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
class CustomFieldForm(forms.ModelForm): class CustomFieldForm(forms.ModelForm):
custom_fields = []
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.custom_fields = []
self.obj_type = ContentType.objects.get_for_model(self._meta.model) self.obj_type = ContentType.objects.get_for_model(self._meta.model)
super(CustomFieldForm, self).__init__(*args, **kwargs) super(CustomFieldForm, self).__init__(*args, **kwargs)
@ -126,22 +124,25 @@ class CustomFieldForm(forms.ModelForm):
return obj return obj
class CustomFieldBulkEditForm(forms.Form): class CustomFieldBulkEditForm(BulkEditForm):
custom_fields = []
def __init__(self, model, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(model)
def __init__(self, *args, **kwargs):
super(CustomFieldBulkEditForm, self).__init__(*args, **kwargs) super(CustomFieldBulkEditForm, self).__init__(*args, **kwargs)
self.custom_fields = []
self.obj_type = ContentType.objects.get_for_model(self.model)
# Add all applicable CustomFields to the form # Add all applicable CustomFields to the form
custom_fields = [] custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items()
for name, field in get_custom_fields_for_model(self.obj_type, bulk_edit=True).items(): for name, field in custom_fields:
# Annotate non-required custom fields as nullable
if not field.required:
self.nullable_fields.append(name)
field.required = False field.required = False
self.fields[name] = field self.fields[name] = field
custom_fields.append(name) # Annotate this as a custom field
self.custom_fields = custom_fields self.custom_fields.append(name)
print(self.nullable_fields)
class CustomFieldFilterForm(forms.Form): class CustomFieldFilterForm(forms.Form):

View File

@ -3,7 +3,6 @@ from django.db.models import Count
from dcim.models import Site, Device, Interface from dcim.models import Site, Device, Interface
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import bulkedit_tenant_choices
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField,
@ -23,18 +22,6 @@ IP_FAMILY_CHOICES = [
] ]
def bulkedit_vrf_choices():
"""
Include an option to assign the object to the global table.
"""
choices = [
(None, '---------'),
(0, 'Global'),
]
choices += [(v.pk, v.name) for v in VRF.objects.all()]
return choices
# #
# VRFs # VRFs
# #
@ -67,9 +54,12 @@ class VRFImportForm(BulkImportForm, BootstrapMixin):
class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['tenant', 'description']
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VRF model = VRF
@ -124,6 +114,9 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
date_added = forms.DateField(required=False) date_added = forms.DateField(required=False)
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['date_added', 'description']
class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Aggregate model = Aggregate
@ -253,12 +246,15 @@ class PrefixImportForm(BulkImportForm, BootstrapMixin):
class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF') vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False) status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
def prefix_status_choices(): def prefix_status_choices():
status_counts = {} status_counts = {}
@ -294,6 +290,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
widget=forms.Select(attrs={'filter-for': 'nat_device'})) widget=forms.Select(attrs={'filter-for': 'nat_device'}))
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device', nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}', widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_inside'})) attrs={'filter-for': 'nat_inside'}))
livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch( livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address') query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address')
@ -407,10 +404,13 @@ class IPAddressImportForm(BulkImportForm, BootstrapMixin):
class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF') vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['vrf', 'tenant', 'description']
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = IPAddress model = IPAddress
@ -509,11 +509,14 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False) status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'description']
def vlan_status_choices(): def vlan_status_choices():
status_counts = {} status_counts = {}

View File

@ -139,7 +139,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
if self.pk: if self.pk:
covered_aggregates = covered_aggregates.exclude(pk=self.pk) covered_aggregates = covered_aggregates.exclude(pk=self.pk)
if covered_aggregates: if covered_aggregates:
raise ValidationError("{} is overlaps with an existing aggregate ({})" raise ValidationError("{} overlaps with an existing aggregate ({})"
.format(self.prefix, covered_aggregates[0])) .format(self.prefix, covered_aggregates[0]))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View File

@ -9,7 +9,7 @@ import os
# access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name. # access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name.
# #
# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local'] # Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
ALLOWED_HOSTS = [os.environ.get('ALLOWED_HOSTS', '')] ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(' ')
# PostgreSQL database configuration. # PostgreSQL database configuration.
DATABASE = { DATABASE = {

View File

@ -12,7 +12,7 @@ except ImportError:
"the documentation.") "the documentation.")
VERSION = '1.6.2-r1' VERSION = '1.6.3'
# Import local configuration # Import local configuration
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@ -162,7 +162,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.8/howto/static-files/ # https://docs.djangoproject.com/en/1.8/howto/static-files/
STATIC_ROOT = BASE_DIR + '/static/' STATIC_ROOT = BASE_DIR + '/static/'
STATIC_URL = '/static/' STATIC_URL = '/{}static/'.format(BASE_PATH)
STATICFILES_DIRS = ( STATICFILES_DIRS = (
os.path.join(BASE_DIR, "project-static"), os.path.join(BASE_DIR, "project-static"),
) )
@ -176,8 +176,7 @@ MESSAGE_TAGS = {
} }
# Authentication URLs # Authentication URLs
LOGIN_URL = '/login/' LOGIN_URL = '/{}login/'.format(BASE_PATH)
LOGIN_REDIRECT_URL = '/'
# Secrets # Secrets
SECRETS_MIN_PUBKEY_SIZE = 2048 SECRETS_MIN_PUBKEY_SIZE = 2048

View File

@ -1,6 +1,6 @@
$(document).ready(function() { $(document).ready(function() {
// "Toggle all" checkbox in a table header // "Toggle all" checkbox (table header)
$('#toggle_all').click(function (event) { $('#toggle_all').click(function (event) {
$('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked')); $('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
if ($(this).is(':checked')) { if ($(this).is(':checked')) {
@ -16,6 +16,15 @@ $(document).ready(function() {
} }
}); });
// Simple "Toggle all" button (panel)
$('button.toggle').click(function (event) {
var selected = $(this).attr('selected');
$(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected);
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-unchecked glyphicon-check');
return false;
});
// Slugify // Slugify
function slugify(s, num_chars) { function slugify(s, num_chars) {
s = s.replace(/[^\-\.\w\s]/g, ''); // Remove unneeded chars s = s.replace(/[^\-\.\w\s]/g, ''); // Remove unneeded chars
@ -37,6 +46,11 @@ $(document).ready(function() {
}) })
} }
// Bulk edit nullification
$('input:checkbox[name=_nullify]').click(function (event) {
$('#id_' + this.value).toggle('disabled');
});
// API select widget // API select widget
$('select[filter-for]').change(function () { $('select[filter-for]').change(function () {

View File

@ -5,7 +5,7 @@ from django import forms
from django.db.models import Count from django.db.models import Count
from dcim.models import Device from dcim.models import Device
from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, FilterChoiceField, SlugField from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
from .models import Secret, SecretRole, UserKey from .models import Secret, SecretRole, UserKey
@ -89,11 +89,14 @@ class SecretImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'})) csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'}))
class SecretBulkEditForm(forms.Form, BootstrapMixin): class SecretBulkEditForm(BulkEditForm, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False) role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)
name = forms.CharField(max_length=100, required=False) name = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['name']
class SecretFilterForm(forms.Form, BootstrapMixin): class SecretFilterForm(forms.Form, BootstrapMixin):
role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug') role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')

View File

@ -314,13 +314,16 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Device Bays</strong> <strong>Device Bays</strong>
{% if perms.dcim.add_devicebay and device_bays|length > 10 %} <div class="pull-right">
<div class="pull-right"> <button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% if perms.dcim.add_devicebay and device_bays|length > 10 %}
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs"> <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
</a> </a>
</div> {% endif %}
{% endif %} </div>
</div> </div>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for devicebay in device_bays %} {% for devicebay in device_bays %}
@ -355,19 +358,22 @@
{% endif %} {% endif %}
{% if interfaces or device.device_type.is_network_device %} {% if interfaces or device.device_type.is_network_device %}
{% if perms.dcim.delete_interface %} {% if perms.dcim.delete_interface %}
<form method="post" action="{% url 'dcim:interface_bulk_delete' pk=device.pk %}"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% endif %} {% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Interfaces</strong> <strong>Interfaces</strong>
{% if perms.dcim.add_interface and interfaces|length > 10 %} <div class="pull-right">
<div class="pull-right"> <button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% if perms.dcim.add_interface and interfaces|length > 10 %}
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs"> <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a> </a>
</div> {% endif %}
{% endif %} </div>
</div> </div>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for iface in interfaces %} {% for iface in interfaces %}
@ -380,8 +386,13 @@
</table> </table>
{% if perms.dcim.add_interface or perms.dcim.delete_interface %} {% if perms.dcim.add_interface or perms.dcim.delete_interface %}
<div class="panel-footer"> <div class="panel-footer">
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit selected
</button>
{% endif %}
{% if interfaces and perms.dcim.delete_interface %} {% if interfaces and perms.dcim.delete_interface %}
<button type="submit" class="btn btn-danger btn-xs"> <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button> </button>
{% endif %} {% endif %}
@ -408,13 +419,16 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Console Server Ports</strong> <strong>Console Server Ports</strong>
{% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %} <div class="pull-right">
<div class="pull-right"> <button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs"> <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
</a> </a>
</div> {% endif %}
{% endif %} </div>
</div> </div>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for csp in cs_ports %} {% for csp in cs_ports %}
@ -455,13 +469,16 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Power Outlets</strong> <strong>Power Outlets</strong>
{% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %} <div class="pull-right">
<div class="pull-right"> <button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs"> <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
</a> </a>
</div> {% endif %}
{% endif %} </div>
</div> </div>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for po in power_outlets %} {% for po in power_outlets %}

View File

@ -72,6 +72,10 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>Instances</td>
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>
</tr>
</table> </table>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">
@ -143,14 +147,14 @@
</div> </div>
{% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %} {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %} {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' delete_url='dcim:devicetype_delete_interface' %} {% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
{% if devicetype.is_parent_device %} {% if devicetype.is_parent_device %}
{% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicetype_add_devicebay' delete_url='dcim:devicetype_delete_devicebay' %} {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicetype_add_devicebay' delete_url='dcim:devicetype_delete_devicebay' %}
{% endif %} {% endif %}
{% if devicetype.is_network_device %} {% if devicetype.is_network_device %}
{% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' delete_url='dcim:devicetype_delete_interface' %} {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %}
{% endif %} {% endif %}
{% if devicetype.is_console_server %} {% if devicetype.is_console_server %}
{% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %} {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %}

View File

@ -1,25 +1,37 @@
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% if perms.dcim.change_devicetype %} {% if perms.dcim.change_devicetype %}
<form method="post" action="{% url delete_url pk=devicetype.pk %}"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>{{ title }}</strong> <strong>{{ title }}</strong>
{% if table.rows|length > 10 %} <div class="pull-right">
<div class="pull-right"> {% if table.rows|length > 3 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
</button>
{% endif %}
{% if table.rows|length > 10 %}
<a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs"> <a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add {{ title }} Add {{ title }}
</a> </a>
</div> {% endif %}
{% endif %} </div>
</div> </div>
{% render_table table 'table.html' %} {% render_table table 'table.html' %}
<div class="panel-footer"> <div class="panel-footer">
{% if table.rows %} {% if table.rows %}
<button type="submit" class="btn btn-xs btn-danger"> {% if edit_url %}
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected <button type="submit" name="_edit" formaction="{% url edit_url pk=devicetype.pk %}" class="btn btn-xs btn-warning">
</button> <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
</button>
{% endif %}
{% if delete_url %}
<button type="submit" name="_delete" formaction="{% url delete_url pk=devicetype.pk %}" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}
{% endif %} {% endif %}
<div class="pull-right"> <div class="pull-right">
<a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs"> <a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs">

View File

@ -0,0 +1,17 @@
{% extends 'utilities/bulk_edit_form.html' %}
{% load form_helpers %}
{% block title %}Interface Bulk Edit{% endblock %}
{% block selected_objects_table %}
<tr>
<th>Name</th>
<th>Form Factor</th>
</tr>
{% for iface in selected_objects %}
<tr>
<td>{{ iface.name }}</td>
<td>{{ iface.get_form_factor_display }}</td>
</tr>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends 'utilities/bulk_edit_form.html' %}
{% load form_helpers %}
{% block title %}Interface Template Bulk Edit{% endblock %}
{% block selected_objects_table %}
<tr>
<th>Name</th>
<th>Form Factor</th>
<th>Management</th>
</tr>
{% for iface in selected_objects %}
<tr>
<td>{{ iface.name }}</td>
<td>{{ iface.get_form_factor_display }}</td>
<td>
{% if iface.mgmt_only %}
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
{% else %}
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
{% endif %}
</td>
</tr>
{% endfor %}
{% endblock %}

View File

@ -8,13 +8,13 @@
<tr> <tr>
<td>{{ field }}</td> <td>{{ field }}</td>
<td> <td>
{% if value == True %} {% if field.type == 300 and value == True %}
<i class="glyphicon glyphicon-ok text-success" title="True"></i> <i class="glyphicon glyphicon-ok text-success" title="True"></i>
{% elif value == False %} {% elif field.type == 300 and value == False %}
<i class="glyphicon glyphicon-remove text-danger" title="False"></i> <i class="glyphicon glyphicon-remove text-danger" title="False"></i>
{% elif field.type == 500 and value %} {% elif field.type == 500 and value %}
{{ value|urlizetrunc:75 }} <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
{% elif value %} {% elif field.type == 200 or value %}
{{ value }} {{ value }}
{% elif field.required %} {% elif field.required %}
<span class="text-warning">Not defined</span> <span class="text-warning">Not defined</span>

View File

@ -8,6 +8,9 @@
{% if request.POST.redirect_url %} {% if request.POST.redirect_url %}
<input type="hidden" name="redirect_url" value="{{ request.POST.redirect_url }}" /> <input type="hidden" name="redirect_url" value="{{ request.POST.redirect_url }}" />
{% endif %} {% endif %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="row"> <div class="row">
<div class="col-md-7"> <div class="col-md-7">
<div class="panel panel-default"> <div class="panel panel-default">
@ -29,7 +32,13 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>{% block form_title %}Attributes{% endblock %}</strong></div> <div class="panel-heading"><strong>{% block form_title %}Attributes{% endblock %}</strong></div>
<div class="panel-body"> <div class="panel-body">
{% render_form form %} {% for field in form.visible_fields %}
{% if field.name in form.nullable_fields %}
{% render_field field bulk_nullable=True %}
{% else %}
{% render_field field %}
{% endif %}
{% endfor %}
</div> </div>
</div> </div>
<div class="form-group text-right"> <div class="form-group text-right">

View File

@ -5,26 +5,26 @@
<div class="col-md-9 col-md-offset-3"> <div class="col-md-9 col-md-offset-3">
<div class="checkbox{% if field.errors %} has-error{% endif %}"> <div class="checkbox{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}"> <label for="{{ field.id_for_label }}">
{{ field }} {{ field }} {{ field.label }}
{{ field.label }}
</label> </label>
{% if field.help_text %} {% if field.help_text %}
<span class="help-block">{{ field.help_text|safe }}</span> <span class="help-block">{{ field.help_text|safe }}</span>
{% endif %} {% endif %}
</div> </div>
</div> {% if bulk_nullable %}
{% elif field|widget_type == 'radioselect' %} <label class="checkbox-inline">
<div class="col-md-9 col-md-offset-3"> <input type="checkbox" name="_nullify" value="{{ field.name }}" /> Set null
<div class="radio{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}">
{{ field }}
{{ field.label }}
</label> </label>
</div> {% endif %}
</div> </div>
{% elif field|widget_type == 'textarea' %} {% elif field|widget_type == 'textarea' %}
<div class="col-md-12"> <div class="col-md-12">
{{ field }} {{ field }}
{% if bulk_nullable %}
<label class="checkbox-inline">
<input type="checkbox" name="_nullify" value="{{ field.name }}" /> Set null
</label>
{% endif %}
{% if field.help_text %} {% if field.help_text %}
<span class="help-block">{{ field.help_text|safe }}</span> <span class="help-block">{{ field.help_text|safe }}</span>
{% endif %} {% endif %}
@ -40,6 +40,11 @@
<label class="col-md-3 control-label{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label> <label class="col-md-3 control-label{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
<div class="col-md-9"> <div class="col-md-9">
{{ field }} {{ field }}
{% if bulk_nullable %}
<label class="checkbox-inline">
<input type="checkbox" name="_nullify" value="{{ field.name }}" /> Set null
</label>
{% endif %}
{% if field.help_text %} {% if field.help_text %}
<span class="help-block">{{ field.help_text|safe }}</span> <span class="help-block">{{ field.help_text|safe }}</span>
{% endif %} {% endif %}

View File

@ -7,30 +7,6 @@ from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDat
from .models import Tenant, TenantGroup from .models import Tenant, TenantGroup
def bulkedit_tenantgroup_choices():
"""
Include an option to remove the currently assigned TenantGroup from a Tenant.
"""
choices = [
(None, '---------'),
(0, 'None'),
]
choices += [(g.pk, g.name) for g in TenantGroup.objects.all()]
return choices
def bulkedit_tenant_choices():
"""
Include an option to remove the currently assigned Tenant from an object.
"""
choices = [
(None, '---------'),
(0, 'None'),
]
choices += [(t.pk, t.name) for t in Tenant.objects.all()]
return choices
# #
# Tenant groups # Tenant groups
# #
@ -71,7 +47,10 @@ class TenantImportForm(BulkImportForm, BootstrapMixin):
class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput)
group = forms.TypedChoiceField(choices=bulkedit_tenantgroup_choices, coerce=int, required=False, label='Group') group = forms.ModelChoiceField(queryset=TenantGroup.objects.all(), required=False)
class Meta:
nullable_fields = ['group']
class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):

View File

@ -1,10 +1,9 @@
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import redirect, render, resolve_url from django.shortcuts import redirect, render
from django.utils.http import is_safe_url from django.utils.http import is_safe_url
from secrets.forms import UserKeyForm from secrets.forms import UserKeyForm
@ -26,7 +25,7 @@ def login(request):
# Determine where to direct user after successful login # Determine where to direct user after successful login
redirect_to = request.POST.get('next', '') redirect_to = request.POST.get('next', '')
if not is_safe_url(url=redirect_to, host=request.get_host()): if not is_safe_url(url=redirect_to, host=request.get_host()):
redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL) redirect_to = reverse('home')
# Authenticate user # Authenticate user
auth_login(request, form.get_user()) auth_login(request, form.get_user())

View File

@ -34,7 +34,7 @@ def add_blank_choice(choices):
""" """
Add a blank choice to the beginning of a choices list. Add a blank choice to the beginning of a choices list.
""" """
return ((None, '---------'),) + choices return ((None, '---------'),) + tuple(choices)
# #
@ -294,6 +294,18 @@ class ConfirmationForm(forms.Form, BootstrapMixin):
confirm = forms.BooleanField(required=True) confirm = forms.BooleanField(required=True)
class BulkEditForm(forms.Form):
def __init__(self, model, *args, **kwargs):
super(BulkEditForm, self).__init__(*args, **kwargs)
self.model = model
# Copy any nullable fields defined in Meta
if hasattr(self.Meta, 'nullable_fields'):
self.nullable_fields = [field for field in self.Meta.nullable_fields]
else:
self.nullable_fields = []
class BulkImportForm(forms.Form): class BulkImportForm(forms.Form):
def clean(self): def clean(self):

View File

@ -12,4 +12,4 @@ class LoginRequiredMiddleware:
def process_request(self, request): def process_request(self, request):
if LOGIN_REQUIRED and not request.user.is_authenticated(): if LOGIN_REQUIRED and not request.user.is_authenticated():
if request.path_info != settings.LOGIN_URL: if request.path_info != settings.LOGIN_URL:
return HttpResponseRedirect(settings.LOGIN_URL) return HttpResponseRedirect('{}?next={}'.format(settings.LOGIN_URL, request.path_info))

View File

@ -5,12 +5,13 @@ register = template.Library()
@register.inclusion_tag('utilities/render_field.html') @register.inclusion_tag('utilities/render_field.html')
def render_field(field): def render_field(field, bulk_nullable=False):
""" """
Render a single form field from template Render a single form field from template
""" """
return { return {
'field': field, 'field': field,
'bulk_nullable': bulk_nullable,
} }

View File

@ -280,42 +280,59 @@ class BulkImportView(View):
class BulkEditView(View): class BulkEditView(View):
cls = None cls = None
parent_cls = None
form = None form = None
template_name = None template_name = None
default_redirect_url = None default_redirect_url = None
def get(self, request, *args, **kwargs): def get(self):
return redirect(self.default_redirect_url) return redirect(self.default_redirect_url)
def post(self, request, *args, **kwargs): def post(self, request, **kwargs):
# Attempt to derive parent object if a parent class has been given
if self.parent_cls:
parent_obj = get_object_or_404(self.parent_cls, **kwargs)
else:
parent_obj = None
# Determine URL to redirect users upon modification of objects
posted_redirect_url = request.POST.get('redirect_url') posted_redirect_url = request.POST.get('redirect_url')
if posted_redirect_url and is_safe_url(url=posted_redirect_url, host=request.get_host()): if posted_redirect_url and is_safe_url(url=posted_redirect_url, host=request.get_host()):
redirect_url = posted_redirect_url redirect_url = posted_redirect_url
else: elif parent_obj:
redirect_url = parent_obj.get_absolute_url()
elif self.default_redirect_url:
redirect_url = reverse(self.default_redirect_url) redirect_url = reverse(self.default_redirect_url)
else:
raise ImproperlyConfigured('No redirect URL has been provided.')
# Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'): if request.POST.get('_all'):
pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk] pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
else: else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')] pk_list = [int(pk) for pk in request.POST.getlist('pk')]
if '_apply' in request.POST: if '_apply' in request.POST:
if hasattr(self.form, 'custom_fields'): form = self.form(self.cls, request.POST)
form = self.form(self.cls, request.POST)
else:
form = self.form(request.POST)
if form.is_valid(): if form.is_valid():
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk'] standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk']
# Update objects # Update standard fields. If a field is listed in _nullify, delete its value.
updated_count = self.update_objects(pk_list, form, standard_fields) nullified_fields = request.POST.getlist('_nullify')
fields_to_update = {}
for field in standard_fields:
if field in form.nullable_fields and field in nullified_fields:
fields_to_update[field] = ''
elif form.cleaned_data[field]:
fields_to_update[field] = form.cleaned_data[field]
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
# Update custom fields for objects # Update custom fields for objects
if custom_fields: if custom_fields:
objs_updated = self.update_custom_fields(pk_list, form, custom_fields) objs_updated = self.update_custom_fields(pk_list, form, custom_fields, nullified_fields)
if objs_updated and not updated_count: if objs_updated and not updated_count:
updated_count = objs_updated updated_count = objs_updated
@ -326,10 +343,7 @@ class BulkEditView(View):
return redirect(redirect_url) return redirect(redirect_url)
else: else:
if hasattr(self.form, 'custom_fields'): form = self.form(self.cls, initial={'pk': pk_list})
form = self.form(self.cls, initial={'pk': pk_list})
else:
form = self.form(initial={'pk': pk_list})
selected_objects = self.cls.objects.filter(pk__in=pk_list) selected_objects = self.cls.objects.filter(pk__in=pk_list)
if not selected_objects: if not selected_objects:
@ -342,26 +356,23 @@ class BulkEditView(View):
'cancel_url': redirect_url, 'cancel_url': redirect_url,
}) })
def update_objects(self, pk_list, form, fields): def update_custom_fields(self, pk_list, form, fields, nullified_fields):
fields_to_update = {}
for name in fields:
# Check for zero value (bulk editing)
if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
fields_to_update[name] = None
elif form.cleaned_data[name]:
fields_to_update[name] = form.cleaned_data[name]
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
def update_custom_fields(self, pk_list, form, fields):
obj_type = ContentType.objects.get_for_model(self.cls) obj_type = ContentType.objects.get_for_model(self.cls)
objs_updated = False objs_updated = False
for name in fields: for name in fields:
if form.cleaned_data[name] not in [None, u'']:
field = form.fields[name].model field = form.fields[name].model
# Setting the field to null
if name in form.nullable_fields and name in nullified_fields:
# Delete all CustomFieldValues for instances of this field belonging to the selected objects.
CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list).delete()
objs_updated = True
# Updating the value of the field
elif form.cleaned_data[name] not in [None, u'']:
# Check for zero value (bulk editing) # Check for zero value (bulk editing)
if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0: if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
@ -400,7 +411,7 @@ class BulkDeleteView(View):
template_name = 'utilities/confirm_bulk_delete.html' template_name = 'utilities/confirm_bulk_delete.html'
default_redirect_url = None default_redirect_url = None
def post(self, request, *args, **kwargs): def post(self, request, **kwargs):
# Attempt to derive parent object if a parent class has been given # Attempt to derive parent object if a parent class has been given
if self.parent_cls: if self.parent_cls:
@ -421,9 +432,9 @@ class BulkDeleteView(View):
# Are we deleting *all* objects in the queryset or just a selected subset? # Are we deleting *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'): if request.POST.get('_all'):
pk_list = [x for x in request.POST.get('pk_all').split(',') if x] pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk]
else: else:
pk_list = request.POST.getlist('pk') pk_list = [int(pk) for pk in request.POST.getlist('pk')]
form_cls = self.get_form() form_cls = self.get_form()