diff --git a/README.md b/README.md index 9d3bbe0e8..9fa3acb94 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/). Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), -or join us in the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/)! +or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)! ### Build Status @@ -41,3 +41,4 @@ and run `upgrade.sh`. * [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine)) * [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle)) +* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae)) diff --git a/docs/installation/web-server.md b/docs/installation/web-server.md index f9a304ff5..39235200b 100644 --- a/docs/installation/web-server.md +++ b/docs/installation/web-server.md @@ -82,6 +82,7 @@ Once Apache is installed, proceed with the following configuration (Be sure to m ProxyPass ! + RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME} ProxyPass / http://127.0.0.1:8001/ ProxyPassReverse / http://127.0.0.1:8001/ @@ -92,6 +93,7 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c ```no-highlight # a2enmod proxy # a2enmod proxy_http +# a2enmod headers # a2ensite netbox # service apache2 restart ``` diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 29203fc8a..bfcfa7187 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -8,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, - CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField, + AnnotatedMultipleChoiceField, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, + ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField, ) from .constants import CIRCUIT_STATUS_CHOICES from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -169,13 +169,6 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['tenant', 'commit_rate', 'description', 'comments'] -def circuit_status_choices(): - status_counts = {} - for status in Circuit.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in CIRCUIT_STATUS_CHOICES] - - class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Circuit q = forms.CharField(required=False, label='Search') @@ -187,7 +180,12 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=Provider.objects.annotate(filter_count=Count('circuits')), to_field_name='slug' ) - status = forms.MultipleChoiceField(choices=circuit_status_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=CIRCUIT_STATUS_CHOICES, + annotate=Circuit.objects.all(), + annotate_field='status', + required=False + ) tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug', diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 12e657e79..13f68639f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -6,6 +6,9 @@ from django.conf import settings from django.db import transaction from django.http import HttpResponseBadRequest, HttpResponseForbidden from django.shortcuts import get_object_or_404 +from drf_yasg import openapi +from drf_yasg.openapi import Parameter +from drf_yasg.utils import swagger_auto_schema from rest_framework.decorators import detail_route from rest_framework.mixins import ListModelMixin from rest_framework.response import Response @@ -418,14 +421,20 @@ class ConnectedDeviceViewSet(ViewSet): * `peer-interface`: The name of the peer interface """ permission_classes = [IsAuthenticatedOrLoginNotRequired] + _device_param = Parameter('peer-device', 'query', + description='The name of the peer device', required=True, type=openapi.TYPE_STRING) + _interface_param = Parameter('peer-interface', 'query', + description='The name of the peer interface', required=True, type=openapi.TYPE_STRING) def get_view_name(self): return "Connected Device Locator" + @swagger_auto_schema( + manual_parameters=[_device_param, _interface_param], responses={'200': serializers.DeviceSerializer}) def list(self, request): - peer_device_name = request.query_params.get('peer-device') - peer_interface_name = request.query_params.get('peer-interface') + peer_device_name = request.query_params.get(self._device_param.name) + peer_interface_name = request.query_params.get(self._interface_param.name) if not peer_device_name or not peer_interface_name: raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.') diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e71f44389..6795726a6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -14,11 +14,10 @@ from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, - CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, - FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, - SmallTextarea, SlugField, + AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, + BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, + ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField, + FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField, ) from virtualization.models import Cluster from .constants import ( @@ -37,6 +36,12 @@ from .models import ( DEVICE_BY_PK_RE = '{\d+\}' +INTERFACE_MODE_HELP_TEXT = """ +Access: One untagged VLAN
+Tagged: One untagged VLAN and/or one or more tagged VLANs
+Tagged All: Implies all VLANs are available (w/optional untagged VLAN) +""" + def get_device_by_name_or_pk(name): """ @@ -172,17 +177,15 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone'] -def site_status_choices(): - status_counts = {} - for status in Site.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in SITE_STATUS_CHOICES] - - class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site q = forms.CharField(required=False, label='Search') - status = forms.MultipleChoiceField(choices=site_status_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=SITE_STATUS_CHOICES, + annotate=Site.objects.all(), + annotate_field='status', + required=False + ) region = FilterTreeNodeMultipleChoiceField( queryset=Region.objects.annotate(filter_count=Count('sites')), to_field_name='slug', @@ -700,13 +703,21 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class PlatformCSVForm(forms.ModelForm): slug = SlugField() + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + required=True, + to_field_name='name', + help_text='Manufacturer name', + error_messages={ + 'invalid_choice': 'Manufacturer not found.', + } + ) class Meta: model = Platform fields = Platform.csv_headers help_texts = { 'name': 'Platform name', - 'manufacturer': 'Manufacturer name', } @@ -1040,13 +1051,6 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['tenant', 'platform', 'serial'] -def device_status_choices(): - status_counts = {} - for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in DEVICE_STATUS_CHOICES] - - class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device q = forms.CharField(required=False, label='Search') @@ -1084,7 +1088,12 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_label='-- None --', ) - status = forms.MultipleChoiceField(choices=device_status_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=DEVICE_STATUS_CHOICES, + annotate=Device.objects.all(), + annotate_field='status', + required=False + ) mac_address = forms.CharField(required=False, label='MAC address') has_primary_ip = forms.NullBooleanField( required=False, @@ -1648,63 +1657,23 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): # Interfaces # -class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - label='VLAN site', - widget=forms.Select( - attrs={'filter-for': 'vlan_group', 'nullable': 'true'}, - ) - ) - vlan_group = ChainedModelChoiceField( - queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), - required=False, - label='VLAN group', - widget=APISelect( - attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'}, - api_url='/api/ipam/vlan-groups/?site_id={{site}}', - ) - ) - untagged_vlan = ChainedModelChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Untagged VLAN', - widget=APISelect( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - display_field='display_name' - ) - ) - tagged_vlans = ChainedModelMultipleChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Tagged VLANs', - widget=APISelectMultiple( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - display_field='display_name' - ) - ) +class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', - 'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans', + 'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', + 'mode', 'untagged_vlan', 'tagged_vlans', ] widgets = { 'device': forms.HiddenInput(), } + labels = { + 'mode': '802.1Q Mode', + } + help_texts = { + 'mode': INTERFACE_MODE_HELP_TEXT, + } def __init__(self, *args, **kwargs): super(InterfaceForm, self).__init__(*args, **kwargs) @@ -1721,58 +1690,108 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin): device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG ) - # Limit the queryset for the site to only include the interface's device's site - if device and device.site: - self.fields['site'].queryset = Site.objects.filter(pk=device.site.id) - self.fields['site'].initial = None - else: - self.fields['site'].queryset = Site.objects.none() - self.fields['site'].initial = None + def clean(self): - # Limit the initial vlan choices - if self.is_bound and self.data.get('vlan_group') and self.data.get('site'): - filter_dict = { - 'group_id': self.data.get('vlan_group'), - 'site_id': self.data.get('site'), - } - elif self.initial.get('untagged_vlan'): - filter_dict = { - 'group_id': self.instance.untagged_vlan.group, - 'site_id': self.instance.untagged_vlan.site, - } - elif self.initial.get('tagged_vlans'): - filter_dict = { - 'group_id': self.instance.tagged_vlans.first().group, - 'site_id': self.instance.tagged_vlans.first().site, - } - else: - filter_dict = { - 'group_id': None, - 'site_id': None, - } + super(InterfaceForm, self).clean() - self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict) - self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict) + # Validate VLAN assignments + tagged_vlans = self.cleaned_data['tagged_vlans'] - def clean_tagged_vlans(self): - """ - Because tagged_vlans is a many-to-many relationship, validation must be done in the form - """ - if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']: - raise forms.ValidationError( - "An Access interface cannot have tagged VLANs." + # Untagged interfaces cannot be assigned tagged VLANs + if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans: + raise forms.ValidationError({ + 'mode': "An access interface cannot have tagged VLANs assigned." + }) + + # Remove all tagged VLAN assignments from "tagged all" interfaces + elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL: + self.cleaned_data['tagged_vlans'] = [] + + +class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): + vlans = forms.MultipleChoiceField( + choices=[], + label='VLANs', + widget=forms.SelectMultiple(attrs={'size': 20}) + ) + tagged = forms.BooleanField( + required=False, + initial=True + ) + + class Meta: + model = Interface + fields = [] + + def __init__(self, *args, **kwargs): + + super(InterfaceAssignVLANsForm, self).__init__(*args, **kwargs) + + if self.instance.mode == IFACE_MODE_ACCESS: + self.initial['tagged'] = False + + # Find all VLANs already assigned to the interface for exclusion from the list + assigned_vlans = [v.pk for v in self.instance.tagged_vlans.all()] + if self.instance.untagged_vlan is not None: + assigned_vlans.append(self.instance.untagged_vlan.pk) + + # Compile VLAN choices + vlan_choices = [] + + # Add global VLANs + global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans) + vlan_choices.append(( + 'Global', [(vlan.pk, vlan) for vlan in global_vlans]) + ) + + # Add grouped global VLANs + for group in VLANGroup.objects.filter(site=None): + global_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) + vlan_choices.append( + (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) ) - if self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL and self.cleaned_data['tagged_vlans']: - raise forms.ValidationError( - "Interface mode Tagged All implies all VLANs are tagged. " - "Do not select any tagged VLANs." - ) + parent = self.instance.parent + if parent is not None: - return self.cleaned_data['tagged_vlans'] + # Add site VLANs + site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans) + vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans])) + + # Add grouped site VLANs + for group in VLANGroup.objects.filter(site=parent.site): + site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) + vlan_choices.append(( + '{} / {}'.format(group.site.name, group.name), + [(vlan.pk, vlan) for vlan in site_group_vlans] + )) + + self.fields['vlans'].choices = vlan_choices + + def clean(self): + + super(InterfaceAssignVLANsForm, self).clean() + + # Only untagged VLANs permitted on an access interface + if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1: + raise forms.ValidationError("Only one VLAN may be assigned to an access interface.") + + # 'tagged' is required if more than one VLAN is selected + if not self.cleaned_data['tagged'] and len(self.cleaned_data['vlans']) > 1: + raise forms.ValidationError("Only one untagged VLAN may be selected.") + + def save(self, *args, **kwargs): + + if self.cleaned_data['tagged']: + for vlan in self.cleaned_data['vlans']: + self.instance.tagged_vlans.add(vlan) + else: + self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0] + + return super(InterfaceAssignVLANsForm, self).save(*args, **kwargs) -class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin): +class InterfaceCreateForm(ComponentForm, forms.Form): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) enabled = forms.BooleanField(required=False) @@ -1786,50 +1805,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin): ) description = forms.CharField(max_length=100, required=False) mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - label='VLAN Site', - widget=forms.Select( - attrs={'filter-for': 'vlan_group', 'nullable': 'true'}, - ) - ) - vlan_group = ChainedModelChoiceField( - queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), - required=False, - label='VLAN group', - widget=APISelect( - attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'}, - api_url='/api/ipam/vlan-groups/?site_id={{site}}', - ) - ) - untagged_vlan = ChainedModelChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Untagged VLAN', - widget=APISelect( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - ) - ) - tagged_vlans = ChainedModelMultipleChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Tagged VLANs', - widget=APISelectMultiple( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - ) - ) def __init__(self, *args, **kwargs): @@ -1847,41 +1822,8 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin): else: self.fields['lag'].queryset = Interface.objects.none() - # Limit the queryset for the site to only include the interface's device's site - if self.parent is not None and self.parent.site: - self.fields['site'].queryset = Site.objects.filter(pk=self.parent.site.id) - self.fields['site'].initial = None - else: - self.fields['site'].queryset = Site.objects.none() - self.fields['site'].initial = None - # Limit the initial vlan choices - if self.is_bound and self.data.get('vlan_group') and self.data.get('site'): - filter_dict = { - 'group_id': self.data.get('vlan_group'), - 'site_id': self.data.get('site'), - } - elif self.initial.get('untagged_vlan'): - filter_dict = { - 'group_id': self.untagged_vlan.group, - 'site_id': self.untagged_vlan.site, - } - elif self.initial.get('tagged_vlans'): - filter_dict = { - 'group_id': self.tagged_vlans.first().group, - 'site_id': self.tagged_vlans.first().site, - } - else: - filter_dict = { - 'group_id': None, - 'site_id': None, - } - - self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict) - self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict) - - -class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin): +class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) @@ -1890,53 +1832,9 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin): mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only') description = forms.CharField(max_length=100, required=False) mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - label='VLAN Site', - widget=forms.Select( - attrs={'filter-for': 'vlan_group', 'nullable': 'true'}, - ) - ) - vlan_group = ChainedModelChoiceField( - queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), - required=False, - label='VLAN group', - widget=APISelect( - attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'}, - api_url='/api/ipam/vlan-groups/?site_id={{site}}', - ) - ) - untagged_vlan = ChainedModelChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Untagged VLAN', - widget=APISelect( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - ) - ) - tagged_vlans = ChainedModelMultipleChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Tagged VLANs', - widget=APISelectMultiple( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - ) - ) class Meta: - nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans'] + nullable_fields = ['lag', 'mtu', 'description', 'mode'] def __init__(self, *args, **kwargs): super(InterfaceBulkEditForm, self).__init__(*args, **kwargs) @@ -1951,28 +1849,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin): else: self.fields['lag'].choices = [] - # Limit the queryset for the site to only include the interface's device's site - if device and device.site: - self.fields['site'].queryset = Site.objects.filter(pk=device.site.id) - self.fields['site'].initial = None - else: - self.fields['site'].queryset = Site.objects.none() - self.fields['site'].initial = None - - if self.is_bound and self.data.get('vlan_group') and self.data.get('site'): - filter_dict = { - 'group_id': self.data.get('vlan_group'), - 'site_id': self.data.get('site'), - } - else: - filter_dict = { - 'group_id': None, - 'site_id': None, - } - - self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict) - self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict) - class InterfaceBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index cfae0d6d2..ac1affdef 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1455,6 +1455,18 @@ class Interface(models.Model): "device/VM, or it must be global".format(self.untagged_vlan) }) + def save(self, *args, **kwargs): + + # Remove untagged VLAN assignment for non-802.1Q interfaces + if self.mode is None: + self.untagged_vlan = None + + # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.) + if self.pk and self.mode is not IFACE_MODE_TAGGED: + self.tagged_vlans.clear() + + return super(Interface, self).save(*args, **kwargs) + @property def parent(self): return self.device or self.virtual_machine diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index aef6b3308..e71395ebc 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -47,8 +47,13 @@ REGION_ACTIONS = """ """ RACKGROUP_ACTIONS = """ + + + {% if perms.dcim.change_rackgroup %} - + + + {% endif %} """ @@ -128,6 +133,10 @@ SUBDEVICE_ROLE_TEMPLATE = """ {% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %} """ +DEVICETYPE_INSTANCES_TEMPLATE = """ +{{ record.instance_count }} +""" + UTILIZATION_GRAPH = """ {% load helpers %} {% utilization_graph value %} @@ -182,12 +191,21 @@ class SiteTable(BaseTable): class RackGroupTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(verbose_name='Name') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') - rack_count = tables.Column(verbose_name='Racks') - slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=RACKGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}}, - verbose_name='') + name = tables.LinkColumn() + site = tables.LinkColumn( + viewname='dcim:site', + args=[Accessor('site.slug')], + verbose_name='Site' + ) + rack_count = tables.Column( + verbose_name='Racks' + ) + slug = tables.Column() + actions = tables.TemplateColumn( + template_code=RACKGROUP_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) class Meta(BaseTable.Meta): model = RackGroup @@ -299,13 +317,23 @@ class ManufacturerTable(BaseTable): class DeviceTypeTable(BaseTable): pk = ToggleColumn() - model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type') + model = tables.LinkColumn( + viewname='dcim:devicetype', + args=[Accessor('pk')], + verbose_name='Device Type' + ) is_full_depth = tables.BooleanColumn(verbose_name='Full Depth') is_console_server = tables.BooleanColumn(verbose_name='CS') is_pdu = tables.BooleanColumn(verbose_name='PDU') is_network_device = tables.BooleanColumn(verbose_name='Net') - subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role') - instance_count = tables.Column(verbose_name='Instances') + subdevice_role = tables.TemplateColumn( + template_code=SUBDEVICE_ROLE_TEMPLATE, + verbose_name='Subdevice Role' + ) + instance_count = tables.TemplateColumn( + template_code=DEVICETYPE_INSTANCES_TEMPLATE, + verbose_name='Instances' + ) class Meta(BaseTable.Meta): model = DeviceType diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index ef17a8786..37743b499 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -5,7 +5,9 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase -from dcim.constants import IFACE_FF_1GE_FIXED, IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT +from dcim.constants import ( + IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, +) from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, @@ -2319,6 +2321,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase): data = { 'device': self.device.pk, 'name': 'Test Interface 4', + 'mode': IFACE_MODE_TAGGED, 'tagged_vlans': [self.vlan1.id, self.vlan2.id], 'untagged_vlan': self.vlan3.id } @@ -2366,18 +2369,21 @@ class InterfaceTest(HttpStatusMixin, APITestCase): { 'device': self.device.pk, 'name': 'Test Interface 4', + 'mode': IFACE_MODE_TAGGED, 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, }, { 'device': self.device.pk, 'name': 'Test Interface 5', + 'mode': IFACE_MODE_TAGGED, 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, }, { 'device': self.device.pk, 'name': 'Test Interface 6', + 'mode': IFACE_MODE_TAGGED, 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, }, diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index e7e1e41df..5682bd8e7 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -185,6 +185,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'), url(r'^interface-connections/(?P\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'), url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), + url(r'^interfaces/(?P\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), url(r'^interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8a8fb8d4c..84760348b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -962,11 +962,9 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): def get(self, request, pk): device = get_object_or_404(Device, pk=pk) - interfaces = Interface.objects.order_naturally( + interfaces = device.vc_interfaces.order_naturally( device.device_type.interface_ordering - ).connectable().filter( - device=device - ).select_related( + ).connectable().select_related( 'connected_as_a', 'connected_as_b' ) @@ -1645,6 +1643,12 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): template_name = 'dcim/interface_edit.html' +class InterfaceAssignVLANsView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_interface' + model = Interface + model_form = forms.InterfaceAssignVLANsForm + + class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interface' model = Interface @@ -2226,7 +2230,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi device = member_select_form.cleaned_data['device'] device.virtual_chassis = virtual_chassis data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']} - membership_form = forms.DeviceVCMembershipForm(data, validate_vc_position=True, instance=device) + membership_form = forms.DeviceVCMembershipForm(data=data, validate_vc_position=True, instance=device) if membership_form.is_valid(): @@ -2242,7 +2246,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi else: - membership_form = forms.DeviceVCMembershipForm(request.POST) + membership_form = forms.DeviceVCMembershipForm(data=request.POST) return render(request, 'dcim/virtualchassis_add_member.html', { 'virtual_chassis': virtual_chassis, diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 341405016..75945adcd 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -127,7 +127,7 @@ class CustomField(models.Model): """ Convert a string into the object it represents depending on the type of field """ - if serialized_value is '': + if serialized_value == '': return None if self.type == CF_TYPE_INTEGER: return int(serialized_value) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index c6d73e6f4..5b2c6e672 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -9,9 +9,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField, - ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField, - add_blank_choice, + AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, + CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, + SlugField, add_blank_choice, ) from virtualization.models import VirtualMachine from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES @@ -350,13 +350,6 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description'] -def prefix_status_choices(): - status_counts = {} - for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES] - - class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Prefix q = forms.CharField(required=False, label='Search') @@ -376,7 +369,12 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_label='-- None --' ) - status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=PREFIX_STATUS_CHOICES, + annotate=Prefix.objects.all(), + annotate_field='status', + required=False + ) site = FilterChoiceField( queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', @@ -688,20 +686,6 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): address = forms.CharField(label='IP Address') -def ipaddress_status_choices(): - status_counts = {} - for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES] - - -def ipaddress_role_choices(): - role_counts = {} - for role in IPAddress.objects.values('role').annotate(count=Count('role')).order_by('role'): - role_counts[role['role']] = role['count'] - return [(r[0], '{} ({})'.format(r[1], role_counts.get(r[0], 0))) for r in IPADDRESS_ROLE_CHOICES] - - class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): model = IPAddress q = forms.CharField(required=False, label='Search') @@ -721,8 +705,18 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_label='-- None --' ) - status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False) - role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=IPADDRESS_STATUS_CHOICES, + annotate=IPAddress.objects.all(), + annotate_field='status', + required=False + ) + role = AnnotatedMultipleChoiceField( + choices=IPADDRESS_ROLE_CHOICES, + annotate=IPAddress.objects.all(), + annotate_field='role', + required=False + ) # @@ -878,13 +872,6 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['site', 'group', 'tenant', 'role', 'description'] -def vlan_status_choices(): - status_counts = {} - for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES] - - class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VLAN q = forms.CharField(required=False, label='Search') @@ -903,7 +890,12 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_label='-- None --' ) - status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=VLAN_STATUS_CHOICES, + annotate=VLAN.objects.all(), + annotate_field='status', + required=False + ) role = FilterChoiceField( queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index d8e2aae97..9aea44229 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -6,6 +6,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.db.models import Q from django.db.models.expressions import RawSQL from django.urls import reverse from django.utils.encoding import python_2_unicode_compatible @@ -365,7 +366,8 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): child_prefixes = netaddr.IPSet([p.prefix for p in queryset]) return int(float(child_prefixes.size) / self.prefix.size * 100) else: - child_count = self.get_child_ips().count() + # Compile an IPSet to avoid counting duplicate IPs + child_count = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]).size prefix_size = self.prefix.size if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool: prefix_size -= 2 @@ -615,6 +617,13 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): def get_status_class(self): return STATUS_CHOICE_CLASSES[self.status] + def get_members(self): + # Return all interfaces assigned to this VLAN + return Interface.objects.filter( + Q(untagged_vlan_id=self.pk) | + Q(tagged_vlans=self.pk) + ) + @python_2_unicode_compatible class Service(CreatedUpdatedModel): diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index bfc65dacc..f0b05b3db 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import django_tables2 as tables from django_tables2.utils import Accessor +from dcim.models import Interface from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, ToggleColumn from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF @@ -138,6 +139,18 @@ VLANGROUP_ACTIONS = """ {% endif %} """ +VLAN_MEMBER_UNTAGGED = """ +{% if record.untagged_vlan_id == vlan.pk %} + +{% endif %} +""" + +VLAN_MEMBER_ACTIONS = """ +{% if perms.dcim.change_interface %} + +{% endif %} +""" + TENANT_LINK = """ {% if record.tenant %} {{ record.tenant }} @@ -361,3 +374,21 @@ class VLANDetailTable(VLANTable): class Meta(VLANTable.Meta): fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') + + +class VLANMemberTable(BaseTable): + parent = tables.LinkColumn(order_by=['device', 'virtual_machine']) + name = tables.Column(verbose_name='Interface') + untagged = tables.TemplateColumn( + template_code=VLAN_MEMBER_UNTAGGED, + orderable=False + ) + actions = tables.TemplateColumn( + template_code=VLAN_MEMBER_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = Interface + fields = ('parent', 'name', 'untagged', 'actions') diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 383b13d8f..aa7c17a5c 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -80,6 +80,7 @@ urlpatterns = [ url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), url(r'^vlans/(?P\d+)/$', views.VLANView.as_view(), name='vlan'), + url(r'^vlans/(?P\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'), url(r'^vlans/(?P\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'), url(r'^vlans/(?P\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 18e7ff7e5..5c8ce68b6 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -851,6 +851,38 @@ class VLANView(View): }) +class VLANMembersView(View): + + def get(self, request, pk): + + vlan = get_object_or_404(VLAN.objects.all(), pk=pk) + members = vlan.get_members().select_related('device', 'virtual_machine') + + members_table = tables.VLANMemberTable(members) + # if request.user.has_perm('dcim.change_interface'): + # members_table.columns.show('pk') + + paginate = { + 'klass': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(members_table) + + # Compile permissions list for rendering the object table + # permissions = { + # 'add': request.user.has_perm('ipam.add_ipaddress'), + # 'change': request.user.has_perm('ipam.change_ipaddress'), + # 'delete': request.user.has_perm('ipam.delete_ipaddress'), + # } + + return render(request, 'ipam/vlan_members.html', { + 'vlan': vlan, + 'members_table': members_table, + # 'permissions': permissions, + # 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix), + }) + + class VLANCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_vlan' model = VLAN diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index d79dc6ca5..52cd80169 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.3.1' +VERSION = '2.3.2' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -133,7 +133,6 @@ INSTALLED_APPS = ( 'django_tables2', 'mptt', 'rest_framework', - 'rest_framework_swagger', 'timezone_field', 'circuits', 'dcim', @@ -144,6 +143,7 @@ INSTALLED_APPS = ( 'users', 'utilities', 'virtualization', + 'drf_yasg', ) # Middleware @@ -246,6 +246,32 @@ REST_FRAMEWORK = { 'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name', } +# drf_yasg settings for Swagger +SWAGGER_SETTINGS = { + 'DEFAULT_FIELD_INSPECTORS': [ + 'utilities.custom_inspectors.NullableBooleanFieldInspector', + 'utilities.custom_inspectors.CustomChoiceFieldInspector', + 'drf_yasg.inspectors.CamelCaseJSONFilter', + 'drf_yasg.inspectors.ReferencingSerializerInspector', + 'drf_yasg.inspectors.RelatedFieldInspector', + 'drf_yasg.inspectors.ChoiceFieldInspector', + 'drf_yasg.inspectors.FileFieldInspector', + 'drf_yasg.inspectors.DictFieldInspector', + 'drf_yasg.inspectors.SimpleFieldInspector', + 'drf_yasg.inspectors.StringDefaultFieldInspector', + ], + 'DEFAULT_FILTER_INSPECTORS': [ + 'utilities.custom_inspectors.IdInFilterInspector', + 'drf_yasg.inspectors.CoreAPICompatInspector', + ], + 'DEFAULT_PAGINATOR_INSPECTORS': [ + 'utilities.custom_inspectors.NullablePaginatorInspector', + 'drf_yasg.inspectors.DjangoRestResponsePagination', + 'drf_yasg.inspectors.CoreAPICompatInspector', + ] +} + + # Django debug toolbar INTERNAL_IPS = ( '127.0.0.1', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 6cd7a9e8d..5f7b26a71 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -4,12 +4,24 @@ from django.conf import settings from django.conf.urls import include, url from django.contrib import admin from django.views.static import serve -from rest_framework_swagger.views import get_swagger_view +from drf_yasg.views import get_schema_view +from drf_yasg import openapi from netbox.views import APIRootView, HomeView, SearchView from users.views import LoginView, LogoutView -swagger_view = get_swagger_view(title='NetBox API') +schema_view = get_schema_view( + openapi.Info( + title="NetBox API", + default_version='v2', + description="API to access NetBox", + terms_of_service="https://github.com/digitalocean/netbox", + contact=openapi.Contact(email="netbox@digitalocean.com"), + license=openapi.License(name="Apache v2 License"), + ), + validators=['flex', 'ssv'], + public=True, +) _patterns = [ @@ -40,7 +52,9 @@ _patterns = [ url(r'^api/secrets/', include('secrets.api.urls')), url(r'^api/tenancy/', include('tenancy.api.urls')), url(r'^api/virtualization/', include('virtualization.api.urls')), - url(r'^api/docs/', swagger_view, name='api_docs'), + url(r'^api/docs/$', schema_view.with_ui('swagger', cache_timeout=None), name='api_docs'), + url(r'^api/redoc/$', schema_view.with_ui('redoc', cache_timeout=None), name='api_redocs'), + url(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(cache_timeout=None), name='schema_swagger'), # Serving static media in Django to pipe it through LoginRequiredMiddleware url(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index bcc79e2a5..8f8107805 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -58,17 +58,34 @@ class SecretRoleCSVForm(forms.ModelForm): # class SecretForm(BootstrapMixin, forms.ModelForm): - plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext', - widget=forms.PasswordInput(attrs={'class': 'requires-session-key'})) - plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)', - widget=forms.PasswordInput()) + plaintext = forms.CharField( + max_length=65535, + required=False, + label='Plaintext', + widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}) + ) + plaintext2 = forms.CharField( + max_length=65535, + required=False, + label='Plaintext (verify)', + widget=forms.PasswordInput() + ) class Meta: model = Secret fields = ['role', 'name', 'plaintext', 'plaintext2'] + def __init__(self, *args, **kwargs): + + super(SecretForm, self).__init__(*args, **kwargs) + + # A plaintext value is required when creating a new Secret + if not self.instance.pk: + self.fields['plaintext'].required = True + def clean(self): + # Verify that the provided plaintext values match if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']: raise forms.ValidationError({ 'plaintext2': "The two given plaintext values do not match. Please check your input." diff --git a/netbox/templates/dcim/inc/interface_vlans_table.html b/netbox/templates/dcim/inc/interface_vlans_table.html new file mode 100644 index 000000000..863921b0d --- /dev/null +++ b/netbox/templates/dcim/inc/interface_vlans_table.html @@ -0,0 +1,55 @@ + + + + + + + + {% with tagged_vlans=obj.tagged_vlans.all %} + {% if obj.untagged_vlan and obj.untagged_vlan not in tagged_vlans %} + + + + + + + {% endif %} + {% for vlan in tagged_vlans %} + + + + + + + {% endfor %} + {% if not obj.untagged_vlan and not tagged_vlans %} + + + + {% else %} + + + + + + {% endif %} + {% endwith %} +
VIDNameUntaggedTagged
+ {{ obj.untagged_vlan.vid }} + {{ obj.untagged_vlan.name }} + + + +
+ {{ vlan.vid }} + {{ vlan.name }} + + + +
+ No VLANs assigned +
+ Clear + + Clear All +
diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 648d73151..0e212cf3e 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -13,16 +13,44 @@ {% render_field form.mtu %} {% render_field form.mgmt_only %} {% render_field form.description %} - - -
-
802.1Q Encapsulation
-
{% render_field form.mode %} - {% render_field form.site %} - {% render_field form.vlan_group %} - {% render_field form.untagged_vlan %} - {% render_field form.tagged_vlans %}
+ {% if obj.mode %} +
+
802.1Q VLANs
+ {% include 'dcim/inc/interface_vlans_table.html' %} + +
+ {% endif %} +{% endblock %} + +{% block buttons %} + {% if obj.pk %} + + + {% else %} + + + {% endif %} + Cancel +{% endblock %} + +{% block javascript %} + {% endblock %} diff --git a/netbox/templates/ipam/inc/vlan_header.html b/netbox/templates/ipam/inc/vlan_header.html new file mode 100644 index 000000000..bf5d4ccdd --- /dev/null +++ b/netbox/templates/ipam/inc/vlan_header.html @@ -0,0 +1,46 @@ +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.ipam.change_vlan %} + + + Edit this VLAN + + {% endif %} + {% if perms.ipam.delete_vlan %} + + + Delete this VLAN + + {% endif %} +
+

