Compare commits

...

83 Commits

Author SHA1 Message Date
Jeremy Stretch
74d525364a Merge pull request #2494 from digitalocean/develop
Release v2.4.6
2018-10-05 15:48:11 -04:00
Jeremy Stretch
c2f4cf3407 Release v2.4.6 2018-10-05 15:43:43 -04:00
Jeremy Stretch
83f3dc99ce Changelog entry for #2492 2018-10-05 15:39:30 -04:00
Jeremy Stretch
44d3606ce1 Fixes #2492: Sanitize hostname and port values returned through LLDP 2018-10-05 15:38:32 -04:00
Jeremy Stretch
470d22c835 PEP8 fix 2018-10-05 15:36:48 -04:00
Jeremy Stretch
4c37628784 Fixes #2393: Fix Unicode support for CSV import under Python 2 2018-10-05 15:33:29 -04:00
Marc Heckmann
1daf7f8e2b Sanitize hostname and port values returned through LLDP
If hostname or port are null set to empty string ("").

This avoids breaking the LLDP neighbors (NAPALM) view
2018-10-05 14:30:54 -04:00
Jeremy Stretch
841db3b0c2 Fixes #2491: Fix exception when importing devices with invalid device type 2018-10-05 12:22:46 -04:00
Jeremy Stretch
5d10d8418e Closes #2479: Add user permissions for creating/modifying API tokens 2018-10-05 11:06:59 -04:00
Jeremy Stretch
2fee977b4c Fixes #2485: Fix cancel button when assigning a service to a device/VM 2018-10-05 10:30:13 -04:00
Jeremy Stretch
52f1b1c3bf Changelog entry for #2487 2018-10-04 16:24:09 -04:00
Jeremy Stretch
ab53d0e863 Merge pull request #2488 from digitalocean/2487-api-brief
Closes #2487: Enable brief API output utilizing nested serializers
2018-10-04 16:22:30 -04:00
Jeremy Stretch
259da2d18a #2487: Added API tests 2018-10-04 16:20:01 -04:00
Jeremy Stretch
bf47e7cae3 #2487: Require the 'brief' parameter to evaluate True 2018-10-04 14:50:57 -04:00
Jeremy Stretch
db2721c581 Enable brief API output utilizing nested serializers 2018-10-04 13:43:50 -04:00
John Anderson
1b2e9a6d06 fixes #2484 - Local config context not available on the Virtual Machine Edit Form 2018-10-03 17:16:01 -04:00
Jeremy Stretch
99848aab6a Fixes #2483: Set max item count of API-populated form fields to MAX_PAGE_SIZE 2018-10-03 16:17:17 -04:00
Jeremy Stretch
7583912de7 Post-release version bump 2018-10-02 15:30:28 -04:00
Jeremy Stretch
125975832b Merge pull request #2478 from digitalocean/develop
Release v2.4.5
2018-10-02 15:29:13 -04:00
Jeremy Stretch
20fed375d1 Release v2.4.5 2018-10-02 15:24:42 -04:00
Jeremy Stretch
fc1b3d6927 Fixes #2471: Fix ReadTheDocs theme 2018-10-02 11:51:53 -04:00
Jeremy Stretch
aed2a3cd1b Closes #2438: API optimizations for tagged objects 2018-09-28 16:44:05 -04:00
Jeremy Stretch
15babeb584 Fixes #2414: Tags field missing from device/VM component creation forms 2018-09-28 16:26:08 -04:00
Jeremy Stretch
020b5ea870 Fixes #2470: Log the creation of device/VM components as object changes 2018-09-28 16:04:51 -04:00
Jeremy Stretch
2ee5b2344e Changelog and misc cleanup 2018-09-28 14:21:49 -04:00
Jeremy Stretch
7616bcad3d Merge pull request #2445 from digitalocean/local-config-context
Local config context
2018-09-28 14:03:28 -04:00
John Anderson
f76ce980e3 remove templates no longer needed for local config context 2018-09-26 10:30:34 -04:00
Jeremy Stretch
9440ac7640 Fixes #2455: Ignore unique address enforcement for IPs with a shared/virtual role 2018-09-24 16:59:33 -04:00
Jeremy Stretch
0e18997c79 Merge pull request #2446 from hellerve/patch-1
docs: typo fix in devices
2018-09-19 09:47:46 -04:00
Veit Heller
95464772ac docs: typo fix in devices 2018-09-19 10:57:09 +02:00
Jeremy Stretch
b4445dfdf8 Fixes #2442: Nullify "next" link in API when limit=0 is passed 2018-09-18 13:59:50 -04:00
John Anderson
fb5dca2711 Merge branch 'develop' of github.com:digitalocean/netbox into local-config-context 2018-09-18 12:16:07 -04:00
Jeremy Stretch
6cdff955dc Fixes #2444: Improve validation of interface MAC addresses 2018-09-18 12:02:59 -04:00
John Anderson
4039753b2f refactored UI for local config context 2018-09-18 11:52:12 -04:00
Jeremy Stretch
9df33cef8b Fixes #2443: Enforce JSON object format when creating config contexts 2018-09-18 11:46:22 -04:00
John Anderson
e3e9211e8a PEP8 fix 2018-09-16 00:30:51 -04:00
John Anderson
0da113b723 implemnted #2392 - local config context for devices and VMs 2018-09-16 00:25:20 -04:00
John Anderson
e965adad7c changelog for #2432 2018-09-15 17:25:50 -04:00
John Anderson
57b225b680 fixes #2423 - interface connection links 2018-09-15 17:23:58 -04:00
Jeremy Stretch
b97597c645 Merge pull request #2421 from sieben/docs_community
Add content about related projects
2018-09-13 12:29:17 -04:00
Rémy Léone
162828da90 Add a page related to community related projects 2018-09-13 17:54:13 +02:00
Jeremy Stretch
292647da14 Closes #2402: Order and format JSON data in form fields 2018-09-13 11:31:34 -04:00
Jeremy Stretch
3a88e43103 Fixes #2406: Remove hard-coded limit of 1000 objects from API-populated form fields 2018-09-13 11:21:40 -04:00
Jeremy Stretch
010765e131 Post-release version bump 2018-08-22 11:55:51 -04:00
Jeremy Stretch
bcf22831e2 Merge pull request #2387 from digitalocean/develop
Release v2.4.4
2018-08-22 11:53:56 -04:00
Jeremy Stretch
cde6e9757b Release v2.4.4 2018-08-22 11:51:15 -04:00
Jeremy Stretch
f2d9a3e0a1 Added note about CHANGELOG to release checklist 2018-08-22 11:50:25 -04:00
Jeremy Stretch
b917e8d3b0 #2376: Add libapache2-mod-wsgi-py3 to CentOS installation section 2018-08-22 11:46:13 -04:00
Jeremy Stretch
3b26ce6501 Merge pull request #2386 from digitalocean/revert-2376-patch-1
Revert "Add missing library"
2018-08-22 11:44:31 -04:00
Jeremy Stretch
1b2d3bf08b Revert "Add missing library" 2018-08-22 11:44:07 -04:00
Jeremy Stretch
492bc9f86e Merge pull request #2376 from craig/patch-1
Add missing library
2018-08-22 11:43:46 -04:00
Jeremy Stretch
a457a73826 Merge pull request #2382 from consentfactory/develop
Fixed typo for supervisorctl
2018-08-22 11:41:12 -04:00
Jeremy Stretch
ac36339491 Closes #2168: Added Extreme SummitStack interface form factors 2018-08-22 11:33:43 -04:00
Jeremy Stretch
dbbf7ab664 Fixes #2353: Handle DoesNotExist exception when deleting a device with connected interfaces 2018-08-22 10:35:56 -04:00
Jeremy Stretch
66400a98f1 Fixes #2354: Increased maximum MTU for interfaces to 65536 bytes 2018-08-22 10:25:07 -04:00
Jeremy Stretch
aa50e2e385 Fixes #2378: Corrected "edit" link for virtual machine interfaces 2018-08-22 10:06:01 -04:00
Jimmy Taylor
118b8db209 Fixed typo for supervisorctl 2018-08-21 08:28:23 -06:00
Craig
967feb6931 Add missing library
WSGIPassAuthorization fails if libapache2-mod-wsgi-py3 is missing
2018-08-21 00:41:29 +02:00
Jeremy Stretch
e1e41a768a Fixes #2369: Corrected time zone validation on site API serializer 2018-08-20 16:53:23 -04:00
Jeremy Stretch
c333af33dc Fixes #2370: Redirect to parent device after deleting device bays 2018-08-20 14:40:19 -04:00
Jeremy Stretch
9e5b482b1d Fixes #2374: Fix toggling display of IP addresses in virtual machine interfaces list 2018-08-20 13:49:15 -04:00
John Anderson
771747147c #2254 changelog entry 2018-08-17 18:41:58 -04:00
John Anderson
bc49979243 added rack group search #2254 2018-08-17 18:37:48 -04:00
Jeremy Stretch
d46b3e2446 #2368: Append changelog 2018-08-17 14:32:51 -04:00
Jeremy Stretch
2804d89c5e Fixes #2368: Record change in device changelog when altering cluster assignment 2018-08-17 14:26:50 -04:00
Jeremy Stretch
fd32a71131 Rename changelog 2018-08-16 16:29:12 -04:00
Jeremy Stretch
1556fd0e92 Added a release changelog 2018-08-16 16:27:41 -04:00
Jeremy Stretch
5dce7c4e48 Closes #2356: Include cluster site as read-only field in VirtualMachine serializer 2018-08-16 11:57:20 -04:00
Jeremy Stretch
4bfc32ec99 Closes #2355: Added item count to inventory tab on device view 2018-08-16 10:20:22 -04:00
Jeremy Stretch
ff65f7fd7b Fixes #2362: Implemented custom admin site to properly handle BASE_PATH 2018-08-16 09:44:00 -04:00
Jeremy Stretch
cd2aee3053 Post-release version bump 2018-08-09 16:41:11 -04:00
Jeremy Stretch
f224ad2959 Merge pull request #2346 from digitalocean/develop
Release v2.4.3
2018-08-09 16:39:45 -04:00
Jeremy Stretch
9d9318f38a Corrected typo 2018-08-09 16:37:58 -04:00
Jeremy Stretch
f43d861b50 Release v2.4.3 2018-08-09 16:36:23 -04:00
Jeremy Stretch
17714b0c12 Fixes #2342: IntegrityError raised when attempting to assign an invalid IP address as the primary for a VM 2018-08-09 16:34:17 -04:00
Jeremy Stretch
9914576eaa Fixes #2344: AttributeError when assigning VLANs to an interface on a device/VM not assigned to a site 2018-08-09 15:46:18 -04:00
Jeremy Stretch
bf8eff11ea Closes #2333: Added search filters for ConfigContexts 2018-08-09 12:22:34 -04:00
Jeremy Stretch
a6c78b99c4 Fixes #2340: API requires manufacturer field when creating/updating an inventory item 2018-08-09 09:34:54 -04:00
Jeremy Stretch
6a56ffc650 Fixes #2337: Attempting to create the next available prefix within a parent assigned to a VRF raises an AssertionError 2018-08-08 16:16:49 -04:00
Jeremy Stretch
05059606c5 Fixes #2336: Bulk deleting power outlets and console server ports from a device redirects to home page 2018-08-08 15:22:26 -04:00
Jeremy Stretch
a2ff21fab9 Fixes #2334: TypeError raised when WritableNestedSerializer receives a non-integer value 2018-08-08 15:09:30 -04:00
Jeremy Stretch
134370f48d Fixes #2335: API requires group field when creating/updating a rack 2018-08-08 14:58:16 -04:00
Jeremy Stretch
c7fa610842 Post-release version bump 2018-08-08 09:19:33 -04:00
75 changed files with 2769 additions and 227 deletions

1693
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

@@ -12,5 +12,5 @@ While NetBox has many configuration settings, only a few of them must be defined
Configuration settings may be changed at any time. However, the NetBox service must be restarted before the changes will take effect:
```no-highlight
# sudo supervsiorctl restart netbox
# sudo supervisorctl restart netbox
```

View File

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

View File

@@ -48,9 +48,9 @@ Close the release milestone on GitHub. Ensure that there are no remaining open i
Ensure that continuous integration testing on the `develop` branch is completing successfully.
## Update VERSION
## Update Version and Changelog
Update the `VERSION` constant in `settings.py` to the new release.
Update the `VERSION` constant in `settings.py` to the new release version and add the current date to the release notes in `CHANGELOG.md`.
## Submit a Pull Request

View File

@@ -56,7 +56,7 @@ To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https:
## Option B: Apache
```no-highlight
# apt-get install -y apache2
# apt-get install -y apache2 libapache2-mod-wsgi-py3
```
Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately):

View File

@@ -1,4 +1,5 @@
site_name: NetBox
theme: readthedocs
repo_url: https://github.com/digitalocean/netbox
pages:

View File

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

View File

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

View File

@@ -120,7 +120,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer()
group = NestedRackGroupSerializer(required=False, allow_null=True)
group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
tenant = NestedTenantSerializer(required=False, allow_null=True)
role = NestedRackRoleSerializer(required=False, allow_null=True)
type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False, allow_null=True)
@@ -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']
#
@@ -666,7 +685,7 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer()
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
tags = TagListSerializerField(required=False)
class Meta:

