mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-27 23:57:46 -06:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74d525364a | ||
|
|
c2f4cf3407 | ||
|
|
83f3dc99ce | ||
|
|
44d3606ce1 | ||
|
|
470d22c835 | ||
|
|
4c37628784 | ||
|
|
1daf7f8e2b | ||
|
|
841db3b0c2 | ||
|
|
5d10d8418e | ||
|
|
2fee977b4c | ||
|
|
52f1b1c3bf | ||
|
|
ab53d0e863 | ||
|
|
259da2d18a | ||
|
|
bf47e7cae3 | ||
|
|
db2721c581 | ||
|
|
1b2e9a6d06 | ||
|
|
99848aab6a | ||
|
|
7583912de7 | ||
|
|
125975832b | ||
|
|
20fed375d1 | ||
|
|
fc1b3d6927 | ||
|
|
aed2a3cd1b | ||
|
|
15babeb584 | ||
|
|
020b5ea870 | ||
|
|
2ee5b2344e | ||
|
|
7616bcad3d | ||
|
|
f76ce980e3 | ||
|
|
9440ac7640 | ||
|
|
0e18997c79 | ||
|
|
95464772ac | ||
|
|
b4445dfdf8 | ||
|
|
fb5dca2711 | ||
|
|
6cdff955dc | ||
|
|
4039753b2f | ||
|
|
9df33cef8b | ||
|
|
e3e9211e8a | ||
|
|
0da113b723 | ||
|
|
e965adad7c | ||
|
|
57b225b680 | ||
|
|
b97597c645 | ||
|
|
162828da90 | ||
|
|
292647da14 | ||
|
|
3a88e43103 | ||
|
|
010765e131 |
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,3 +1,42 @@
|
||||
v2.4.6 (2018-10-05)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#2479](https://github.com/digitalocean/netbox/issues/2479) - Add user permissions for creating/modifying API tokens
|
||||
* [#2487](https://github.com/digitalocean/netbox/issues/2487) - Return abbreviated API output when passed `?brief=1`
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2393](https://github.com/digitalocean/netbox/issues/2393) - Fix Unicode support for CSV import under Python 2
|
||||
* [#2483](https://github.com/digitalocean/netbox/issues/2483) - Set max item count of API-populated form fields to MAX_PAGE_SIZE
|
||||
* [#2484](https://github.com/digitalocean/netbox/issues/2484) - Local config context not available on the Virtual Machine Edit Form
|
||||
* [#2485](https://github.com/digitalocean/netbox/issues/2485) - Fix cancel button when assigning a service to a device/VM
|
||||
* [#2491](https://github.com/digitalocean/netbox/issues/2491) - Fix exception when importing devices with invalid device type
|
||||
* [#2492](https://github.com/digitalocean/netbox/issues/2492) - Sanitize hostname and port values returned through LLDP
|
||||
|
||||
---
|
||||
|
||||
v2.4.5 (2018-10-02)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#2392](https://github.com/digitalocean/netbox/issues/2392) - Implemented local context data for devices and virtual machines
|
||||
* [#2402](https://github.com/digitalocean/netbox/issues/2402) - Order and format JSON data in form fields
|
||||
* [#2432](https://github.com/digitalocean/netbox/issues/2432) - Link remote interface connections to the Interface view
|
||||
* [#2438](https://github.com/digitalocean/netbox/issues/2438) - API optimizations for tagged objects
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2406](https://github.com/digitalocean/netbox/issues/2406) - Remove hard-coded limit of 1000 objects from API-populated form fields
|
||||
* [#2414](https://github.com/digitalocean/netbox/issues/2414) - Tags field missing from device/VM component creation forms
|
||||
* [#2442](https://github.com/digitalocean/netbox/issues/2442) - Nullify "next" link in API when limit=0 is passed
|
||||
* [#2443](https://github.com/digitalocean/netbox/issues/2443) - Enforce JSON object format when creating config contexts
|
||||
* [#2444](https://github.com/digitalocean/netbox/issues/2444) - Improve validation of interface MAC addresses
|
||||
* [#2455](https://github.com/digitalocean/netbox/issues/2455) - Ignore unique address enforcement for IPs with a shared/virtual role
|
||||
* [#2470](https://github.com/digitalocean/netbox/issues/2470) - Log the creation of device/VM components as object changes
|
||||
|
||||
---
|
||||
|
||||
v2.4.4 (2018-08-22)
|
||||
|
||||
## Enhancements
|
||||
|
||||
15
README.md
15
README.md
@@ -42,3 +42,18 @@ and run `upgrade.sh`.
|
||||
* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
|
||||
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
|
||||
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
|
||||
|
||||
# Related projects
|
||||
|
||||
## Supported SDK
|
||||
|
||||
- [pynetbox](https://github.com/digitalocean/pynetbox) Python API client library for Netbox.
|
||||
|
||||
## Community SDK
|
||||
|
||||
- [netbox-client-ruby](https://github.com/ninech/netbox-client-ruby) A ruby client library for Netbox v2.
|
||||
|
||||
## Ansible Inventory
|
||||
|
||||
- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) Ansible dynamic inventory script for Netbox.
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Contextual Configuration Data
|
||||
|
||||
Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. For example, you might want to associate a set of syslog servers for all devices at a particular site. Context data enables the association of arbitrary data to devices and virtual machines grouped by region, site, role, platform, and/or tenant. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object.
|
||||
|
||||
Devices and Virtual Machines may also have a local config context defined. This local context will always overwrite the rendered config context objects for the Device/VM. This is useful in situations were the device requires a one-off value different from the rest of the environment.
|
||||
|
||||
@@ -4,6 +4,9 @@ The NetBox API employs token-based authentication. For convenience, cookie authe
|
||||
|
||||
A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`.
|
||||
|
||||
!!! note
|
||||
The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access.
|
||||
|
||||
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
|
||||
|
||||
By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
|
||||
|
||||
@@ -99,7 +99,7 @@ Device bays represent the ability of a device to house child devices. For exampl
|
||||
|
||||
# Platforms
|
||||
|
||||
A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15.
|
||||
A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of the same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15.
|
||||
|
||||
The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. See the [API documentation](api/napalm-integration.md) for more information on NAPALM integration.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
site_name: NetBox
|
||||
theme: readthedocs
|
||||
repo_url: https://github.com/digitalocean/netbox
|
||||
|
||||
pages:
|
||||
|
||||
@@ -29,7 +29,7 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
#
|
||||
|
||||
class ProviderViewSet(CustomFieldModelViewSet):
|
||||
queryset = Provider.objects.all()
|
||||
queryset = Provider.objects.prefetch_related('tags')
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
filter_class = filters.ProviderFilter
|
||||
|
||||
@@ -59,7 +59,7 @@ class CircuitTypeViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class CircuitViewSet(CustomFieldModelViewSet):
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider').prefetch_related('tags')
|
||||
serializer_class = serializers.CircuitSerializer
|
||||
filter_class = filters.CircuitFilter
|
||||
|
||||
|
||||
@@ -56,6 +56,16 @@ class ProviderTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_providers_brief(self):
|
||||
|
||||
url = reverse('circuits-api:provider-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_provider(self):
|
||||
|
||||
data = {
|
||||
@@ -147,6 +157,16 @@ class CircuitTypeTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_circuittypes_brief(self):
|
||||
|
||||
url = reverse('circuits-api:circuittype-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_circuittype(self):
|
||||
|
||||
data = {
|
||||
@@ -216,6 +236,16 @@ class CircuitTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_circuits_brief(self):
|
||||
|
||||
url = reverse('circuits-api:circuit-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['cid', 'id', 'url']
|
||||
)
|
||||
|
||||
def test_create_circuit(self):
|
||||
|
||||
data = {
|
||||
|
||||
@@ -412,7 +412,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
'last_updated', 'local_context_data',
|
||||
]
|
||||
validators = []
|
||||
|
||||
@@ -448,7 +448,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields',
|
||||
'config_context', 'created', 'last_updated',
|
||||
'config_context', 'created', 'last_updated', 'local_context_data',
|
||||
]
|
||||
|
||||
def get_config_context(self, obj):
|
||||
@@ -492,6 +492,15 @@ class ConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
fields = ['id', 'device', 'name', 'cs_port', 'connection_status', 'tags']
|
||||
|
||||
|
||||
class NestedConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['id', 'url', 'device', 'name']
|
||||
|
||||
|
||||
#
|
||||
# Power outlets
|
||||
#
|
||||
@@ -529,6 +538,15 @@ class PowerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
fields = ['id', 'device', 'name', 'power_outlet', 'connection_status', 'tags']
|
||||
|
||||
|
||||
class NestedPowerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['id', 'url', 'device', 'name']
|
||||
|
||||
|
||||
#
|
||||
# Interfaces
|
||||
#
|
||||
@@ -652,10 +670,11 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
|
||||
class NestedDeviceBaySerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = ['id', 'url', 'name']
|
||||
fields = ['id', 'url', 'device', 'name']
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import unicode_literals
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.openapi import Parameter
|
||||
@@ -60,7 +60,7 @@ class RegionViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class SiteViewSet(CustomFieldModelViewSet):
|
||||
queryset = Site.objects.select_related('region', 'tenant')
|
||||
queryset = Site.objects.select_related('region', 'tenant').prefetch_related('tags')
|
||||
serializer_class = serializers.SiteSerializer
|
||||
filter_class = filters.SiteFilter
|
||||
|
||||
@@ -100,7 +100,7 @@ class RackRoleViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class RackViewSet(CustomFieldModelViewSet):
|
||||
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
|
||||
queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('tags')
|
||||
serializer_class = serializers.RackSerializer
|
||||
filter_class = filters.RackFilter
|
||||
|
||||
@@ -154,7 +154,7 @@ class ManufacturerViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||
queryset = DeviceType.objects.select_related('manufacturer')
|
||||
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags')
|
||||
serializer_class = serializers.DeviceTypeSerializer
|
||||
filter_class = filters.DeviceTypeFilter
|
||||
|
||||
@@ -228,7 +228,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
||||
'virtual_chassis__master',
|
||||
).prefetch_related(
|
||||
'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
|
||||
'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
|
||||
)
|
||||
filter_class = filters.DeviceFilter
|
||||
|
||||
@@ -238,6 +238,11 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
"""
|
||||
if self.action == 'retrieve':
|
||||
return serializers.DeviceWithConfigContextSerializer
|
||||
|
||||
request = self.get_serializer_context()['request']
|
||||
if request.query_params.get('brief', False):
|
||||
return serializers.NestedDeviceSerializer
|
||||
|
||||
return serializers.DeviceSerializer
|
||||
|
||||
@action(detail=True, url_path='napalm')
|
||||
@@ -315,31 +320,31 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
#
|
||||
|
||||
class ConsolePortViewSet(ModelViewSet):
|
||||
queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
|
||||
queryset = ConsolePort.objects.select_related('device', 'cs_port__device').prefetch_related('tags')
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
filter_class = filters.ConsolePortFilter
|
||||
|
||||
|
||||
class ConsoleServerPortViewSet(ModelViewSet):
|
||||
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
|
||||
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device').prefetch_related('tags')
|
||||
serializer_class = serializers.ConsoleServerPortSerializer
|
||||
filter_class = filters.ConsoleServerPortFilter
|
||||
|
||||
|
||||
class PowerPortViewSet(ModelViewSet):
|
||||
queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
|
||||
queryset = PowerPort.objects.select_related('device', 'power_outlet__device').prefetch_related('tags')
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
filter_class = filters.PowerPortFilter
|
||||
|
||||
|
||||
class PowerOutletViewSet(ModelViewSet):
|
||||
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
|
||||
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device').prefetch_related('tags')
|
||||
serializer_class = serializers.PowerOutletSerializer
|
||||
filter_class = filters.PowerOutletFilter
|
||||
|
||||
|
||||
class InterfaceViewSet(ModelViewSet):
|
||||
queryset = Interface.objects.select_related('device')
|
||||
queryset = Interface.objects.select_related('device').prefetch_related('tags')
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
filter_class = filters.InterfaceFilter
|
||||
|
||||
@@ -355,13 +360,13 @@ class InterfaceViewSet(ModelViewSet):
|
||||
|
||||
|
||||
class DeviceBayViewSet(ModelViewSet):
|
||||
queryset = DeviceBay.objects.select_related('installed_device')
|
||||
queryset = DeviceBay.objects.select_related('installed_device').prefetch_related('tags')
|
||||
serializer_class = serializers.DeviceBaySerializer
|
||||
filter_class = filters.DeviceBayFilter
|
||||
|
||||
|
||||
class InventoryItemViewSet(ModelViewSet):
|
||||
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
|
||||
queryset = InventoryItem.objects.select_related('device', 'manufacturer').prefetch_related('tags')
|
||||
serializer_class = serializers.InventoryItemSerializer
|
||||
filter_class = filters.InventoryItemFilter
|
||||
|
||||
@@ -393,7 +398,7 @@ class InterfaceConnectionViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class VirtualChassisViewSet(ModelViewSet):
|
||||
queryset = VirtualChassis.objects.all()
|
||||
queryset = VirtualChassis.objects.prefetch_related('tags')
|
||||
serializer_class = serializers.VirtualChassisSerializer
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from netaddr import EUI, mac_unix_expanded
|
||||
from netaddr import AddrFormatError, EUI, mac_unix_expanded
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.db import models
|
||||
|
||||
from .formfields import MACAddressFormField
|
||||
|
||||
|
||||
class ASNField(models.BigIntegerField):
|
||||
description = "32-bit ASN field"
|
||||
@@ -35,7 +33,7 @@ class MACAddressField(models.Field):
|
||||
return value
|
||||
try:
|
||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||
except ValueError as e:
|
||||
except AddrFormatError as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
def db_type(self, connection):
|
||||
@@ -45,11 +43,3 @@ class MACAddressField(models.Field):
|
||||
if not value:
|
||||
return None
|
||||
return str(self.to_python(value))
|
||||
|
||||
def form_class(self):
|
||||
return MACAddressFormField
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
defaults = {'form_class': self.form_class()}
|
||||
defaults.update(kwargs)
|
||||
return super(MACAddressField, self).formfield(**defaults)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from netaddr import EUI, AddrFormatError
|
||||
|
||||
|
||||
#
|
||||
# Form fields
|
||||
#
|
||||
|
||||
class MACAddressFormField(forms.Field):
|
||||
default_error_messages = {
|
||||
'invalid': "Enter a valid MAC address.",
|
||||
}
|
||||
|
||||
def to_python(self, value):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if isinstance(value, EUI):
|
||||
return value
|
||||
|
||||
try:
|
||||
return EUI(value, version=48)
|
||||
except AddrFormatError:
|
||||
raise ValidationError("Please specify a valid MAC address.")
|
||||
@@ -18,7 +18,7 @@ from utilities.forms import (
|
||||
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
|
||||
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
|
||||
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
|
||||
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
|
||||
FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from .constants import (
|
||||
@@ -27,7 +27,6 @@ from .constants import (
|
||||
RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
|
||||
SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
|
||||
)
|
||||
from .formfields import MACAddressFormField
|
||||
from .models import (
|
||||
DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
|
||||
Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
|
||||
@@ -823,16 +822,19 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
)
|
||||
comments = CommentField()
|
||||
tags = TagField(required=False)
|
||||
local_context_data = JSONField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face',
|
||||
'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags',
|
||||
'local_context_data'
|
||||
]
|
||||
help_texts = {
|
||||
'device_role': "The function this device serves",
|
||||
'serial': "Chassis serial number",
|
||||
'local_context_data': "Local config context data overwrites all sources contexts in the final rendered config context"
|
||||
}
|
||||
widgets = {
|
||||
'face': forms.Select(attrs={'filter-for': 'position'}),
|
||||
@@ -1192,6 +1194,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class ConsolePortCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
tags = TagField(required=False)
|
||||
|
||||
|
||||
class ConsoleConnectionCSVForm(forms.ModelForm):
|
||||
@@ -1362,6 +1365,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class ConsoleServerPortCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
tags = TagField(required=False)
|
||||
|
||||
|
||||
class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
|
||||
@@ -1459,6 +1463,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class PowerPortCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
tags = TagField(required=False)
|
||||
|
||||
|
||||
class PowerConnectionCSVForm(forms.ModelForm):
|
||||
@@ -1629,6 +1634,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class PowerOutletCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
tags = TagField(required=False)
|
||||
|
||||
|
||||
class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
|
||||
@@ -1854,7 +1860,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
|
||||
enabled = forms.BooleanField(required=False)
|
||||
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
|
||||
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
|
||||
mac_address = MACAddressFormField(required=False, label='MAC Address')
|
||||
mac_address = forms.CharField(required=False, label='MAC Address')
|
||||
mgmt_only = forms.BooleanField(
|
||||
required=False,
|
||||
label='OOB Management',
|
||||
@@ -1862,6 +1868,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
|
||||
)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
|
||||
tags = TagField(required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -2099,6 +2106,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class DeviceBayCreateForm(ComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
tags = TagField(required=False)
|
||||
|
||||
|
||||
class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
||||
|
||||
19
netbox/dcim/migrations/0063_device_local_context_data.py
Normal file
19
netbox/dcim/migrations/0063_device_local_context_data.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.0.8 on 2018-09-16 02:01
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0062_interface_mtu'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='local_context_data',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -10,7 +10,7 @@ from django.contrib.postgres.fields import ArrayField, JSONField
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Count, Q, ObjectDoesNotExist
|
||||
from django.db.models import Count, Q
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
@@ -1400,7 +1400,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
})
|
||||
|
||||
# Validate manufacturer/platform
|
||||
if self.device_type and self.platform:
|
||||
if hasattr(self, 'device_type') and self.platform:
|
||||
if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
|
||||
raise ValidationError({
|
||||
'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
|
||||
|
||||
@@ -614,10 +614,12 @@ class PowerConnectionTable(BaseTable):
|
||||
class InterfaceConnectionTable(BaseTable):
|
||||
device_a = tables.LinkColumn('dcim:device', accessor=Accessor('interface_a.device'),
|
||||
args=[Accessor('interface_a.device.pk')], verbose_name='Device A')
|
||||
interface_a = tables.Column(verbose_name='Interface A')
|
||||
interface_a = tables.LinkColumn('dcim:interface', accessor=Accessor('interface_a'),
|
||||
args=[Accessor('interface_a.pk')], verbose_name='Interface A')
|
||||
device_b = tables.LinkColumn('dcim:device', accessor=Accessor('interface_b.device'),
|
||||
args=[Accessor('interface_b.device.pk')], verbose_name='Device B')
|
||||
interface_b = tables.Column(verbose_name='Interface B')
|
||||
interface_b = tables.LinkColumn('dcim:interface', accessor=Accessor('interface_b'),
|
||||
args=[Accessor('interface_b.pk')], verbose_name='Interface B')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = InterfaceConnection
|
||||
|
||||
@@ -44,6 +44,16 @@ class RegionTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_regions_brief(self):
|
||||
|
||||
url = reverse('dcim-api:region-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_region(self):
|
||||
|
||||
data = {
|
||||
@@ -158,6 +168,16 @@ class SiteTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_sites_brief(self):
|
||||
|
||||
url = reverse('dcim-api:site-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_site(self):
|
||||
|
||||
data = {
|
||||
@@ -262,6 +282,16 @@ class RackGroupTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_rackgroups_brief(self):
|
||||
|
||||
url = reverse('dcim-api:rackgroup-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_rackgroup(self):
|
||||
|
||||
data = {
|
||||
@@ -360,6 +390,16 @@ class RackRoleTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_rackroles_brief(self):
|
||||
|
||||
url = reverse('dcim-api:rackrole-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_rackrole(self):
|
||||
|
||||
data = {
|
||||
@@ -477,6 +517,16 @@ class RackTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_racks_brief(self):
|
||||
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['display_name', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_rack(self):
|
||||
|
||||
data = {
|
||||
@@ -693,6 +743,16 @@ class ManufacturerTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_manufacturers_brief(self):
|
||||
|
||||
url = reverse('dcim-api:manufacturer-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_manufacturer(self):
|
||||
|
||||
data = {
|
||||
@@ -792,6 +852,16 @@ class DeviceTypeTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_devicetypes_brief(self):
|
||||
|
||||
url = reverse('dcim-api:devicetype-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'manufacturer', 'model', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_devicetype(self):
|
||||
|
||||
data = {
|
||||
@@ -1496,6 +1566,16 @@ class DeviceRoleTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_deviceroles_brief(self):
|
||||
|
||||
url = reverse('dcim-api:devicerole-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_devicerole(self):
|
||||
|
||||
data = {
|
||||
@@ -1594,6 +1674,16 @@ class PlatformTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_platforms_brief(self):
|
||||
|
||||
url = reverse('dcim-api:platform-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_platform(self):
|
||||
|
||||
data = {
|
||||
@@ -1722,6 +1812,16 @@ class DeviceTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_devices_brief(self):
|
||||
|
||||
url = reverse('dcim-api:device-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['display_name', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_device(self):
|
||||
|
||||
data = {
|
||||
@@ -1848,6 +1948,16 @@ class ConsolePortTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_consoleports_brief(self):
|
||||
|
||||
url = reverse('dcim-api:consoleport-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['device', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_consoleport(self):
|
||||
|
||||
data = {
|
||||
@@ -1953,6 +2063,16 @@ class ConsoleServerPortTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_consoleserverports_brief(self):
|
||||
|
||||
url = reverse('dcim-api:consoleserverport-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['device', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_consoleserverport(self):
|
||||
|
||||
data = {
|
||||
@@ -2054,6 +2174,16 @@ class PowerPortTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_powerports_brief(self):
|
||||
|
||||
url = reverse('dcim-api:powerport-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['device', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_powerport(self):
|
||||
|
||||
data = {
|
||||
@@ -2159,6 +2289,16 @@ class PowerOutletTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_poweroutlets_brief(self):
|
||||
|
||||
url = reverse('dcim-api:poweroutlet-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['device', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_poweroutlet(self):
|
||||
|
||||
data = {
|
||||
@@ -2285,6 +2425,16 @@ class InterfaceTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_interfaces_brief(self):
|
||||
|
||||
url = reverse('dcim-api:interface-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['device', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_interface(self):
|
||||
|
||||
data = {
|
||||
@@ -2456,6 +2606,16 @@ class DeviceBayTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_devicebays_brief(self):
|
||||
|
||||
url = reverse('dcim-api:devicebay-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['device', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_devicebay(self):
|
||||
|
||||
data = {
|
||||
@@ -2778,6 +2938,16 @@ class InterfaceConnectionTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_interfaceconnections_brief(self):
|
||||
|
||||
url = reverse('dcim-api:interfaceconnection-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['connection_status', 'id', 'url']
|
||||
)
|
||||
|
||||
def test_create_interfaceconnection(self):
|
||||
|
||||
data = {
|
||||
@@ -2973,6 +3143,16 @@ class VirtualChassisTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 2)
|
||||
|
||||
def test_list_virtualchassis_brief(self):
|
||||
|
||||
url = reverse('dcim-api:virtualchassis-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'url']
|
||||
)
|
||||
|
||||
def test_create_virtualchassis(self):
|
||||
|
||||
data = {
|
||||
|
||||
@@ -700,9 +700,22 @@ class ConfigContext(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:configcontext', kwargs={'pk': self.pk})
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Verify that JSON data is provided as an object
|
||||
if type(self.data) is not dict:
|
||||
raise ValidationError(
|
||||
{'data': 'JSON data must be in object form. Example: {"foo": 123}'}
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextModel(models.Model):
|
||||
|
||||
local_context_data = JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -716,6 +729,10 @@ class ConfigContextModel(models.Model):
|
||||
for context in ConfigContext.objects.get_for_object(self):
|
||||
data.update(context.data)
|
||||
|
||||
# If the object has local config context data defined, that data overwrites all rendered data
|
||||
if self.local_context_data is not None:
|
||||
data.update(self.local_context_data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -106,9 +106,11 @@ class ObjectConfigContextView(View):
|
||||
|
||||
obj = get_object_or_404(self.object_class, pk=pk)
|
||||
source_contexts = ConfigContext.objects.get_for_object(obj)
|
||||
model_name = self.object_class._meta.model_name
|
||||
|
||||
return render(request, 'extras/object_configcontext.html', {
|
||||
self.object_class._meta.model_name: obj,
|
||||
model_name: obj,
|
||||
'obj': obj,
|
||||
'rendered_context': obj.get_config_context(),
|
||||
'source_contexts': source_contexts,
|
||||
'base_template': self.base_template,
|
||||
|
||||
@@ -33,7 +33,7 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
#
|
||||
|
||||
class VRFViewSet(CustomFieldModelViewSet):
|
||||
queryset = VRF.objects.select_related('tenant')
|
||||
queryset = VRF.objects.select_related('tenant').prefetch_related('tags')
|
||||
serializer_class = serializers.VRFSerializer
|
||||
filter_class = filters.VRFFilter
|
||||
|
||||
@@ -53,7 +53,7 @@ class RIRViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class AggregateViewSet(CustomFieldModelViewSet):
|
||||
queryset = Aggregate.objects.select_related('rir')
|
||||
queryset = Aggregate.objects.select_related('rir').prefetch_related('tags')
|
||||
serializer_class = serializers.AggregateSerializer
|
||||
filter_class = filters.AggregateFilter
|
||||
|
||||
@@ -73,7 +73,7 @@ class RoleViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class PrefixViewSet(CustomFieldModelViewSet):
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role').prefetch_related('tags')
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
filter_class = filters.PrefixFilter
|
||||
|
||||
@@ -245,7 +245,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
queryset = IPAddress.objects.select_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine'
|
||||
).prefetch_related(
|
||||
'nat_outside'
|
||||
'nat_outside', 'tags',
|
||||
)
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
filter_class = filters.IPAddressFilter
|
||||
@@ -266,7 +266,7 @@ class VLANGroupViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class VLANViewSet(CustomFieldModelViewSet):
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('tags')
|
||||
serializer_class = serializers.VLANSerializer
|
||||
filter_class = filters.VLANFilter
|
||||
|
||||
@@ -276,6 +276,6 @@ class VLANViewSet(CustomFieldModelViewSet):
|
||||
#
|
||||
|
||||
class ServiceViewSet(ModelViewSet):
|
||||
queryset = Service.objects.select_related('device')
|
||||
queryset = Service.objects.select_related('device').prefetch_related('tags')
|
||||
serializer_class = serializers.ServiceSerializer
|
||||
filter_class = filters.ServiceFilter
|
||||
|
||||
@@ -51,6 +51,16 @@ IPADDRESS_ROLE_CHOICES = (
|
||||
(IPADDRESS_ROLE_CARP, 'CARP'),
|
||||
)
|
||||
|
||||
IPADDRESS_ROLES_NONUNIQUE = (
|
||||
# IPAddress roles which are exempt from unique address enforcement
|
||||
IPADDRESS_ROLE_ANYCAST,
|
||||
IPADDRESS_ROLE_VIP,
|
||||
IPADDRESS_ROLE_VRRP,
|
||||
IPADDRESS_ROLE_HSRP,
|
||||
IPADDRESS_ROLE_GLBP,
|
||||
IPADDRESS_ROLE_CARP,
|
||||
)
|
||||
|
||||
# VLAN statuses
|
||||
VLAN_STATUS_ACTIVE = 1
|
||||
VLAN_STATUS_RESERVED = 2
|
||||
|
||||
@@ -596,7 +596,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
if self.address:
|
||||
|
||||
# Enforce unique IP space (if applicable)
|
||||
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
|
||||
if self.role not in IPADDRESS_ROLES_NONUNIQUE and (
|
||||
self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE
|
||||
) or (
|
||||
self.vrf and self.vrf.enforce_unique
|
||||
):
|
||||
duplicate_ips = self.get_duplicates()
|
||||
if duplicate_ips:
|
||||
raise ValidationError({
|
||||
|
||||
@@ -34,6 +34,16 @@ class VRFTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_vrfs_brief(self):
|
||||
|
||||
url = reverse('ipam-api:vrf-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'rd', 'url']
|
||||
)
|
||||
|
||||
def test_create_vrf(self):
|
||||
|
||||
data = {
|
||||
@@ -125,6 +135,16 @@ class RIRTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_rirs_brief(self):
|
||||
|
||||
url = reverse('ipam-api:rir-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_rir(self):
|
||||
|
||||
data = {
|
||||
@@ -218,6 +238,16 @@ class AggregateTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_aggregates_brief(self):
|
||||
|
||||
url = reverse('ipam-api:aggregate-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['family', 'id', 'prefix', 'url']
|
||||
)
|
||||
|
||||
def test_create_aggregate(self):
|
||||
|
||||
data = {
|
||||
@@ -309,6 +339,16 @@ class RoleTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_roles_brief(self):
|
||||
|
||||
url = reverse('ipam-api:role-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_role(self):
|
||||
|
||||
data = {
|
||||
@@ -397,13 +437,23 @@ class PrefixTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['prefix'], str(self.prefix1.prefix))
|
||||
|
||||
def test_list_prefixs(self):
|
||||
def test_list_prefixes(self):
|
||||
|
||||
url = reverse('ipam-api:prefix-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_prefixes_brief(self):
|
||||
|
||||
url = reverse('ipam-api:prefix-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['family', 'id', 'prefix', 'url']
|
||||
)
|
||||
|
||||
def test_create_prefix(self):
|
||||
|
||||
data = {
|
||||
@@ -630,6 +680,16 @@ class IPAddressTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_ipaddresses_brief(self):
|
||||
|
||||
url = reverse('ipam-api:ipaddress-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['address', 'family', 'id', 'url']
|
||||
)
|
||||
|
||||
def test_create_ipaddress(self):
|
||||
|
||||
data = {
|
||||
@@ -718,6 +778,16 @@ class VLANGroupTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_vlangroups_brief(self):
|
||||
|
||||
url = reverse('ipam-api:vlangroup-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_vlangroup(self):
|
||||
|
||||
data = {
|
||||
@@ -809,6 +879,16 @@ class VLANTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_vlans_brief(self):
|
||||
|
||||
url = reverse('ipam-api:vlan-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['display_name', 'id', 'name', 'url', 'vid']
|
||||
)
|
||||
|
||||
def test_create_vlan(self):
|
||||
|
||||
data = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import netaddr
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from ipam.constants import IPADDRESS_ROLE_VIP
|
||||
from ipam.models import IPAddress, Prefix, VRF
|
||||
|
||||
|
||||
@@ -59,3 +60,8 @@ class TestIPAddress(TestCase):
|
||||
IPAddress.objects.create(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
|
||||
duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
|
||||
self.assertRaises(ValidationError, duplicate_ip.clean)
|
||||
|
||||
@override_settings(ENFORCE_GLOBAL_UNIQUE=True)
|
||||
def test_duplicate_nonunique_role(self):
|
||||
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPADDRESS_ROLE_VIP)
|
||||
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPADDRESS_ROLE_VIP)
|
||||
|
||||
@@ -991,6 +991,9 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine'])
|
||||
return obj
|
||||
|
||||
def get_return_url(self, request, service):
|
||||
return service.parent.get_absolute_url()
|
||||
|
||||
|
||||
class ServiceEditView(ServiceCreateView):
|
||||
permission_required = 'ipam.change_service'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from rest_framework import authentication, exceptions
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
|
||||
@@ -104,8 +105,6 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
|
||||
def get_limit(self, request):
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
if self.limit_query_param:
|
||||
try:
|
||||
limit = int(request.query_params[self.limit_query_param])
|
||||
@@ -123,6 +122,22 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
|
||||
return self.default_limit
|
||||
|
||||
def get_next_link(self):
|
||||
|
||||
# Pagination has been disabled
|
||||
if not self.limit:
|
||||
return None
|
||||
|
||||
return super(OptionalLimitOffsetPagination, self).get_next_link()
|
||||
|
||||
def get_previous_link(self):
|
||||
|
||||
# Pagination has been disabled
|
||||
if not self.limit:
|
||||
return None
|
||||
|
||||
return super(OptionalLimitOffsetPagination, self).get_previous_link()
|
||||
|
||||
|
||||
#
|
||||
# Miscellaneous
|
||||
|
||||
@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
VERSION = '2.4.4'
|
||||
VERSION = '2.4.6'
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
if ($(parent).val() || $(parent).attr('nullable') == 'true') {
|
||||
var api_url = child_field.attr('api-url') + '&limit=1000';
|
||||
var api_url = child_field.attr('api-url') + '&limit=0&brief=1';
|
||||
var disabled_indicator = child_field.attr('disabled-indicator');
|
||||
var initial_value = child_field.attr('initial');
|
||||
var display_field = child_field.attr('display-field') || 'name';
|
||||
|
||||
@@ -48,7 +48,7 @@ class SecretViewSet(ModelViewSet):
|
||||
queryset = Secret.objects.select_related(
|
||||
'device__primary_ip4', 'device__primary_ip6', 'role',
|
||||
).prefetch_related(
|
||||
'role__users', 'role__groups',
|
||||
'role__users', 'role__groups', 'tags',
|
||||
)
|
||||
serializer_class = serializers.SecretSerializer
|
||||
filter_class = filters.SecretFilter
|
||||
|
||||
@@ -73,6 +73,16 @@ class SecretRoleTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_secretroles_brief(self):
|
||||
|
||||
url = reverse('secrets-api:secretrole-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_secretrole(self):
|
||||
|
||||
data = {
|
||||
|
||||
@@ -77,6 +77,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Local Config Context Data</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.local_context_data %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Tags</strong></div>
|
||||
<div class="panel-body">
|
||||
|
||||
@@ -64,8 +64,10 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
// Clean up hostnames/interfaces learned via LLDP
|
||||
var lldp_device = neighbor['hostname'].split(".")[0]; // Strip off any trailing domain name
|
||||
var lldp_interface = neighbor['port'].split(".")[0]; // Strip off any trailing subinterface ID
|
||||
var neighbor_host = neighbor['hostname'] || ""; // sanitize hostname if it's null to avoid breaking the split func
|
||||
var neighbor_port = neighbor['port'] || ""; // sanitize port if it's null to avoid breaking the split func
|
||||
var lldp_device = neighbor_host.split(".")[0]; // Strip off any trailing domain name
|
||||
var lldp_interface = neighbor_port.split(".")[0]; // Strip off any trailing subinterface ID
|
||||
|
||||
// Add LLDP neighbors to table
|
||||
row.children('td.device').html(lldp_device);
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span>
|
||||
<a href="{% url 'dcim:interface' pk=connected_iface.pk %}"><span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span></a>
|
||||
</td>
|
||||
{% endwith %}
|
||||
{% elif iface.circuit_termination %}
|
||||
|
||||
@@ -134,7 +134,9 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>{{ connected_interface.name }}</td>
|
||||
<td>
|
||||
<a href="{{ connected_interface.get_absolute_url }}">{{ connected_interface.name }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
|
||||
@@ -14,11 +14,6 @@
|
||||
{% render_field form.mgmt_only %}
|
||||
{% render_field form.description %}
|
||||
{% render_field form.mode %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Tags</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,24 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Local Context</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if obj.local_context_data %}
|
||||
<pre>{{ obj.local_context_data|render_json }}</pre>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<span class="help-block">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
The local config context overwrites all source contexts.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Source Contexts</strong>
|
||||
|
||||
@@ -10,8 +10,12 @@
|
||||
<div class="panel panel-{% if token.is_expired %}danger{% else %}default{% endif %}">
|
||||
<div class="panel-heading">
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
|
||||
<a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
|
||||
{% if perms.users.change_token %}
|
||||
<a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
|
||||
{% endif %}
|
||||
{% if perms.users.delete_token %}
|
||||
<a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<i class="fa fa-key"></i> {{ token.key }}
|
||||
{% if token.is_expired %}
|
||||
@@ -49,10 +53,16 @@
|
||||
{% empty %}
|
||||
<p>You do not have any API tokens.</p>
|
||||
{% endfor %}
|
||||
<a href="{% url 'user:token_add' %}" class="btn btn-primary">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a token
|
||||
</a>
|
||||
{% if perms.users.add_token %}
|
||||
<a href="{% url 'user:token_add' %}" class="btn btn-primary">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a token
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="alert alert-info text-center" role="alert">
|
||||
You do not have permission to create new API tokens. If needed, ask an administrator to enable token creation for your account or an assigned group.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{% render_field form.mtu %}
|
||||
{% render_field form.description %}
|
||||
{% render_field form.mode %}
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
</div>
|
||||
{% if obj.mode %}
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Local Config Context Data</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.local_context_data %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Tags</strong></div>
|
||||
<div class="panel-body">
|
||||
|
||||
@@ -30,6 +30,6 @@ class TenantGroupViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class TenantViewSet(CustomFieldModelViewSet):
|
||||
queryset = Tenant.objects.select_related('group')
|
||||
queryset = Tenant.objects.select_related('group').prefetch_related('tags')
|
||||
serializer_class = serializers.TenantSerializer
|
||||
filter_class = filters.TenantFilter
|
||||
|
||||
@@ -31,6 +31,16 @@ class TenantGroupTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_tenantgroups_brief(self):
|
||||
|
||||
url = reverse('tenancy-api:tenantgroup-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_tenantgroup(self):
|
||||
|
||||
data = {
|
||||
@@ -124,6 +134,16 @@ class TenantTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_tenants_brief(self):
|
||||
|
||||
url = reverse('tenancy-api:tenant-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_tenant(self):
|
||||
|
||||
data = {
|
||||
|
||||
17
netbox/users/migrations/0003_token_permissions.py
Normal file
17
netbox/users/migrations/0003_token_permissions.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 2.0.8 on 2018-10-05 14:32
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0001_api_tokens_squashed_0002_unicode_literals'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='token',
|
||||
options={},
|
||||
),
|
||||
]
|
||||
@@ -43,7 +43,7 @@ class Token(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
default_permissions = []
|
||||
pass
|
||||
|
||||
def __str__(self):
|
||||
# Only display the last 24 bits of the token to avoid accidental exposure.
|
||||
|
||||
@@ -3,8 +3,8 @@ from __future__ import unicode_literals
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||
from django.http import HttpResponseForbidden, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
@@ -231,8 +231,12 @@ class TokenEditView(LoginRequiredMixin, View):
|
||||
def get(self, request, pk=None):
|
||||
|
||||
if pk is not None:
|
||||
if not request.user.has_perm('users.change_token'):
|
||||
return HttpResponseForbidden()
|
||||
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
|
||||
else:
|
||||
if not request.user.has_perm('users.add_token'):
|
||||
return HttpResponseForbidden()
|
||||
token = Token(user=request.user)
|
||||
|
||||
form = TokenForm(instance=token)
|
||||
@@ -274,7 +278,8 @@ class TokenEditView(LoginRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
class TokenDeleteView(LoginRequiredMixin, View):
|
||||
class TokenDeleteView(PermissionRequiredMixin, View):
|
||||
permission_required = 'users.delete_token'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
|
||||
@@ -192,6 +192,19 @@ class ModelViewSet(_ModelViewSet):
|
||||
|
||||
return super(ModelViewSet, self).get_serializer(*args, **kwargs)
|
||||
|
||||
def get_serializer_class(self):
|
||||
|
||||
# If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one
|
||||
# exists
|
||||
request = self.get_serializer_context()['request']
|
||||
if request.query_params.get('brief', False):
|
||||
serializer_class = get_serializer_for_model(self.queryset.model, prefix='Nested')
|
||||
if serializer_class is not None:
|
||||
return serializer_class
|
||||
|
||||
# Fall back to the hard-coded serializer class
|
||||
return self.serializer_class
|
||||
|
||||
|
||||
class FieldChoicesViewSet(ViewSet):
|
||||
"""
|
||||
|
||||
@@ -2,11 +2,13 @@ from __future__ import unicode_literals
|
||||
|
||||
import csv
|
||||
from io import StringIO
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.forms import JSONField as _JSONField
|
||||
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
|
||||
from django.db.models import Count
|
||||
from django.urls import reverse_lazy
|
||||
from mptt.forms import TreeNodeMultipleChoiceField
|
||||
@@ -149,6 +151,11 @@ def add_blank_choice(choices):
|
||||
return ((None, '---------'),) + tuple(choices)
|
||||
|
||||
|
||||
def utf8_encoder(data):
|
||||
for line in data:
|
||||
yield line.encode('utf-8')
|
||||
|
||||
|
||||
#
|
||||
# Widgets
|
||||
#
|
||||
@@ -302,7 +309,12 @@ class CSVDataField(forms.CharField):
|
||||
def to_python(self, value):
|
||||
|
||||
records = []
|
||||
reader = csv.reader(StringIO(value))
|
||||
|
||||
# Python 2 hack for Unicode support in the CSV reader
|
||||
if sys.version_info[0] < 3:
|
||||
reader = csv.reader(utf8_encoder(StringIO(value)))
|
||||
else:
|
||||
reader = csv.reader(StringIO(value))
|
||||
|
||||
# Consume and validate the first line of CSV data as column headers
|
||||
headers = next(reader)
|
||||
@@ -556,9 +568,11 @@ class JSONField(_JSONField):
|
||||
self.widget.attrs['placeholder'] = ''
|
||||
|
||||
def prepare_value(self, value):
|
||||
if isinstance(value, InvalidJSONInput):
|
||||
return value
|
||||
if value is None:
|
||||
return ''
|
||||
return super(JSONField, self).prepare_value(value)
|
||||
return json.dumps(value, sort_keys=True, indent=4)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -710,22 +710,17 @@ class ComponentCreateView(View):
|
||||
if form.is_valid():
|
||||
|
||||
new_components = []
|
||||
data = deepcopy(form.cleaned_data)
|
||||
data = deepcopy(request.POST)
|
||||
data[self.parent_field] = parent.pk
|
||||
|
||||
for name in form.cleaned_data['name_pattern']:
|
||||
component_data = {
|
||||
self.parent_field: parent.pk,
|
||||
'name': name,
|
||||
}
|
||||
# Replace objects with their primary key to keep component_form.clean() happy
|
||||
for k, v in data.items():
|
||||
if hasattr(v, 'pk'):
|
||||
component_data[k] = v.pk
|
||||
else:
|
||||
component_data[k] = v
|
||||
component_form = self.model_form(component_data)
|
||||
|
||||
# Initialize the individual component form
|
||||
data['name'] = name
|
||||
component_form = self.model_form(data)
|
||||
|
||||
if component_form.is_valid():
|
||||
new_components.append(component_form.save(commit=False))
|
||||
new_components.append(component_form)
|
||||
else:
|
||||
for field, errors in component_form.errors.as_data().items():
|
||||
# Assign errors on the child form's name field to name_pattern on the parent form
|
||||
@@ -735,26 +730,10 @@ class ComponentCreateView(View):
|
||||
form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
|
||||
|
||||
if not form.errors:
|
||||
self.model.objects.bulk_create(new_components)
|
||||
|
||||
# ManyToMany relations are bulk created via the through model
|
||||
m2m_fields = [field for field in component_form.fields if type(component_form.fields[field]) in M2M_FIELD_TYPES]
|
||||
if m2m_fields:
|
||||
for field in m2m_fields:
|
||||
field_links = []
|
||||
for new_component in new_components:
|
||||
for related_obj in component_form.cleaned_data[field]:
|
||||
# The through model columns are the id's of our M2M relation objects
|
||||
through_kwargs = {}
|
||||
new_component_column = new_component.__class__.__name__ + '_id'
|
||||
related_obj_column = related_obj.__class__.__name__ + '_id'
|
||||
through_kwargs.update({
|
||||
new_component_column.lower(): new_component.id,
|
||||
related_obj_column.lower(): related_obj.id
|
||||
})
|
||||
field_link = getattr(self.model, field).through(**through_kwargs)
|
||||
field_links.append(field_link)
|
||||
getattr(self.model, field).through.objects.bulk_create(field_links)
|
||||
# Create the new components
|
||||
for component_form in new_components:
|
||||
component_form.save()
|
||||
|
||||
messages.success(request, "Added {} {} to {}.".format(
|
||||
len(new_components), self.model._meta.verbose_name_plural, parent
|
||||
|
||||
@@ -107,6 +107,7 @@ class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
fields = [
|
||||
'id', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'local_context_data',
|
||||
]
|
||||
|
||||
|
||||
@@ -117,6 +118,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
|
||||
fields = [
|
||||
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||
'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
'local_context_data',
|
||||
]
|
||||
|
||||
def get_config_context(self, obj):
|
||||
@@ -167,7 +169,8 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
|
||||
class NestedInterfaceSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail')
|
||||
virtual_machine = NestedVirtualMachineSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['id', 'url', 'name']
|
||||
fields = ['id', 'url', 'virtual_machine', 'name']
|
||||
|
||||
@@ -35,7 +35,7 @@ class ClusterGroupViewSet(ModelViewSet):
|
||||
|
||||
|
||||
class ClusterViewSet(CustomFieldModelViewSet):
|
||||
queryset = Cluster.objects.select_related('type', 'group')
|
||||
queryset = Cluster.objects.select_related('type', 'group').prefetch_related('tags')
|
||||
serializer_class = serializers.ClusterSerializer
|
||||
filter_class = filters.ClusterFilter
|
||||
|
||||
@@ -47,7 +47,7 @@ class ClusterViewSet(CustomFieldModelViewSet):
|
||||
class VirtualMachineViewSet(CustomFieldModelViewSet):
|
||||
queryset = VirtualMachine.objects.select_related(
|
||||
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6'
|
||||
)
|
||||
).prefetch_related('tags')
|
||||
filter_class = filters.VirtualMachineFilter
|
||||
|
||||
def get_serializer_class(self):
|
||||
@@ -56,10 +56,24 @@ class VirtualMachineViewSet(CustomFieldModelViewSet):
|
||||
"""
|
||||
if self.action == 'retrieve':
|
||||
return serializers.VirtualMachineWithConfigContextSerializer
|
||||
|
||||
request = self.get_serializer_context()['request']
|
||||
if request.query_params.get('brief', False):
|
||||
return serializers.NestedVirtualMachineSerializer
|
||||
|
||||
return serializers.VirtualMachineSerializer
|
||||
|
||||
|
||||
class InterfaceViewSet(ModelViewSet):
|
||||
queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
|
||||
queryset = Interface.objects.filter(
|
||||
virtual_machine__isnull=False
|
||||
).select_related('virtual_machine').prefetch_related('tags')
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
filter_class = filters.InterfaceFilter
|
||||
|
||||
def get_serializer_class(self):
|
||||
request = self.get_serializer_context()['request']
|
||||
if request.query_params.get('brief', False):
|
||||
# Override get_serializer_for_model(), which will return the DCIM NestedInterfaceSerializer
|
||||
return serializers.NestedInterfaceSerializer
|
||||
return serializers.InterfaceSerializer
|
||||
|
||||
@@ -8,7 +8,6 @@ from taggit.forms import TagField
|
||||
|
||||
from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL
|
||||
from dcim.forms import INTERFACE_MODE_HELP_TEXT
|
||||
from dcim.formfields import MACAddressFormField
|
||||
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
|
||||
from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
|
||||
from ipam.models import IPAddress
|
||||
@@ -17,7 +16,8 @@ from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
|
||||
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, add_blank_choice
|
||||
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea,
|
||||
add_blank_choice
|
||||
)
|
||||
from .constants import VM_STATUS_CHOICES
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
@@ -247,13 +247,17 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
)
|
||||
)
|
||||
tags = TagField(required=False)
|
||||
local_context_data = JSONField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = [
|
||||
'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
'vcpus', 'memory', 'disk', 'comments', 'tags',
|
||||
'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
|
||||
]
|
||||
help_texts = {
|
||||
'local_context_data': "Local config context data overwrites all sources contexts in the final rendered config context",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -415,11 +419,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
#
|
||||
|
||||
class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
tags = TagField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
|
||||
'virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags',
|
||||
'untagged_vlan', 'tagged_vlans',
|
||||
]
|
||||
widgets = {
|
||||
@@ -456,8 +461,9 @@ class InterfaceCreateForm(ComponentForm):
|
||||
form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput())
|
||||
enabled = forms.BooleanField(required=False)
|
||||
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
|
||||
mac_address = MACAddressFormField(required=False, label='MAC Address')
|
||||
mac_address = forms.CharField(required=False, label='MAC Address')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
tags = TagField(required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.0.8 on 2018-09-16 02:01
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('virtualization', '0007_change_logging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='virtualmachine',
|
||||
name='local_context_data',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -35,6 +35,16 @@ class ClusterTypeTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_clustertypes_brief(self):
|
||||
|
||||
url = reverse('virtualization-api:clustertype-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_clustertype(self):
|
||||
|
||||
data = {
|
||||
@@ -126,6 +136,16 @@ class ClusterGroupTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_clustergroups_brief(self):
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_clustergroup(self):
|
||||
|
||||
data = {
|
||||
@@ -220,6 +240,16 @@ class ClusterTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_clusters_brief(self):
|
||||
|
||||
url = reverse('virtualization-api:cluster-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_cluster(self):
|
||||
|
||||
data = {
|
||||
@@ -324,6 +354,16 @@ class VirtualMachineTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_virtualmachines_brief(self):
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_virtualmachine(self):
|
||||
|
||||
data = {
|
||||
@@ -447,6 +487,16 @@ class InterfaceTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_interfaces_brief(self):
|
||||
|
||||
url = reverse('virtualization-api:interface-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'url', 'virtual_machine']
|
||||
)
|
||||
|
||||
def test_create_interface(self):
|
||||
|
||||
data = {
|
||||
|
||||
Reference in New Issue
Block a user