{% block title %}VLAN {{ vlan.display_name }}{% endblock %}

+{% include 'inc/created_updated.html' with obj=vlan %} + diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index ae353e823..971c3359f 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -1,48 +1,7 @@ {% extends '_base.html' %} {% block content %} -
-
- -
-
-
-
- - - - -
-
-
-
-
- {% if perms.ipam.change_vlan %} - - - Edit this VLAN - - {% endif %} - {% if perms.ipam.delete_vlan %} - - - Delete this VLAN - - {% endif %} -
-

{% block title %}VLAN {{ vlan.display_name }}{% endblock %}

-{% include 'inc/created_updated.html' with obj=vlan %} +{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
diff --git a/netbox/templates/ipam/vlan_members.html b/netbox/templates/ipam/vlan_members.html new file mode 100644 index 000000000..27d5d50f7 --- /dev/null +++ b/netbox/templates/ipam/vlan_members.html @@ -0,0 +1,12 @@ +{% extends '_base.html' %} + +{% block title %}{{ vlan }} - Members{% endblock %} + +{% block content %} + {% include 'ipam/inc/vlan_header.html' with active_tab='members' %} +
+
+ {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %} +
+
+{% endblock %} diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html index 2b24208fd..16acc32ed 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/netbox/templates/utilities/obj_edit.html @@ -31,13 +31,15 @@
- {% if obj.pk %} - - {% else %} - - - {% endif %} - Cancel + {% block buttons %} + {% if obj.pk %} + + {% else %} + + + {% endif %} + Cancel + {% endblock %}
diff --git a/netbox/templates/virtualization/interface_edit.html b/netbox/templates/virtualization/interface_edit.html new file mode 100644 index 000000000..b3aa38fd3 --- /dev/null +++ b/netbox/templates/virtualization/interface_edit.html @@ -0,0 +1,53 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Interface
+
+ {% render_field form.name %} + {% render_field form.enabled %} + {% render_field form.mac_address %} + {% render_field form.mtu %} + {% render_field form.description %} + {% render_field form.mode %} +
+
+ {% if obj.mode %} +
+
802.1Q VLANs
+ {% include 'dcim/inc/interface_vlans_table.html' %} + +
+ {% endif %} +{% endblock %} + +{% block buttons %} + {% if obj.pk %} + + + {% else %} + + + {% endif %} + Cancel +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py new file mode 100644 index 000000000..b97506b85 --- /dev/null +++ b/netbox/utilities/custom_inspectors.py @@ -0,0 +1,76 @@ +from drf_yasg import openapi +from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector +from rest_framework.fields import ChoiceField + +from extras.api.customfields import CustomFieldsSerializer +from utilities.api import ChoiceFieldSerializer + + +class CustomChoiceFieldInspector(FieldInspector): + def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): + # this returns a callable which extracts title, description and other stuff + # https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types + SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) + + if isinstance(field, ChoiceFieldSerializer): + value_schema = openapi.Schema(type=openapi.TYPE_INTEGER) + + choices = list(field._choices.keys()) + if set([None] + choices) == {None, True, False}: + # DeviceType.subdevice_role, Device.face and InterfaceConnection.connection_status all need to be + # differentiated since they each have subtly different values in their choice keys. + # - subdevice_role and connection_status are booleans, although subdevice_role includes None + # - face is an integer set {0, 1} which is easily confused with {False, True} + schema_type = openapi.TYPE_INTEGER + if all(type(x) == bool for x in [c for c in choices if c is not None]): + schema_type = openapi.TYPE_BOOLEAN + value_schema = openapi.Schema(type=schema_type) + value_schema['x-nullable'] = True + + schema = SwaggerType(type=openapi.TYPE_OBJECT, required=["label", "value"], properties={ + "label": openapi.Schema(type=openapi.TYPE_STRING), + "value": value_schema + }) + + return schema + + elif isinstance(field, CustomFieldsSerializer): + schema = SwaggerType(type=openapi.TYPE_OBJECT) + return schema + + return NotHandled + + +class NullableBooleanFieldInspector(FieldInspector): + def process_result(self, result, method_name, obj, **kwargs): + + if isinstance(result, openapi.Schema) and isinstance(obj, ChoiceField) and result.type == 'boolean': + keys = obj.choices.keys() + if set(keys) == {None, True, False}: + result['x-nullable'] = True + result.type = 'boolean' + + return result + + +class IdInFilterInspector(FilterInspector): + def process_result(self, result, method_name, obj, **kwargs): + if isinstance(result, list): + params = [p for p in result if isinstance(p, openapi.Parameter) and p.name == 'id__in'] + for p in params: + p.type = 'string' + + return result + + +class NullablePaginatorInspector(PaginatorInspector): + def process_result(self, result, method_name, obj, **kwargs): + if method_name == 'get_paginated_response' and isinstance(result, openapi.Schema): + next = result.properties['next'] + if isinstance(next, openapi.Schema): + next['x-nullable'] = True + previous = result.properties['previous'] + if isinstance(previous, openapi.Schema): + previous['x-nullable'] = True + + return result diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index a2bfef001..15fb69f7f 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -6,6 +6,7 @@ import re from django import forms from django.conf import settings +from django.db.models import Count from django.urls import reverse_lazy from mptt.forms import TreeNodeMultipleChoiceField @@ -38,6 +39,7 @@ COLOR_CHOICES = ( ('111111', 'Black'), ) NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]' +ALPHANUMERIC_EXPANSION_PATTERN = '\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]' IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]' IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]' @@ -76,6 +78,45 @@ def expand_numeric_pattern(string): yield "{}{}{}".format(lead, i, remnant) +def parse_alphanumeric_range(string): + """ + Expand an alphanumeric range (continuous or not) into a list. + 'a-d,f' => [a, b, c, d, f] + '0-3,a-d' => [0, 1, 2, 3, a, b, c, d] + """ + values = [] + for dash_range in string.split(','): + try: + begin, end = dash_range.split('-') + vals = begin + end + # Break out of loop if there's an invalid pattern to return an error + if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())): + return [] + except ValueError: + begin, end = dash_range, dash_range + if begin.isdigit() and end.isdigit(): + for n in list(range(int(begin), int(end) + 1)): + values.append(n) + else: + for n in list(range(ord(begin), ord(end) + 1)): + values.append(chr(n)) + return values + + +def expand_alphanumeric_pattern(string): + """ + Expand an alphabetic pattern into a list of strings. + """ + lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1) + parsed_range = parse_alphanumeric_range(pattern) + for i in parsed_range: + if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant): + for string in expand_alphanumeric_pattern(remnant): + yield "{}{}{}".format(lead, i, string) + else: + yield "{}{}{}".format(lead, i, remnant) + + def expand_ipaddress_pattern(string, family): """ Expand an IP address pattern into a list of strings. Examples: @@ -305,12 +346,15 @@ class ExpandableNameField(forms.CharField): def __init__(self, *args, **kwargs): super(ExpandableNameField, self).__init__(*args, **kwargs) if not self.help_text: - self.help_text = 'Numeric ranges are supported for bulk creation.
'\ - 'Example: ge-0/0/[0-23,25,30]' + self.help_text = 'Alphanumeric ranges are supported for bulk creation.
' \ + 'Mixed cases and types within a single range are not supported.
' \ + 'Examples:
  • ge-0/0/[0-23,25,30]
  • ' \ + '
  • e[0-3][a-d,f]
  • ' \ + '
  • e[0-3,a-d,f]