View File

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

View File

@@ -93,6 +93,11 @@ IFACE_FF_STACKWISE_PLUS = 5050
IFACE_FF_FLEXSTACK = 5100
IFACE_FF_FLEXSTACK_PLUS = 5150
IFACE_FF_JUNIPER_VCP = 5200
IFACE_FF_SUMMITSTACK = 5300
IFACE_FF_SUMMITSTACK128 = 5310
IFACE_FF_SUMMITSTACK256 = 5320
IFACE_FF_SUMMITSTACK512 = 5330
# Other
IFACE_FF_OTHER = 32767
@@ -168,6 +173,10 @@ IFACE_FF_CHOICES = [
[IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
[IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
[IFACE_FF_JUNIPER_VCP, 'Juniper VCP'],
[IFACE_FF_SUMMITSTACK, 'Extreme SummitStack'],
[IFACE_FF_SUMMITSTACK128, 'Extreme SummitStack-128'],
[IFACE_FF_SUMMITSTACK256, 'Extreme SummitStack-256'],
[IFACE_FF_SUMMITSTACK512, 'Extreme SummitStack-512'],
]
],
[

View File

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

View File

@@ -112,6 +112,10 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
class RackGroupFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -127,6 +131,15 @@ class RackGroupFilter(django_filters.FilterSet):
model = RackGroup
fields = ['site_id', 'name', 'slug']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(slug__icontains=value)
)
return queryset.filter(qs_filter)
class RackRoleFilter(django_filters.FilterSet):

View File

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

View File

@@ -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):
@@ -1795,7 +1801,7 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
# Compile VLAN choices
vlan_choices = []
# Add global VLANs
# Add non-grouped global VLANs
global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
vlan_choices.append((
'Global', [(vlan.pk, vlan) for vlan in global_vlans])
@@ -1808,16 +1814,15 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
)
parent = self.instance.parent
if parent is not None:
site = getattr(self.instance.parent, 'site', None)
if site is not None:
# Add site VLANs
if parent.site:
site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans)
vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add non-grouped site VLANs
site_vlans = VLAN.objects.filter(site=site, group=None).exclude(pk__in=assigned_vlans)
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs
for group in VLANGroup.objects.filter(site=parent.site):
for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
vlan_choices.append((
'{} / {}'.format(group.site.name, group.name),
@@ -1855,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',
@@ -1863,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):
@@ -2100,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):

View File

@@ -0,0 +1,29 @@
# Generated by Django 2.0.8 on 2018-08-22 14:23
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0061_platform_napalm_args'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='mtu',
field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65536)], verbose_name='MTU'),
),
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
),
]

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

View File

@@ -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 "
@@ -1809,9 +1809,10 @@ class Interface(ComponentModel):
blank=True,
verbose_name='MAC Address'
)
mtu = models.PositiveSmallIntegerField(
mtu = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1), MaxValueValidator(65536)],
verbose_name='MTU'
)
mgmt_only = models.BooleanField(
@@ -2071,6 +2072,7 @@ class InterfaceConnection(models.Model):
(self.interface_a, self.interface_b),
(self.interface_b, self.interface_a),
)
for interface, peer_interface in interfaces:
if action == OBJECTCHANGE_ACTION_DELETE:
connection_data = {
@@ -2081,11 +2083,17 @@ class InterfaceConnection(models.Model):
'connected_interface': peer_interface.pk,
'connection_status': self.connection_status
}
try:
parent_obj = interface.parent
except ObjectDoesNotExist:
parent_obj = None
ObjectChange(
user=user,
request_id=request_id,
changed_object=interface,
related_object=interface.parent,
related_object=parent_obj,
action=OBJECTCHANGE_ACTION_UPDATE,
object_data=serialize_object(interface, extra=connection_data)
).save()

View File

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

View File

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

View File

