diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index 3a2391028..d6c863983 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -1,4 +1,4 @@ -This section entails features of NetBox which are not crucial to its primary functions, but that provide additional value. +This section entails features of NetBox which are not crucial to its primary functions, but provide additional value. # Custom Fields @@ -17,15 +17,15 @@ Custom fields must be created through the admin UI under Extras > Custom Fields. Assign the field a name. This should be a simple database-friendly string, e.g. `tps_report`. You may optionally assign the field a human-friendly label (e.g. "TPS report") as well; the label will be displayed on forms. If a description is provided, it will appear beneath the field in a form. -Marking the field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields. (The default value has no effect for selection fields.) +Marking the field as required will require the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields. (The default value has no effect for selection fields.) -When creating a selection field, you must create at least two choices. These choices will be arranged first by weight, with lower weights appearing higher in the list, and then alphabetically. +When creating a selection field, you should create at least two choices. These choices will be arranged first by weight, with lower weights appearing higher in the list, and then alphabetically. ## Using Custom Fields -When a single object is edited, the form will include any custom fields which have been defined for its type. These fields are included in the "Custom Fields" panel. Each custom field value must be saved independently from the core object, so it's best to avoid adding too many custom fields per object. +When a single object is edited, the form will include any custom fields which have been defined for the object type. These fields are included in the "Custom Fields" panel. On the backend, each custom field value is saved separately from the core object as an independent database call, so it's best to avoid adding too many custom fields per object. -When editing multiple objects, values are saved in bulk per field. That is, there is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field. +When editing multiple objects, custom field values are saved in bulk. There is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field. # Export Templates diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 6db3076a4..55f4a0dbf 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -6,7 +6,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField, + APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea, + SlugField, get_filter_choices, ) from .models import Circuit, CircuitType, Provider, Termination @@ -64,8 +65,7 @@ def provider_site_choices(): class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Provider - site = forms.MultipleChoiceField(required=False, choices=provider_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug')) # @@ -85,6 +85,19 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin): # class CircuitForm(BootstrapMixin, CustomFieldForm): + site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) + rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack', + widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', + 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'})) + livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( + query_key='q', query_url='dcim-api:device_list', field_to_update='device') + ) + interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface', + widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical', + disabled_indicator='is_connected')) comments = CommentField() class Meta: @@ -95,126 +108,15 @@ class CircuitForm(BootstrapMixin, CustomFieldForm): help_texts = { 'cid': "Unique circuit ID", 'install_date': "Format: YYYY-MM-DD", - } - - def __init__(self, *args, **kwargs): - - super(CircuitForm, self).__init__(*args, **kwargs) - - -class CircuitFromCSVForm(forms.ModelForm): - provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Provider not found.'}) - type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Invalid circuit type.'}) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) - - class Meta: - model = Circuit - fields = ['cid', 'provider', 'type', 'tenant', 'install_date'] - - -class CircuitImportForm(BulkImportForm, BootstrapMixin): - csv = CSVDataField(csv_form=CircuitFromCSVForm) - - -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') - comments = CommentField() - - -def circuit_type_choices(): - type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits')) - return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in type_choices] - - -def circuit_provider_choices(): - provider_choices = Provider.objects.annotate(circuit_count=Count('circuits')) - return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices] - - -def circuit_tenant_choices(): - tenant_choices = Tenant.objects.annotate(circuit_count=Count('circuits')) - return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in tenant_choices] - - -class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): - model = Circuit - type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices) - provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - tenant = forms.MultipleChoiceField(required=False, choices=circuit_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - - -# -# Terminations -# -class TerminationForm(BootstrapMixin, CustomFieldForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) - rack = forms.ModelChoiceField( - queryset=Rack.objects.all(), - required=False, - label='Rack', - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - 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'} - ) - ) - livesearch = forms.CharField( - required=False, - label='Device', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device_list', - field_to_update='device' - ) - ) - interface = forms.ModelChoiceField( - queryset=Interface.objects.all(), - required=False, - label='Interface', - widget=APISelect( - api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical', - disabled_indicator='is_connected' - ) - ) - comments = CommentField() - - class Meta: - model = Termination - fields = [ - 'tid', 'site', 'rack', 'device', 'livesearch', - 'interface', 'port_speed', 'upstream_speed', 'commit_rate', - 'xconnect_id', 'pp_info', 'comments' - ] - help_texts = { - 'tid': "Termination ID", 'port_speed': "Physical circuit speed", 'commit_rate': "Commited rate", 'xconnect_id': "ID of the local cross-connect", 'pp_info': "Patch panel ID and port number(s)" } - widgets = { - 'circuit': forms.HiddenInput(), - } def __init__(self, *args, **kwargs): - super(TerminationForm, self).__init__(*args, **kwargs) + super(CircuitForm, self).__init__(*args, **kwargs) # If this circuit has been assigned to an interface, initialize rack and device if self.instance.interface: @@ -240,11 +142,11 @@ class TerminationForm(BootstrapMixin, CustomFieldForm): # Limit interface choices if self.is_bound and self.data.get('device'): interfaces = Interface.objects.filter(device=self.data['device'])\ - .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('termination', 'connected_as_a', 'connected_as_b') + .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b') self.fields['interface'].widget.attrs['initial'] = self.data.get('interface') elif self.initial.get('device'): interfaces = Interface.objects.filter(device=self.initial['device'])\ - .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('termination', 'connected_as_a', 'connected_as_b') + .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b') self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface') else: interfaces = [] @@ -254,3 +156,40 @@ class TerminationForm(BootstrapMixin, CustomFieldForm): 'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'), }) for iface in interfaces ] + + +class CircuitFromCSVForm(forms.ModelForm): + provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Provider not found.'}) + type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Invalid circuit type.'}) + tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, + error_messages={'invalid_choice': 'Tenant not found.'}) + site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Site not found.'}) + + class Meta: + model = Circuit + fields = ['cid', 'provider', 'type', 'tenant', 'install_date'] + + +class CircuitImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=CircuitFromCSVForm) + + +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') + port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)') + commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)') + comments = CommentField() + + +class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Circuit + type = FilterChoiceField(choices=get_filter_choices(CircuitType, id_field='slug', count_field='circuits')) + provider = FilterChoiceField(choices=get_filter_choices(Provider, id_field='slug', count_field='circuits')) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='circuits')) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='circuits')) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 21671234c..e0a7a51d7 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,7 +1,7 @@ import re from django import forms -from django.db.models import Count, Q +from django.db.models import Q from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress @@ -9,7 +9,8 @@ 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, - FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, + FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, + get_filter_choices ) from .models import ( @@ -117,15 +118,9 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') -def site_tenant_choices(): - tenant_choices = Tenant.objects.annotate(site_count=Count('sites')) - return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices] - - class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site - tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='sites')) # @@ -140,14 +135,8 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin): fields = ['site', 'name', 'slug'] -def rackgroup_site_choices(): - site_choices = Site.objects.annotate(rack_count=Count('rack_groups')) - return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices] - - class RackGroupFilterForm(forms.Form, BootstrapMixin): - site = forms.MultipleChoiceField(required=False, choices=rackgroup_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='rack_groups')) # @@ -254,36 +243,13 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): comments = CommentField() -def rack_site_choices(): - site_choices = Site.objects.annotate(rack_count=Count('racks')) - return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices] - - -def rack_group_choices(): - group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) - return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices] - - -def rack_tenant_choices(): - tenant_choices = Tenant.objects.annotate(rack_count=Count('racks')) - return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices] - - -def rack_role_choices(): - role_choices = RackRole.objects.annotate(rack_count=Count('racks')) - return [(r.slug, u'{} ({})'.format(r.name, r.rack_count)) for r in role_choices] - - class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Rack - site = forms.MultipleChoiceField(required=False, choices=rack_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group', - widget=forms.SelectMultiple(attrs={'size': 8})) - tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - role = forms.MultipleChoiceField(required=False, choices=rack_role_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='racks')) + group_id = FilterChoiceField(choices=get_filter_choices(RackGroup, select_related=['site'], count_field='racks'), + label='Rack Group') + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='racks')) + role = FilterChoiceField(choices=get_filter_choices(RackRole, id_field='slug', count_field='racks')) # @@ -317,14 +283,9 @@ class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin): u_height = forms.IntegerField(min_value=1, required=False) -def devicetype_manufacturer_choices(): - manufacturer_choices = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) - return [(m.slug, u'{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices] - - class DeviceTypeFilterForm(forms.Form, BootstrapMixin): - manufacturer = forms.MultipleChoiceField(required=False, choices=devicetype_manufacturer_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + manufacturer = FilterChoiceField(choices=get_filter_choices(Manufacturer, id_field='slug', + count_field='device_types')) # @@ -627,49 +588,18 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): serial = forms.CharField(max_length=50, required=False, label='Serial Number') -def device_site_choices(): - site_choices = Site.objects.annotate(device_count=Count('racks__devices')) - return [(s.slug, u'{} ({})'.format(s.name, s.device_count)) for s in site_choices] - - -def device_rack_group_choices(): - group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices')) - return [(g.pk, u'{} ({})'.format(g, g.device_count)) for g in group_choices] - - -def device_role_choices(): - role_choices = DeviceRole.objects.annotate(device_count=Count('devices')) - return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices] - - -def device_tenant_choices(): - tenant_choices = Tenant.objects.annotate(device_count=Count('devices')) - return [(t.slug, u'{} ({})'.format(t.name, t.device_count)) for t in tenant_choices] - - -def device_type_choices(): - type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances')) - return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices] - - -def device_platform_choices(): - platform_choices = Platform.objects.annotate(device_count=Count('devices')) - return [(p.slug, u'{} ({})'.format(p.name, p.device_count)) for p in platform_choices] - - class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device - site = forms.MultipleChoiceField(required=False, choices=device_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group', - widget=forms.SelectMultiple(attrs={'size': 8})) - role = forms.MultipleChoiceField(required=False, choices=device_role_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - tenant = forms.MultipleChoiceField(required=False, choices=device_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type', - widget=forms.SelectMultiple(attrs={'size': 8})) - platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='racks__devices')) + rack_group_id = FilterChoiceField(choices=get_filter_choices(RackGroup, select_related=['site'], + count_field='racks__devices'), + label='Rack Group') + role = FilterChoiceField(choices=get_filter_choices(DeviceRole, id_field='slug', count_field='devices')) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='devices')) + device_type_id = FilterChoiceField(choices=get_filter_choices(DeviceType, select_related=['manufacturer'], + count_field='instances'), + label='Type') + platform = FilterChoiceField(choices=get_filter_choices(Platform, id_field='slug', count_field='devices')) status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES)) diff --git a/netbox/dcim/migrations/0019_new_iface_form_factors.py b/netbox/dcim/migrations/0019_new_iface_form_factors.py new file mode 100644 index 000000000..b2358ba5e --- /dev/null +++ b/netbox/dcim/migrations/0019_new_iface_form_factors.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-09-13 15:20 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0018_device_add_asset_tag'), + ] + + operations = [ + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index f46cb2655..7855ed35c 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -77,23 +77,39 @@ ROLE_COLOR_CHOICES = [ [COLOR_GRAY3, 'Dark Gray'], ] +# Virtual IFACE_FF_VIRTUAL = 0 -IFACE_FF_100M_COPPER = 800 -IFACE_FF_1GE_COPPER = 1000 -IFACE_FF_GBIC = 1050 -IFACE_FF_SFP = 1100 -IFACE_FF_10GE_COPPER = 1150 -IFACE_FF_SFP_PLUS = 1200 -IFACE_FF_XFP = 1300 -IFACE_FF_QSFP_PLUS = 1400 -IFACE_FF_CFP = 1500 -IFACE_FF_QSFP28 = 1600 +# Ethernet +IFACE_FF_100ME_FIXED = 800 +IFACE_FF_1GE_FIXED = 1000 +IFACE_FF_1GE_GBIC = 1050 +IFACE_FF_1GE_SFP = 1100 +IFACE_FF_10GE_FIXED = 1150 +IFACE_FF_10GE_SFP_PLUS = 1200 +IFACE_FF_10GE_XFP = 1300 +IFACE_FF_10GE_XENPAK = 1310 +IFACE_FF_10GE_X2 = 1320 +IFACE_FF_25GE_SFP28 = 1350 +IFACE_FF_40GE_QSFP_PLUS = 1400 +IFACE_FF_100GE_CFP = 1500 +IFACE_FF_100GE_QSFP28 = 1600 +# Fibrechannel +IFACE_FF_1GFC_SFP = 3010 +IFACE_FF_2GFC_SFP = 3020 +IFACE_FF_4GFC_SFP = 3040 +IFACE_FF_8GFC_SFP_PLUS = 3080 +IFACE_FF_16GFC_SFP_PLUS = 3160 +# Serial IFACE_FF_T1 = 4000 IFACE_FF_E1 = 4010 IFACE_FF_T3 = 4040 IFACE_FF_E3 = 4050 +# Stacking IFACE_FF_STACKWISE = 5000 IFACE_FF_STACKWISE_PLUS = 5050 +# Other +IFACE_FF_OTHER = 32767 + IFACE_FF_CHOICES = [ [ 'Virtual interfaces', @@ -102,23 +118,36 @@ IFACE_FF_CHOICES = [ ] ], [ - 'Ethernet', + 'Ethernet (fixed)', [ - [IFACE_FF_100M_COPPER, '100BASE-TX (10/100M)'], - [IFACE_FF_1GE_COPPER, '1000BASE-T (1GE)'], - [IFACE_FF_10GE_COPPER, '10GBASE-T (10GE)'], + [IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'], + [IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'], + [IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'], ] ], [ - 'Modular', + 'Ethernet (modular)', [ - [IFACE_FF_GBIC, 'GBIC (1GE)'], - [IFACE_FF_SFP, 'SFP (1GE)'], - [IFACE_FF_XFP, 'XFP (10GE)'], - [IFACE_FF_SFP_PLUS, 'SFP+ (10GE)'], - [IFACE_FF_QSFP_PLUS, 'QSFP+ (40GE)'], - [IFACE_FF_CFP, 'CFP (100GE)'], - [IFACE_FF_QSFP28, 'QSFP28 (100GE)'], + [IFACE_FF_1GE_GBIC, 'GBIC (1GE)'], + [IFACE_FF_1GE_SFP, 'SFP (1GE)'], + [IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'], + [IFACE_FF_10GE_XFP, 'XFP (10GE)'], + [IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'], + [IFACE_FF_10GE_X2, 'X2 (10GE)'], + [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'], + [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], + [IFACE_FF_100GE_CFP, 'CFP (100GE)'], + [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'], + ] + ], + [ + 'FibreChannel', + [ + [IFACE_FF_1GFC_SFP, 'SFP (1GFC)'], + [IFACE_FF_2GFC_SFP, 'SFP (2GFC)'], + [IFACE_FF_4GFC_SFP, 'SFP (4GFC)'], + [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'], + [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'], ] ], [ @@ -137,6 +166,12 @@ IFACE_FF_CHOICES = [ [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'], ] ], + [ + 'Other', + [ + [IFACE_FF_OTHER, 'Other'], + ] + ], ] STATUS_ACTIVE = True @@ -647,7 +682,7 @@ class InterfaceTemplate(models.Model): """ device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE) name = models.CharField(max_length=30) - form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS) + form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) mgmt_only = models.BooleanField(default=False, verbose_name='Management only') objects = InterfaceTemplateManager() @@ -1023,7 +1058,7 @@ class Interface(models.Model): """ device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE) name = models.CharField(max_length=30) - form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS) + form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address') mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management', help_text="This interface is used only for out-of-band management") diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index bb41d59a2..8ec70d2e8 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -29,8 +29,8 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F elif cf.type == CF_TYPE_BOOLEAN: choices = ( (None, '---------'), - (True, 'True'), - (False, 'False'), + (1, 'True'), + (0, 'False'), ) if cf.default.lower() in ['true', 'yes', '1']: initial = True diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 6d173b62d..ce5b1d43f 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -234,10 +234,10 @@ class ExportTemplate(models.Model): """ template = Template(self.template_code) mime_type = 'text/plain' if not self.mime_type else self.mime_type - response = HttpResponse( - template.render(Context(context_dict)), - content_type=mime_type - ) + output = template.render(Context(context_dict)) + # Replace CRLF-style line terminators + output = output.replace('\r\n', '\n') + response = HttpResponse(output, content_type=mime_type) if self.file_extension: filename += '.{}'.format(self.file_extension) response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 938f5aeb6..073c06f27 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -5,7 +5,10 @@ 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 BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField +from utilities.forms import ( + APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, + get_filter_choices, +) from .models import ( Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF, @@ -14,6 +17,11 @@ from .models import ( FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES +IP_FAMILY_CHOICES = [ + ('', 'All'), + (4, 'IPv4'), + (6, 'IPv6'), +] def bulkedit_vrf_choices(): @@ -64,15 +72,9 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def vrf_tenant_choices(): - tenant_choices = Tenant.objects.annotate(vrf_count=Count('vrfs')) - return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices] - - class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VRF - tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='vrfs')) # @@ -123,15 +125,10 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def aggregate_rir_choices(): - rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates')) - return [(r.slug, u'{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices] - - class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Aggregate - rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR', - widget=forms.SelectMultiple(attrs={'size': 8})) + family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') + rir = FilterChoiceField(choices=get_filter_choices(RIR, id_field='slug', count_field='aggregates'), label='RIR') # @@ -262,21 +259,6 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def prefix_vrf_choices(): - vrf_choices = VRF.objects.annotate(prefix_count=Count('prefixes')) - return [(v.pk, u'{} ({})'.format(v.name, v.prefix_count)) for v in vrf_choices] - - -def tenant_choices(): - tenant_choices = Tenant.objects.all() - return [(t.slug, t.name) for t in tenant_choices] - - -def prefix_site_choices(): - site_choices = Site.objects.annotate(prefix_count=Count('prefixes')) - return [(s.slug, u'{} ({})'.format(s.name, s.prefix_count)) for s in site_choices] - - def prefix_status_choices(): status_counts = {} for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'): @@ -284,26 +266,18 @@ def prefix_status_choices(): return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES] -def prefix_role_choices(): - role_choices = Role.objects.annotate(prefix_count=Count('prefixes')) - return [(r.slug, u'{} ({})'.format(r.name, r.prefix_count)) for r in role_choices] - - class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Prefix parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={ 'placeholder': 'Network', })) - vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF', - widget=forms.SelectMultiple(attrs={'size': 6})) - tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant', - widget=forms.SelectMultiple(attrs={'size': 6})) - status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices, - widget=forms.SelectMultiple(attrs={'size': 6})) - site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices, - widget=forms.SelectMultiple(attrs={'size': 6})) - role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices, - widget=forms.SelectMultiple(attrs={'size': 6})) + family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') + vrf = FilterChoiceField(choices=get_filter_choices(VRF, count_field='prefixes'), label='VRF') + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='prefixes'), + label='Tenant') + status = FilterChoiceField(choices=prefix_status_choices) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='prefixes')) + role = FilterChoiceField(choices=get_filter_choices(Role, id_field='slug', count_field='prefixes')) expand = forms.BooleanField(required=False, label='Expand prefix hierarchy') @@ -434,25 +408,15 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def ipaddress_family_choices(): - return [('', 'All'), (4, 'IPv4'), (6, 'IPv6')] - - -def ipaddress_vrf_choices(): - vrf_choices = VRF.objects.annotate(ipaddress_count=Count('ip_addresses')) - return [(v.pk, u'{} ({})'.format(v.name, v.ipaddress_count)) for v in vrf_choices] - - class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): model = IPAddress parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={ 'placeholder': 'Prefix', })) - family = forms.ChoiceField(required=False, choices=ipaddress_family_choices, label='Address Family') - vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF', - widget=forms.SelectMultiple(attrs={'size': 6})) - tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant', - widget=forms.SelectMultiple(attrs={'size': 6})) + family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') + vrf = FilterChoiceField(choices=get_filter_choices(VRF, count_field='ip_addresses'), label='VRF') + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='prefixes'), + label='Tenant') # @@ -467,14 +431,8 @@ class VLANGroupForm(forms.ModelForm, BootstrapMixin): fields = ['site', 'name', 'slug'] -def vlangroup_site_choices(): - site_choices = Site.objects.annotate(vlangroup_count=Count('vlan_groups')) - return [(s.slug, u'{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices] - - class VLANGroupFilterForm(forms.Form, BootstrapMixin): - site = forms.MultipleChoiceField(required=False, choices=vlangroup_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='vlan_groups')) # @@ -552,21 +510,6 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def vlan_site_choices(): - site_choices = Site.objects.annotate(vlan_count=Count('vlans')) - return [(s.slug, u'{} ({})'.format(s.name, s.vlan_count)) for s in site_choices] - - -def vlan_group_choices(): - group_choices = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans')) - return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices] - - -def vlan_tenant_choices(): - tenant_choices = Tenant.objects.annotate(vrf_count=Count('vlans')) - return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices] - - def vlan_status_choices(): status_counts = {} for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'): @@ -574,19 +517,11 @@ def vlan_status_choices(): return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES] -def vlan_role_choices(): - role_choices = Role.objects.annotate(vlan_count=Count('vlans')) - return [(r.slug, u'{} ({})'.format(r.name, r.vlan_count)) for r in role_choices] - - class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VLAN - site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group', - widget=forms.SelectMultiple(attrs={'size': 8})) - tenant = forms.MultipleChoiceField(required=False, choices=vlan_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices) - role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='vlans')) + group_id = FilterChoiceField(choices=get_filter_choices(VLANGroup, select_related=['site'], count_field='vlans'), + label='VLAN Group') + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='vlans')) + status = FilterChoiceField(choices=vlan_status_choices) + role = FilterChoiceField(choices=get_filter_choices(Role, id_field='slug', count_field='vlans')) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 289367d37..bf63b272b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.5.3-dev' +VERSION = '1.6.1-dev' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 193fddf14..635745309 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -34,7 +34,8 @@ body { footer p { margin: 20px 0; } -@media (max-width: 1120px) { + +@media (max-width: 1200px) { .navbar-header { float: none; } @@ -54,6 +55,7 @@ footer p { } .navbar-collapse.collapse { display: none!important; + max-height: none; } .navbar-nav { float: none!important; @@ -84,13 +86,11 @@ th.pk, td.pk { width: 30px; } - /* Paginator */ nav ul.pagination { margin-top: 0; } - /* Racks */ div.rack_header { margin-left: 36px; diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index f8e3a2b20..4f0592b34 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -32,61 +32,6 @@ $(document).ready(function() { }) } - // Helper select fields - $('select.helper-parent').change(function () { - - // Resolve child field by ID specified in parent - var child_field = $('#id_' + $(this).attr('child')); - - // Wipe out any existing options within the child field - child_field.empty(); - child_field.append($("").attr("value", "").text("")); - - // If the parent has a value set, fetch a list of child options via the API and populate the child field with them - if ($(this).val()) { - - // Construct the API request URL - var api_url = $(this).attr('child-source'); - var parent_accessor = $(this).attr('parent-accessor'); - if (parent_accessor) { - api_url += '?' + parent_accessor + '=' + $(this).val(); - } else { - api_url += '?' + $(this).attr('name') + '_id=' + $(this).val(); - } - var api_url_extra = $(this).attr('child-filter'); - if (api_url_extra) { - api_url += '&' + api_url_extra; - } - - var disabled_indicator = $(this).attr('disabled-indicator'); - var disabled_exempt = child_field.attr('exempt'); - var child_display = $(this).attr('child-display'); - if (!child_display) { - child_display = 'name'; - } - - $.ajax({ - url: api_url, - dataType: 'json', - success: function (response, status) { - console.log(response); - $.each(response, function (index, choice) { - var option = $("").attr("value", choice.id).text(choice[child_display]); - if (disabled_indicator && choice[disabled_indicator] && choice.id != disabled_exempt) { - option.attr("disabled", "disabled") - } - child_field.append(option); - }); - } - }); - - } - - // Trigger change event in case the child field is the parent of another field - child_field.change(); - - }); - // API select widget $('select[filter-for]').change(function () { diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 1e45fe163..e3ebb7500 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -2,10 +2,11 @@ from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA from django import forms -from django.db.models import Count from dcim.models import Device -from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, SlugField +from utilities.forms import ( + BootstrapMixin, BulkImportForm, CSVDataField, FilterChoiceField, SlugField, get_filter_choices, +) from .models import Secret, SecretRole, UserKey @@ -95,13 +96,8 @@ class SecretBulkEditForm(forms.Form, BootstrapMixin): name = forms.CharField(max_length=100, required=False) -def secret_role_choices(): - role_choices = SecretRole.objects.annotate(secret_count=Count('secrets')) - return [(r.slug, u'{} ({})'.format(r.name, r.secret_count)) for r in role_choices] - - class SecretFilterForm(forms.Form, BootstrapMixin): - role = forms.MultipleChoiceField(required=False, choices=secret_role_choices) + role = FilterChoiceField(choices=get_filter_choices(SecretRole, id_field='slug', count_field='secrets')) # diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index f0291f250..0a9cb20d4 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -167,7 +167,7 @@ {% if perms.ipam.add_rir or perms.ipam.add_role %}
{% endif %} -