From 8ed174e7af96499cff261f46664322aa2b311083 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 30 Sep 2016 11:26:08 -0400 Subject: [PATCH 01/16] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c1f3bcae3..dbb69137f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.6.2' +VERSION = '1.6.3-dev' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: From 36066068d4b96085774182036334977d37559285 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 30 Sep 2016 16:17:41 -0400 Subject: [PATCH 02/16] #527: Initial work to allow nullifying fields during bulk edit --- netbox/circuits/forms.py | 9 ++- netbox/dcim/forms.py | 63 ++++++------------- netbox/extras/forms.py | 6 +- netbox/ipam/forms.py | 40 ++++++------ netbox/project-static/js/forms.js | 5 ++ netbox/secrets/forms.py | 7 ++- .../templates/utilities/bulk_edit_form.html | 11 +++- netbox/templates/utilities/render_field.html | 25 +++++--- netbox/tenancy/forms.py | 29 ++------- netbox/utilities/forms.py | 7 +++ netbox/utilities/templatetags/form_helpers.py | 3 +- netbox/utilities/views.py | 23 +++---- 12 files changed, 106 insertions(+), 122 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index f3c210dc2..b3749947b 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -3,7 +3,6 @@ from django.db.models import Count 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 @@ -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 b65916267..0d5c4fbac 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -5,11 +5,11 @@ 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 ( @@ -42,39 +42,6 @@ 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(): - """ - 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 - - # # Sites # @@ -114,7 +81,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 +204,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 +252,7 @@ class DeviceTypeForm(forms.ModelForm, BootstrapMixin): '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) @@ -583,12 +556,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 diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 540651a01..79f980028 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -3,7 +3,7 @@ from collections import OrderedDict 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) @@ -126,7 +124,7 @@ class CustomFieldForm(forms.ModelForm): return obj -class CustomFieldBulkEditForm(forms.Form): +class CustomFieldBulkEditForm(BulkEditForm): custom_fields = [] def __init__(self, model, *args, **kwargs): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 6382895f8..6586c3435 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -3,7 +3,6 @@ from django.db.models import Count 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 @@ 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 # @@ -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 = {} @@ -407,10 +403,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 +508,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/project-static/js/forms.js b/netbox/project-static/js/forms.js index d437c3e4a..944b01868 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -37,6 +37,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 cd2a77ae3..a163640b8 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -5,7 +5,7 @@ from django import forms 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/utilities/bulk_edit_form.html b/netbox/templates/utilities/bulk_edit_form.html index 98c07519f..24a1b1077 100644 --- a/netbox/templates/utilities/bulk_edit_form.html +++ b/netbox/templates/utilities/bulk_edit_form.html @@ -8,6 +8,9 @@ {% if request.POST.redirect_url %} {% endif %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
@@ -29,7 +32,13 @@
{% block form_title %}Attributes{% endblock %}
- {% 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 %}
diff --git a/netbox/templates/utilities/render_field.html b/netbox/templates/utilities/render_field.html index b53c5ab3e..09a91f752 100644 --- a/netbox/templates/utilities/render_field.html +++ b/netbox/templates/utilities/render_field.html @@ -5,26 +5,26 @@
{% if field.help_text %} {{ field.help_text|safe }} {% endif %}
-
- {% elif field|widget_type == 'radioselect' %} -
-
-
+ {% endif %}
{% elif field|widget_type == 'textarea' %}
{{ field }} + {% if bulk_nullable %} + + {% endif %} {% if field.help_text %} {{ field.help_text|safe }} {% endif %} @@ -40,6 +40,11 @@
{{ field }} + {% if bulk_nullable %} + + {% endif %} {% if field.help_text %} {{ field.help_text|safe }} {% endif %} diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index b49e972a7..0fe6d933f 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -7,30 +7,6 @@ from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDat 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 # @@ -71,7 +47,10 @@ class TenantImportForm(BulkImportForm, BootstrapMixin): class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): 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): diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 1574f4aff..e27b59e65 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -294,6 +294,13 @@ class ConfirmationForm(forms.Form, BootstrapMixin): confirm = forms.BooleanField(required=True) +class BulkEditForm(forms.Form): + + def __init__(self, *args, **kwargs): + super(BulkEditForm, self).__init__(*args, **kwargs) + self.nullable_fields = getattr(self.Meta, 'nullable_fields') + + class BulkImportForm(forms.Form): def clean(self): diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py index 3d7540cc7..572be15fe 100644 --- a/netbox/utilities/templatetags/form_helpers.py +++ b/netbox/utilities/templatetags/form_helpers.py @@ -5,12 +5,13 @@ register = template.Library() @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 """ return { 'field': field, + 'bulk_nullable': bulk_nullable, } diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 5e8b01d6f..d0ac4e26a 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -310,8 +310,15 @@ class BulkEditView(View): 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'] - # Update objects - updated_count = self.update_objects(pk_list, form, standard_fields) + # Update standard fields. If a field is listed in _nullify, delete its value. + 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 if custom_fields: @@ -342,18 +349,6 @@ class BulkEditView(View): 'cancel_url': redirect_url, }) - def update_objects(self, pk_list, form, 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) objs_updated = False From b68c64041e13b1b59f5785572fbd784e251924f8 Mon Sep 17 00:00:00 2001 From: lf Date: Sun, 2 Oct 2016 21:01:29 -0600 Subject: [PATCH 03/16] Allow multiple ALLOWED_HOSTS on docker Change ALLOWED_HOSTS to be a space delimited list. --- netbox/netbox/configuration.docker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/configuration.docker.py b/netbox/netbox/configuration.docker.py index 81993ee21..c57aca6f4 100644 --- a/netbox/netbox/configuration.docker.py +++ b/netbox/netbox/configuration.docker.py @@ -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. # # 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 = { From 73945899fee56950774e35286565f24eb69ef849 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Oct 2016 15:17:17 -0400 Subject: [PATCH 04/16] Fixes #527: Support for nullifying custom fields during bulk editing --- netbox/extras/forms.py | 23 +++++++++++++---------- netbox/utilities/forms.py | 9 +++++++-- netbox/utilities/views.py | 27 +++++++++++++++------------ 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 79f980028..6780cfba7 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -71,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) @@ -125,21 +125,24 @@ class CustomFieldForm(forms.ModelForm): 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) + 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/utilities/forms.py b/netbox/utilities/forms.py index e27b59e65..13173642a 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -296,9 +296,14 @@ class ConfirmationForm(forms.Form, BootstrapMixin): class BulkEditForm(forms.Form): - def __init__(self, *args, **kwargs): + def __init__(self, model, *args, **kwargs): super(BulkEditForm, self).__init__(*args, **kwargs) - self.nullable_fields = getattr(self.Meta, 'nullable_fields') + 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): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index d0ac4e26a..4e3637ee0 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -301,10 +301,7 @@ class BulkEditView(View): pk_list = [int(pk) for pk in request.POST.getlist('pk')] if '_apply' in request.POST: - if hasattr(self.form, 'custom_fields'): - form = self.form(self.cls, request.POST) - else: - form = self.form(request.POST) + form = self.form(self.cls, request.POST) if form.is_valid(): custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] @@ -322,7 +319,7 @@ class BulkEditView(View): # Update custom fields for objects 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: updated_count = objs_updated @@ -333,10 +330,7 @@ class BulkEditView(View): return redirect(redirect_url) else: - if hasattr(self.form, 'custom_fields'): - form = self.form(self.cls, initial={'pk': pk_list}) - else: - form = self.form(initial={'pk': pk_list}) + form = self.form(self.cls, initial={'pk': pk_list}) selected_objects = self.cls.objects.filter(pk__in=pk_list) if not selected_objects: @@ -349,14 +343,23 @@ class BulkEditView(View): 'cancel_url': redirect_url, }) - def update_custom_fields(self, pk_list, form, fields): + def update_custom_fields(self, pk_list, form, fields, nullified_fields): obj_type = ContentType.objects.get_for_model(self.cls) objs_updated = False 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) if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0: From 330abe5a2d2a94cae746a800e84b5f52ca71e7ef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Oct 2016 15:29:16 -0400 Subject: [PATCH 05/16] Fixes #602: Correct display of custom integer fields with value of 0 or 1 --- netbox/templates/inc/custom_fields_panel.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index fed172aa6..2450e3031 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -8,13 +8,13 @@ {{ field }} - {% if value == True %} + {% if field.type == 300 and value == True %} - {% elif value == False %} + {% elif field.type == 300 and value == False %} {% elif field.type == 500 and value %} {{ value|urlizetrunc:75 }} - {% elif value %} + {% elif field.type == 200 or value %} {{ value }} {% elif field.required %} Not defined From 0ff46bf5d08ef67eda1c1df84b0e19eeb703be62 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Oct 2016 12:18:32 -0400 Subject: [PATCH 06/16] Fixes #611: Power/console/interface connection import: status field should be case-insensitive --- netbox/dcim/forms.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 0d5c4fbac..d9b526005 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,6 +1,7 @@ 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 @@ -42,6 +43,14 @@ def get_device_by_name_or_pk(name): return device +def validate_connection_status(value): + """ + Custom validator for connection statuses. value must be either "planned" or "connected" (case-insensitive). + """ + if value.lower() not in ['planned', 'connected']: + raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value)) + + # # Sites # @@ -606,7 +615,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): @@ -801,7 +810,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): @@ -1062,7 +1071,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): From 464797858f55c1d895d264da4f091c0c08abbc22 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Oct 2016 15:21:36 -0400 Subject: [PATCH 07/16] Fixes #604: Correct display of unnamed devices in form selection fields --- netbox/circuits/forms.py | 2 +- netbox/dcim/forms.py | 8 +++++--- netbox/ipam/forms.py | 1 + 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index b3749947b..288f2255f 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -88,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') ) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d9b526005..f9dec9f69 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -679,6 +679,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') @@ -746,7 +747,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') ) @@ -875,7 +876,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') ) @@ -942,7 +943,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') ) @@ -1017,6 +1018,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') diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 6586c3435..1a08aa7fc 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -290,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') From 579ed0a98571ff6027b3506f140ba01b54d680a3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Oct 2016 16:12:27 -0400 Subject: [PATCH 08/16] Redirect user to previous page after logging in --- netbox/utilities/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index 563e4a0fe..e37c1fcca 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -12,4 +12,4 @@ class LoginRequiredMiddleware: def process_request(self, request): if LOGIN_REQUIRED and not request.user.is_authenticated(): if request.path_info != settings.LOGIN_URL: - return HttpResponseRedirect(settings.LOGIN_URL) + return HttpResponseRedirect('{}?next={}'.format(settings.LOGIN_URL, request.path_info)) From 49cbdc22daaa765843b88253c9c35cadbe3ec2c1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Oct 2016 16:27:09 -0400 Subject: [PATCH 09/16] Fixes #615: Account for BASE_PATH in static URLs and during login --- netbox/netbox/settings.py | 5 ++--- netbox/users/views.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index dbb69137f..6d2190516 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -162,7 +162,7 @@ USE_TZ = True # 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 @@ MESSAGE_TAGS = { } # Authentication URLs -LOGIN_URL = '/login/' -LOGIN_REDIRECT_URL = '/' +LOGIN_URL = '/{}login/'.format(BASE_PATH) # Secrets SECRETS_MIN_PUBKEY_SIZE = 2048 diff --git a/netbox/users/views.py b/netbox/users/views.py index 7a01539b7..7a9b50266 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -1,10 +1,9 @@ -from django.conf import settings 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.decorators import login_required from django.core.urlresolvers import reverse 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 secrets.forms import UserKeyForm @@ -26,7 +25,7 @@ def login(request): # Determine where to direct user after successful login redirect_to = request.POST.get('next', '') 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 auth_login(request, form.get_user()) From 5a4ccbc06670e367bb3b62bb4f0535ffa0d2e670 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Oct 2016 11:08:09 -0400 Subject: [PATCH 10/16] Fixes #616: Correct display of custom URL fields --- netbox/templates/inc/custom_fields_panel.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index 2450e3031..a185a2ae7 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -13,7 +13,7 @@ {% elif field.type == 300 and value == False %} {% elif field.type == 500 and value %} - {{ value|urlizetrunc:75 }} + {{ value|truncatechars:70 }} {% elif field.type == 200 or value %} {{ value }} {% elif field.required %} From 0da3661ff0c94ea5e9c4ef17918aaefca1f2d453 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Oct 2016 16:38:46 -0400 Subject: [PATCH 11/16] #353: Allow bulk editing of interfaces --- netbox/dcim/forms.py | 15 ++++++++--- netbox/dcim/urls.py | 5 +--- netbox/dcim/views.py | 8 ++++++ netbox/templates/dcim/device.html | 9 +++++-- .../templates/dcim/interface_bulk_edit.html | 17 +++++++++++++ netbox/utilities/forms.py | 2 +- netbox/utilities/views.py | 25 ++++++++++++++----- 7 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 netbox/templates/dcim/interface_bulk_edit.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f9dec9f69..3908b35af 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -16,9 +16,9 @@ from utilities.forms import ( 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 ) @@ -1008,6 +1008,15 @@ class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin): pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) +class InterfaceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + 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 # diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index dfeb06467..f5c5f69c0 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -3,10 +3,6 @@ from django.conf.urls import url from secrets.views import secret_add from . import views -from .models import ( - ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, PowerPortTemplate, PowerOutletTemplate, - InterfaceTemplate, -) urlpatterns = [ @@ -159,6 +155,7 @@ urlpatterns = [ # 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 44b39573c..112734c22 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1425,6 +1425,14 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView): 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/templates/dcim/device.html b/netbox/templates/dcim/device.html index 673c25c59..98f43be19 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -355,7 +355,7 @@ {% endif %} {% if interfaces or device.device_type.is_network_device %} {% if perms.dcim.delete_interface %} -
+ {% csrf_token %} {% endif %}
@@ -380,8 +380,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 6cdb1562b..c71c5b4da 100644 --- a/netbox/templates/dcim/inc/devicetype_component_table.html +++ b/netbox/templates/dcim/inc/devicetype_component_table.html @@ -1,6 +1,6 @@ {% load render_table from django_tables2 %} {% if perms.dcim.change_devicetype %} - + {% csrf_token %}
@@ -17,9 +17,16 @@ {% render_table table 'table.html' %}