1
0
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:
Jeremy Stretch
2019-09-20 08:58:55 -04:00
32 changed files with 606 additions and 258 deletions

View File

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

View File

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

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
```
{% 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

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

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:
* 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.

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:
* 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):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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');
}
});

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. "
"Please note that passphrase-protected keys are not supported.",
}
labels = {
'public_key': ''
}
def clean_public_key(self):
key = self.cleaned_data['public_key']

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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