@@ -4,12 +4,9 @@ from django import forms
from django.contrib import admin
from django.utils.safestring import mark_safe
from netbox.admin import admin_site
from utilities.forms import LaxURLField
from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
from .models import (
ConfigContext, CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction,
Webhook,
)
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction, Webhook
def order_content_types(field):
@@ -39,7 +36,7 @@ class WebhookForm(forms.ModelForm):
order_content_types(self.fields['obj_type'])
@admin.register(Webhook)
@admin.register(Webhook, site=admin_site)
class WebhookAdmin(admin.ModelAdmin):
list_display = [
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update',
@@ -72,7 +69,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
extra = 5
@admin.register(CustomField)
@admin.register(CustomField, site=admin_site)
class CustomFieldAdmin(admin.ModelAdmin):
inlines = [CustomFieldChoiceAdmin]
list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description']
@@ -86,7 +83,7 @@ class CustomFieldAdmin(admin.ModelAdmin):
# Graphs
#
@admin.register(Graph)
@admin.register(Graph, site=admin_site)
class GraphAdmin(admin.ModelAdmin):
list_display = ['name', 'type', 'weight', 'source']
@@ -109,7 +106,7 @@ class ExportTemplateForm(forms.ModelForm):
self.fields['content_type'].choices.insert(0, ('', '---------'))
@admin.register(ExportTemplate)
@admin.register(ExportTemplate, site=admin_site)
class ExportTemplateAdmin(admin.ModelAdmin):
list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension']
form = ExportTemplateForm
@@ -119,7 +116,7 @@ class ExportTemplateAdmin(admin.ModelAdmin):
# Topology maps
#
@admin.register(TopologyMap)
@admin.register(TopologyMap, site=admin_site)
class TopologyMapAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'site']
prepopulated_fields = {
@@ -131,7 +128,7 @@ class TopologyMapAdmin(admin.ModelAdmin):
# User actions
#
@admin.register(UserAction)
@admin.register(UserAction, site=admin_site)
class UserActionAdmin(admin.ModelAdmin):
actions = None
list_display = ['user', 'action', 'content_type', 'object_id', '_message']

View File

@@ -138,8 +138,11 @@ class ImageAttachmentViewSet(ModelViewSet):
#
class ConfigContextViewSet(ModelViewSet):
queryset = ConfigContext.objects.prefetch_related('regions', 'sites', 'roles', 'platforms', 'tenants')
queryset = ConfigContext.objects.prefetch_related(
'regions', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
)
serializer_class = serializers.ConfigContextSerializer
filter_class = filters.ConfigContextFilter
#

View File

@@ -6,9 +6,10 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from taggit.models import Tag
from dcim.models import Site
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
from .models import CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction
class CustomFieldFilter(django_filters.Filter):
@@ -124,6 +125,92 @@ class TopologyMapFilter(django_filters.FilterSet):
fields = ['name', 'slug']
class ConfigContextFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
region_id = django_filters.ModelMultipleChoiceFilter(
name='regions',
queryset=Region.objects.all(),
label='Region',
)
region = django_filters.ModelMultipleChoiceFilter(
name='regions__slug',
queryset=Region.objects.all(),
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='sites',
queryset=Site.objects.all(),
label='Site',
)
site = django_filters.ModelMultipleChoiceFilter(
name='sites__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
name='roles',
queryset=DeviceRole.objects.all(),
label='Role',
)
role = django_filters.ModelMultipleChoiceFilter(
name='roles__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label='Role (slug)',
)
platform_id = django_filters.ModelMultipleChoiceFilter(
name='platforms',
queryset=Platform.objects.all(),
label='Platform',
)
platform = django_filters.ModelMultipleChoiceFilter(
name='platforms__slug',
queryset=Platform.objects.all(),
to_field_name='slug',
label='Platform (slug)',
)
tenant_group_id = django_filters.ModelMultipleChoiceFilter(
name='tenant_groups',
queryset=TenantGroup.objects.all(),
label='Tenant group',
)
tenant_group = django_filters.ModelMultipleChoiceFilter(
name='tenant_groups__slug',
queryset=TenantGroup.objects.all(),
to_field_name='slug',
label='Tenant group (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
name='tenants',
queryset=Tenant.objects.all(),
label='Tenant',
)
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenants__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
class Meta:
model = ConfigContext
fields = ['name', 'is_active']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(data__icontains=value)
)
class ObjectChangeFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',

View File

@@ -10,8 +10,12 @@ from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField
from taggit.models import Tag
from dcim.models import Region
from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, JSONField, SlugField
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditForm, FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField,
JSONField, SlugField,
)
from .constants import (
CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
OBJECTCHANGE_ACTION_CHOICES,
@@ -223,6 +227,37 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
]
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
label='Search'
)
region = FilterTreeNodeMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug'
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug'
)
role = FilterChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='slug'
)
platform = FilterChoiceField(
queryset=Platform.objects.all(),
to_field_name='slug'
)
tenant_group = FilterChoiceField(
queryset=TenantGroup.objects.all(),
to_field_name='slug'
)
tenant = FilterChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug'
)
#
# Image attachments
#

View File

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

View File

