diff --git a/README.md b/README.md index c21b140cf..d946215d5 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,4 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for inst * [Docker container](https://github.com/digitalocean/netbox-docker) * [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku)) +* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index 7f0046231..1a13353b5 100644 --- a/docs/installation/netbox.md +++ b/docs/installation/netbox.md @@ -5,14 +5,14 @@ Python 3: ```no-highlight -# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev +# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev # update-alternatives --install /usr/bin/python python /usr/bin/python3 1 ``` Python 2: ```no-highlight -# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev +# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev ``` **CentOS/RHEL** diff --git a/docs/installation/web-server.md b/docs/installation/web-server.md index 44e7ee533..7cbfad2e4 100644 --- a/docs/installation/web-server.md +++ b/docs/installation/web-server.md @@ -73,6 +73,9 @@ Once Apache is installed, proceed with the following configuration (Be sure to m Alias /static /opt/netbox/netbox/static + # Needed to allow token-based API authentication + WSGIPassAuthorization on + Options Indexes FollowSymLinks MultiViews AllowOverride None diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 4f02db847..2959aa901 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -581,9 +581,18 @@ class WritablePowerPortSerializer(serializers.ModelSerializer): # Interfaces # +class NestedInterfaceSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') + + class Meta: + model = Interface + fields = ['id', 'url', 'name'] + + class InterfaceSerializer(serializers.ModelSerializer): device = NestedDeviceSerializer() form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) + lag = NestedInterfaceSerializer() connection = serializers.SerializerMethodField(read_only=True) connected_interface = serializers.SerializerMethodField(read_only=True) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 9efa4efaa..39838a265 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -477,6 +477,11 @@ class InterfaceFilter(DeviceComponentFilterSet): method='filter_type', label='Interface type', ) + lag_id = django_filters.ModelMultipleChoiceFilter( + name='lag', + queryset=Interface.objects.all(), + label='LAG interface (ID)', + ) mac_address = django_filters.CharFilter( method='_mac_address', label='MAC address', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 414e54306..c110f1d47 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -674,7 +674,7 @@ class BaseDeviceFromCSVForm(forms.ModelForm): queryset=Platform.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'Invalid platform.'} ) - status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in STATUS_CHOICES]) + status = forms.CharField() class Meta: fields = [] @@ -692,8 +692,12 @@ class BaseDeviceFromCSVForm(forms.ModelForm): except DeviceType.DoesNotExist: self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name)) - def clean_status_name(self): - return dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']] + def clean_status(self): + status_choices = {s[1].lower(): s[0] for s in STATUS_CHOICES} + try: + return status_choices[self.cleaned_data['status'].lower()] + except KeyError: + raise ValidationError("Invalid status: {}".format(self.cleaned_data['status'])) class DeviceFromCSVForm(BaseDeviceFromCSVForm): @@ -707,8 +711,8 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm): class Meta(BaseDeviceFromCSVForm.Meta): fields = [ - 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', - 'status_name', 'site', 'rack_name', 'position', 'face', + 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', + 'site', 'rack_name', 'position', 'face', ] def clean(self): @@ -751,8 +755,8 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): class Meta(BaseDeviceFromCSVForm.Meta): fields = [ - 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', - 'status_name', 'parent', 'device_bay_name', + 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', + 'parent', 'device_bay_name', ] def clean(self): @@ -817,13 +821,15 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): rack_id = FilterChoiceField( queryset=Rack.objects.annotate(filter_count=Count('devices')), label='Rack', + null_option=(0, 'None'), ) role = FilterChoiceField( queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug', ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug', + queryset=Tenant.objects.annotate(filter_count=Count('devices')), + to_field_name='slug', null_option=(0, 'None'), ) manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer') @@ -1207,7 +1213,7 @@ class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor ) power_outlet = ChainedModelChoiceField( queryset=PowerOutlet.objects.all(), - chains={'device': 'device'}, + chains={'device': 'pdu'}, label='Outlet', widget=APISelect( api_url='/api/dcim/power-outlets/?device_id={{pdu}}', @@ -1441,7 +1447,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor label='Interface', widget=APISelect( api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical', - disabled_indicator='is_connected' + disabled_indicator='connection' ) ) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index ec596d612..e45543479 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.core.exceptions import ValidationError from django.db.models import Count from dcim.models import Site, Rack, Device, Interface @@ -195,14 +196,16 @@ class PrefixFromCSVForm(forms.ModelForm): error_messages={'invalid_choice': 'Site not found.'}) vlan_group_name = forms.CharField(required=False) vlan_vid = forms.IntegerField(required=False) - status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in PREFIX_STATUS_CHOICES]) + status = forms.CharField() role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'Invalid role.'}) class Meta: model = Prefix - fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'is_pool', - 'description'] + fields = [ + 'prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status', 'role', 'is_pool', + 'description', + ] def clean(self): @@ -237,12 +240,12 @@ class PrefixFromCSVForm(forms.ModelForm): except VLAN.MultipleObjectsReturned: self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid)) - def save(self, *args, **kwargs): - - # Assign Prefix status by name - self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']] - - return super(PrefixFromCSVForm, self).save(*args, **kwargs) + def clean_status(self): + status_choices = {s[1].lower(): s[0] for s in PREFIX_STATUS_CHOICES} + try: + return status_choices[self.cleaned_data['status'].lower()] + except KeyError: + raise ValidationError("Invalid status: {}".format(self.cleaned_data['status'])) class PrefixImportForm(BootstrapMixin, BulkImportForm): @@ -491,7 +494,7 @@ class IPAddressFromCSVForm(forms.ModelForm): error_messages={'invalid_choice': 'VRF not found.'}) tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, error_messages={'invalid_choice': 'Tenant not found.'}) - status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in IPADDRESS_STATUS_CHOICES]) + status = forms.CharField() device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'Device not found.'}) interface_name = forms.CharField(required=False) @@ -499,7 +502,7 @@ class IPAddressFromCSVForm(forms.ModelForm): class Meta: model = IPAddress - fields = ['address', 'vrf', 'tenant', 'status_name', 'device', 'interface_name', 'is_primary', 'description'] + fields = ['address', 'vrf', 'tenant', 'status', 'device', 'interface_name', 'is_primary', 'description'] def clean(self): @@ -522,10 +525,14 @@ class IPAddressFromCSVForm(forms.ModelForm): if is_primary and not device: self.add_error('is_primary', "No device specified; cannot set as primary IP") - def save(self, *args, **kwargs): + def clean_status(self): + status_choices = {s[1].lower(): s[0] for s in IPADDRESS_STATUS_CHOICES} + try: + return status_choices[self.cleaned_data['status'].lower()] + except KeyError: + raise ValidationError("Invalid status: {}".format(self.cleaned_data['status'])) - # Assign status by name - self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']] + def save(self, *args, **kwargs): # Set interface if self.cleaned_data['device'] and self.cleaned_data['interface_name']: @@ -612,6 +619,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), + required=False, widget=forms.Select( attrs={'filter-for': 'group', 'nullable': 'true'} ) @@ -649,7 +657,7 @@ class VLANFromCSVForm(forms.ModelForm): Tenant.objects.all(), to_field_name='name', required=False, error_messages={'invalid_choice': 'Tenant not found.'} ) - status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES]) + status = forms.CharField() role = forms.ModelChoiceField( queryset=Role.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'Invalid role.'} @@ -657,7 +665,7 @@ class VLANFromCSVForm(forms.ModelForm): class Meta: model = VLAN - fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status_name', 'role', 'description'] + fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] def clean(self): @@ -671,6 +679,13 @@ class VLANFromCSVForm(forms.ModelForm): except VLANGroup.DoesNotExist: self.add_error('group_name', "Invalid VLAN group {}.".format(group_name)) + def clean_status(self): + status_choices = {s[1].lower(): s[0] for s in VLAN_STATUS_CHOICES} + try: + return status_choices[self.cleaned_data['status'].lower()] + except KeyError: + raise ValidationError("Invalid status: {}".format(self.cleaned_data['status'])) + def save(self, *args, **kwargs): vlan = super(VLANFromCSVForm, self).save(commit=False) @@ -679,9 +694,6 @@ class VLANFromCSVForm(forms.ModelForm): if self.cleaned_data['group_name']: vlan.group = VLANGroup.objects.get(site=self.cleaned_data['site'], name=self.cleaned_data['group_name']) - # Assign VLAN status by name - vlan.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']] - if kwargs.get('commit'): vlan.save() return vlan diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 7e2ce017b..562713f5b 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -70,9 +70,9 @@ IPADDRESS_LINK = """ {% if record.pk %} {{ record.address }} {% elif perms.ipam.add_ipaddress %} - {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }} + {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available {% else %} - {{ record.0 }} + {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available {% endif %} """ diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 255e449a0..82e5f8331 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -525,6 +525,7 @@ def prefix_ipaddresses(request, pk): 'prefix': prefix, 'ip_table': ip_table, 'permissions': permissions, + 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf or '0', prefix.prefix), }) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 4eee4b485..82c4554f0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ except ImportError: ) -VERSION = '2.0.2' +VERSION = '2.0.3' # Import local configuration ALLOWED_HOSTS = DATABASE = SECRET_KEY = None diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 79ffa651e..f602a46f4 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -1,3 +1,4 @@ +from collections import OrderedDict import sys from rest_framework.views import APIView @@ -27,91 +28,91 @@ from .forms import SearchForm SEARCH_MAX_RESULTS = 15 -SEARCH_TYPES = { +SEARCH_TYPES = OrderedDict(( # Circuits - 'provider': { + ('provider', { 'queryset': Provider.objects.all(), 'filter': ProviderFilter, 'table': ProviderSearchTable, 'url': 'circuits:provider_list', - }, - 'circuit': { + }), + ('circuit', { 'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'), 'filter': CircuitFilter, 'table': CircuitSearchTable, 'url': 'circuits:circuit_list', - }, + }), # DCIM - 'site': { + ('site', { 'queryset': Site.objects.select_related('region', 'tenant'), 'filter': SiteFilter, 'table': SiteSearchTable, 'url': 'dcim:site_list', - }, - 'rack': { + }), + ('rack', { 'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'), 'filter': RackFilter, 'table': RackSearchTable, 'url': 'dcim:rack_list', - }, - 'devicetype': { + }), + ('devicetype', { 'queryset': DeviceType.objects.select_related('manufacturer'), 'filter': DeviceTypeFilter, 'table': DeviceTypeSearchTable, 'url': 'dcim:devicetype_list', - }, - 'device': { + }), + ('device', { 'queryset': Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'), 'filter': DeviceFilter, 'table': DeviceSearchTable, 'url': 'dcim:device_list', - }, + }), # IPAM - 'vrf': { + ('vrf', { 'queryset': VRF.objects.select_related('tenant'), 'filter': VRFFilter, 'table': VRFSearchTable, 'url': 'ipam:vrf_list', - }, - 'aggregate': { + }), + ('aggregate', { 'queryset': Aggregate.objects.select_related('rir'), 'filter': AggregateFilter, 'table': AggregateSearchTable, 'url': 'ipam:aggregate_list', - }, - 'prefix': { + }), + ('prefix', { 'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), 'filter': PrefixFilter, 'table': PrefixSearchTable, 'url': 'ipam:prefix_list', - }, - 'ipaddress': { + }), + ('ipaddress', { 'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'), 'filter': IPAddressFilter, 'table': IPAddressSearchTable, 'url': 'ipam:ipaddress_list', - }, - 'vlan': { + }), + ('vlan', { 'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'), 'filter': VLANFilter, 'table': VLANSearchTable, 'url': 'ipam:vlan_list', - }, + }), # Secrets - 'secret': { + ('secret', { 'queryset': Secret.objects.select_related('role', 'device'), 'filter': SecretFilter, 'table': SecretSearchTable, 'url': 'secrets:secret_list', - }, + }), # Tenancy - 'tenant': { + ('tenant', { 'queryset': Tenant.objects.select_related('group'), 'filter': TenantFilter, 'table': TenantSearchTable, 'url': 'tenancy:tenant_list', - }, -} + }), +)) def home(request): diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 1eadac7e4..f725dafa3 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -74,6 +74,13 @@ footer p { } } +/* Hide the nav search bar on displays less than 1600px wide */ +@media (max-width: 1599px) { + #navbar_search { + display: none; + } +} + /* Forms */ label { font-weight: normal; diff --git a/netbox/project-static/js/secrets.js b/netbox/project-static/js/secrets.js index 89326ff15..638638623 100644 --- a/netbox/project-static/js/secrets.js +++ b/netbox/project-static/js/secrets.js @@ -16,7 +16,7 @@ $(document).ready(function() { // Adding/editing a secret $('form').submit(function(event) { - $(this).find('input.requires-session-key').each(function() { + $(this).find('.requires-session-key').each(function() { if (this.value && document.cookie.indexOf('session_key') == -1) { console.log('Field ' + this.value + ' requires a session key'); $('#privkey_modal').modal('show'); diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index cb7a059fe..f7280f95e 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -246,8 +246,8 @@