Jeremy Stretch 6 лет назад
Родитель
Сommit
56dcadb69b

+ 28 - 11
CHANGELOG.md

@@ -4,9 +4,26 @@ v2.7.0 (FUTURE)
 
 * [#2902](https://github.com/digitalocean/netbox/issues/2902) - Replace supervisord with systemd
 
-## Housekeeping
+---
+
+v2.6.4 (2019-09-19)
+
+## Enhancements
+
+* [#2160](https://github.com/netbox-community/netbox/issues/2160) - Add bulk editing for interface VLAN assignment
+* [#3027](https://github.com/netbox-community/netbox/issues/3028) - Add `local_context_data` boolean filter for devices
+* [#3318](https://github.com/netbox-community/netbox/issues/3318) - Increase length of platform name and slug to 100 characters
+* [#3341](https://github.com/netbox-community/netbox/issues/3341) - Enable inline VLAN assignment while editing an interface
+* [#3485](https://github.com/netbox-community/netbox/issues/3485) - Enable embedded graphs for devices
+* [#3510](https://github.com/netbox-community/netbox/issues/3510) - Add minimum/maximum prefix length enforcement for `IPNetworkVar`
+
+## Bug Fixes
 
-* [#3407](https://github.com/netbox-community/netbox/issues/3407) - Added code coverage reporting to the CI pipeline
+* [#3489](https://github.com/netbox-community/netbox/issues/3489) - Prevent exception triggered by webhook upon object deletion
+* [#3501](https://github.com/netbox-community/netbox/issues/3501) - Fix rendering of checkboxes on custom script forms
+* [#3511](https://github.com/netbox-community/netbox/issues/3511) - Correct API URL for nested device bays
+* [#3513](https://github.com/netbox-community/netbox/issues/3513) - Fix assignment of tags when creating front/rear ports
+* [#3514](https://github.com/netbox-community/netbox/issues/3514) - Label TextVar fields when rendering custom script forms
 
 ---
 
@@ -20,15 +37,6 @@ Custom scripts allow for the execution of arbitrary code via the NetBox UI. They
 
 Note: There are currently no API endpoints for this feature. These are planned for the upcoming v2.7 release.
 
-## Bug Fixes
-
-* [#3392](https://github.com/netbox-community/netbox/issues/3392) - Add database index for ObjectChange time
-* [#3420](https://github.com/netbox-community/netbox/issues/3420) - Serial number filter for racks, devices, and inventory items is now case-insensitive
-* [#3428](https://github.com/netbox-community/netbox/issues/3428) - Fixed cache invalidation issues ([#3300](https://github.com/netbox-community/netbox/issues/3300), [#3363](https://github.com/netbox-community/netbox/issues/3363), [#3379](https://github.com/netbox-community/netbox/issues/3379), [#3382](https://github.com/netbox-community/netbox/issues/3382)) by switching to `prefetch_related()` instead of `select_related()` and removing use of `update()`
-* [#3421](https://github.com/netbox-community/netbox/issues/3421) - Fix exception when ordering power connections list by PDU
-* [#3424](https://github.com/netbox-community/netbox/issues/3424) - Fix tag coloring for non-linked tags
-* [#3426](https://github.com/netbox-community/netbox/issues/3426) - Improve API error handling for ChoiceFields
-
 ## Enhancements
 
 * [#3386](https://github.com/netbox-community/netbox/issues/3386) - Add `mac_address` filter for virtual machines
@@ -39,6 +47,15 @@ Note: There are currently no API endpoints for this feature. These are planned f
 * [#3454](https://github.com/netbox-community/netbox/issues/3454) - Enable filtering circuits by region
 * [#3456](https://github.com/netbox-community/netbox/issues/3456) - Enable bulk editing of tag color
 
+## Bug Fixes
+
+* [#3392](https://github.com/netbox-community/netbox/issues/3392) - Add database index for ObjectChange time
+* [#3420](https://github.com/netbox-community/netbox/issues/3420) - Serial number filter for racks, devices, and inventory items is now case-insensitive
+* [#3428](https://github.com/netbox-community/netbox/issues/3428) - Fixed cache invalidation issues ([#3300](https://github.com/netbox-community/netbox/issues/3300), [#3363](https://github.com/netbox-community/netbox/issues/3363), [#3379](https://github.com/netbox-community/netbox/issues/3379), [#3382](https://github.com/netbox-community/netbox/issues/3382)) by switching to `prefetch_related()` instead of `select_related()` and removing use of `update()`
+* [#3421](https://github.com/netbox-community/netbox/issues/3421) - Fix exception when ordering power connections list by PDU
+* [#3424](https://github.com/netbox-community/netbox/issues/3424) - Fix tag coloring for non-linked tags
+* [#3426](https://github.com/netbox-community/netbox/issues/3426) - Improve API error handling for ChoiceFields
+
 ---
 
 v2.6.2 (2019-08-02)

+ 1 - 12
README.md

@@ -43,15 +43,4 @@ and run `upgrade.sh`.
 
 # Related projects
 
-## Supported SDK
-
-- [pynetbox](https://github.com/digitalocean/pynetbox) - A Python API client library for Netbox
-
-## Community SDK
-
-- [netbox-client-ruby](https://github.com/ninech/netbox-client-ruby) - A Ruby client library for Netbox
-- [powerbox](https://github.com/BatmanAMA/powerbox) - A PowerShell library for Netbox
-
-## Ansible Inventory
-
-- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) - Ansible dynamic inventory script for Netbox
+Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for a list of relevant community projects.

+ 9 - 1
docs/additional-features/custom-links.md

@@ -24,11 +24,19 @@ Only links which render with non-empty text are included on the page. You can em
 For example, if you only want to display a link for active devices, you could set the link text to
 
 ```
-{% if device.status == 1 %}View NMS{% endif %}
+{% if obj.status == 1 %}View NMS{% endif %}
 ```
 
 The link will not appear when viewing a device with any status other than "active."
 
+Another example, if you want to only show an object of a certain manufacturer, you could set the link text to:
+
+```
+{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS {% endif %}
+```
+
+The link will only appear when viewing a device with a manufacturer name of "Cisco."
+
 ## Link Groups
 
 You can specify a group name to organize links into related sets. Grouped links will render as a dropdown menu beneath a

+ 4 - 2
docs/api/overview.md

@@ -1,5 +1,3 @@
-NetBox v2.0 and later includes a full-featured REST API that allows its data model to be read and manipulated externally.
-
 # What is a REST API?
 
 REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP to create, retrieve, update, and delete objects from a database. (This set of operations is commonly referred to as CRUD.) Each type of operation is associated with a particular HTTP verb:
@@ -34,6 +32,10 @@ $ curl -s http://localhost:8000/api/ipam/ip-addresses/2954/ | jq '.'
 
 Each attribute of the NetBox object is expressed as a field in the dictionary. Fields may include their own nested objects, as in the case of the `status` field above. Every object includes a primary key named `id` which uniquely identifies it in the database.
 
+# Interactive Documentation
+
+Comprehensive, interactive documentation of all API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with NetBox's various API endpoints and different request types.
+
 # URL Hierarchy
 
 NetBox's entire API is housed under the API root at `https://<hostname>/api/`. The URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example, the provider and circuit objects are located under the "circuits" application:

+ 22 - 22
docs/configuration/required-settings.md

@@ -16,11 +16,11 @@ ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
 
 NetBox requires access to a PostgreSQL database service to store data. This service can run locally or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
 
-* NAME - Database name
-* USER - PostgreSQL username
-* PASSWORD - PostgreSQL password
-* HOST - Name or IP address of the database server (use `localhost` if running locally)
-* PORT - TCP port of the PostgreSQL service; leave blank for default port (5432)
+* `NAME` - Database name
+* `USER` - PostgreSQL username
+* `PASSWORD` - PostgreSQL password
+* `HOST` - Name or IP address of the database server (use `localhost` if running locally)
+* `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432)
 
 Example:
 
@@ -36,16 +36,6 @@ DATABASE = {
 
 ---
 
-## SECRET_KEY
-
-This is a secret cryptographic key is used to improve the security of cookies and password resets. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions.
-
-Please note that this key is **not** used for hashing user passwords or for the encrypted storage of secret data in NetBox.
-
-`SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `netbox/generate_secret_key.py` may be used to generate a suitable key.
-
----
-
 ## REDIS
 
 [Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of
@@ -54,13 +44,13 @@ functionality (as well as other planned features).
 
 Redis is configured using a configuration setting similar to `DATABASE`:
 
-* HOST - Name or IP address of the Redis server (use `localhost` if running locally)
-* PORT - TCP port of the Redis service; leave blank for default port (6379)
-* PASSWORD - Redis password (if set)
-* DATABASE - Numeric database ID for webhooks
-* CACHE_DATABASE - Numeric database ID for caching
-* DEFAULT_TIMEOUT - Connection timeout in seconds
-* SSL - Use SSL connection to Redis
+* `HOST` - Name or IP address of the Redis server (use `localhost` if running locally)
+* `PORT` - TCP port of the Redis service; leave blank for default port (6379)
+* `PASSWORD` - Redis password (if set)
+* `DATABASE` - Numeric database ID for webhooks
+* `CACHE_DATABASE` - Numeric database ID for caching
+* `DEFAULT_TIMEOUT` - Connection timeout in seconds
+* `SSL` - Use SSL connection to Redis
 
 Example:
 
@@ -84,3 +74,13 @@ REDIS = {
 !!! warning:
     It is highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result in webhook
     processing data being lost in cache flushing events.
+
+---
+
+## SECRET_KEY
+
+This is a secret cryptographic key is used to improve the security of cookies and password resets. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions.
+
+Please note that this key is **not** used for hashing user passwords or for the encrypted storage of secret data in NetBox.
+
+`SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `netbox/generate_secret_key.py` may be used to generate a suitable key.

+ 18 - 16
docs/installation/2-netbox.md

@@ -101,9 +101,10 @@ Move into the NetBox configuration directory and make a copy of `configuration.e
 
 Open `configuration.py` with your preferred editor and set the following variables:
 
-* ALLOWED_HOSTS
-* DATABASE
-* SECRET_KEY
+* `ALLOWED_HOSTS`
+* `DATABASE`
+* `REDIS`
+* `SECRET_KEY`
 
 ## ALLOWED_HOSTS
 
@@ -117,7 +118,7 @@ ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
 
 ## DATABASE
 
-This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address.
+This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address. See the [configuration documentation](../configuration/required-settings/#database) for more detail on individual parameters.
 
 Example:
 
@@ -131,30 +132,31 @@ DATABASE = {
 }
 ```
 
-## SECRET_KEY
-
-Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system.
-
-You may use the script located at `netbox/generate_secret_key.py` to generate a suitable key.
-
-!!! note
-    In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
-
-## Webhooks Configuration
+## REDIS
 
-If you have opted to enable the webhooks, set `WEBHOOKS_ENABLED = True` and define the relevant `REDIS` database parameters. Below is an example:
+Redis is a in-memory key-value store required as part of the NetBox installation. It is used for features such as webhooks and caching. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-settings/#redis) for more detail on individual parameters.
 
 ```python
-WEBHOOKS_ENABLED = True
 REDIS = {
     'HOST': 'localhost',
     'PORT': 6379,
     'PASSWORD': '',
     'DATABASE': 0,
+    'CACHE_DATABASE': 1,
     'DEFAULT_TIMEOUT': 300,
+    'SSL': False,
 }
 ```
 
+## SECRET_KEY
+
+Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system.
+
+You may use the script located at `netbox/generate_secret_key.py` to generate a suitable key.
+
+!!! note
+    In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
+
 # Run Database Migrations
 
 Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):

+ 1 - 1
netbox/circuits/api/views.py

@@ -35,7 +35,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
     filterset_class = filters.ProviderFilter
 
     @action(detail=True)
-    def graphs(self, request, pk=None):
+    def graphs(self, request, pk):
         """
         A convenience method for rendering graphs for a particular provider.
         """

+ 1 - 1
netbox/dcim/api/nested_serializers.py

@@ -228,7 +228,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
 
 
 class NestedDeviceBaySerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
     device = NestedDeviceSerializer(read_only=True)
 
     class Meta:

+ 2 - 2
netbox/dcim/api/serializers.py

@@ -476,7 +476,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
         return super().validate(data)
 
 
-class RearPortSerializer(ValidatedModelSerializer):
+class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PORT_TYPE_CHOICES)
     cable = NestedCableSerializer(read_only=True)
@@ -498,7 +498,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name']
 
 
-class FrontPortSerializer(ValidatedModelSerializer):
+class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer):
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PORT_TYPE_CHOICES)
     rear_port = FrontPortRearPortSerializer()

+ 15 - 3
netbox/dcim/api/views.py

@@ -23,7 +23,8 @@ from dcim.models import (
 )
 from extras.api.serializers import RenderedGraphSerializer
 from extras.api.views import CustomFieldModelViewSet
-from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
+from extras.constants import GRAPH_TYPE_DEVICE, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
+from extras.models import Graph
 from ipam.models import Prefix, VLAN
 from utilities.api import (
     get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable,
@@ -123,7 +124,7 @@ class SiteViewSet(CustomFieldModelViewSet):
     filterset_class = filters.SiteFilter
 
     @action(detail=True)
-    def graphs(self, request, pk=None):
+    def graphs(self, request, pk):
         """
         A convenience method for rendering graphs for a particular site.
         """
@@ -346,6 +347,17 @@ class DeviceViewSet(CustomFieldModelViewSet):
 
         return serializers.DeviceWithConfigContextSerializer
 
+    @action(detail=True)
+    def graphs(self, request, pk):
+        """
+        A convenience method for rendering graphs for a particular Device.
+        """
+        device = get_object_or_404(Device, pk=pk)
+        queryset = Graph.objects.filter(type=GRAPH_TYPE_DEVICE)
+        serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device})
+
+        return Response(serializer.data)
+
     @action(detail=True, url_path='napalm')
     def napalm(self, request, pk):
         """
@@ -458,7 +470,7 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
     filterset_class = filters.InterfaceFilter
 
     @action(detail=True)
-    def graphs(self, request, pk=None):
+    def graphs(self, request, pk):
         """
         A convenience method for rendering graphs for a particular interface.
         """

+ 2 - 2
netbox/dcim/filters.py

@@ -3,7 +3,7 @@ from django.contrib.auth.models import User
 from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import Q
 
-from extras.filters import CustomFieldFilterSet
+from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.models import Tenant
 from utilities.constants import COLOR_CHOICES
@@ -424,7 +424,7 @@ class PlatformFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug', 'napalm_driver']
 
 
-class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
+class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet):
     id__in = NumericInFilter(
         field_name='id',
         lookup_expr='in'

+ 158 - 90
netbox/dcim/forms.py

@@ -13,7 +13,9 @@ from taggit.forms import TagField
 from timezone_field import TimeZoneFormField
 
 from circuits.models import Circuit, Provider
-from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
+from extras.forms import (
+    AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm, LocalConfigContextFilterForm
+)
 from ipam.models import IPAddress, VLAN, VLANGroup
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyFilterForm
@@ -54,6 +56,25 @@ def get_device_by_name_or_pk(name):
     return device
 
 
+class InterfaceCommonForm:
+    def clean(self):
+
+        super().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 BulkRenameForm(forms.Form):
     """
     An extendable form to be used for renaming device components in bulk.
@@ -788,6 +809,7 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
     slug = SlugField(
         slug_source='model'
     )
+    comments = CommentField()
     tags = TagField(
         required=False
     )
@@ -1221,7 +1243,9 @@ class DeviceRoleCSVForm(forms.ModelForm):
 #
 
 class PlatformForm(BootstrapMixin, forms.ModelForm):
-    slug = SlugField()
+    slug = SlugField(
+        max_length=64
+    )
 
     class Meta:
         model = Platform
@@ -1335,7 +1359,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     )
     comments = CommentField()
     tags = TagField(required=False)
-    local_context_data = JSONField(required=False)
+    local_context_data = JSONField(
+        required=False,
+        label=''
+    )
 
     class Meta:
         model = Device
@@ -1675,7 +1702,7 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         ]
 
 
-class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
+class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm):
     model = Device
     field_order = [
         'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant',
@@ -2108,7 +2135,26 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
 # Interfaces
 #
 
-class InterfaceForm(BootstrapMixin, forms.ModelForm):
+class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
+    untagged_vlan = forms.ModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/vlans/",
+            display_field='display_name',
+            full=True
+        )
+    )
+    tagged_vlans = forms.ModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/ipam/vlans/",
+            display_field='display_name',
+            full=True
+        )
+    )
+
     tags = TagField(
         required=False
     )
@@ -2147,112 +2193,38 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
                 device__in=[self.instance.device, self.instance.device.get_vc_master()], type=IFACE_TYPE_LAG
             )
 
-    def clean(self):
-
-        super().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 InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
-    vlans = forms.MultipleChoiceField(
-        choices=[],
-        label='VLANs',
-        widget=StaticSelect2Multiple(
-            attrs={
-                'size': 20,
-            }
-        )
-    )
-    tagged = forms.BooleanField(
-        required=False,
-        initial=True
-    )
-
-    class Meta:
-        model = Interface
-        fields = []
-
-    def __init__(self, *args, **kwargs):
-
-        super().__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
+        # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
         vlan_choices = []
-
-        # Add non-grouped global VLANs
-        global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
+        global_vlans = VLAN.objects.filter(site=None, group=None)
         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)
+            global_group_vlans = VLAN.objects.filter(group=group)
             vlan_choices.append(
                 (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
             )
 
-        site = getattr(self.instance.parent, 'site', None)
+        site = getattr(self.instance.device, 'site', None)
         if site is not None:
 
             # Add non-grouped site VLANs
-            site_vlans = VLAN.objects.filter(site=site, group=None).exclude(pk__in=assigned_vlans)
+            site_vlans = VLAN.objects.filter(site=site, group=None)
             vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
 
             # Add grouped site VLANs
             for group in VLANGroup.objects.filter(site=site):
-                site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
+                site_group_vlans = VLAN.objects.filter(group=group)
                 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().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().save(*args, **kwargs)
+        self.fields['untagged_vlan'].choices = vlan_choices
+        self.fields['tagged_vlans'].choices = vlan_choices
 
 
-class InterfaceCreateForm(ComponentForm, forms.Form):
+class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
     name_pattern = ExpandableNameField(
         label='Name'
     )
@@ -2296,6 +2268,24 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
     tags = TagField(
         required=False
     )
+    untagged_vlan = forms.ModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/vlans/",
+            display_field='display_name',
+            full=True
+        )
+    )
+    tagged_vlans = forms.ModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/ipam/vlans/",
+            display_field='display_name',
+            full=True
+        )
+    )
 
     def __init__(self, *args, **kwargs):
 
@@ -2313,8 +2303,38 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
         else:
             self.fields['lag'].queryset = Interface.objects.none()
 
+        # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
+        vlan_choices = []
+        global_vlans = VLAN.objects.filter(site=None, group=None)
+        vlan_choices.append(
+            ('Global', [(vlan.pk, vlan) for vlan in global_vlans])
+        )
+        for group in VLANGroup.objects.filter(site=None):
+            global_group_vlans = VLAN.objects.filter(group=group)
+            vlan_choices.append(
+                (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
+            )
+
+        site = getattr(self.parent, 'site', None)
+        if site is not None:
 
-class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+            # Add non-grouped site VLANs
+            site_vlans = VLAN.objects.filter(site=site, group=None)
+            vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
+
+            # Add grouped site VLANs
+            for group in VLANGroup.objects.filter(site=site):
+                site_group_vlans = VLAN.objects.filter(group=group)
+                vlan_choices.append((
+                    '{} / {}'.format(group.site.name, group.name),
+                    [(vlan.pk, vlan) for vlan in site_group_vlans]
+                ))
+
+        self.fields['untagged_vlan'].choices = vlan_choices
+        self.fields['tagged_vlans'].choices = vlan_choices
+
+
+class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Interface.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -2358,10 +2378,28 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         required=False,
         widget=StaticSelect2()
     )
+    untagged_vlan = forms.ModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/vlans/",
+            display_field='display_name',
+            full=True
+        )
+    )
+    tagged_vlans = forms.ModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/ipam/vlans/",
+            display_field='display_name',
+            full=True
+        )
+    )
 
     class Meta:
         nullable_fields = [
-            'lag', 'mac_address', 'mtu', 'description', 'mode',
+            'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
         ]
 
     def __init__(self, *args, **kwargs):
@@ -2377,6 +2415,36 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         else:
             self.fields['lag'].choices = []
 
+        # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
+        vlan_choices = []
+        global_vlans = VLAN.objects.filter(site=None, group=None)
+        vlan_choices.append(
+            ('Global', [(vlan.pk, vlan) for vlan in global_vlans])
+        )
+        for group in VLANGroup.objects.filter(site=None):
+            global_group_vlans = VLAN.objects.filter(group=group)
+            vlan_choices.append(
+                (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
+            )
+        if self.parent_obj is not None:
+            site = getattr(self.parent_obj, 'site', None)
+            if site is not None:
+
+                # Add non-grouped site VLANs
+                site_vlans = VLAN.objects.filter(site=site, group=None)
+                vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
+
+                # Add grouped site VLANs
+                for group in VLANGroup.objects.filter(site=site):
+                    site_group_vlans = VLAN.objects.filter(group=group)
+                    vlan_choices.append((
+                        '{} / {}'.format(group.site.name, group.name),
+                        [(vlan.pk, vlan) for vlan in site_group_vlans]
+                    ))
+
+        self.fields['untagged_vlan'].choices = vlan_choices
+        self.fields['tagged_vlans'].choices = vlan_choices
+
 
 class InterfaceBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(

+ 23 - 0
netbox/dcim/migrations/0074_increase_field_length_platform_name_slug.py

@@ -0,0 +1,23 @@
+# Generated by Django 2.2 on 2019-07-17 20:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0073_interface_form_factor_to_type'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='platform',
+            name='name',
+            field=models.CharField(max_length=100, unique=True),
+        ),
+        migrations.AlterField(
+            model_name='platform',
+            name='slug',
+            field=models.SlugField(max_length=100, unique=True),
+        ),
+    ]

+ 3 - 2
netbox/dcim/models.py

@@ -1369,11 +1369,12 @@ class Platform(ChangeLoggedModel):
     specifying a NAPALM driver.
     """
     name = models.CharField(
-        max_length=50,
+        max_length=100,
         unique=True
     )
     slug = models.SlugField(
-        unique=True
+        unique=True,
+        max_length=100
     )
     manufacturer = models.ForeignKey(
         to='dcim.Manufacturer',

+ 0 - 1
netbox/dcim/urls.py

@@ -209,7 +209,6 @@ urlpatterns = [
     path(r'interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
     path(r'interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
     path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
-    path(r'interfaces/<int:pk>/assign-vlans/', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
     path(r'interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
     path(r'interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
     path(r'interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),

+ 4 - 11
netbox/dcim/views.py

@@ -16,7 +16,8 @@ from django.utils.safestring import mark_safe
 from django.views.generic import View
 
 from circuits.models import Circuit
-from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
+from extras.constants import GRAPH_TYPE_DEVICE, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
+from extras.models import Graph
 from extras.views import ObjectConfigContextView
 from ipam.models import Prefix, VLAN
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
@@ -970,9 +971,6 @@ class DeviceView(PermissionRequiredMixin, View):
             'rack', 'device_type__manufacturer'
         )[:10]
 
-        # Show graph button on interfaces only if at least one graph has been created.
-        show_graphs = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists()
-
         return render(request, 'dcim/device.html', {
             'device': device,
             'console_ports': console_ports,
@@ -987,7 +985,8 @@ class DeviceView(PermissionRequiredMixin, View):
             'secrets': secrets,
             'vc_members': vc_members,
             'related_devices': related_devices,
-            'show_graphs': show_graphs,
+            'show_graphs': Graph.objects.filter(type=GRAPH_TYPE_DEVICE).exists(),
+            'show_interface_graphs': Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists(),
         })
 
 
@@ -1346,12 +1345,6 @@ 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

+ 2 - 0
netbox/extras/constants.py

@@ -88,10 +88,12 @@ BUTTON_CLASS_CHOICES = (
 
 # Graph types
 GRAPH_TYPE_INTERFACE = 100
+GRAPH_TYPE_DEVICE = 150
 GRAPH_TYPE_PROVIDER = 200
 GRAPH_TYPE_SITE = 300
 GRAPH_TYPE_CHOICES = (
     (GRAPH_TYPE_INTERFACE, 'Interface'),
+    (GRAPH_TYPE_DEVICE, 'Device'),
     (GRAPH_TYPE_PROVIDER, 'Provider'),
     (GRAPH_TYPE_SITE, 'Site'),
 )

+ 14 - 0
netbox/extras/filters.py

@@ -189,6 +189,20 @@ class ConfigContextFilter(django_filters.FilterSet):
         )
 
 
+#
+# Filter for Local Config Context Data
+#
+
+class LocalConfigContextFilter(django_filters.FilterSet):
+    local_context_data = django_filters.BooleanFilter(
+        method='_local_context_data',
+        label='Has local config context data',
+    )
+
+    def _local_context_data(self, queryset, name, value):
+        return queryset.exclude(local_context_data__isnull=value)
+
+
 class ObjectChangeFilter(django_filters.FilterSet):
     q = django_filters.CharFilter(
         method='search',

+ 19 - 2
netbox/extras/forms.py

@@ -11,7 +11,8 @@ from tenancy.models import Tenant, TenantGroup
 from utilities.constants import COLOR_CHOICES
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
-    CommentField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField,
+    CommentField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, StaticSelect2,
+    BOOLEAN_WITH_BLANK_CHOICES,
 )
 from .constants import (
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
@@ -240,7 +241,9 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm):
 #
 
 class ConfigContextForm(BootstrapMixin, forms.ModelForm):
-    data = JSONField()
+    data = JSONField(
+        label=''
+    )
 
     class Meta:
         model = ConfigContext
@@ -349,6 +352,20 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
     )
 
 
+#
+# Filter form for local config context data
+#
+
+class LocalConfigContextFilterForm(forms.Form):
+    local_context_data = forms.NullBooleanField(
+        required=False,
+        label='Has local config context data',
+        widget=StaticSelect2(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+
+
 #
 # Image attachments
 #

+ 22 - 12
netbox/extras/middleware.py

@@ -6,6 +6,7 @@ from datetime import timedelta
 from django.conf import settings
 from django.db.models.signals import post_delete, post_save
 from django.utils import timezone
+from django.utils.functional import curry
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 
 from .constants import (
@@ -18,10 +19,11 @@ from .webhooks import enqueue_webhooks
 _thread_locals = threading.local()
 
 
-def cache_changed_object(sender, instance, **kwargs):
+def handle_changed_object(sender, instance, **kwargs):
     """
-    Cache an object being created or updated for the changelog.
+    Fires when an object is created or updated
     """
+    # Queue the object and a new ObjectChange for processing once the request completes
     if hasattr(instance, 'to_objectchange'):
         action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
         objectchange = instance.to_objectchange(action)
@@ -30,15 +32,22 @@ def cache_changed_object(sender, instance, **kwargs):
         )
 
 
-def cache_deleted_object(sender, instance, **kwargs):
+def _handle_deleted_object(request, sender, instance, **kwargs):
     """
-    Cache an object being deleted for the changelog.
+    Fires when an object is deleted
     """
+    # Record an Object Change
     if hasattr(instance, 'to_objectchange'):
         objectchange = instance.to_objectchange(OBJECTCHANGE_ACTION_DELETE)
-        _thread_locals.changed_objects.append(
-            (instance, objectchange)
-        )
+        objectchange.user = request.user
+        objectchange.request_id = request.id
+        objectchange.save()
+
+    # Enqueue webhooks
+    enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
+
+    # Increment metric counters
+    model_deletes.labels(instance._meta.model_name).inc()
 
 
 def purge_objectchange_cache(sender, **kwargs):
@@ -54,7 +63,7 @@ class ObjectChangeMiddleware(object):
 
         1. Create an ObjectChange to reflect the modification to the object in the changelog.
         2. Enqueue any relevant webhooks.
-        3. Increment metric counter for the event type
+        3. Increment the metric counter for the event type.
 
     The post_save and post_delete signals are employed to catch object modifications, however changes are recorded a bit
     differently for each. Objects being saved are cached into thread-local storage for action *after* the response has
@@ -74,9 +83,12 @@ class ObjectChangeMiddleware(object):
         # the same request.
         request.id = uuid.uuid4()
 
+        # Signals don't include the request context, so we're currying it into the post_delete function ahead of time.
+        handle_deleted_object = curry(_handle_deleted_object, request)
+
         # Connect our receivers to the post_save and post_delete signals.
-        post_save.connect(cache_changed_object, dispatch_uid='cache_changed_object')
-        post_delete.connect(cache_deleted_object, dispatch_uid='cache_deleted_object')
+        post_save.connect(handle_changed_object, dispatch_uid='cache_changed_object')
+        post_delete.connect(handle_deleted_object, dispatch_uid='cache_deleted_object')
 
         # Provide a hook for purging the change cache
         purge_changelog.connect(purge_objectchange_cache)
@@ -104,8 +116,6 @@ class ObjectChangeMiddleware(object):
                 model_inserts.labels(obj._meta.model_name).inc()
             elif objectchange.action == OBJECTCHANGE_ACTION_UPDATE:
                 model_updates.labels(obj._meta.model_name).inc()
-            elif objectchange.action == OBJECTCHANGE_ACTION_DELETE:
-                model_deletes.labels(obj._meta.model_name).inc()
 
         # Housekeeping: 1% chance of clearing out expired ObjectChanges. This applies only to requests which result in
         # one or more changes being logged.

+ 18 - 1
netbox/extras/scripts.py

@@ -16,6 +16,7 @@ from mptt.models import MPTTModel
 
 from ipam.formfields import IPFormField
 from utilities.exceptions import AbortTransaction
+from utilities.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator
 from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
 from .forms import ScriptForm
 from .signals import purge_changelog
@@ -61,7 +62,8 @@ class ScriptVariable:
         Render the variable as a Django form field.
         """
         form_field = self.form_field(**self.field_attrs)
-        form_field.widget.attrs['class'] = 'form-control'
+        if not isinstance(form_field.widget, forms.CheckboxInput):
+            form_field.widget.attrs['class'] = 'form-control'
 
         return form_field
 
@@ -161,6 +163,21 @@ class IPNetworkVar(ScriptVariable):
     """
     form_field = IPFormField
 
+    def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self.field_attrs['validators'] = list()
+
+        # Optional minimum/maximum prefix lengths
+        if min_prefix_length is not None:
+            self.field_attrs['validators'].append(
+                MinPrefixLengthValidator(min_prefix_length)
+            )
+        if max_prefix_length is not None:
+            self.field_attrs['validators'].append(
+                MaxPrefixLengthValidator(max_prefix_length)
+            )
+
 
 #
 # Scripts

+ 67 - 11
netbox/project-static/js/forms.js

@@ -47,9 +47,10 @@ $(document).ready(function() {
     });
     if (slug_field) {
         var slug_source = $('#id_' + slug_field.attr('slug-source'));
+        var slug_length = slug_field.attr('maxlength');
         slug_source.on('keyup change', function() {
             if (slug_field && !slug_field.attr('_changed')) {
-                slug_field.val(slugify($(this).val(), 50));
+                slug_field.val(slugify($(this).val(), (slug_length ? slug_length : 50)));
             }
         })
     }
@@ -74,7 +75,7 @@ $(document).ready(function() {
         var rendered_url = url;
         var filter_field;
         while (match = filter_regex.exec(url)) {
-            filter_field = $('#id_' + match[1]);
+            filter_field = $('#id_' + match[1]);untagged
             var custom_attr = $('option:selected', filter_field).attr('api-value');
             if (custom_attr) {
                 rendered_url = rendered_url.replace(match[0], custom_attr);
@@ -143,11 +144,13 @@ $(document).ready(function() {
                 // Base query params
                 var parameters = {
                     q: params.term,
-                    brief: 1,
                     limit: 50,
                     offset: offset,
                 };
 
+                // Allow for controlling the brief setting from within APISelect
+                parameters.brief = ( $(element).is('[data-full]') ? undefined : true );
+
                 // filter-for fields from a chain
                 var attr_name = "data-filter-for-" + $(element).attr("name");
                 var form = $(element).closest('form');
@@ -194,18 +197,41 @@ $(document).ready(function() {
 
             processResults: function (data) {
                 var element = this.$element[0];
-                // Clear any disabled options
                 $(element).children('option').attr('disabled', false);
-                var results = $.map(data.results, function (obj) {
-                    obj.text = obj[element.getAttribute('display-field')] || obj.name;
-                    obj.id = obj[element.getAttribute('value-field')] || obj.id;
+                var results = data.results;
 
-                    if(element.getAttribute('disabled-indicator') && obj[element.getAttribute('disabled-indicator')]) {
+                results = results.reduce((results,record) => {
+                    record.text = record[element.getAttribute('display-field')] || record.name;
+                    record.id = record[element.getAttribute('value-field')] || record.id;
+                    if(element.getAttribute('disabled-indicator') && record[element.getAttribute('disabled-indicator')]) {
                         // The disabled-indicator equated to true, so we disable this option
-                        obj.disabled = true;
+                        record.disabled = true;
                     }
-                    return obj;
-                });
+
+                    if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) {
+                        results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] }
+                        results[record.site.name + ":" + record.group.name].children.push(record);
+                    }
+                    else if( record.group !== undefined && record.group !== null ) {
+                        results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] }
+                        results[record.group.name].children.push(record);
+                    }
+                    else if( record.site !== undefined && record.site !== null ) {
+                        results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] }
+                        results[record.site.name].children.push(record);
+                    }
+                    else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) {
+                        results['global'] = results['global'] || { text: 'Global', children: [] }
+                        results['global'].children.push(record);
+                    }
+                    else {
+                        results[record.id] = record
+                    }
+
+                    return results;
+                },Object.create(null));
+
+                results = Object.values(results);
 
                 // Handle the null option, but only add it once
                 if (element.getAttribute('data-null-option') && data.previous === null) {
@@ -300,4 +326,34 @@ $(document).ready(function() {
             $('#id_tags').append(option).trigger('change');
         }
     });
+
+    if( $('select#id_mode').length > 0 ) {
+        $('select#id_mode').on('change', function () {
+            if ($(this).val() == '') {
+                $('select#id_untagged_vlan').val();
+                $('select#id_untagged_vlan').trigger('change');
+                $('select#id_tagged_vlans').val([]);
+                $('select#id_tagged_vlans').trigger('change');
+                $('select#id_untagged_vlan').parent().parent().hide();
+                $('select#id_tagged_vlans').parent().parent().hide();
+            }
+            else if ($(this).val() == 100) {
+                $('select#id_tagged_vlans').val([]);
+                $('select#id_tagged_vlans').trigger('change');
+                $('select#id_untagged_vlan').parent().parent().show();
+                $('select#id_tagged_vlans').parent().parent().hide();
+            }
+            else if ($(this).val() == 200) {
+                $('select#id_untagged_vlan').parent().parent().show();
+                $('select#id_tagged_vlans').parent().parent().show();
+            }
+            else if ($(this).val() == 300) {
+                $('select#id_tagged_vlans').val([]);
+                $('select#id_tagged_vlans').trigger('change');
+                $('select#id_untagged_vlan').parent().parent().show();
+                $('select#id_tagged_vlans').parent().parent().hide();
+            }
+        });
+        $('select#id_mode').trigger('change');
+    }
 });

+ 3 - 0
netbox/secrets/forms.py

@@ -199,6 +199,9 @@ class UserKeyForm(BootstrapMixin, forms.ModelForm):
             'public_key': "Enter your public RSA key. Keep the private one with you; you'll need it for decryption. "
                           "Please note that passphrase-protected keys are not supported.",
         }
+        labels = {
+            'public_key': ''
+        }
 
     def clean_public_key(self):
         key = self.cleaned_data['public_key']

+ 6 - 0
netbox/templates/dcim/device.html

@@ -35,6 +35,12 @@
         </div>
     </div>
     <div class="pull-right noprint">
+        {% if show_graphs %}
+            <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }}" data-url="{% url 'dcim-api:device-graphs' pk=device.pk %}" title="Show graphs">
+                <i class="fa fa-signal" aria-hidden="true"></i>
+                Graphs
+            </button>
+        {% endif %}
         {% if perms.dcim.change_device %}
             <div class="btn-group">
                 <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">

+ 1 - 1
netbox/templates/dcim/inc/interface.html

@@ -135,7 +135,7 @@
 
     {# Buttons #}
     <td class="text-right text-nowrap noprint">
-        {% if show_graphs %}
+        {% if show_interface_graphs %}
             {% if iface.connected_endpoint %}
                 <button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface-graphs' pk=iface.pk %}" title="Show graphs">
                     <i class="glyphicon glyphicon-signal" aria-hidden="true"></i>

+ 3 - 31
netbox/templates/dcim/interface_edit.html

@@ -14,6 +14,8 @@
             {% render_field form.mgmt_only %}
             {% render_field form.description %}
             {% render_field form.mode %}
+            {% render_field form.untagged_vlan %}
+            {% render_field form.tagged_vlans %}
         </div>
     </div>
     <div class="panel panel-default">
@@ -22,21 +24,6 @@
             {% render_field form.tags %}
         </div>
     </div>
-    <div class="panel panel-default" id="vlans_panel">
-        <div class="panel-heading"><strong>802.1Q VLANs</strong></div>
-        {% if obj.mode %}
-            {% include 'dcim/inc/interface_vlans_table.html' %}
-            <div class="panel-footer text-right">
-                <a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
-                    <i class="glyphicon glyphicon-plus"></i> Add VLANs
-                </a>
-            </div>
-        {% else %}
-            <div class="panel-body text-center text-muted">
-                <p>802.1Q mode not set</p>
-            </div>
-        {% endif %}
-    </div>
 {% endblock %}
 
 {% block buttons %}
@@ -48,19 +35,4 @@
         <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
     {% endif %}
     <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
-{% endblock %}
-
-{% block javascript %}
-    <script type="text/javascript">
-        $(document).ready(function() {
-            $('#clear_untagged_vlan').click(function () {
-                $('input[name="untagged_vlan"]').prop("checked", false);
-                return false;
-            });
-            $('#clear_tagged_vlans').click(function () {
-                $('input[name="tagged_vlans"]').prop("checked", false);
-                return false;
-            });
-        });
-    </script>
-{% endblock %}
+{% endblock %}

+ 1 - 1
netbox/templates/utilities/render_field.html

@@ -24,7 +24,7 @@
                 </ul>
             {% endif %}
         </div>
-    {% elif field|widget_type == 'textarea' %}
+    {% elif field|widget_type == 'textarea' and not field.label %}
         <div class="col-md-12">
             {{ field }}
             {% if bulk_nullable %}

+ 5 - 2
netbox/utilities/forms.py

@@ -298,6 +298,7 @@ class APISelect(SelectWithDisabled):
         conditional_query_params=None,
         additional_query_params=None,
         null_option=False,
+        full=False,
         *args,
         **kwargs
     ):
@@ -306,6 +307,8 @@ class APISelect(SelectWithDisabled):
 
         self.attrs['class'] = 'netbox-select2-api'
         self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/'))  # Inject BASE_PATH
+        if full:
+            self.attrs['data-full'] = full
         if display_field:
             self.attrs['display-field'] = display_field
         if value_field:
@@ -381,7 +384,7 @@ class CSVDataField(forms.CharField):
 
         self.strip = False
         if not self.label:
-            self.label = 'CSV Data'
+            self.label = ''
         if not self.initial:
             self.initial = ','.join(required_fields) + '\n'
         if not self.help_text:
@@ -481,7 +484,7 @@ class CommentField(forms.CharField):
     A textarea with support for GitHub-Flavored Markdown. Exists mostly just to add a standard help_text.
     """
     widget = forms.Textarea
-    default_label = 'Comments'
+    default_label = ''
     # TODO: Port GFM syntax cheat sheet to internal documentation
     default_helptext = '<i class="fa fa-info-circle"></i> '\
                        '<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank">'\

+ 17 - 1
netbox/utilities/validators.py

@@ -1,6 +1,6 @@
 import re
 
-from django.core.validators import _lazy_re_compile, URLValidator
+from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
 
 
 class EnhancedURLValidator(URLValidator):
@@ -26,3 +26,19 @@ class EnhancedURLValidator(URLValidator):
         r'(?:[/?#][^\s]*)?'                 # Path
         r'\Z', re.IGNORECASE)
     schemes = AnyURLScheme()
+
+
+class MaxPrefixLengthValidator(BaseValidator):
+    message = 'The prefix length must be less than or equal to %(limit_value)s.'
+    code = 'max_prefix_length'
+
+    def compare(self, a, b):
+        return a.prefixlen > b
+
+
+class MinPrefixLengthValidator(BaseValidator):
+    message = 'The prefix length must be greater than or equal to %(limit_value)s.'
+    code = 'min_prefix_length'
+
+    def compare(self, a, b):
+        return a.prefixlen < b

+ 7 - 2
netbox/utilities/views.py

@@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.db import transaction, IntegrityError
 from django.db.models import Count, ProtectedError
+from django.db.models.query import QuerySet
 from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
 from django.http import HttpResponse, HttpResponseServerError
 from django.shortcuts import get_object_or_404, redirect, render
@@ -530,9 +531,13 @@ class BulkEditView(GetReturnURLMixin, View):
 
                             # Update standard fields. If a field is listed in _nullify, delete its value.
                             for name in standard_fields:
-                                if name in form.nullable_fields and name in nullified_fields:
+                                if name in form.nullable_fields and name in nullified_fields and isinstance(form.cleaned_data[name], QuerySet):
+                                    getattr(obj, name).set([])
+                                elif name in form.nullable_fields and name in nullified_fields:
                                     setattr(obj, name, '' if isinstance(form.fields[name], CharField) else None)
-                                elif form.cleaned_data[name] not in (None, ''):
+                                elif isinstance(form.cleaned_data[name], QuerySet) and form.cleaned_data[name]:
+                                    getattr(obj, name).set(form.cleaned_data[name])
+                                elif form.cleaned_data[name] not in (None, '') and not isinstance(form.cleaned_data[name], QuerySet):
                                     setattr(obj, name, form.cleaned_data[name])
                             obj.full_clean()
                             obj.save()

+ 3 - 4
netbox/virtualization/forms.py

@@ -79,9 +79,7 @@ class ClusterGroupCSVForm(forms.ModelForm):
 #
 
 class ClusterForm(BootstrapMixin, CustomFieldForm):
-    comments = CommentField(
-        widget=SmallTextarea()
-    )
+    comments = CommentField()
     tags = TagField(
         required=False
     )
@@ -331,7 +329,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         required=False
     )
     local_context_data = JSONField(
-        required=False
+        required=False,
+        label=''
     )
 
     class Meta: