Merged v2.6.4

This commit is contained in:
Jeremy Stretch 2019-09-20 08:35:14 -04:00
commit 56dcadb69b
31 changed files with 482 additions and 248 deletions

View File

@ -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 ---
* [#3407](https://github.com/netbox-community/netbox/issues/3407) - Added code coverage reporting to the CI pipeline 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
--- ---
@ -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)

View File

@ -43,15 +43,4 @@ and run `upgrade.sh`.
# Related projects # Related projects
## Supported SDK Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for a list of relevant community projects.
- [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

View File

@ -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

View File

@ -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:

View File

@ -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 * `NAME` - Database name
* USER - PostgreSQL username * `USER` - PostgreSQL username
* PASSWORD - PostgreSQL password * `PASSWORD` - PostgreSQL password
* HOST - Name or IP address of the database server (use `localhost` if running locally) * `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) * `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) * `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) * `PORT` - TCP port of the Redis service; leave blank for default port (6379)
* PASSWORD - Redis password (if set) * `PASSWORD` - Redis password (if set)
* DATABASE - Numeric database ID for webhooks * `DATABASE` - Numeric database ID for webhooks
* CACHE_DATABASE - Numeric database ID for caching * `CACHE_DATABASE` - Numeric database ID for caching
* DEFAULT_TIMEOUT - Connection timeout in seconds * `DEFAULT_TIMEOUT` - Connection timeout in seconds
* SSL - Use SSL connection to Redis * `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.

View File

@ -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 * `ALLOWED_HOSTS`
* DATABASE * `DATABASE`
* SECRET_KEY * `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,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 ## 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. 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 !!! 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. 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 # 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):

View File

@ -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.
""" """

View File

@ -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:

View File

@ -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()

View File

@ -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.
""" """

View File

@ -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'

View File

@ -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): # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
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
vlan_choices = [] vlan_choices = []
global_vlans = VLAN.objects.filter(site=None, group=None)
# Add non-grouped global VLANs
global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
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 self.fields['untagged_vlan'].choices = vlan_choices
self.fields['tagged_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)
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])
)
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( 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(

View File

@ -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),
),
]

View File

@ -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',

View File

@ -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}),

View File

@ -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

View File

@ -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'),
) )

View File

@ -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',

View File

@ -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
# #

View File

@ -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( objectchange.user = request.user
(instance, objectchange) 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_save.connect(handle_changed_object, dispatch_uid='cache_changed_object')
post_delete.connect(cache_deleted_object, dispatch_uid='cache_deleted_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.

View File

@ -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,6 +62,7 @@ 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)
if not isinstance(form_field.widget, forms.CheckboxInput):
form_field.widget.attrs['class'] = 'form-control' 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

View File

@ -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) { var results = data.results;
obj.text = obj[element.getAttribute('display-field')] || obj.name;
obj.id = obj[element.getAttribute('value-field')] || obj.id;
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');
}
}); });

View File

@ -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']

View File

@ -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">

View File

@ -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>

View File

@ -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 %}
@ -49,18 +36,3 @@
{% endif %} {% endif %}
<a href="{{ return_url }}" class="btn btn-default">Cancel</a> <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
{% endblock %} {% 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 %}

View File

@ -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 %}

View File

@ -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">'\

View File

@ -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

View File

@ -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()

View File

@ -79,9 +79,7 @@ class ClusterGroupCSVForm(forms.ModelForm):
# #
class ClusterForm(BootstrapMixin, CustomFieldForm): class ClusterForm(BootstrapMixin, CustomFieldForm):
comments = CommentField( comments = CommentField()
widget=SmallTextarea()
)
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: