mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Extend DeviceType import to include related objects
This commit is contained in:
37
CHANGELOG.md
37
CHANGELOG.md
@ -1,3 +1,22 @@
|
||||
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
|
||||
|
||||
* [#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
|
||||
|
||||
v2.6.3 (2019-09-04)
|
||||
|
||||
## New Features
|
||||
@ -8,15 +27,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
|
||||
@ -27,6 +37,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)
|
||||
|
13
README.md
13
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.
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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.
|
||||
|
@ -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,6 +132,22 @@ DATABASE = {
|
||||
}
|
||||
```
|
||||
|
||||
## REDIS
|
||||
|
||||
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
|
||||
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.
|
||||
@ -140,21 +157,6 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
|
||||
!!! 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
|
||||
|
||||
If you have opted to enable the webhooks, set `WEBHOOKS_ENABLED = True` and define the relevant `REDIS` database parameters. Below is an example:
|
||||
|
||||
```python
|
||||
WEBHOOKS_ENABLED = True
|
||||
REDIS = {
|
||||
'HOST': 'localhost',
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
}
|
||||
```
|
||||
|
||||
# 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):
|
||||
|
@ -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.
|
||||
"""
|
||||
|
@ -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:
|
||||
|
@ -480,7 +480,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)
|
||||
@ -502,7 +502,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()
|
||||
|
@ -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.
|
||||
"""
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
@ -55,6 +57,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.
|
||||
@ -789,6 +810,7 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
|
||||
slug = SlugField(
|
||||
slug_source='model'
|
||||
)
|
||||
comments = CommentField()
|
||||
tags = TagField(
|
||||
required=False
|
||||
)
|
||||
@ -832,15 +854,85 @@ class DeviceTypeCSVForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class InterfaceTemplateImportForm(BootstrapMixin, forms.ModelForm):
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name'
|
||||
class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
def clean_device_type(self):
|
||||
|
||||
data = self.cleaned_data['device_type']
|
||||
|
||||
# Limit fields referencing other components to the parent DeviceType
|
||||
for field_name, field in self.fields.items():
|
||||
if isinstance(field, forms.ModelChoiceField) and not field_name == 'device_type':
|
||||
field.queryset = field.queryset.filter(device_type=data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePortTemplate
|
||||
fields = [
|
||||
'device_type', 'name',
|
||||
]
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = [
|
||||
'device_type', 'name',
|
||||
]
|
||||
|
||||
|
||||
class PowerPortTemplateImportForm(ComponentTemplateImportForm):
|
||||
|
||||
class Meta:
|
||||
model = PowerPortTemplate
|
||||
fields = [
|
||||
'device_type', 'name', 'maximum_draw', 'allocated_draw',
|
||||
]
|
||||
|
||||
|
||||
class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
|
||||
power_port = forms.ModelChoiceField(
|
||||
queryset=PowerPortTemplate.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
fields = [
|
||||
'device_type', 'name', 'power_port', 'feed_leg',
|
||||
]
|
||||
|
||||
|
||||
class InterfaceTemplateImportForm(ComponentTemplateImportForm):
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = [
|
||||
'type', 'mgmt_only',
|
||||
'device_type', 'name', 'type', 'mgmt_only',
|
||||
]
|
||||
|
||||
|
||||
class FrontPortTemplateImportForm(ComponentTemplateImportForm):
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
fields = [
|
||||
'device_type', 'name', 'type', 'rear_port', 'rear_port_position',
|
||||
]
|
||||
|
||||
|
||||
class RearPortTemplateImportForm(ComponentTemplateImportForm):
|
||||
|
||||
class Meta:
|
||||
model = RearPortTemplate
|
||||
fields = [
|
||||
'device_type', 'name', 'type', 'positions',
|
||||
]
|
||||
|
||||
|
||||
@ -849,18 +941,61 @@ class DeviceTypeImportForm(forms.ModelForm):
|
||||
queryset=Manufacturer.objects.all(),
|
||||
to_field_name='name'
|
||||
)
|
||||
console_ports = MultiObjectField(
|
||||
form=ConsolePortTemplateImportForm,
|
||||
required=False
|
||||
)
|
||||
console_server_ports = MultiObjectField(
|
||||
form=ConsoleServerPortTemplateImportForm,
|
||||
required=False
|
||||
)
|
||||
power_ports = MultiObjectField(
|
||||
form=PowerPortTemplateImportForm,
|
||||
required=False
|
||||
)
|
||||
power_outlets = MultiObjectField(
|
||||
form=PowerOutletTemplateImportForm,
|
||||
required=False
|
||||
)
|
||||
interfaces = MultiObjectField(
|
||||
form=InterfaceTemplateImportForm,
|
||||
required=False
|
||||
)
|
||||
front_ports = MultiObjectField(
|
||||
form=FrontPortTemplateImportForm,
|
||||
required=False
|
||||
)
|
||||
rear_ports = MultiObjectField(
|
||||
form=RearPortTemplateImportForm,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
'interfaces',
|
||||
]
|
||||
|
||||
def save(self, commit=True):
|
||||
|
||||
instance = super().save(commit)
|
||||
|
||||
if commit:
|
||||
|
||||
# Save related components
|
||||
for field_name, field in self.fields.items():
|
||||
if isinstance(field, MultiObjectField):
|
||||
for data in self.cleaned_data[field_name]:
|
||||
data.update({
|
||||
'device_type': instance.pk
|
||||
})
|
||||
print(data)
|
||||
form = field.form(data)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
@ -1252,7 +1387,9 @@ class DeviceRoleCSVForm(forms.ModelForm):
|
||||
#
|
||||
|
||||
class PlatformForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
slug = SlugField(
|
||||
max_length=64
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
@ -1366,7 +1503,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
|
||||
@ -1706,7 +1846,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',
|
||||
@ -2139,7 +2279,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
|
||||
)
|
||||
@ -2178,112 +2337,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'
|
||||
)
|
||||
@ -2327,6 +2412,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):
|
||||
|
||||
@ -2344,8 +2447,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])
|
||||
)
|
||||
|
||||
class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
site = getattr(self.parent, '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 InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@ -2389,10 +2522,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):
|
||||
@ -2408,6 +2559,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(
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
@ -1385,11 +1385,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',
|
||||
|
@ -210,7 +210,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}),
|
||||
|
@ -17,7 +17,8 @@ from django.utils.text import slugify
|
||||
from django.views.generic import View
|
||||
|
||||
from circuits.models import Circuit
|
||||
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||
from extras.constants import GRAPH_TYPE_DEVICE, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||
from extras.models import Graph, TopologyMap
|
||||
from extras.views import ObjectConfigContextView
|
||||
from ipam.models import Prefix, VLAN
|
||||
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
|
||||
@ -980,9 +981,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,
|
||||
@ -997,7 +995,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(),
|
||||
})
|
||||
|
||||
|
||||
@ -1356,12 +1355,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
|
||||
|
@ -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'),
|
||||
)
|
||||
|
@ -207,6 +207,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',
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.6.4-dev'
|
||||
VERSION = '2.6.5-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
@ -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']
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
@ -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 %}
|
||||
|
@ -297,6 +297,7 @@ class APISelect(SelectWithDisabled):
|
||||
conditional_query_params=None,
|
||||
additional_query_params=None,
|
||||
null_option=False,
|
||||
full=False,
|
||||
*args,
|
||||
**kwargs
|
||||
):
|
||||
@ -305,6 +306,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:
|
||||
@ -380,7 +383,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:
|
||||
@ -480,7 +483,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">'\
|
||||
@ -563,10 +566,9 @@ class MultiObjectField(forms.Field):
|
||||
|
||||
def clean(self, value):
|
||||
|
||||
for obj in value:
|
||||
subform = self.form(obj)
|
||||
if not subform.is_valid():
|
||||
raise forms.ValidationError(mark_safe(subform.errors.items()))
|
||||
# Value needs to be an iterable
|
||||
if value is None:
|
||||
return list()
|
||||
|
||||
return value
|
||||
|
||||
|
@ -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
|
||||
|
@ -9,6 +9,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
|
||||
@ -433,8 +434,8 @@ class ObjectImportView(GetReturnURLMixin, View):
|
||||
|
||||
if model_form.is_valid():
|
||||
|
||||
obj = model_form.save(commit=False)
|
||||
# assert False, model_form.cleaned_data['interfaces']
|
||||
with transaction.atomic():
|
||||
obj = model_form.save()
|
||||
|
||||
messages.success(request, "Imported object: {}".format(obj))
|
||||
return redirect(self.get_return_url(request))
|
||||
@ -588,9 +589,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()
|
||||
|
@ -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:
|
||||
|
Reference in New Issue
Block a user