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
 * [#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.
 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
 ## Enhancements
 
 
 * [#3386](https://github.com/netbox-community/netbox/issues/3386) - Add `mac_address` filter for virtual machines
 * [#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
 * [#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
 * [#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)
 v2.6.2 (2019-08-02)

+ 1 - 12
README.md

@@ -43,15 +43,4 @@ and run `upgrade.sh`.
 
 
 # Related projects
 # 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
 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."
 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
 ## Link Groups
 
 
 You can specify a group name to organize links into related sets. Grouped links will render as a dropdown menu beneath a
 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?
 # 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:
 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.
 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
 # 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:
 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:
 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:
 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
 
 
 [Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of
 [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`:
 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:
 Example:
 
 
@@ -84,3 +74,13 @@ REDIS = {
 !!! warning:
 !!! warning:
     It is highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result in webhook
     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.
     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:
 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
 ## ALLOWED_HOSTS
 
 
@@ -117,7 +118,7 @@ ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
 
 
 ## DATABASE
 ## 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:
 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
 ```python
-WEBHOOKS_ENABLED = True
 REDIS = {
 REDIS = {
     'HOST': 'localhost',
     'HOST': 'localhost',
     'PORT': 6379,
     'PORT': 6379,
     'PASSWORD': '',
     'PASSWORD': '',
     'DATABASE': 0,
     'DATABASE': 0,
+    'CACHE_DATABASE': 1,
     'DEFAULT_TIMEOUT': 300,
     '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
 # 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):
 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
     filterset_class = filters.ProviderFilter
 
 
     @action(detail=True)
     @action(detail=True)
-    def graphs(self, request, pk=None):
+    def graphs(self, request, pk):
         """
         """
         A convenience method for rendering graphs for a particular provider.
         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):
 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)
     device = NestedDeviceSerializer(read_only=True)
 
 
     class Meta:
     class Meta:

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

@@ -476,7 +476,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
         return super().validate(data)
         return super().validate(data)
 
 
 
 
-class RearPortSerializer(ValidatedModelSerializer):
+class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PORT_TYPE_CHOICES)
     type = ChoiceField(choices=PORT_TYPE_CHOICES)
     cable = NestedCableSerializer(read_only=True)
     cable = NestedCableSerializer(read_only=True)
@@ -498,7 +498,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name']
         fields = ['id', 'url', 'name']
 
 
 
 
-class FrontPortSerializer(ValidatedModelSerializer):
+class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=PORT_TYPE_CHOICES)
     type = ChoiceField(choices=PORT_TYPE_CHOICES)
     rear_port = FrontPortRearPortSerializer()
     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.serializers import RenderedGraphSerializer
 from extras.api.views import CustomFieldModelViewSet
 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 ipam.models import Prefix, VLAN
 from utilities.api import (
 from utilities.api import (
     get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable,
     get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable,
@@ -123,7 +124,7 @@ class SiteViewSet(CustomFieldModelViewSet):
     filterset_class = filters.SiteFilter
     filterset_class = filters.SiteFilter
 
 
     @action(detail=True)
     @action(detail=True)
-    def graphs(self, request, pk=None):
+    def graphs(self, request, pk):
         """
         """
         A convenience method for rendering graphs for a particular site.
         A convenience method for rendering graphs for a particular site.
         """
         """
@@ -346,6 +347,17 @@ class DeviceViewSet(CustomFieldModelViewSet):
 
 
         return serializers.DeviceWithConfigContextSerializer
         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')
     @action(detail=True, url_path='napalm')
     def napalm(self, request, pk):
     def napalm(self, request, pk):
         """
         """
@@ -458,7 +470,7 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
     filterset_class = filters.InterfaceFilter
     filterset_class = filters.InterfaceFilter
 
 
     @action(detail=True)
     @action(detail=True)
-    def graphs(self, request, pk=None):
+    def graphs(self, request, pk):
         """
         """
         A convenience method for rendering graphs for a particular interface.
         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.core.exceptions import ObjectDoesNotExist
 from django.db.models import Q
 from django.db.models import Q
 
 
-from extras.filters import CustomFieldFilterSet
+from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.constants import COLOR_CHOICES
 from utilities.constants import COLOR_CHOICES
@@ -424,7 +424,7 @@ class PlatformFilter(NameSlugSearchFilterSet):
         fields = ['id', 'name', 'slug', 'napalm_driver']
         fields = ['id', 'name', 'slug', 'napalm_driver']
 
 
 
 
-class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
+class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'

+ 158 - 90
netbox/dcim/forms.py

@@ -13,7 +13,9 @@ from taggit.forms import TagField
 from timezone_field import TimeZoneFormField
 from timezone_field import TimeZoneFormField
 
 
 from circuits.models import Circuit, Provider
 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 ipam.models import IPAddress, VLAN, VLANGroup
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
@@ -54,6 +56,25 @@ def get_device_by_name_or_pk(name):
     return device
     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):
 class BulkRenameForm(forms.Form):
     """
     """
     An extendable form to be used for renaming device components in bulk.
     An extendable form to be used for renaming device components in bulk.
@@ -788,6 +809,7 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
     slug = SlugField(
     slug = SlugField(
         slug_source='model'
         slug_source='model'
     )
     )
+    comments = CommentField()
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -1221,7 +1243,9 @@ class DeviceRoleCSVForm(forms.ModelForm):
 #
 #
 
 
 class PlatformForm(BootstrapMixin, forms.ModelForm):
 class PlatformForm(BootstrapMixin, forms.ModelForm):
-    slug = SlugField()
+    slug = SlugField(
+        max_length=64
+    )
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
@@ -1335,7 +1359,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     )
     )
     comments = CommentField()
     comments = CommentField()
     tags = TagField(required=False)
     tags = TagField(required=False)
-    local_context_data = JSONField(required=False)
+    local_context_data = JSONField(
+        required=False,
+        label=''
+    )
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
@@ -1675,7 +1702,7 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         ]
         ]
 
 
 
 
-class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
+class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm):
     model = Device
     model = Device
     field_order = [
     field_order = [
         'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant',
         'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant',
@@ -2108,7 +2135,26 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
 # Interfaces
 # 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(
     tags = TagField(
         required=False
         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
                 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 = []
         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(
         vlan_choices.append(
             ('Global', [(vlan.pk, vlan) for vlan in global_vlans])
             ('Global', [(vlan.pk, vlan) for vlan in global_vlans])
         )
         )
-
-        # Add grouped global VLANs
         for group in VLANGroup.objects.filter(site=None):
         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(
             vlan_choices.append(
                 (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
                 (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:
         if site is not None:
 
 
             # Add non-grouped site VLANs
             # 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]))
             vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
 
 
             # Add grouped site VLANs
             # Add grouped site VLANs
             for group in VLANGroup.objects.filter(site=site):
             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((
                 vlan_choices.append((
                     '{} / {}'.format(group.site.name, group.name),
                     '{} / {}'.format(group.site.name, group.name),
                     [(vlan.pk, vlan) for vlan in site_group_vlans]
                     [(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(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
@@ -2296,6 +2268,24 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
     tags = TagField(
     tags = TagField(
         required=False
         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):
     def __init__(self, *args, **kwargs):
 
 
@@ -2313,8 +2303,38 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
         else:
         else:
             self.fields['lag'].queryset = Interface.objects.none()
             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(
     pk = forms.ModelMultipleChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
@@ -2358,10 +2378,28 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         required=False,
         required=False,
         widget=StaticSelect2()
         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:
     class Meta:
         nullable_fields = [
         nullable_fields = [
-            'lag', 'mac_address', 'mtu', 'description', 'mode',
+            'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -2377,6 +2415,36 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         else:
         else:
             self.fields['lag'].choices = []
             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):
 class InterfaceBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(
     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.
     specifying a NAPALM driver.
     """
     """
     name = models.CharField(
     name = models.CharField(
-        max_length=50,
+        max_length=100,
         unique=True
         unique=True
     )
     )
     slug = models.SlugField(
     slug = models.SlugField(
-        unique=True
+        unique=True,
+        max_length=100
     )
     )
     manufacturer = models.ForeignKey(
     manufacturer = models.ForeignKey(
         to='dcim.Manufacturer',
         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: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>/', views.InterfaceView.as_view(), name='interface'),
     path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
     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>/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>/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}),
     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 django.views.generic import View
 
 
 from circuits.models import Circuit
 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 extras.views import ObjectConfigContextView
 from ipam.models import Prefix, VLAN
 from ipam.models import Prefix, VLAN
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
@@ -970,9 +971,6 @@ class DeviceView(PermissionRequiredMixin, View):
             'rack', 'device_type__manufacturer'
             'rack', 'device_type__manufacturer'
         )[:10]
         )[: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', {
         return render(request, 'dcim/device.html', {
             'device': device,
             'device': device,
             'console_ports': console_ports,
             'console_ports': console_ports,
@@ -987,7 +985,8 @@ class DeviceView(PermissionRequiredMixin, View):
             'secrets': secrets,
             'secrets': secrets,
             'vc_members': vc_members,
             'vc_members': vc_members,
             'related_devices': related_devices,
             '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'
     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):
 class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_interface'
     permission_required = 'dcim.delete_interface'
     model = Interface
     model = Interface

+ 2 - 0
netbox/extras/constants.py

@@ -88,10 +88,12 @@ BUTTON_CLASS_CHOICES = (
 
 
 # Graph types
 # Graph types
 GRAPH_TYPE_INTERFACE = 100
 GRAPH_TYPE_INTERFACE = 100
+GRAPH_TYPE_DEVICE = 150
 GRAPH_TYPE_PROVIDER = 200
 GRAPH_TYPE_PROVIDER = 200
 GRAPH_TYPE_SITE = 300
 GRAPH_TYPE_SITE = 300
 GRAPH_TYPE_CHOICES = (
 GRAPH_TYPE_CHOICES = (
     (GRAPH_TYPE_INTERFACE, 'Interface'),
     (GRAPH_TYPE_INTERFACE, 'Interface'),
+    (GRAPH_TYPE_DEVICE, 'Device'),
     (GRAPH_TYPE_PROVIDER, 'Provider'),
     (GRAPH_TYPE_PROVIDER, 'Provider'),
     (GRAPH_TYPE_SITE, 'Site'),
     (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):
 class ObjectChangeFilter(django_filters.FilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         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.constants import COLOR_CHOICES
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
     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 (
 from .constants import (
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     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):
 class ConfigContextForm(BootstrapMixin, forms.ModelForm):
-    data = JSONField()
+    data = JSONField(
+        label=''
+    )
 
 
     class Meta:
     class Meta:
         model = ConfigContext
         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
 # Image attachments
 #
 #

+ 22 - 12
netbox/extras/middleware.py

@@ -6,6 +6,7 @@ from datetime import timedelta
 from django.conf import settings
 from django.conf import settings
 from django.db.models.signals import post_delete, post_save
 from django.db.models.signals import post_delete, post_save
 from django.utils import timezone
 from django.utils import timezone
+from django.utils.functional import curry
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 
 
 from .constants import (
 from .constants import (
@@ -18,10 +19,11 @@ from .webhooks import enqueue_webhooks
 _thread_locals = threading.local()
 _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'):
     if hasattr(instance, 'to_objectchange'):
         action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
         action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
         objectchange = instance.to_objectchange(action)
         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'):
     if hasattr(instance, 'to_objectchange'):
         objectchange = instance.to_objectchange(OBJECTCHANGE_ACTION_DELETE)
         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):
 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.
         1. Create an ObjectChange to reflect the modification to the object in the changelog.
         2. Enqueue any relevant webhooks.
         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
     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
     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.
         # the same request.
         request.id = uuid.uuid4()
         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.
         # 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
         # Provide a hook for purging the change cache
         purge_changelog.connect(purge_objectchange_cache)
         purge_changelog.connect(purge_objectchange_cache)
@@ -104,8 +116,6 @@ class ObjectChangeMiddleware(object):
                 model_inserts.labels(obj._meta.model_name).inc()
                 model_inserts.labels(obj._meta.model_name).inc()
             elif objectchange.action == OBJECTCHANGE_ACTION_UPDATE:
             elif objectchange.action == OBJECTCHANGE_ACTION_UPDATE:
                 model_updates.labels(obj._meta.model_name).inc()
                 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
         # Housekeeping: 1% chance of clearing out expired ObjectChanges. This applies only to requests which result in
         # one or more changes being logged.
         # 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 ipam.formfields import IPFormField
 from utilities.exceptions import AbortTransaction
 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 .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
 from .forms import ScriptForm
 from .forms import ScriptForm
 from .signals import purge_changelog
 from .signals import purge_changelog
@@ -61,7 +62,8 @@ class ScriptVariable:
         Render the variable as a Django form field.
         Render the variable as a Django form field.
         """
         """
         form_field = self.form_field(**self.field_attrs)
         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
         return form_field
 
 
@@ -161,6 +163,21 @@ class IPNetworkVar(ScriptVariable):
     """
     """
     form_field = IPFormField
     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
 # Scripts

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

@@ -47,9 +47,10 @@ $(document).ready(function() {
     });
     });
     if (slug_field) {
     if (slug_field) {
         var slug_source = $('#id_' + slug_field.attr('slug-source'));
         var slug_source = $('#id_' + slug_field.attr('slug-source'));
+        var slug_length = slug_field.attr('maxlength');
         slug_source.on('keyup change', function() {
         slug_source.on('keyup change', function() {
             if (slug_field && !slug_field.attr('_changed')) {
             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 rendered_url = url;
         var filter_field;
         var filter_field;
         while (match = filter_regex.exec(url)) {
         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');
             var custom_attr = $('option:selected', filter_field).attr('api-value');
             if (custom_attr) {
             if (custom_attr) {
                 rendered_url = rendered_url.replace(match[0], custom_attr);
                 rendered_url = rendered_url.replace(match[0], custom_attr);
@@ -143,11 +144,13 @@ $(document).ready(function() {
                 // Base query params
                 // Base query params
                 var parameters = {
                 var parameters = {
                     q: params.term,
                     q: params.term,
-                    brief: 1,
                     limit: 50,
                     limit: 50,
                     offset: offset,
                     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
                 // filter-for fields from a chain
                 var attr_name = "data-filter-for-" + $(element).attr("name");
                 var attr_name = "data-filter-for-" + $(element).attr("name");
                 var form = $(element).closest('form');
                 var form = $(element).closest('form');
@@ -194,18 +197,41 @@ $(document).ready(function() {
 
 
             processResults: function (data) {
             processResults: function (data) {
                 var element = this.$element[0];
                 var element = this.$element[0];
-                // Clear any disabled options
                 $(element).children('option').attr('disabled', false);
                 $(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
                         // 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
                 // Handle the null option, but only add it once
                 if (element.getAttribute('data-null-option') && data.previous === null) {
                 if (element.getAttribute('data-null-option') && data.previous === null) {
@@ -300,4 +326,34 @@ $(document).ready(function() {
             $('#id_tags').append(option).trigger('change');
             $('#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. "
             '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.",
                           "Please note that passphrase-protected keys are not supported.",
         }
         }
+        labels = {
+            'public_key': ''
+        }
 
 
     def clean_public_key(self):
     def clean_public_key(self):
         key = self.cleaned_data['public_key']
         key = self.cleaned_data['public_key']

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

@@ -35,6 +35,12 @@
         </div>
         </div>
     </div>
     </div>
     <div class="pull-right noprint">
     <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 %}
         {% if perms.dcim.change_device %}
             <div class="btn-group">
             <div class="btn-group">
                 <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                 <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 #}
     {# Buttons #}
     <td class="text-right text-nowrap noprint">
     <td class="text-right text-nowrap noprint">
-        {% if show_graphs %}
+        {% if show_interface_graphs %}
             {% if iface.connected_endpoint %}
             {% 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">
                 <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>
                     <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.mgmt_only %}
             {% render_field form.description %}
             {% render_field form.description %}
             {% render_field form.mode %}
             {% render_field form.mode %}
+            {% render_field form.untagged_vlan %}
+            {% render_field form.tagged_vlans %}
         </div>
         </div>
     </div>
     </div>
     <div class="panel panel-default">
     <div class="panel panel-default">
@@ -22,21 +24,6 @@
             {% render_field form.tags %}
             {% render_field form.tags %}
         </div>
         </div>
     </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 %}
 {% endblock %}
 
 
 {% block buttons %}
 {% block buttons %}
@@ -48,19 +35,4 @@
         <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
         <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
     {% endif %}
     {% endif %}
     <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
     <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>
                 </ul>
             {% endif %}
             {% endif %}
         </div>
         </div>
-    {% elif field|widget_type == 'textarea' %}
+    {% elif field|widget_type == 'textarea' and not field.label %}
         <div class="col-md-12">
         <div class="col-md-12">
             {{ field }}
             {{ field }}
             {% if bulk_nullable %}
             {% if bulk_nullable %}

+ 5 - 2
netbox/utilities/forms.py

@@ -298,6 +298,7 @@ class APISelect(SelectWithDisabled):
         conditional_query_params=None,
         conditional_query_params=None,
         additional_query_params=None,
         additional_query_params=None,
         null_option=False,
         null_option=False,
+        full=False,
         *args,
         *args,
         **kwargs
         **kwargs
     ):
     ):
@@ -306,6 +307,8 @@ class APISelect(SelectWithDisabled):
 
 
         self.attrs['class'] = 'netbox-select2-api'
         self.attrs['class'] = 'netbox-select2-api'
         self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/'))  # Inject BASE_PATH
         self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/'))  # Inject BASE_PATH
+        if full:
+            self.attrs['data-full'] = full
         if display_field:
         if display_field:
             self.attrs['display-field'] = display_field
             self.attrs['display-field'] = display_field
         if value_field:
         if value_field:
@@ -381,7 +384,7 @@ class CSVDataField(forms.CharField):
 
 
         self.strip = False
         self.strip = False
         if not self.label:
         if not self.label:
-            self.label = 'CSV Data'
+            self.label = ''
         if not self.initial:
         if not self.initial:
             self.initial = ','.join(required_fields) + '\n'
             self.initial = ','.join(required_fields) + '\n'
         if not self.help_text:
         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.
     A textarea with support for GitHub-Flavored Markdown. Exists mostly just to add a standard help_text.
     """
     """
     widget = forms.Textarea
     widget = forms.Textarea
-    default_label = 'Comments'
+    default_label = ''
     # TODO: Port GFM syntax cheat sheet to internal documentation
     # TODO: Port GFM syntax cheat sheet to internal documentation
     default_helptext = '<i class="fa fa-info-circle"></i> '\
     default_helptext = '<i class="fa fa-info-circle"></i> '\
                        '<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank">'\
                        '<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
 import re
 
 
-from django.core.validators import _lazy_re_compile, URLValidator
+from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
 
 
 
 
 class EnhancedURLValidator(URLValidator):
 class EnhancedURLValidator(URLValidator):
@@ -26,3 +26,19 @@ class EnhancedURLValidator(URLValidator):
         r'(?:[/?#][^\s]*)?'                 # Path
         r'(?:[/?#][^\s]*)?'                 # Path
         r'\Z', re.IGNORECASE)
         r'\Z', re.IGNORECASE)
     schemes = AnyURLScheme()
     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.core.exceptions import ValidationError
 from django.db import transaction, IntegrityError
 from django.db import transaction, IntegrityError
 from django.db.models import Count, ProtectedError
 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.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
 from django.http import HttpResponse, HttpResponseServerError
 from django.http import HttpResponse, HttpResponseServerError
 from django.shortcuts import get_object_or_404, redirect, render
 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.
                             # Update standard fields. If a field is listed in _nullify, delete its value.
                             for name in standard_fields:
                             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)
                                     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])
                                     setattr(obj, name, form.cleaned_data[name])
                             obj.full_clean()
                             obj.full_clean()
                             obj.save()
                             obj.save()

+ 3 - 4
netbox/virtualization/forms.py

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