' def to_python(self, value): - if re.search(NUMERIC_EXPANSION_PATTERN, value): - return list(expand_numeric_pattern(value)) + if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): + return list(expand_alphanumeric_pattern(value)) return [value] @@ -450,6 +494,38 @@ class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultiple pass +class AnnotatedMultipleChoiceField(forms.MultipleChoiceField): + """ + Render a set of static choices with each choice annotated to include a count of related objects. For example, this + field can be used to display a list of all available device statuses along with the number of devices currently + assigned to each status. + """ + + def annotate_choices(self): + queryset = self.annotate.values( + self.annotate_field + ).annotate( + count=Count(self.annotate_field) + ).order_by( + self.annotate_field + ) + choice_counts = { + c[self.annotate_field]: c['count'] for c in queryset + } + annotated_choices = [ + (c[0], '{} ({})'.format(c[1], choice_counts.get(c[0], 0))) for c in self.static_choices + ] + + return annotated_choices + + def __init__(self, choices, annotate, annotate_field, *args, **kwargs): + self.annotate = annotate + self.annotate_field = annotate_field + self.static_choices = choices + + super(AnnotatedMultipleChoiceField, self).__init__(choices=self.annotate_choices, *args, **kwargs) + + class LaxURLField(forms.URLField): """ Modifies Django's built-in URLField in two ways: diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 06b992203..4dfea1b42 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -5,7 +5,8 @@ from django.core.exceptions import ValidationError from django.db.models import Count from mptt.forms import TreeNodeChoiceField -from dcim.constants import IFACE_FF_VIRTUAL +from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL +from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.formfields import MACAddressFormField from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm @@ -13,9 +14,9 @@ from ipam.models import IPAddress from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, + AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm, - ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, + ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, add_blank_choice ) from .constants import VM_STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -361,13 +362,6 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] -def vm_status_choices(): - status_counts = {} - for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VM_STATUS_CHOICES] - - class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VirtualMachine q = forms.CharField(required=False, label='Search') @@ -395,7 +389,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_label='-- None --' ) - status = forms.MultipleChoiceField(choices=vm_status_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=VM_STATUS_CHOICES, + annotate=VirtualMachine.objects.all(), + annotate_field='status', + required=False + ) tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')), to_field_name='slug', @@ -416,11 +415,37 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface - fields = ['virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description'] + fields = [ + 'virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + 'untagged_vlan', 'tagged_vlans', + ] widgets = { 'virtual_machine': forms.HiddenInput(), 'form_factor': forms.HiddenInput(), } + labels = { + 'mode': '802.1Q Mode', + } + help_texts = { + 'mode': INTERFACE_MODE_HELP_TEXT, + } + + def clean(self): + + super(InterfaceForm, self).clean() + + # Validate VLAN assignments + tagged_vlans = self.cleaned_data['tagged_vlans'] + + # Untagged interfaces cannot be assigned tagged VLANs + if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans: + raise forms.ValidationError({ + 'mode': "An access interface cannot have tagged VLANs assigned." + }) + + # Remove all tagged VLAN assignments from "tagged all" interfaces + elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL: + self.cleaned_data['tagged_vlans'] = [] class InterfaceCreateForm(ComponentForm): diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index d8c168a7a..0a6abc400 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -283,7 +283,6 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): else: return None + @property def site(self): - # used when a child compent (eg Interface) needs to know its parent's site but - # the parent could be either a device or a virtual machine return self.cluster.site diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 82267fc00..6de6b86c7 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -329,6 +329,7 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_interface' model = Interface model_form = forms.InterfaceForm + template_name = 'virtualization/interface_edit.html' class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): diff --git a/old_requirements.txt b/old_requirements.txt index 610ec6c44..b3f7b3c47 100644 --- a/old_requirements.txt +++ b/old_requirements.txt @@ -1,2 +1,3 @@ +django-rest-swagger psycopg2 pycrypto diff --git a/requirements.txt b/requirements.txt index 89c880815..5b7b3e73e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,10 +3,10 @@ django-cors-headers>=2.1.0 django-debug-toolbar>=1.9.0 django-filter>=1.1.0 django-mptt>=0.9.0 -django-rest-swagger>=2.1.0 django-tables2>=1.19.0 django-timezone-field>=2.0 djangorestframework>=3.7.7 +drf-yasg[validation]>=1.4.4 graphviz>=0.8.2 Markdown>=2.6.11 natsort>=5.2.0