diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index f3c210dc2e8..288f2255f5a 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -3,7 +3,6 @@ from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm -from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( 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') comments = CommentField() + class Meta: + nullable_fields = ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] + class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Provider @@ -86,7 +88,7 @@ class CircuitForm(BootstrapMixin, CustomFieldForm): attrs={'filter-for': 'device'})) device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device', 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( 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) type = forms.ModelChoiceField(queryset=CircuitType.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)') commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)') comments = CommentField() + class Meta: + nullable_fields = ['tenant', 'port_speed', 'commit_rate', 'comments'] + class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Circuit diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index b659162671b..3c6ac7e43a9 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,23 +1,24 @@ import re from django import forms +from django.core.exceptions import ValidationError from django.db.models import Count, Q from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress -from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( - APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField, - FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, + APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField, + ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, + SlugField, ) from .models import ( DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, - Interface, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, - PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole, - Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD + Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, + Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, + Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD ) @@ -42,37 +43,12 @@ def get_device_by_name_or_pk(name): return device -def bulkedit_platform_choices(): - 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. - """ - choices = [ - (None, '---------'), - (0, 'None'), - ] - choices += [(r.pk, r) for r in RackGroup.objects.all()] - return choices - - -def bulkedit_rackrole_choices(): +def validate_connection_status(value): """ - Include an option to remove the currently assigned role from a rack. + Custom validator for connection statuses. value must be either "planned" or "connected" (case-insensitive). """ - choices = [ - (None, '---------'), - (0, 'None'), - ] - choices += [(r.pk, r.name) for r in RackRole.objects.all()] - return choices + if value.lower() not in ['planned', 'connected']: + raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value)) # @@ -114,7 +90,10 @@ class SiteImportForm(BulkImportForm, BootstrapMixin): class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): 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): @@ -234,14 +213,17 @@ class RackImportForm(BulkImportForm, BootstrapMixin): class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site') - group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group') - tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') - role = forms.TypedChoiceField(choices=bulkedit_rackrole_choices, coerce=int, required=False, label='Role') + group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group') + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) + role = forms.ModelChoiceField(queryset=RackRole.objects.all(), required=False) 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') u_height = forms.IntegerField(required=False, label='Height (U)') comments = CommentField() + class Meta: + nullable_fields = ['group', 'tenant', 'role', 'comments'] + class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Rack @@ -279,7 +261,7 @@ class Meta: '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) manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) u_height = forms.IntegerField(min_value=1, required=False) @@ -334,6 +316,14 @@ class Meta: 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): name_pattern = ExpandableNameField(label='Name') @@ -583,12 +573,14 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type') 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') - platform = forms.TypedChoiceField(choices=bulkedit_platform_choices, coerce=int, required=False, - label='Platform') + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) + platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False) status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status') serial = forms.CharField(max_length=50, required=False, label='Serial Number') + class Meta: + nullable_fields = ['tenant', 'platform'] + class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device @@ -631,7 +623,7 @@ class ConsoleConnectionCSVForm(forms.Form): device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', error_messages={'invalid_choice': 'Device not found'}) console_port = forms.CharField() - status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')]) + status = forms.CharField(validators=[validate_connection_status]) def clean(self): @@ -695,6 +687,7 @@ class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin): widget=forms.Select(attrs={'filter-for': 'console_server'})) 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', + display_field='display_name', attrs={'filter-for': 'cs_port'})) livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch( 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'})) device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, 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( 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', error_messages={'invalid_choice': 'Device not found'}) power_port = forms.CharField() - status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')]) + status = forms.CharField(validators=[validate_connection_status]) def clean(self): @@ -891,7 +884,7 @@ class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin): widget=forms.Select(attrs={'filter-for': 'pdu'})) pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False, 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( 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'})) device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, 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( 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) +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 # @@ -1033,6 +1035,7 @@ class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin): widget=forms.Select(attrs={'filter-for': 'device_b'})) device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}', + display_field='display_name', attrs={'filter-for': 'interface_b'})) livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( 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', error_messages={'invalid_choice': 'Device B not found.'}) interface_b = forms.CharField() - status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')]) + status = forms.CharField(validators=[validate_connection_status]) def clean(self): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index dfeb064670f..b5b960f57e3 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -3,10 +3,6 @@ from secrets.views import secret_add from . import views -from .models import ( - ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, PowerPortTemplate, PowerOutletTemplate, - InterfaceTemplate, -) urlpatterns = [ @@ -75,6 +71,7 @@ # Interface templates url(r'^device-types/(?P\d+)/interfaces/add/$', views.InterfaceTemplateAddView.as_view(), name='devicetype_add_interface'), + url(r'^device-types/(?P\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'), url(r'^device-types/(?P\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'), # Device bay templates @@ -159,6 +156,7 @@ # Interfaces url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_add_multi'), url(r'^devices/(?P\d+)/interfaces/add/$', views.interface_add, name='interface_add'), + url(r'^devices/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), url(r'^devices/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), url(r'^devices/(?P\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'), url(r'^interface-connections/(?P\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 44b39573c4e..b68a1dba054 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -457,6 +457,14 @@ class InterfaceTemplateAddView(ComponentTemplateCreateView): 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): permission_required = 'dcim.delete_interfacetemplate' cls = InterfaceTemplate @@ -1425,6 +1433,14 @@ def update_objects(self, pk_list, form, fields): 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): permission_required = 'dcim.delete_interface' cls = Interface diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 540651a0156..6780cfba799 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -3,7 +3,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from utilities.forms import LaxURLField +from utilities.forms import BulkEditForm, LaxURLField from .models import ( 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 elif cf.type == CF_TYPE_SELECT: choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] - if not cf.required: - choices = [(0, 'None')] + choices if bulk_edit or filterable_only: choices = [(None, '---------')] + choices 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): - custom_fields = [] def __init__(self, *args, **kwargs): + self.custom_fields = [] self.obj_type = ContentType.objects.get_for_model(self._meta.model) super(CustomFieldForm, self).__init__(*args, **kwargs) @@ -126,22 +124,25 @@ def save(self, commit=True): return obj -class CustomFieldBulkEditForm(forms.Form): - custom_fields = [] - - def __init__(self, model, *args, **kwargs): - - self.obj_type = ContentType.objects.get_for_model(model) +class CustomFieldBulkEditForm(BulkEditForm): + def __init__(self, *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 - custom_fields = [] - for name, field in get_custom_fields_for_model(self.obj_type, bulk_edit=True).items(): + custom_fields = 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 self.fields[name] = field - custom_fields.append(name) - self.custom_fields = custom_fields + # Annotate this as a custom field + self.custom_fields.append(name) + print(self.nullable_fields) class CustomFieldFilterForm(forms.Form): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 6382895f8ad..1a08aa7fc51 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -3,7 +3,6 @@ from dcim.models import Site, Device, Interface from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm -from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, @@ -23,18 +22,6 @@ ] -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 # @@ -67,9 +54,12 @@ class VRFImportForm(BulkImportForm, BootstrapMixin): class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): 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) + class Meta: + nullable_fields = ['tenant', 'description'] + class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VRF @@ -124,6 +114,9 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): date_added = forms.DateField(required=False) description = forms.CharField(max_length=100, required=False) + class Meta: + nullable_fields = ['date_added', 'description'] + class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Aggregate @@ -253,12 +246,15 @@ class PrefixImportForm(BulkImportForm, BootstrapMixin): class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) - vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF') - tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') + vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False) role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) description = forms.CharField(max_length=100, required=False) + class Meta: + nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description'] + def prefix_status_choices(): status_counts = {} @@ -294,6 +290,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm): widget=forms.Select(attrs={'filter-for': 'nat_device'})) nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device', widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}', + display_field='display_name', attrs={'filter-for': 'nat_inside'})) 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') @@ -407,10 +404,13 @@ class IPAddressImportForm(BulkImportForm, BootstrapMixin): class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) - vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF') - tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') + vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) description = forms.CharField(max_length=100, required=False) + class Meta: + nullable_fields = ['vrf', 'tenant', 'description'] + class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): model = IPAddress @@ -509,11 +509,14 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.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) role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) description = forms.CharField(max_length=100, required=False) + class Meta: + nullable_fields = ['group', 'tenant', 'role', 'description'] + def vlan_status_choices(): status_counts = {} diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index c602fa3e0db..794198b9254 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -139,7 +139,7 @@ def clean(self): if self.pk: covered_aggregates = covered_aggregates.exclude(pk=self.pk) if covered_aggregates: - raise ValidationError("{} is overlaps with an existing aggregate ({})" + raise ValidationError("{} overlaps with an existing aggregate ({})" .format(self.prefix, covered_aggregates[0])) def save(self, *args, **kwargs): diff --git a/netbox/netbox/configuration.docker.py b/netbox/netbox/configuration.docker.py index 81993ee21ff..c57aca6f474 100644 --- a/netbox/netbox/configuration.docker.py +++ b/netbox/netbox/configuration.docker.py @@ -9,7 +9,7 @@ # 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'] -ALLOWED_HOSTS = [os.environ.get('ALLOWED_HOSTS', '')] +ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(' ') # PostgreSQL database configuration. DATABASE = { diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index cd75c2017fe..68ecac7924d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ "the documentation.") -VERSION = '1.6.2-r1' +VERSION = '1.6.3' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: @@ -162,7 +162,7 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.8/howto/static-files/ STATIC_ROOT = BASE_DIR + '/static/' -STATIC_URL = '/static/' +STATIC_URL = '/{}static/'.format(BASE_PATH) STATICFILES_DIRS = ( os.path.join(BASE_DIR, "project-static"), ) @@ -176,8 +176,7 @@ } # Authentication URLs -LOGIN_URL = '/login/' -LOGIN_REDIRECT_URL = '/' +LOGIN_URL = '/{}login/'.format(BASE_PATH) # Secrets SECRETS_MIN_PUBKEY_SIZE = 2048 diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index d437c3e4ad5..439cf8701ce 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -1,6 +1,6 @@ $(document).ready(function() { - // "Toggle all" checkbox in a table header + // "Toggle all" checkbox (table header) $('#toggle_all').click(function (event) { $('td input:checkbox[name=pk]').prop('checked', $(this).prop('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 function slugify(s, num_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 $('select[filter-for]').change(function () { diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index cd2a77ae3e4..a163640b831 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -5,7 +5,7 @@ from django.db.models import Count 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 @@ -89,11 +89,14 @@ class SecretImportForm(BulkImportForm, BootstrapMixin): 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) role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False) name = forms.CharField(max_length=100, required=False) + class Meta: + nullable_fields = ['name'] + class SecretFilterForm(forms.Form, BootstrapMixin): role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug') diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 673c25c5932..62683930b94 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -314,13 +314,16 @@
Device Bays - {% if perms.dcim.add_devicebay and device_bays|length > 10 %} -
+
+ + {% if perms.dcim.add_devicebay and device_bays|length > 10 %} Add device bays -
- {% endif %} + {% endif %} +
{% for devicebay in device_bays %} @@ -355,19 +358,22 @@ {% endif %} {% if interfaces or device.device_type.is_network_device %} {% if perms.dcim.delete_interface %} - + {% csrf_token %} {% endif %}
Interfaces - {% if perms.dcim.add_interface and interfaces|length > 10 %} -
+
+ + {% if perms.dcim.add_interface and interfaces|length > 10 %} Add interfaces -
- {% endif %} + {% endif %} +
{% for iface in interfaces %} @@ -380,8 +386,13 @@
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
{% 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' %} {% endif %} {% 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 %} {% 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' %} diff --git a/netbox/templates/dcim/inc/devicetype_component_table.html b/netbox/templates/dcim/inc/devicetype_component_table.html index 6cdb1562b31..9954fec23bf 100644 --- a/netbox/templates/dcim/inc/devicetype_component_table.html +++ b/netbox/templates/dcim/inc/devicetype_component_table.html @@ -1,25 +1,37 @@ {% load render_table from django_tables2 %} {% if perms.dcim.change_devicetype %} - + {% csrf_token %}
{{ title }} - {% if table.rows|length > 10 %} -
+
+ {% if table.rows|length > 3 %} + + {% endif %} + {% if table.rows|length > 10 %} Add {{ title }} -
- {% endif %} + {% endif %} +
{% render_table table 'table.html' %}