@@ -72,15 +72,10 @@ class ConfigContextTable(BaseTable):
is_active = BooleanColumn(
verbose_name='Active'
)
actions = tables.TemplateColumn(
template_code=CONFIGCONTEXT_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = ConfigContext
fields = ('pk', 'name', 'weight', 'is_active', 'description', 'actions')
fields = ('pk', 'name', 'weight', 'is_active', 'description')
class ObjectChangeTable(BaseTable):

View File

@@ -14,7 +14,7 @@ from taggit.models import Tag
from utilities.forms import ConfirmationForm
from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters
from .forms import ConfigContextForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm
from .forms import ConfigContextForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
from .reports import get_report, get_reports
from .tables import ConfigContextTable, ObjectChangeTable, TagTable
@@ -56,6 +56,8 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ConfigContextListView(ObjectListView):
queryset = ConfigContext.objects.all()
filter = filters.ConfigContextFilter
filter_form = ConfigContextFilterForm
table = ConfigContextTable
template_name = 'extras/configcontext_list.html'
@@ -104,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,

View File

@@ -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
@@ -140,10 +140,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
available_prefixes.remove(allocated_prefix)
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True)
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
else:
serializer = serializers.PrefixSerializer(data=requested_prefixes[0])
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
# Create the new Prefix(es)
if serializer.is_valid():
@@ -199,10 +200,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True)
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
else:
serializer = serializers.IPAddressSerializer(data=requested_ips[0])
serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
# Create the new IP address(es)
if serializer.is_valid():
@@ -243,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
@@ -264,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
@@ -274,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

View File

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

View File

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

View File

