Merge branch 'develop-2.5' into 20-physical-cabling

This commit is contained in:
Jeremy Stretch 2018-10-19 13:34:21 -04:00
commit a36b120c8b
28 changed files with 615 additions and 72 deletions

View File

@ -1,7 +1,5 @@
v2.5.0 (FUTURE)
---
## Notes
* As promised, Python 2 support has been completed removed. Python 3.5 or higher is now required to run NetBox.
@ -19,6 +17,33 @@ v2.5.0 (FUTURE)
---
v2.4.7 (FUTURE)
## Bug Fixes
* [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces
* [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled
---
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

View File

@ -4,6 +4,14 @@ A webhook defines an HTTP request that is sent to an external application when c
An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content.
## Installation
If you are upgrading from a previous version of Netbox and want to enable the webhook feature, please follow the directions listed in the sections below.
* [Install Redis server and djano-rq package](../installation/2-netbox/#install-python-packages)
* [Modify configuration to enable webhooks](../installation/2-netbox/#webhooks-configuration)
* [Create supervisord program to run the rqworker process](../installation/3-http-daemon/#supervisord-installation)
## Requests
The webhook POST request is structured as so (assuming `application/json` as the Content-Type):

View File

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

View File

@ -58,7 +58,7 @@ A device is said to be full depth if its installation on one rack face prevents
## Device Roles
Devices can be organized by functional roles. These roles are fully cusomizable. For example, you might create roles for core switches, distribution switches, and access switches.
Devices can be organized by functional roles. These roles are fully customizable. For example, you might create roles for core switches, distribution switches, and access switches.
---
@ -101,7 +101,7 @@ Device bays represent the ability of a device to house child devices. For exampl
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.
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.
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.

View File

@ -71,7 +71,7 @@ Checking connectivity... done.
`# chown -R netbox:netbox /opt/netbox/netbox/media/`
## Install Python Packages
# Install Python Packages
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
@ -82,7 +82,7 @@ Install the required Python packages using pip. (If you encounter any compilatio
!!! note
If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip3 -V`.
### NAPALM Automation (Optional)
## NAPALM Automation (Optional)
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3:
@ -90,7 +90,7 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automati
# pip3 install napalm
```
### Webhooks (Optional)
## Webhooks (Optional)
[Webhooks](../data-model/extras/#webhooks) allow NetBox to integrate with external services by pushing out a notification each time a relevant object is created, updated, or deleted. Enabling the webhooks feature requires [Redis](https://redis.io/), a lightweight in-memory database. You may opt to install a Redis sevice locally (see below) or connect to an external one.

View File

@ -54,6 +54,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 = {
@ -145,6 +155,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 = {
@ -214,6 +234,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 = {

View File

@ -511,10 +511,14 @@ class ConsoleServerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer(read_only=True)
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = ConsoleServerPort
fields = ['id', 'url', 'device', 'name']
fields = ['id', 'url', 'device', 'name', 'is_connected']
def get_is_connected(self, obj):
return hasattr(obj, 'connected_console') and obj.connected_console is not None
#
@ -531,6 +535,19 @@ 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)
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = ConsolePort
fields = ['id', 'url', 'device', 'name', 'is_connected']
def get_is_connected(self, obj):
return obj.cs_port is not None
#
# Power outlets
#
@ -548,10 +565,14 @@ class PowerOutletSerializer(TaggitSerializer, ValidatedModelSerializer):
class NestedPowerOutletSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer(read_only=True)
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = PowerOutlet
fields = ['id', 'url', 'device', 'name']
fields = ['id', 'url', 'device', 'name', 'is_connected']
def get_is_connected(self, obj):
return hasattr(obj, 'connected_port') and obj.connected_port is not None
#
@ -568,17 +589,46 @@ 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)
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = PowerPort
fields = ['id', 'url', 'device', 'name', 'is_connected']
def get_is_connected(self, obj):
return obj.power_outlet is not None
#
# Interfaces
#
class NestedInterfaceSerializer(WritableNestedSerializer):
class IsConnectedMixin(object):
"""
Provide a method for setting is_connected on Interface serializers.
"""
def get_is_connected(self, obj):
"""
Return True if the interface has a connected interface or circuit.
"""
if obj.connection:
return True
if hasattr(obj, 'circuit_termination') and obj.circuit_termination is not None:
return True
return False
class NestedInterfaceSerializer(IsConnectedMixin, WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Interface
fields = ['id', 'url', 'device', 'name']
fields = ['id', 'url', 'device', 'name', 'is_connected']
class InterfaceNestedCircuitSerializer(serializers.ModelSerializer):
@ -608,7 +658,7 @@ class InterfaceVLANSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'vid', 'name', 'display_name']
class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
class InterfaceSerializer(TaggitSerializer, IsConnectedMixin, ValidatedModelSerializer):
device = NestedDeviceSerializer()
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
@ -652,19 +702,6 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
return super(InterfaceSerializer, self).validate(data)
def get_is_connected(self, obj):
"""
Return True if the interface has a connected interface or circuit termination.
"""
if obj.connection:
return True
try:
circuit_termination = obj.circuit_termination
return True
except CircuitTermination.DoesNotExist:
pass
return False
def get_interface_connection(self, obj):
if obj.connection:
context = {
@ -736,10 +773,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']
#

View File

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

View File

@ -1421,7 +1421,7 @@ class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelF
label='Port',
widget=APISelect(
api_url='/api/dcim/console-server-ports/?device_id={{console_server}}',
disabled_indicator='connected_console',
disabled_indicator='is_connected',
)
)
@ -1512,7 +1512,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.
label='Port',
widget=APISelect(
api_url='/api/dcim/console-ports/?device_id={{device}}',
disabled_indicator='cs_port'
disabled_indicator='is_connected'
)
)
connection_status = forms.BooleanField(
@ -1690,7 +1690,7 @@ class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
label='Outlet',
widget=APISelect(
api_url='/api/dcim/power-outlets/?device_id={{pdu}}',
disabled_indicator='connected_port'
disabled_indicator='is_connected'
)
)
@ -1781,7 +1781,7 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
label='Port',
widget=APISelect(
api_url='/api/dcim/power-ports/?device_id={{device}}',
disabled_indicator='power_outlet'
disabled_indicator='is_connected'
)
)
connection_status = forms.BooleanField(

View File

@ -1465,7 +1465,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 "
@ -2088,25 +2088,44 @@ class InterfaceConnection(models.Model):
csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
def clean(self):
try:
if self.interface_a == self.interface_b:
raise ValidationError({
'interface_b': "Cannot connect an interface to itself."
})
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_a': '{} is not a connectable interface type.'.format(
self.interface_a.get_form_factor_display()
)
})
if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_b': '{} is not a connectable interface type.'.format(
self.interface_b.get_form_factor_display()
)
})
except ObjectDoesNotExist:
pass
# An interface cannot be connected to itself
if self.interface_a == self.interface_b:
raise ValidationError({
'interface_b': "Cannot connect an interface to itself."
})
# Only connectable interface types are permitted
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_a': '{} is not a connectable interface type.'.format(
self.interface_a.get_form_factor_display()
)
})
if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_b': '{} is not a connectable interface type.'.format(
self.interface_b.get_form_factor_display()
)
})
# Prevent the A side of one connection from being the B side of another
interface_a_connections = InterfaceConnection.objects.filter(
Q(interface_a=self.interface_a) |
Q(interface_b=self.interface_a)
).exclude(pk=self.pk)
if interface_a_connections.exists():
raise ValidationError({
'interface_a': "This interface is already connected."
})
interface_b_connections = InterfaceConnection.objects.filter(
Q(interface_a=self.interface_b) |
Q(interface_b=self.interface_b)
).exclude(pk=self.pk)
if interface_b_connections.exists():
raise ValidationError({
'interface_b': "This interface is already connected."
})
def to_csv(self):
return (

View File

@ -42,6 +42,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 = {
@ -156,6 +166,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 = {
@ -260,6 +280,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 = {
@ -358,6 +388,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 = {
@ -475,6 +515,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 = {
@ -691,6 +741,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 = {
@ -790,6 +850,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 = {
@ -1494,6 +1564,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 = {
@ -1592,6 +1672,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 = {
@ -1720,6 +1810,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 = {
@ -1846,6 +1946,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', 'is_connected', 'name', 'url']
)
def test_create_consoleport(self):
data = {
@ -1951,6 +2061,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', 'is_connected', 'name', 'url']
)
def test_create_consoleserverport(self):
data = {
@ -2052,6 +2172,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', 'is_connected', 'name', 'url']
)
def test_create_powerport(self):
data = {
@ -2157,6 +2287,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', 'is_connected', 'name', 'url']
)
def test_create_poweroutlet(self):
data = {
@ -2283,6 +2423,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', 'is_connected', 'name', 'url']
)
def test_create_interface(self):
data = {
@ -2454,6 +2604,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 = {
@ -2776,6 +2936,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 = {
@ -2971,6 +3141,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 = {

View File

@ -32,6 +32,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 = {
@ -123,6 +133,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 = {
@ -216,6 +236,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 = {
@ -307,6 +337,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 = {
@ -395,13 +435,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 = {
@ -628,6 +678,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 = {
@ -716,6 +776,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 = {
@ -807,6 +877,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 = {

View File

@ -989,6 +989,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'

View File

@ -23,8 +23,9 @@ admin_site.register(User, UserAdmin)
admin_site.register(Tag, TagAdmin)
# Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK)
try:
import django_rq
admin_site.index_template = 'django_rq/index.html'
except ImportError:
pass
if settings.WEBHOOKS_ENABLED:
try:
import django_rq
admin_site.index_template = 'django_rq/index.html'
except ImportError:
pass

View File

@ -82,7 +82,7 @@ $(document).ready(function() {
}
if ($(parent).val() || $(parent).attr('nullable') == 'true') {
var api_url = child_field.attr('api-url');
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';

View File

@ -71,6 +71,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 = {

View File

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

View File

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

View File

@ -29,6 +29,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 = {
@ -122,6 +132,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 = {

View 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={},
),
]

View File

@ -39,7 +39,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.

View File

@ -1,8 +1,8 @@
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
@ -217,8 +217,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)
@ -260,7 +264,8 @@ class TokenEditView(LoginRequiredMixin, View):
})
class TokenDeleteView(LoginRequiredMixin, View):
class TokenDeleteView(PermissionRequiredMixin, View):
permission_required = 'users.delete_token'
def get(self, request, pk):

View File

@ -190,6 +190,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):
"""

View File

@ -2,6 +2,7 @@ import csv
from io import StringIO
import json
import re
import sys
from django import forms
from django.conf import settings
@ -148,6 +149,11 @@ def add_blank_choice(choices):
return ((None, '---------'),) + tuple(choices)
def utf8_encoder(data):
for line in data:
yield line.encode('utf-8')
#
# Widgets
#
@ -301,7 +307,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)

View File

@ -167,7 +167,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']

View File

@ -54,6 +54,11 @@ 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
@ -63,3 +68,10 @@ class InterfaceViewSet(ModelViewSet):
).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

View File

@ -251,7 +251,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
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",

View File

@ -33,6 +33,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 = {
@ -124,6 +134,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 = {
@ -218,6 +238,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 = {
@ -322,6 +352,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 = {
@ -445,6 +485,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 = {