@@ -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 = {
@@ -494,7 +544,8 @@ class PrefixTest(APITestCase):
def test_create_single_available_prefix(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True)
vrf = VRF.objects.create(name='Test VRF 1', rd='1234')
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
# Create four available prefixes with individual requests
@@ -512,6 +563,7 @@ class PrefixTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['prefix'], prefixes_to_be_created[i])
self.assertEqual(response.data['vrf']['id'], vrf.pk)
self.assertEqual(response.data['description'], data['description'])
# Try to create one more prefix
@@ -562,7 +614,8 @@ class PrefixTest(APITestCase):
def test_create_single_available_ip(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), is_pool=True)
vrf = VRF.objects.create(name='Test VRF 1', rd='1234')
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), vrf=vrf, is_pool=True)
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
# Create all four available IPs with individual requests
@@ -572,6 +625,7 @@ class PrefixTest(APITestCase):
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['vrf']['id'], vrf.pk)
self.assertEqual(response.data['description'], data['description'])
# Try to create one more IP
@@ -626,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 = {
@@ -714,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 = {
@@ -805,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 = {

View File

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

View File

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

30
netbox/netbox/admin.py Normal file
View File

@@ -0,0 +1,30 @@
from django.conf import settings
from django.contrib.admin import AdminSite
from django.contrib.auth.models import Group, User
from django.contrib.auth.admin import GroupAdmin, UserAdmin
from taggit.admin import TagAdmin
from taggit.models import Tag
class NetBoxAdminSite(AdminSite):
"""
Custom admin site
"""
site_header = 'NetBox Administration'
site_title = 'NetBox'
site_url = '/{}'.format(settings.BASE_PATH)
admin_site = NetBoxAdminSite(name='admin')
# Register external models
admin_site.register(Group, GroupAdmin)
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

View File

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

View File

@@ -13,6 +13,7 @@ OBJ_TYPE_CHOICES = (
('DCIM', (
('site', 'Sites'),
('rack', 'Racks'),
('rackgroup', 'Rack Groups'),
('devicetype', 'Device types'),
('device', 'Devices'),
('virtualchassis', 'Virtual Chassis'),

View File

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning
)
VERSION = '2.4.2'
VERSION = '2.4.6'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -272,7 +272,6 @@ RQ_QUEUES = {
'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT,
}
}
RQ_SHOW_ADMIN_LINK = True
# drf_yasg settings for Swagger
SWAGGER_SETTINGS = {

View File

@@ -2,13 +2,13 @@ from __future__ import unicode_literals
from django.conf import settings
from django.conf.urls import include, url
from django.contrib import admin
from django.views.static import serve
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from netbox.views import APIRootView, HomeView, SearchView
from users.views import LoginView, LogoutView
from .admin import admin_site
schema_view = get_schema_view(
openapi.Info(
@@ -60,7 +60,7 @@ _patterns = [
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
# Admin
url(r'^admin/', admin.site.urls),
url(r'^admin/', admin_site.urls),
]
@@ -69,7 +69,6 @@ if settings.WEBHOOKS_ENABLED:
url(r'^admin/webhook-backend-status/', include('django_rq.urls')),
]
if settings.DEBUG:
import debug_toolbar
_patterns += [

View File

@@ -12,9 +12,16 @@ from rest_framework.views import APIView
from circuits.filters import CircuitFilter, ProviderFilter
from circuits.models import Circuit, Provider
from circuits.tables import CircuitTable, ProviderTable
from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter, VirtualChassisFilter
from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site, VirtualChassis
from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable, VirtualChassisTable
from dcim.filters import (
DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
)
from dcim.models import (
ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, RackGroup, Site,
VirtualChassis
)
from dcim.tables import (
DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
)
from extras.models import ObjectChange, ReportResult, TopologyMap
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
@@ -58,6 +65,12 @@ SEARCH_TYPES = OrderedDict((
'table': RackTable,
'url': 'dcim:rack_list',
}),
('rackgroup', {
'queryset': RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')),
'filter': RackGroupFilter,
'table': RackGroupTable,
'url': 'dcim:rackgroup_list',
}),
('devicetype', {
'queryset': DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')),
'filter': DeviceTypeFilter,

View 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';

View File

@@ -3,11 +3,12 @@ from __future__ import unicode_literals
from django.contrib import admin, messages
from django.shortcuts import redirect, render
from netbox.admin import admin_site
from .forms import ActivateUserKeyForm
from .models import UserKey
@admin.register(UserKey)
@admin.register(UserKey, site=admin_site)
class UserKeyAdmin(admin.ModelAdmin):
actions = ['activate_selected']
list_display = ['user', 'is_filled', 'is_active', 'created']

View File

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

View File

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

View File

@@ -54,7 +54,9 @@
<a href="{% url 'dcim:device' pk=device.pk %}">Device</a>
</li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">
Inventory <span class="badge">{{ device.inventory_items.count }}</span>
</a>
</li>
{% if perms.dcim.napalm_read %}
{% if device.status != 1 %}
@@ -445,7 +447,7 @@
<div class="col-md-12">
{% if device_bays or device.device_type.is_parent_device %}
{% if perms.dcim.delete_devicebay %}
<form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}">
<form method="post">
{% csrf_token %}
{% endif %}
<div class="panel panel-default">
@@ -481,7 +483,7 @@
</button>
{% endif %}
{% if device_bays and perms.dcim.delete_devicebay %}
<button type="submit" class="btn btn-danger btn-xs">
<button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
@@ -573,7 +575,7 @@
{% endif %}
{% if cs_ports or device.device_type.is_console_server %}
{% if perms.dcim.delete_consoleserverport %}
<form method="post" action="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}">
<form method="post">
{% csrf_token %}
{% endif %}
<div class="panel panel-default">
@@ -606,12 +608,12 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if cs_ports and perms.dcim.delete_consoleserverport %}
<button type="submit" class="btn btn-danger btn-xs">
<button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
@@ -631,7 +633,7 @@
{% endif %}
{% if power_outlets or device.device_type.is_pdu %}
{% if perms.dcim.delete_poweroutlet %}
<form method="post" action="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}">
<form method="post">
{% csrf_token %}
{% endif %}
<div class="panel panel-default">
@@ -664,12 +666,12 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if power_outlets and perms.dcim.delete_poweroutlet %}
<button type="submit" class="btn btn-danger btn-xs">
<button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}

View File

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

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

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

View File

@@ -17,12 +17,12 @@
</div>
<div class="pull-right">
{% if perms.dcim.change_interface %}
<a href="{% url 'dcim:interface_edit' pk=interface.pk %}" class="btn btn-warning">
<a href="{% if interface.device %}{% url 'dcim:interface_edit' pk=interface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=interface.pk %}{% endif %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit this interface
</a>
{% endif %}
{% if perms.dcim.delete_interface %}
<a href="{% url 'dcim:interface_delete' pk=interface.pk %}" class="btn btn-danger">
<a href="{% if interface.device %}{% url 'dcim:interface_delete' pk=interface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=interface.pk %}{% endif %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete this interface
</a>
{% endif %}
@@ -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>

View File

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

View File

@@ -140,6 +140,20 @@
{% endif %}
</td>
</tr>
<tr>
<td>Tenant Groups</td>
<td>
{% if configcontext.tenant_groups.all %}
<ul>
{% for tenant_group in configcontext.tenant_groups.all %}
<li><a href="{{ tenant_group.get_absolute_url }}">{{ tenant_group }}</a></li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Tenants</td>
<td>

View File

@@ -9,8 +9,11 @@
</div>
<h1>{% block title %}Config Contexts{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

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

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

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

View File

@@ -315,9 +315,9 @@
$('button.toggle-ips').click(function() {
var selected = $(this).attr('selected');
if (selected) {
$('#interfaces_table tr.ipaddress').hide();
$('#interfaces_table tr.ipaddresses').hide();
} else {
$('#interfaces_table tr.ipaddress').show();
$('#interfaces_table tr.ipaddresses').show();
}
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
from django import forms
from django.contrib import admin
from netbox.admin import admin_site
from .models import Token
@@ -14,7 +15,7 @@ class TokenAdminForm(forms.ModelForm):
model = Token
@admin.register(Token)
@admin.register(Token, site=admin_site)
class TokenAdmin(admin.ModelAdmin):
form = TokenAdminForm
list_display = ['key', 'user', 'created', 'expires', 'write_enabled', 'description']

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

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

View File

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

View File

@@ -108,10 +108,9 @@ class TimeZoneField(Field):
def to_internal_value(self, data):
if not data:
return ""
try:
return pytz.timezone(str(data))
except pytz.exceptions.UnknownTimeZoneError:
raise ValidationError('Invalid time zone "{}"'.format(data))
if data not in pytz.common_timezones:
raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data))
return pytz.timezone(data)
class SerializedPKRelatedField(PrimaryKeyRelatedField):
@@ -170,7 +169,9 @@ class WritableNestedSerializer(ModelSerializer):
if data is None:
return None
try:
return self.Meta.model.objects.get(pk=data)
return self.Meta.model.objects.get(pk=int(data))
except (TypeError, ValueError):
raise ValidationError("Primary key must be an integer")
except ObjectDoesNotExist:
raise ValidationError("Invalid ID")
@@ -191,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):
"""

View File

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

View File

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

View File

@@ -92,6 +92,7 @@ class VirtualMachineIPAddressSerializer(WritableNestedSerializer):
class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
status = ChoiceField(choices=VM_STATUS_CHOICES, required=False)
site = NestedSiteSerializer(read_only=True)
cluster = NestedClusterSerializer(required=False, allow_null=True)
role = NestedDeviceRoleSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -104,8 +105,9 @@ class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
class Meta:
model = VirtualMachine
fields = [
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'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',
]
@@ -116,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):
@@ -166,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']

View File

@@ -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
@@ -45,7 +45,9 @@ class ClusterViewSet(CustomFieldModelViewSet):
#
class VirtualMachineViewSet(CustomFieldModelViewSet):
queryset = VirtualMachine.objects.all()
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):
@@ -54,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

View File

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

View 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 = [
('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),
),
]

View File

@@ -260,6 +260,22 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
def get_absolute_url(self):
return reverse('virtualization:virtualmachine', args=[self.pk])
def clean(self):
# Validate primary IP addresses
interfaces = self.interfaces.all()
for field in ['primary_ip4', 'primary_ip6']:
ip = getattr(self, field)
if ip is not None:
if ip.interface in interfaces:
pass
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in interfaces:
pass
else:
raise ValidationError({
field: "The specified IP address ({}) is not assigned to this VM.".format(ip),
})
def to_csv(self):
return (
self.name,

View File

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

View File

@@ -2,6 +2,7 @@ from __future__ import unicode_literals
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -183,17 +184,21 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
if form.is_valid():
# Assign the selected Devices to the Cluster
devices = form.cleaned_data['devices']
Device.objects.filter(pk__in=devices).update(cluster=cluster)
device_pks = form.cleaned_data['devices']
with transaction.atomic():
# Assign the selected Devices to the Cluster
for device in Device.objects.filter(pk__in=device_pks):
device.cluster = cluster
device.save()
messages.success(request, "Added {} devices to cluster {}".format(
len(devices), cluster
len(device_pks), cluster
))
return redirect(cluster.get_absolute_url())
return render(request, self.template_name, {
'cluser': cluster,
'cluster': cluster,
'form': form,
'return_url': cluster.get_absolute_url(),
})
@@ -212,12 +217,16 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
form = self.form(request.POST)
if form.is_valid():
# Remove the selected Devices from the Cluster
devices = form.cleaned_data['pk']
Device.objects.filter(pk__in=devices).update(cluster=None)
device_pks = form.cleaned_data['pk']
with transaction.atomic():
# Remove the selected Devices from the Cluster
for device in Device.objects.filter(pk__in=device_pks):
device.cluster = None
device.save()
messages.success(request, "Removed {} devices from cluster {}".format(
len(devices), cluster
len(device_pks), cluster
))
return redirect(cluster.get_absolute_url())