mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-03 23:49:31 +01:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bac3ace8fc | ||
|
|
60deb3f0ba | ||
|
|
eaaaaec5a5 | ||
|
|
5bcf85e57d | ||
|
|
1d466d6fd1 | ||
|
|
57cfb4ed7e | ||
|
|
9fa4cbdfa5 | ||
|
|
5af2b3c2f5 | ||
|
|
2e5058c4c9 | ||
|
|
9fc4a4f24a | ||
|
|
9fd36279ab | ||
|
|
40947f8cb2 | ||
|
|
9abc67bbeb | ||
|
|
16cdf3006f | ||
|
|
15004c654f | ||
|
|
062a319a7c | ||
|
|
ed9ca270a7 | ||
|
|
20ec700045 | ||
|
|
ecd3963b7c | ||
|
|
1ea368856b | ||
|
|
a8077e6ed1 | ||
|
|
7def37961a | ||
|
|
4f830c9c22 | ||
|
|
032f87caec | ||
|
|
e616aad911 | ||
|
|
c2f6f5a7cd | ||
|
|
d3fbaca228 | ||
|
|
ae913f14ce | ||
|
|
1ee79ee61e | ||
|
|
b5ebfd0b07 | ||
|
|
665646707c | ||
|
|
279ae7ea10 | ||
|
|
8cc1dc9f1c | ||
|
|
86e5a09b01 | ||
|
|
1d5f2fbd11 | ||
|
|
4219691e62 | ||
|
|
4ae1879b87 | ||
|
|
d2dce6db25 | ||
|
|
fae115b995 | ||
|
|
8f9dcf5a97 | ||
|
|
91ba44cc96 | ||
|
|
5330914431 | ||
|
|
927c012fc9 | ||
|
|
56f6698ba5 | ||
|
|
edf15532d2 | ||
|
|
d23b18beb5 | ||
|
|
56b7ab1734 | ||
|
|
68599351aa | ||
|
|
c9a7527f33 | ||
|
|
5f9b25453d | ||
|
|
ccc31b2c7c | ||
|
|
e54d441433 | ||
|
|
88cffca270 | ||
|
|
92f49b4711 | ||
|
|
faf3885775 | ||
|
|
f04340679e | ||
|
|
7f5583c7ae | ||
|
|
a5785552d9 | ||
|
|
abcd26da43 | ||
|
|
4545c15173 | ||
|
|
b7cf85e8c8 | ||
|
|
9cde377133 | ||
|
|
74c29b0bb7 | ||
|
|
ff3b348771 | ||
|
|
27700d316f | ||
|
|
1f5d2520c3 | ||
|
|
d2e1428c75 | ||
|
|
cd236aa886 | ||
|
|
3c8e7e739d | ||
|
|
a64351279d | ||
|
|
ba91b3aa2e | ||
|
|
8394ff5537 | ||
|
|
14744da8f6 | ||
|
|
2c2d6c6d47 | ||
|
|
422eeddbef |
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -30,10 +30,9 @@ about: Report a reproducible bug in the current release of NetBox
|
||||
library such as pynetbox.
|
||||
-->
|
||||
### Steps to Reproduce
|
||||
1. Disable any installed plugins by commenting out the `PLUGINS` setting in
|
||||
`configuration.py`.
|
||||
2.
|
||||
3.
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
<!-- What did you expect to happen? -->
|
||||
### Expected Behavior
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@
|
||||
/netbox/static
|
||||
/venv/
|
||||
/*.sh
|
||||
local_requirements.txt
|
||||
!upgrade.sh
|
||||
fabfile.py
|
||||
gunicorn.py
|
||||
|
||||
@@ -24,7 +24,7 @@ Only links which render with non-empty text are included on the page. You can em
|
||||
For example, if you only want to display a link for active devices, you could set the link text to
|
||||
|
||||
```
|
||||
{% if obj.status == 1 %}View NMS{% endif %}
|
||||
{% if obj.status == 'active' %}View NMS{% endif %}
|
||||
```
|
||||
|
||||
The link will not appear when viewing a device with any status other than "active."
|
||||
|
||||
@@ -145,3 +145,18 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f
|
||||
```
|
||||
|
||||
The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty.
|
||||
|
||||
|
||||
## Bulk Object Creation
|
||||
|
||||
The REST API supports the creation of multiple objects of the same type using a single `POST` request. For example, to create multiple devices:
|
||||
|
||||
```
|
||||
curl -X POST -H "Authorization: Token <TOKEN>" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/ --data '[
|
||||
{"name": "device1", "device_type": 24, "device_role": 17, "site": 6},
|
||||
{"name": "device2", "device_type": 24, "device_role": 17, "site": 6},
|
||||
{"name": "device3", "device_type": 24, "device_role": 17, "site": 6},
|
||||
]'
|
||||
```
|
||||
|
||||
Bulk creation is all-or-none: If any of the creations fails, the entire operation is rolled back. A successful response returns an HTTP code 201 and the body of the response will be a list/array of the objects created.
|
||||
@@ -13,6 +13,14 @@ ADMINS = [
|
||||
|
||||
---
|
||||
|
||||
## ALLOWED_URL_SCHEMES
|
||||
|
||||
Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
|
||||
|
||||
A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate the entire default list as well (excluding those schemes which are not desirable).
|
||||
|
||||
---
|
||||
|
||||
## BANNER_TOP
|
||||
|
||||
## BANNER_BOTTOM
|
||||
@@ -86,7 +94,12 @@ CORS_ORIGIN_WHITELIST = [
|
||||
|
||||
Default: False
|
||||
|
||||
This setting enables debugging. This should be done only during development or troubleshooting. Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users.
|
||||
This setting enables debugging. This should be done only during development or troubleshooting. Note that only clients
|
||||
which access NetBox from a recognized [internal IP address](#internal_ips) will see debugging tools in the user
|
||||
interface.
|
||||
|
||||
!!! warning
|
||||
Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users.
|
||||
|
||||
---
|
||||
|
||||
@@ -184,6 +197,16 @@ HTTP_PROXIES = {
|
||||
|
||||
---
|
||||
|
||||
## INTERNAL_IPS
|
||||
|
||||
Default: `('127.0.0.1', '::1',)`
|
||||
|
||||
A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For
|
||||
example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
|
||||
addresses (and [`DEBUG`](#debug) is true).
|
||||
|
||||
---
|
||||
|
||||
## LOGGING
|
||||
|
||||
By default, all messages of INFO severity or higher will be logged to the console. Additionally, if `DEBUG` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`.
|
||||
@@ -385,7 +408,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
|
||||
|
||||
## REMOTE_AUTH_AUTO_CREATE_USER
|
||||
|
||||
Default: `True`
|
||||
Default: `False`
|
||||
|
||||
If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
|
||||
| HTTP service | nginx or Apache |
|
||||
| WSGI service | gunicorn or uWSGI |
|
||||
| Application | Django/Python |
|
||||
| Database | PostgreSQL 9.4+ |
|
||||
| Database | PostgreSQL 9.6+ |
|
||||
| Task queuing | Redis/django-rq |
|
||||
| Live device access | NAPALM |
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
|
||||
|
||||
!!! warning
|
||||
NetBox requires PostgreSQL 9.4 or higher. Please note that MySQL and other relational databases are **not** supported.
|
||||
NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** supported.
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
|
||||
@@ -51,7 +51,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a
|
||||
|
||||
```no-highlight
|
||||
# sudo -u postgres psql
|
||||
psql (9.4.5)
|
||||
psql (10.10)
|
||||
Type "help" for help.
|
||||
|
||||
postgres=# CREATE DATABASE netbox;
|
||||
|
||||
@@ -1,6 +1,57 @@
|
||||
# NetBox v2.8
|
||||
|
||||
v2.8.4 (2020-05-13)
|
||||
## v2.8.6 (2020-06-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4698](https://github.com/netbox-community/netbox/issues/4698) - Improve display of template code for object in admin UI
|
||||
* [#4717](https://github.com/netbox-community/netbox/issues/4717) - Introduce `ALLOWED_URL_SCHEMES` configuration parameter to mitigate dangerous hyperlinks
|
||||
* [#4744](https://github.com/netbox-community/netbox/issues/4744) - Hide "IP addresses" tab when viewing a container prefix
|
||||
* [#4755](https://github.com/netbox-community/netbox/issues/4755) - Enable creation of rack reservations directly from navigation menu
|
||||
* [#4761](https://github.com/netbox-community/netbox/issues/4761) - Enable tag assignment during bulk creation of IP addresses
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#4674](https://github.com/netbox-community/netbox/issues/4674) - Fix API definition for available prefix and IP address endpoints
|
||||
* [#4702](https://github.com/netbox-community/netbox/issues/4702) - Catch IntegrityError exception when adding a non-unique secret
|
||||
* [#4707](https://github.com/netbox-community/netbox/issues/4707) - Fix `prefix_count` population on VLAN API serializer
|
||||
* [#4710](https://github.com/netbox-community/netbox/issues/4710) - Fix merging of form fields among custom scripts
|
||||
* [#4725](https://github.com/netbox-community/netbox/issues/4725) - Fix "brief" rendering of various REST API endpoints
|
||||
* [#4736](https://github.com/netbox-community/netbox/issues/4736) - Add cable trace endpoints for pass-through ports
|
||||
* [#4737](https://github.com/netbox-community/netbox/issues/4737) - Fix display of role labels in virtual machines table
|
||||
* [#4743](https://github.com/netbox-community/netbox/issues/4743) - Allow users to create "next available" IPs without needing permission to create prefixes
|
||||
* [#4756](https://github.com/netbox-community/netbox/issues/4756) - Filter parent group by site when creating rack groups
|
||||
* [#4760](https://github.com/netbox-community/netbox/issues/4760) - Enable power port template assignment when bulk editing power outlet templates
|
||||
|
||||
---
|
||||
|
||||
## v2.8.5 (2020-05-26)
|
||||
|
||||
**Note:** The minimum required version of PostgreSQL is now 9.6.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4650](https://github.com/netbox-community/netbox/issues/4650) - Expose `INTERNAL_IPS` configuration parameter
|
||||
* [#4651](https://github.com/netbox-community/netbox/issues/4651) - Add `csrf_token` context for plugin templates
|
||||
* [#4652](https://github.com/netbox-community/netbox/issues/4652) - Add permissions context for plugin templates
|
||||
* [#4665](https://github.com/netbox-community/netbox/issues/4665) - Add NEMA L14 and L21 power port/outlet types
|
||||
* [#4672](https://github.com/netbox-community/netbox/issues/4672) - Set default color for rack and devices roles
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses
|
||||
* [#4525](https://github.com/netbox-community/netbox/issues/4525) - Allow passing initial data to custom script MultiObjectVar
|
||||
* [#4644](https://github.com/netbox-community/netbox/issues/4644) - Fix ordering of services table by parent
|
||||
* [#4646](https://github.com/netbox-community/netbox/issues/4646) - Correct UI link for reports with custom name
|
||||
* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assigning new IP addresses to interfaces
|
||||
* [#4648](https://github.com/netbox-community/netbox/issues/4648) - Fix bulk CSV import of child devices
|
||||
* [#4649](https://github.com/netbox-community/netbox/issues/4649) - Fix interface assignment for bulk-imported IP addresses
|
||||
* [#4676](https://github.com/netbox-community/netbox/issues/4676) - Set default value of `REMOTE_AUTH_AUTO_CREATE_USER` as `False` in docs
|
||||
* [#4684](https://github.com/netbox-community/netbox/issues/4684) - Respect `comments` field when importing device type in YAML/JSON format
|
||||
|
||||
---
|
||||
|
||||
## v2.8.4 (2020-05-13)
|
||||
|
||||
### Enhancements
|
||||
|
||||
|
||||
@@ -1,443 +1,188 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from circuits.choices import *
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
from dcim.models import Site
|
||||
from extras.models import Graph
|
||||
from utilities.testing import APITestCase
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
|
||||
|
||||
class AppTest(APITestCase):
|
||||
|
||||
def test_root(self):
|
||||
|
||||
url = reverse('circuits-api:api-root')
|
||||
response = self.client.get('{}?format=api'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class ProviderTest(APITestCase):
|
||||
class ProviderTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Provider
|
||||
brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Provider 4',
|
||||
'slug': 'provider-4',
|
||||
},
|
||||
{
|
||||
'name': 'Provider 5',
|
||||
'slug': 'provider-5',
|
||||
},
|
||||
{
|
||||
'name': 'Provider 6',
|
||||
'slug': 'provider-6',
|
||||
},
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1')
|
||||
self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2')
|
||||
self.provider3 = Provider.objects.create(name='Test Provider 3', slug='test-provider-3')
|
||||
|
||||
def test_get_provider(self):
|
||||
|
||||
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.provider1.name)
|
||||
providers = (
|
||||
Provider(name='Provider 1', slug='provider-1'),
|
||||
Provider(name='Provider 2', slug='provider-2'),
|
||||
Provider(name='Provider 3', slug='provider-3'),
|
||||
)
|
||||
Provider.objects.bulk_create(providers)
|
||||
|
||||
def test_get_provider_graphs(self):
|
||||
"""
|
||||
Test retrieval of Graphs assigned to Providers.
|
||||
"""
|
||||
provider = self.model.objects.first()
|
||||
ct = ContentType.objects.get(app_label='circuits', model='provider')
|
||||
graphs = (
|
||||
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'),
|
||||
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'),
|
||||
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'),
|
||||
)
|
||||
Graph.objects.bulk_create(graphs)
|
||||
|
||||
provider_ct = ContentType.objects.get(app_label='circuits', model='provider')
|
||||
self.graph1 = Graph.objects.create(
|
||||
type=provider_ct,
|
||||
name='Test Graph 1',
|
||||
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'
|
||||
)
|
||||
self.graph2 = Graph.objects.create(
|
||||
type=provider_ct,
|
||||
name='Test Graph 2',
|
||||
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'
|
||||
)
|
||||
self.graph3 = Graph.objects.create(
|
||||
type=provider_ct,
|
||||
name='Test Graph 3',
|
||||
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'
|
||||
)
|
||||
|
||||
url = reverse('circuits-api:provider-graphs', kwargs={'pk': self.provider1.pk})
|
||||
url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=test-provider-1&foo=1')
|
||||
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=provider-1&foo=1')
|
||||
|
||||
def test_list_providers(self):
|
||||
|
||||
url = reverse('circuits-api:provider-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
model = CircuitType
|
||||
brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
|
||||
create_data = (
|
||||
{
|
||||
'name': 'Circuit Type 4',
|
||||
'slug': 'circuit-type-4',
|
||||
},
|
||||
{
|
||||
'name': 'Circuit Type 5',
|
||||
'slug': 'circuit-type-5',
|
||||
},
|
||||
{
|
||||
'name': 'Circuit Type 6',
|
||||
'slug': 'circuit-type-6',
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
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]),
|
||||
['circuit_count', 'id', 'name', 'slug', 'url']
|
||||
circuit_types = (
|
||||
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
|
||||
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
|
||||
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
|
||||
)
|
||||
CircuitType.objects.bulk_create(circuit_types)
|
||||
|
||||
def test_create_provider(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Provider 4',
|
||||
'slug': 'test-provider-4',
|
||||
}
|
||||
class CircuitTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Circuit
|
||||
brief_fields = ['cid', 'id', 'url']
|
||||
|
||||
url = reverse('circuits-api:provider-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Provider.objects.count(), 4)
|
||||
provider4 = Provider.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(provider4.name, data['name'])
|
||||
self.assertEqual(provider4.slug, data['slug'])
|
||||
providers = (
|
||||
Provider(name='Provider 1', slug='provider-1'),
|
||||
Provider(name='Provider 2', slug='provider-2'),
|
||||
)
|
||||
Provider.objects.bulk_create(providers)
|
||||
|
||||
def test_create_provider_bulk(self):
|
||||
circuit_types = (
|
||||
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
|
||||
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
|
||||
)
|
||||
CircuitType.objects.bulk_create(circuit_types)
|
||||
|
||||
data = [
|
||||
circuits = (
|
||||
Circuit(cid='Circuit 1', provider=providers[0], type=circuit_types[0]),
|
||||
Circuit(cid='Circuit 2', provider=providers[0], type=circuit_types[0]),
|
||||
Circuit(cid='Circuit 3', provider=providers[0], type=circuit_types[0]),
|
||||
)
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Test Provider 4',
|
||||
'slug': 'test-provider-4',
|
||||
'cid': 'Circuit 4',
|
||||
'provider': providers[1].pk,
|
||||
'type': circuit_types[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Provider 5',
|
||||
'slug': 'test-provider-5',
|
||||
'cid': 'Circuit 5',
|
||||
'provider': providers[1].pk,
|
||||
'type': circuit_types[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Provider 6',
|
||||
'slug': 'test-provider-6',
|
||||
'cid': 'Circuit 6',
|
||||
'provider': providers[1].pk,
|
||||
'type': circuit_types[1].pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('circuits-api:provider-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Provider.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
|
||||
model = CircuitTermination
|
||||
brief_fields = ['circuit', 'id', 'term_side', 'url']
|
||||
|
||||
def test_update_provider(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
SIDE_A = CircuitTerminationSideChoices.SIDE_A
|
||||
SIDE_Z = CircuitTerminationSideChoices.SIDE_Z
|
||||
|
||||
data = {
|
||||
'name': 'Test Provider X',
|
||||
'slug': 'test-provider-x',
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Provider.objects.count(), 3)
|
||||
provider1 = Provider.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(provider1.name, data['name'])
|
||||
self.assertEqual(provider1.slug, data['slug'])
|
||||
|
||||
def test_delete_provider(self):
|
||||
|
||||
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Provider.objects.count(), 2)
|
||||
|
||||
|
||||
class CircuitTypeTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1')
|
||||
self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2')
|
||||
self.circuittype3 = CircuitType.objects.create(name='Test Circuit Type 3', slug='test-circuit-type-3')
|
||||
|
||||
def test_get_circuittype(self):
|
||||
|
||||
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.circuittype1.name)
|
||||
|
||||
def test_list_circuittypes(self):
|
||||
|
||||
url = reverse('circuits-api:circuittype-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
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]),
|
||||
['circuit_count', 'id', 'name', 'slug', 'url']
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
def test_create_circuittype(self):
|
||||
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
|
||||
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
|
||||
|
||||
data = {
|
||||
'name': 'Test Circuit Type 4',
|
||||
'slug': 'test-circuit-type-4',
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittype-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(CircuitType.objects.count(), 4)
|
||||
circuittype4 = CircuitType.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(circuittype4.name, data['name'])
|
||||
self.assertEqual(circuittype4.slug, data['slug'])
|
||||
|
||||
def test_update_circuittype(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Circuit Type X',
|
||||
'slug': 'test-circuit-type-x',
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(CircuitType.objects.count(), 3)
|
||||
circuittype1 = CircuitType.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(circuittype1.name, data['name'])
|
||||
self.assertEqual(circuittype1.slug, data['slug'])
|
||||
|
||||
def test_delete_circuittype(self):
|
||||
|
||||
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(CircuitType.objects.count(), 2)
|
||||
|
||||
|
||||
class CircuitTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1')
|
||||
self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2')
|
||||
self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1')
|
||||
self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2')
|
||||
self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=self.provider1, type=self.circuittype1)
|
||||
self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=self.provider1, type=self.circuittype1)
|
||||
self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=self.provider1, type=self.circuittype1)
|
||||
|
||||
def test_get_circuit(self):
|
||||
|
||||
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['cid'], self.circuit1.cid)
|
||||
|
||||
def test_list_circuits(self):
|
||||
|
||||
url = reverse('circuits-api:circuit-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
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']
|
||||
circuits = (
|
||||
Circuit(cid='Circuit 1', provider=provider, type=circuit_type),
|
||||
Circuit(cid='Circuit 2', provider=provider, type=circuit_type),
|
||||
Circuit(cid='Circuit 3', provider=provider, type=circuit_type),
|
||||
)
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
def test_create_circuit(self):
|
||||
circuit_terminations = (
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], port_speed=100000, term_side=SIDE_A),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[1], port_speed=100000, term_side=SIDE_Z),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[0], port_speed=100000, term_side=SIDE_A),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], port_speed=100000, term_side=SIDE_Z),
|
||||
)
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
data = {
|
||||
'cid': 'TEST0004',
|
||||
'provider': self.provider1.pk,
|
||||
'type': self.circuittype1.pk,
|
||||
'status': CircuitStatusChoices.STATUS_ACTIVE,
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuit-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Circuit.objects.count(), 4)
|
||||
circuit4 = Circuit.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(circuit4.cid, data['cid'])
|
||||
self.assertEqual(circuit4.provider_id, data['provider'])
|
||||
self.assertEqual(circuit4.type_id, data['type'])
|
||||
|
||||
def test_create_circuit_bulk(self):
|
||||
|
||||
data = [
|
||||
cls.create_data = [
|
||||
{
|
||||
'cid': 'TEST0004',
|
||||
'provider': self.provider1.pk,
|
||||
'type': self.circuittype1.pk,
|
||||
'status': CircuitStatusChoices.STATUS_ACTIVE,
|
||||
'circuit': circuits[2].pk,
|
||||
'term_side': SIDE_A,
|
||||
'site': sites[1].pk,
|
||||
'port_speed': 200000,
|
||||
},
|
||||
{
|
||||
'cid': 'TEST0005',
|
||||
'provider': self.provider1.pk,
|
||||
'type': self.circuittype1.pk,
|
||||
'status': CircuitStatusChoices.STATUS_ACTIVE,
|
||||
},
|
||||
{
|
||||
'cid': 'TEST0006',
|
||||
'provider': self.provider1.pk,
|
||||
'type': self.circuittype1.pk,
|
||||
'status': CircuitStatusChoices.STATUS_ACTIVE,
|
||||
'circuit': circuits[2].pk,
|
||||
'term_side': SIDE_Z,
|
||||
'site': sites[1].pk,
|
||||
'port_speed': 200000,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('circuits-api:circuit-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Circuit.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['cid'], data[0]['cid'])
|
||||
self.assertEqual(response.data[1]['cid'], data[1]['cid'])
|
||||
self.assertEqual(response.data[2]['cid'], data[2]['cid'])
|
||||
|
||||
def test_update_circuit(self):
|
||||
|
||||
data = {
|
||||
'cid': 'TEST000X',
|
||||
'provider': self.provider2.pk,
|
||||
'type': self.circuittype2.pk,
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Circuit.objects.count(), 3)
|
||||
circuit1 = Circuit.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(circuit1.cid, data['cid'])
|
||||
self.assertEqual(circuit1.provider_id, data['provider'])
|
||||
self.assertEqual(circuit1.type_id, data['type'])
|
||||
|
||||
def test_delete_circuit(self):
|
||||
|
||||
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Circuit.objects.count(), 2)
|
||||
|
||||
|
||||
class CircuitTerminationTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
|
||||
provider = Provider.objects.create(name='Test Provider', slug='test-provider')
|
||||
circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type')
|
||||
self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype)
|
||||
self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype)
|
||||
self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype)
|
||||
self.circuittermination1 = CircuitTermination.objects.create(
|
||||
circuit=self.circuit1,
|
||||
term_side=CircuitTerminationSideChoices.SIDE_A,
|
||||
site=self.site1,
|
||||
port_speed=1000000
|
||||
)
|
||||
self.circuittermination2 = CircuitTermination.objects.create(
|
||||
circuit=self.circuit1,
|
||||
term_side=CircuitTerminationSideChoices.SIDE_Z,
|
||||
site=self.site2,
|
||||
port_speed=1000000
|
||||
)
|
||||
self.circuittermination3 = CircuitTermination.objects.create(
|
||||
circuit=self.circuit2,
|
||||
term_side=CircuitTerminationSideChoices.SIDE_A,
|
||||
site=self.site1,
|
||||
port_speed=1000000
|
||||
)
|
||||
self.circuittermination4 = CircuitTermination.objects.create(
|
||||
circuit=self.circuit2,
|
||||
term_side=CircuitTerminationSideChoices.SIDE_Z,
|
||||
site=self.site2,
|
||||
port_speed=1000000
|
||||
)
|
||||
|
||||
def test_get_circuittermination(self):
|
||||
|
||||
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['id'], self.circuittermination1.pk)
|
||||
|
||||
def test_list_circuitterminations(self):
|
||||
|
||||
url = reverse('circuits-api:circuittermination-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 4)
|
||||
|
||||
def test_create_circuittermination(self):
|
||||
|
||||
data = {
|
||||
'circuit': self.circuit3.pk,
|
||||
'term_side': CircuitTerminationSideChoices.SIDE_A,
|
||||
'site': self.site1.pk,
|
||||
'port_speed': 1000000,
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittermination-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(CircuitTermination.objects.count(), 5)
|
||||
circuittermination4 = CircuitTermination.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(circuittermination4.circuit_id, data['circuit'])
|
||||
self.assertEqual(circuittermination4.term_side, data['term_side'])
|
||||
self.assertEqual(circuittermination4.site_id, data['site'])
|
||||
self.assertEqual(circuittermination4.port_speed, data['port_speed'])
|
||||
|
||||
def test_update_circuittermination(self):
|
||||
|
||||
circuittermination5 = CircuitTermination.objects.create(
|
||||
circuit=self.circuit3,
|
||||
term_side=CircuitTerminationSideChoices.SIDE_A,
|
||||
site=self.site1,
|
||||
port_speed=1000000
|
||||
)
|
||||
|
||||
data = {
|
||||
'circuit': self.circuit3.pk,
|
||||
'term_side': CircuitTerminationSideChoices.SIDE_Z,
|
||||
'site': self.site2.pk,
|
||||
'port_speed': 1000000,
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': circuittermination5.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(CircuitTermination.objects.count(), 5)
|
||||
circuittermination1 = CircuitTermination.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(circuittermination1.term_side, data['term_side'])
|
||||
self.assertEqual(circuittermination1.site_id, data['site'])
|
||||
self.assertEqual(circuittermination1.port_speed, data['port_speed'])
|
||||
|
||||
def test_delete_circuittermination(self):
|
||||
|
||||
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(CircuitTermination.objects.count(), 3)
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.constants import CONNECTION_STATUS_CHOICES
|
||||
from dcim.models import (
|
||||
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate,
|
||||
Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, PowerPortTemplate, Rack,
|
||||
RackGroup, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
|
||||
)
|
||||
from dcim import models
|
||||
from utilities.api import ChoiceField, WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedCableSerializer',
|
||||
'NestedConsolePortSerializer',
|
||||
'NestedConsolePortTemplateSerializer',
|
||||
'NestedConsoleServerPortSerializer',
|
||||
'NestedConsoleServerPortTemplateSerializer',
|
||||
'NestedDeviceBaySerializer',
|
||||
'NestedDeviceBayTemplateSerializer',
|
||||
'NestedDeviceRoleSerializer',
|
||||
'NestedDeviceSerializer',
|
||||
'NestedDeviceTypeSerializer',
|
||||
'NestedFrontPortSerializer',
|
||||
'NestedFrontPortTemplateSerializer',
|
||||
'NestedInterfaceSerializer',
|
||||
'NestedInterfaceTemplateSerializer',
|
||||
'NestedInventoryItemSerializer',
|
||||
'NestedManufacturerSerializer',
|
||||
'NestedPlatformSerializer',
|
||||
'NestedPowerFeedSerializer',
|
||||
'NestedPowerOutletSerializer',
|
||||
'NestedPowerOutletTemplateSerializer',
|
||||
'NestedPowerPanelSerializer',
|
||||
'NestedPowerPortSerializer',
|
||||
'NestedPowerPortTemplateSerializer',
|
||||
'NestedRackGroupSerializer',
|
||||
'NestedRackReservationSerializer',
|
||||
'NestedRackRoleSerializer',
|
||||
'NestedRackSerializer',
|
||||
'NestedRearPortSerializer',
|
||||
@@ -46,7 +49,7 @@ class NestedRegionSerializer(WritableNestedSerializer):
|
||||
site_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
model = models.Region
|
||||
fields = ['id', 'url', 'name', 'slug', 'site_count']
|
||||
|
||||
|
||||
@@ -54,7 +57,7 @@ class NestedSiteSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
model = models.Site
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
@@ -67,7 +70,7 @@ class NestedRackGroupSerializer(WritableNestedSerializer):
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
model = models.RackGroup
|
||||
fields = ['id', 'url', 'name', 'slug', 'rack_count']
|
||||
|
||||
|
||||
@@ -76,7 +79,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
model = models.RackRole
|
||||
fields = ['id', 'url', 'name', 'slug', 'rack_count']
|
||||
|
||||
|
||||
@@ -85,10 +88,22 @@ class NestedRackSerializer(WritableNestedSerializer):
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
model = models.Rack
|
||||
fields = ['id', 'url', 'name', 'display_name', 'device_count']
|
||||
|
||||
|
||||
class NestedRackReservationSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
|
||||
user = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.RackReservation
|
||||
fields = ['id', 'url', 'user', 'units']
|
||||
|
||||
def get_user(self, obj):
|
||||
return obj.user.username
|
||||
|
||||
|
||||
#
|
||||
# Device types
|
||||
#
|
||||
@@ -98,7 +113,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer):
|
||||
devicetype_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
model = models.Manufacturer
|
||||
fields = ['id', 'url', 'name', 'slug', 'devicetype_count']
|
||||
|
||||
|
||||
@@ -108,15 +123,47 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
model = models.DeviceType
|
||||
fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
|
||||
|
||||
|
||||
class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.ConsolePortTemplate
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.ConsoleServerPortTemplate
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
|
||||
|
||||
class Meta:
|
||||
model = PowerPortTemplate
|
||||
model = models.PowerPortTemplate
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class NestedPowerOutletTemplateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutletTemplate
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class NestedInterfaceTemplateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.InterfaceTemplate
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
@@ -124,7 +171,7 @@ class NestedRearPortTemplateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
|
||||
|
||||
class Meta:
|
||||
model = RearPortTemplate
|
||||
model = models.RearPortTemplate
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
@@ -132,7 +179,15 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
model = models.FrontPortTemplate
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.DeviceBayTemplate
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
@@ -146,7 +201,7 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer):
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
model = models.DeviceRole
|
||||
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
|
||||
|
||||
|
||||
@@ -156,7 +211,7 @@ class NestedPlatformSerializer(WritableNestedSerializer):
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
model = models.Platform
|
||||
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
|
||||
|
||||
|
||||
@@ -164,7 +219,7 @@ class NestedDeviceSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
model = models.Device
|
||||
fields = ['id', 'url', 'name', 'display_name']
|
||||
|
||||
|
||||
@@ -174,7 +229,7 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer):
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
model = models.ConsoleServerPort
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
|
||||
|
||||
@@ -184,7 +239,7 @@ class NestedConsolePortSerializer(WritableNestedSerializer):
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
model = models.ConsolePort
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
|
||||
|
||||
@@ -194,7 +249,7 @@ class NestedPowerOutletSerializer(WritableNestedSerializer):
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
model = models.PowerOutlet
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
|
||||
|
||||
@@ -204,7 +259,7 @@ class NestedPowerPortSerializer(WritableNestedSerializer):
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
model = models.PowerPort
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
|
||||
|
||||
@@ -214,7 +269,7 @@ class NestedInterfaceSerializer(WritableNestedSerializer):
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
model = models.Interface
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
|
||||
|
||||
@@ -223,7 +278,7 @@ class NestedRearPortSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
model = models.RearPort
|
||||
fields = ['id', 'url', 'device', 'name', 'cable']
|
||||
|
||||
|
||||
@@ -232,7 +287,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
model = models.FrontPort
|
||||
fields = ['id', 'url', 'device', 'name', 'cable']
|
||||
|
||||
|
||||
@@ -241,7 +296,16 @@ class NestedDeviceBaySerializer(WritableNestedSerializer):
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
model = models.DeviceBay
|
||||
fields = ['id', 'url', 'device', 'name']
|
||||
|
||||
|
||||
class NestedInventoryItemSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.InventoryItem
|
||||
fields = ['id', 'url', 'device', 'name']
|
||||
|
||||
|
||||
@@ -253,7 +317,7 @@ class NestedCableSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
model = models.Cable
|
||||
fields = ['id', 'url', 'label']
|
||||
|
||||
|
||||
@@ -267,7 +331,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
|
||||
member_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
model = models.VirtualChassis
|
||||
fields = ['id', 'url', 'master', 'member_count']
|
||||
|
||||
|
||||
@@ -280,7 +344,7 @@ class NestedPowerPanelSerializer(WritableNestedSerializer):
|
||||
powerfeed_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
model = models.PowerPanel
|
||||
fields = ['id', 'url', 'name', 'powerfeed_count']
|
||||
|
||||
|
||||
@@ -288,5 +352,5 @@ class NestedPowerFeedSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
|
||||
|
||||
class Meta:
|
||||
model = PowerFeed
|
||||
model = models.PowerFeed
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
@@ -502,13 +502,13 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class FrontPortViewSet(ModelViewSet):
|
||||
class FrontPortViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
|
||||
serializer_class = serializers.FrontPortSerializer
|
||||
filterset_class = filters.FrontPortFilterSet
|
||||
|
||||
|
||||
class RearPortViewSet(ModelViewSet):
|
||||
class RearPortViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
|
||||
serializer_class = serializers.RearPortSerializer
|
||||
filterset_class = filters.RearPortFilterSet
|
||||
|
||||
@@ -276,6 +276,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_NEMA_L620P = 'nema-l6-20p'
|
||||
TYPE_NEMA_L630P = 'nema-l6-30p'
|
||||
TYPE_NEMA_L650P = 'nema-l6-50p'
|
||||
TYPE_NEMA_L1420P = 'nema-l14-20p'
|
||||
TYPE_NEMA_L1430P = 'nema-l14-30p'
|
||||
TYPE_NEMA_L2120P = 'nema-l21-20p'
|
||||
TYPE_NEMA_L2130P = 'nema-l21-30p'
|
||||
# California style
|
||||
TYPE_CS6361C = 'cs6361c'
|
||||
TYPE_CS6365C = 'cs6365c'
|
||||
@@ -337,6 +341,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_NEMA_L620P, 'NEMA L6-20P'),
|
||||
(TYPE_NEMA_L630P, 'NEMA L6-30P'),
|
||||
(TYPE_NEMA_L650P, 'NEMA L6-50P'),
|
||||
(TYPE_NEMA_L1420P, 'NEMA L14-20P'),
|
||||
(TYPE_NEMA_L1430P, 'NEMA L14-30P'),
|
||||
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
|
||||
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
|
||||
)),
|
||||
('California Style', (
|
||||
(TYPE_CS6361C, 'CS6361C'),
|
||||
@@ -405,6 +413,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_NEMA_L620R = 'nema-l6-20r'
|
||||
TYPE_NEMA_L630R = 'nema-l6-30r'
|
||||
TYPE_NEMA_L650R = 'nema-l6-50r'
|
||||
TYPE_NEMA_L1420R = 'nema-l14-20r'
|
||||
TYPE_NEMA_L1430R = 'nema-l14-30r'
|
||||
TYPE_NEMA_L2120R = 'nema-l21-20r'
|
||||
TYPE_NEMA_L2130R = 'nema-l21-30r'
|
||||
# California style
|
||||
TYPE_CS6360C = 'CS6360C'
|
||||
TYPE_CS6364C = 'CS6364C'
|
||||
@@ -467,6 +479,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_NEMA_L620R, 'NEMA L6-20R'),
|
||||
(TYPE_NEMA_L630R, 'NEMA L6-30R'),
|
||||
(TYPE_NEMA_L650R, 'NEMA L6-50R'),
|
||||
(TYPE_NEMA_L1420R, 'NEMA L14-20R'),
|
||||
(TYPE_NEMA_L1430R, 'NEMA L14-30R'),
|
||||
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
|
||||
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
|
||||
)),
|
||||
('California Style', (
|
||||
(TYPE_CS6360C, 'CS6360C'),
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.contrib.auth.models import User
|
||||
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filters import TenancyFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.constants import COLOR_CHOICES
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.filters import (
|
||||
BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
|
||||
NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter,
|
||||
@@ -1084,7 +1084,7 @@ class CableFilterSet(BaseFilterSet):
|
||||
choices=CableStatusChoices
|
||||
)
|
||||
color = django_filters.MultipleChoiceFilter(
|
||||
choices=COLOR_CHOICES
|
||||
choices=ColorChoices
|
||||
)
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device'
|
||||
|
||||
@@ -21,10 +21,10 @@ from ipam.models import IPAddress, VLAN
|
||||
from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
|
||||
BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField,
|
||||
CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model,
|
||||
JSONField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
||||
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
|
||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
|
||||
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
||||
BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
|
||||
@@ -363,7 +363,12 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
|
||||
class RackGroupForm(BootstrapMixin, forms.ModelForm):
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all()
|
||||
queryset=Site.objects.all(),
|
||||
widget=APISelect(
|
||||
filter_for={
|
||||
'parent': 'site_id',
|
||||
}
|
||||
)
|
||||
)
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -729,21 +734,32 @@ class RackElevationFilterForm(RackFilterForm):
|
||||
#
|
||||
|
||||
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
||||
rack = forms.ModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
# TODO: Change this to an API-backed form field. We can't do this currently because we want to retain
|
||||
# the multi-line <select> widget for easy selection of multiple rack units.
|
||||
units = SimpleArrayField(
|
||||
base_field=forms.IntegerField(),
|
||||
widget=ArrayFieldSelectMultiple(
|
||||
attrs={
|
||||
'size': 10,
|
||||
widget=APISelect(
|
||||
filter_for={
|
||||
'rack_group': 'site_id',
|
||||
'rack': 'site_id',
|
||||
}
|
||||
)
|
||||
)
|
||||
rack_group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
filter_for={
|
||||
'rack': 'group_id'
|
||||
}
|
||||
)
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all()
|
||||
)
|
||||
units = NumericArrayField(
|
||||
base_field=forms.IntegerField(),
|
||||
help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen."
|
||||
)
|
||||
user = forms.ModelChoiceField(
|
||||
queryset=User.objects.order_by(
|
||||
'username'
|
||||
@@ -757,23 +773,6 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
||||
'rack', 'units', 'user', 'tenant_group', 'tenant', 'description',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Populate rack unit choices
|
||||
if hasattr(self.instance, 'rack'):
|
||||
self.fields['units'].widget.choices = self._get_unit_choices()
|
||||
|
||||
def _get_unit_choices(self):
|
||||
rack = self.instance.rack
|
||||
reserved_units = []
|
||||
for resv in rack.reservations.exclude(pk=self.instance.pk):
|
||||
for u in resv.units:
|
||||
reserved_units.append(u)
|
||||
unit_choices = [(u, {'label': str(u), 'disabled': u in reserved_units}) for u in rack.units]
|
||||
return unit_choices
|
||||
|
||||
|
||||
class RackReservationCSVForm(CSVModelForm):
|
||||
site = CSVModelChoiceField(
|
||||
@@ -932,6 +931,7 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
'comments',
|
||||
]
|
||||
|
||||
|
||||
@@ -1226,11 +1226,21 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
queryset=PowerOutletTemplate.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
device_type = forms.ModelChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False,
|
||||
disabled=True,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
type = forms.ChoiceField(
|
||||
choices=add_blank_choice(PowerOutletTypeChoices),
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
power_port = forms.ModelChoiceField(
|
||||
queryset=PowerPortTemplate.objects.all(),
|
||||
required=False
|
||||
)
|
||||
feed_leg = forms.ChoiceField(
|
||||
choices=add_blank_choice(PowerOutletFeedLegChoices),
|
||||
required=False,
|
||||
@@ -1238,7 +1248,18 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ('type', 'feed_leg')
|
||||
nullable_fields = ('type', 'power_port', 'feed_leg')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit power_port queryset to PowerPortTemplates which belong to the parent DeviceType
|
||||
if 'device_type' in self.initial:
|
||||
device_type = DeviceType.objects.filter(pk=self.initial['device_type']).first()
|
||||
self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(device_type=device_type)
|
||||
else:
|
||||
self.fields['power_port'].choices = ()
|
||||
self.fields['power_port'].widget.attrs['disabled'] = True
|
||||
|
||||
|
||||
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
@@ -1956,7 +1977,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
|
||||
help_text='Parent device'
|
||||
)
|
||||
device_bay = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
queryset=DeviceBay.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Device bay in which this device is installed'
|
||||
)
|
||||
@@ -1976,6 +1997,20 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
|
||||
params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
|
||||
self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Set parent_bay reverse relationship
|
||||
device_bay = self.cleaned_data.get('device_bay')
|
||||
if device_bay:
|
||||
self.instance.parent_bay = device_bay
|
||||
|
||||
# Inherit site and rack from parent device
|
||||
parent = self.cleaned_data.get('parent')
|
||||
if parent:
|
||||
self.instance.site = parent.site
|
||||
self.instance.rack = parent.rack
|
||||
|
||||
|
||||
class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
|
||||
24
netbox/dcim/migrations/0106_role_default_color.py
Normal file
24
netbox/dcim/migrations/0106_role_default_color.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.0.6 on 2020-05-26 13:33
|
||||
|
||||
from django.db import migrations
|
||||
import utilities.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0105_interface_name_collation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='devicerole',
|
||||
name='color',
|
||||
field=utilities.fields.ColorField(default='9e9e9e', max_length=6),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rackrole',
|
||||
name='color',
|
||||
field=utilities.fields.ColorField(default='9e9e9e', max_length=6),
|
||||
),
|
||||
]
|
||||
@@ -23,6 +23,7 @@ from dcim.fields import ASNField
|
||||
from dcim.elevations import RackElevationSVG
|
||||
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.utils import serialize_object, to_meters
|
||||
@@ -379,7 +380,9 @@ class RackRole(ChangeLoggedModel):
|
||||
slug = models.SlugField(
|
||||
unique=True
|
||||
)
|
||||
color = ColorField()
|
||||
color = ColorField(
|
||||
default=ColorChoices.COLOR_GREY
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
@@ -1190,7 +1193,9 @@ class DeviceRole(ChangeLoggedModel):
|
||||
slug = models.SlugField(
|
||||
unique=True
|
||||
)
|
||||
color = ColorField()
|
||||
color = ColorField(
|
||||
default=ColorChoices.COLOR_GREY
|
||||
)
|
||||
vm_role = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name='VM Role',
|
||||
@@ -2110,9 +2115,9 @@ class Cable(ChangeLoggedModel):
|
||||
"""
|
||||
instance = super().from_db(db, field_names, values)
|
||||
|
||||
instance._orig_termination_a_type = instance.termination_a_type
|
||||
instance._orig_termination_a_type_id = instance.termination_a_type_id
|
||||
instance._orig_termination_a_id = instance.termination_a_id
|
||||
instance._orig_termination_b_type = instance.termination_b_type
|
||||
instance._orig_termination_b_type_id = instance.termination_b_type_id
|
||||
instance._orig_termination_b_id = instance.termination_b_id
|
||||
|
||||
return instance
|
||||
@@ -2149,14 +2154,14 @@ class Cable(ChangeLoggedModel):
|
||||
if self.pk:
|
||||
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
|
||||
if (
|
||||
self.termination_a_type != self._orig_termination_a_type or
|
||||
self.termination_a_type_id != self._orig_termination_a_type_id or
|
||||
self.termination_a_id != self._orig_termination_a_id
|
||||
):
|
||||
raise ValidationError({
|
||||
'termination_a': err_msg
|
||||
})
|
||||
if (
|
||||
self.termination_b_type != self._orig_termination_b_type or
|
||||
self.termination_b_type_id != self._orig_termination_b_type_id or
|
||||
self.termination_b_id != self._orig_termination_b_id
|
||||
):
|
||||
raise ValidationError({
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, TagColumn, ToggleColumn
|
||||
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn
|
||||
from .models import (
|
||||
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
||||
@@ -72,15 +72,6 @@ RACKROLE_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
RACK_ROLE = """
|
||||
{% if record.role %}
|
||||
{% load helpers %}
|
||||
<label class="label" style="color: {{ record.role.color|fgcolor }}; background-color: #{{ record.role.color }}">{{ value }}</label>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
RACK_DEVICE_COUNT = """
|
||||
<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
|
||||
"""
|
||||
@@ -137,11 +128,6 @@ PLATFORM_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICE_ROLE = """
|
||||
{% load helpers %}
|
||||
<label class="label" style="color: {{ record.device_role.color|fgcolor }}; background-color: #{{ record.device_role.color }}">{{ value }}</label>
|
||||
"""
|
||||
|
||||
STATUS_LABEL = """
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
"""
|
||||
@@ -325,9 +311,7 @@ class RackTable(BaseTable):
|
||||
status = tables.TemplateColumn(
|
||||
template_code=STATUS_LABEL
|
||||
)
|
||||
role = tables.TemplateColumn(
|
||||
template_code=RACK_ROLE
|
||||
)
|
||||
role = ColoredLabelColumn()
|
||||
u_height = tables.TemplateColumn(
|
||||
template_code="{{ record.u_height }}U",
|
||||
verbose_name='Height'
|
||||
@@ -806,8 +790,7 @@ class DeviceTable(BaseTable):
|
||||
viewname='dcim:rack',
|
||||
args=[Accessor('rack.pk')]
|
||||
)
|
||||
device_role = tables.TemplateColumn(
|
||||
template_code=DEVICE_ROLE,
|
||||
device_role = ColoredLabelColumn(
|
||||
verbose_name='Role'
|
||||
)
|
||||
device_type = tables.LinkColumn(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -198,7 +198,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
||||
cls.form_data = {
|
||||
'rack': rack.pk,
|
||||
'units': [10, 11, 12],
|
||||
'units': "10,11,12",
|
||||
'user': user3.pk,
|
||||
'tenant': None,
|
||||
'description': 'Rack reservation',
|
||||
@@ -366,6 +366,7 @@ manufacturer: Generic
|
||||
model: TEST-1000
|
||||
slug: test-1000
|
||||
u_height: 2
|
||||
comments: test comment
|
||||
console-ports:
|
||||
- name: Console Port 1
|
||||
type: de-9
|
||||
@@ -456,6 +457,7 @@ device-bays:
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
dt = DeviceType.objects.get(model='TEST-1000')
|
||||
self.assertEqual(dt.comments, 'test comment')
|
||||
|
||||
# Verify all of the components were created
|
||||
self.assertEqual(dt.consoleport_templates.count(), 3)
|
||||
|
||||
@@ -1105,7 +1105,7 @@ class DeviceView(PermissionRequiredMixin, View):
|
||||
def get(self, request, pk):
|
||||
|
||||
device = get_object_or_404(Device.objects.prefetch_related(
|
||||
'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
|
||||
'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
|
||||
), pk=pk)
|
||||
|
||||
# VirtualChassis members
|
||||
|
||||
@@ -46,24 +46,19 @@ class WebhookAdmin(admin.ModelAdmin):
|
||||
form = WebhookForm
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': (
|
||||
'name', 'obj_type', 'enabled',
|
||||
)
|
||||
'fields': ('name', 'obj_type', 'enabled')
|
||||
}),
|
||||
('Events', {
|
||||
'fields': (
|
||||
'type_create', 'type_update', 'type_delete',
|
||||
)
|
||||
'fields': ('type_create', 'type_update', 'type_delete')
|
||||
}),
|
||||
('HTTP Request', {
|
||||
'fields': (
|
||||
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
|
||||
)
|
||||
),
|
||||
'classes': ('monospace',)
|
||||
}),
|
||||
('SSL', {
|
||||
'fields': (
|
||||
'ssl_verification', 'ca_file_path',
|
||||
)
|
||||
'fields': ('ssl_verification', 'ca_file_path')
|
||||
})
|
||||
)
|
||||
|
||||
@@ -121,6 +116,8 @@ class CustomLinkForm(forms.ModelForm):
|
||||
'url': forms.Textarea,
|
||||
}
|
||||
help_texts = {
|
||||
'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear '
|
||||
'first in a list.',
|
||||
'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. Links '
|
||||
'which render as empty text will not be displayed.',
|
||||
'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
|
||||
@@ -136,6 +133,15 @@ class CustomLinkForm(forms.ModelForm):
|
||||
|
||||
@admin.register(CustomLink)
|
||||
class CustomLinkAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
('Custom Link', {
|
||||
'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
|
||||
}),
|
||||
('Templates', {
|
||||
'fields': ('text', 'url'),
|
||||
'classes': ('monospace',)
|
||||
})
|
||||
)
|
||||
list_display = [
|
||||
'name', 'content_type', 'group_name', 'weight',
|
||||
]
|
||||
@@ -149,8 +155,29 @@ class CustomLinkAdmin(admin.ModelAdmin):
|
||||
# Graphs
|
||||
#
|
||||
|
||||
class GraphForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Graph
|
||||
exclude = ()
|
||||
widgets = {
|
||||
'source': forms.Textarea,
|
||||
'link': forms.Textarea,
|
||||
}
|
||||
|
||||
|
||||
@admin.register(Graph)
|
||||
class GraphAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
('Graph', {
|
||||
'fields': ('type', 'name', 'weight')
|
||||
}),
|
||||
('Templates', {
|
||||
'fields': ('template_language', 'source', 'link'),
|
||||
'classes': ('monospace',)
|
||||
})
|
||||
)
|
||||
form = GraphForm
|
||||
list_display = [
|
||||
'name', 'type', 'weight', 'template_language', 'source',
|
||||
]
|
||||
@@ -179,6 +206,15 @@ class ExportTemplateForm(forms.ModelForm):
|
||||
|
||||
@admin.register(ExportTemplate)
|
||||
class ExportTemplateAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
('Export Template', {
|
||||
'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension')
|
||||
}),
|
||||
('Content', {
|
||||
'fields': ('template_language', 'template_code'),
|
||||
'classes': ('monospace',)
|
||||
})
|
||||
)
|
||||
list_display = [
|
||||
'name', 'content_type', 'description', 'mime_type', 'file_extension',
|
||||
]
|
||||
|
||||
@@ -1,15 +1,49 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from extras.models import ReportResult
|
||||
from extras import models
|
||||
from utilities.api import WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedConfigContextSerializer',
|
||||
'NestedExportTemplateSerializer',
|
||||
'NestedGraphSerializer',
|
||||
'NestedReportResultSerializer',
|
||||
'NestedTagSerializer',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Reports
|
||||
#
|
||||
class NestedConfigContextSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.ConfigContext
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class NestedExportTemplateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.ExportTemplate
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class NestedGraphSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.Graph
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class NestedTagSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
|
||||
tagged_items = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Tag
|
||||
fields = ['id', 'url', 'name', 'slug', 'color', 'tagged_items']
|
||||
|
||||
|
||||
class NestedReportResultSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
@@ -19,5 +53,5 @@ class NestedReportResultSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ReportResult
|
||||
model = models.ReportResult
|
||||
fields = ['url', 'created', 'user', 'failed']
|
||||
|
||||
@@ -430,18 +430,9 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
||||
help_text="Commit changes to the database (uncheck for a dry-run)"
|
||||
)
|
||||
|
||||
def __init__(self, vars, *args, commit_default=True, **kwargs):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Dynamically populate fields for variables
|
||||
for name, var in vars.items():
|
||||
self.fields[name] = var.as_field()
|
||||
|
||||
# Toggle default commit behavior based on Meta option
|
||||
if not commit_default:
|
||||
self.fields['_commit'].initial = False
|
||||
|
||||
# Move _commit to the end of the form
|
||||
commit = self.fields.pop('_commit')
|
||||
self.fields['_commit'] = commit
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from taggit.models import TagBase, GenericTaggedItemBase
|
||||
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField
|
||||
from utilities.models import ChangeLoggedModel
|
||||
|
||||
@@ -13,7 +14,7 @@ from utilities.models import ChangeLoggedModel
|
||||
|
||||
class Tag(TagBase, ChangeLoggedModel):
|
||||
color = ColorField(
|
||||
default='9e9e9e'
|
||||
default=ColorChoices.COLOR_GREY
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
|
||||
@@ -92,7 +92,7 @@ class Report(object):
|
||||
self.active_test = None
|
||||
self.failed = False
|
||||
|
||||
self.logger = logging.getLogger(f"netbox.reports.{self.module}.{self.name}")
|
||||
self.logger = logging.getLogger(f"netbox.reports.{self.full_name}")
|
||||
|
||||
# Compile test methods and initialize results skeleton
|
||||
test_methods = []
|
||||
@@ -120,7 +120,7 @@ class Report(object):
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
return '.'.join([self.module, self.name])
|
||||
return '.'.join([self.__module__, self.__class__.__name__])
|
||||
|
||||
def _log(self, obj, message, level=LOG_DEFAULT):
|
||||
"""
|
||||
|
||||
@@ -276,13 +276,6 @@ class BaseScript:
|
||||
@classmethod
|
||||
def _get_vars(cls):
|
||||
vars = OrderedDict()
|
||||
|
||||
# Infer order from Meta.field_order (Python 3.5 and lower)
|
||||
field_order = getattr(cls.Meta, 'field_order', [])
|
||||
for name in field_order:
|
||||
vars[name] = getattr(cls, name)
|
||||
|
||||
# Default to order of declaration on class
|
||||
for name, attr in cls.__dict__.items():
|
||||
if name not in vars and issubclass(attr.__class__, ScriptVariable):
|
||||
vars[name] = attr
|
||||
@@ -296,8 +289,16 @@ class BaseScript:
|
||||
"""
|
||||
Return a Django form suitable for populating the context data required to run this Script.
|
||||
"""
|
||||
vars = self._get_vars()
|
||||
form = ScriptForm(vars, data, files, initial=initial, commit_default=getattr(self.Meta, 'commit_default', True))
|
||||
# Create a dynamic ScriptForm subclass from script variables
|
||||
fields = {
|
||||
name: var.as_field() for name, var in self._get_vars().items()
|
||||
}
|
||||
FormClass = type('ScriptForm', (ScriptForm,), fields)
|
||||
|
||||
form = FormClass(data, files, initial=initial)
|
||||
|
||||
# Set initial "commit" checkbox state based on the script's Meta parameter
|
||||
form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
|
||||
|
||||
return form
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ def _get_registered_content(obj, method, template_context):
|
||||
'object': obj,
|
||||
'request': template_context['request'],
|
||||
'settings': template_context['settings'],
|
||||
'csrf_token': template_context['csrf_token'],
|
||||
'perms': template_context['perms'],
|
||||
}
|
||||
|
||||
model_name = obj._meta.label_lower
|
||||
|
||||
@@ -5,13 +5,11 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackRole, Region, Site
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site
|
||||
from extras.api.views import ScriptViewSet
|
||||
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
|
||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||
from extras.utils import FeatureQuery
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.testing import APITestCase
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
|
||||
|
||||
class AppTest(APITestCase):
|
||||
@@ -24,489 +22,150 @@ class AppTest(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class GraphTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
self.graph1 = Graph.objects.create(
|
||||
type=site_ct,
|
||||
name='Test Graph 1',
|
||||
source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'
|
||||
)
|
||||
self.graph2 = Graph.objects.create(
|
||||
type=site_ct,
|
||||
name='Test Graph 2',
|
||||
source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'
|
||||
)
|
||||
self.graph3 = Graph.objects.create(
|
||||
type=site_ct,
|
||||
name='Test Graph 3',
|
||||
source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'
|
||||
)
|
||||
|
||||
def test_get_graph(self):
|
||||
|
||||
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.graph1.name)
|
||||
|
||||
def test_list_graphs(self):
|
||||
|
||||
url = reverse('extras-api:graph-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_graph(self):
|
||||
|
||||
data = {
|
||||
class GraphTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Graph
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'type': 'dcim.site',
|
||||
'name': 'Test Graph 4',
|
||||
'name': 'Graph 4',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
|
||||
}
|
||||
|
||||
url = reverse('extras-api:graph-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Graph.objects.count(), 4)
|
||||
graph4 = Graph.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(graph4.type, ContentType.objects.get_for_model(Site))
|
||||
self.assertEqual(graph4.name, data['name'])
|
||||
self.assertEqual(graph4.source, data['source'])
|
||||
|
||||
def test_create_graph_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'type': 'dcim.site',
|
||||
'name': 'Test Graph 4',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
|
||||
},
|
||||
{
|
||||
'type': 'dcim.site',
|
||||
'name': 'Test Graph 5',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
|
||||
},
|
||||
{
|
||||
'type': 'dcim.site',
|
||||
'name': 'Test Graph 6',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('extras-api:graph-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Graph.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_graph(self):
|
||||
|
||||
data = {
|
||||
},
|
||||
{
|
||||
'type': 'dcim.site',
|
||||
'name': 'Test Graph X',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=99',
|
||||
}
|
||||
'name': 'Graph 5',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
|
||||
},
|
||||
{
|
||||
'type': 'dcim.site',
|
||||
'name': 'Graph 6',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
ct = ContentType.objects.get_for_model(Site)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Graph.objects.count(), 3)
|
||||
graph1 = Graph.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(graph1.type, ContentType.objects.get_for_model(Site))
|
||||
self.assertEqual(graph1.name, data['name'])
|
||||
self.assertEqual(graph1.source, data['source'])
|
||||
|
||||
def test_delete_graph(self):
|
||||
|
||||
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Graph.objects.count(), 2)
|
||||
|
||||
|
||||
class ExportTemplateTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
content_type = ContentType.objects.get_for_model(Device)
|
||||
self.exporttemplate1 = ExportTemplate.objects.create(
|
||||
content_type=content_type, name='Test Export Template 1',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||
)
|
||||
self.exporttemplate2 = ExportTemplate.objects.create(
|
||||
content_type=content_type, name='Test Export Template 2',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||
)
|
||||
self.exporttemplate3 = ExportTemplate.objects.create(
|
||||
content_type=content_type, name='Test Export Template 3',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||
graphs = (
|
||||
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'),
|
||||
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'),
|
||||
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'),
|
||||
)
|
||||
Graph.objects.bulk_create(graphs)
|
||||
|
||||
def test_get_exporttemplate(self):
|
||||
|
||||
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.exporttemplate1.name)
|
||||
|
||||
def test_list_exporttemplates(self):
|
||||
|
||||
url = reverse('extras-api:exporttemplate-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_exporttemplate(self):
|
||||
|
||||
data = {
|
||||
class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ExportTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'content_type': 'dcim.device',
|
||||
'name': 'Test Export Template 4',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
}
|
||||
|
||||
url = reverse('extras-api:exporttemplate-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ExportTemplate.objects.count(), 4)
|
||||
exporttemplate4 = ExportTemplate.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(exporttemplate4.content_type, ContentType.objects.get_for_model(Device))
|
||||
self.assertEqual(exporttemplate4.name, data['name'])
|
||||
self.assertEqual(exporttemplate4.template_code, data['template_code'])
|
||||
|
||||
def test_create_exporttemplate_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'content_type': 'dcim.device',
|
||||
'name': 'Test Export Template 4',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
{
|
||||
'content_type': 'dcim.device',
|
||||
'name': 'Test Export Template 5',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
{
|
||||
'content_type': 'dcim.device',
|
||||
'name': 'Test Export Template 6',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('extras-api:exporttemplate-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ExportTemplate.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_exporttemplate(self):
|
||||
|
||||
data = {
|
||||
},
|
||||
{
|
||||
'content_type': 'dcim.device',
|
||||
'name': 'Test Export Template X',
|
||||
'name': 'Test Export Template 5',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
}
|
||||
},
|
||||
{
|
||||
'content_type': 'dcim.device',
|
||||
'name': 'Test Export Template 6',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
ct = ContentType.objects.get_for_model(Device)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(ExportTemplate.objects.count(), 3)
|
||||
exporttemplate1 = ExportTemplate.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(exporttemplate1.name, data['name'])
|
||||
self.assertEqual(exporttemplate1.template_code, data['template_code'])
|
||||
|
||||
def test_delete_exporttemplate(self):
|
||||
|
||||
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(ExportTemplate.objects.count(), 2)
|
||||
|
||||
|
||||
class TagTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1')
|
||||
self.tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2')
|
||||
self.tag3 = Tag.objects.create(name='Test Tag 3', slug='test-tag-3')
|
||||
|
||||
def test_get_tag(self):
|
||||
|
||||
url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.tag1.name)
|
||||
|
||||
def test_list_tags(self):
|
||||
|
||||
url = reverse('extras-api:tag-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_tag(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Tag 4',
|
||||
'slug': 'test-tag-4',
|
||||
}
|
||||
|
||||
url = reverse('extras-api:tag-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Tag.objects.count(), 4)
|
||||
tag4 = Tag.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(tag4.name, data['name'])
|
||||
self.assertEqual(tag4.slug, data['slug'])
|
||||
|
||||
def test_create_tag_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Tag 4',
|
||||
'slug': 'test-tag-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test Tag 5',
|
||||
'slug': 'test-tag-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test Tag 6',
|
||||
'slug': 'test-tag-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('extras-api:tag-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Tag.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_tag(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Tag X',
|
||||
'slug': 'test-tag-x',
|
||||
}
|
||||
|
||||
url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Tag.objects.count(), 3)
|
||||
tag1 = Tag.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(tag1.name, data['name'])
|
||||
self.assertEqual(tag1.slug, data['slug'])
|
||||
|
||||
def test_delete_tag(self):
|
||||
|
||||
url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Tag.objects.count(), 2)
|
||||
|
||||
|
||||
class ConfigContextTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.configcontext1 = ConfigContext.objects.create(
|
||||
name='Test Config Context 1',
|
||||
weight=100,
|
||||
data={'foo': 123}
|
||||
export_templates = (
|
||||
ExportTemplate(
|
||||
content_type=ct,
|
||||
name='Export Template 1',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||
),
|
||||
ExportTemplate(
|
||||
content_type=ct,
|
||||
name='Export Template 2',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||
),
|
||||
ExportTemplate(
|
||||
content_type=ct,
|
||||
name='Export Template 3',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||
),
|
||||
)
|
||||
self.configcontext2 = ConfigContext.objects.create(
|
||||
name='Test Config Context 2',
|
||||
weight=200,
|
||||
data={'bar': 456}
|
||||
ExportTemplate.objects.bulk_create(export_templates)
|
||||
|
||||
|
||||
class TagTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Tag
|
||||
brief_fields = ['color', 'id', 'name', 'slug', 'tagged_items', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Tag 4',
|
||||
'slug': 'tag-4',
|
||||
},
|
||||
{
|
||||
'name': 'Tag 5',
|
||||
'slug': 'tag-5',
|
||||
},
|
||||
{
|
||||
'name': 'Tag 6',
|
||||
'slug': 'tag-6',
|
||||
},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
tags = (
|
||||
Tag(name='Tag 1', slug='tag-1'),
|
||||
Tag(name='Tag 2', slug='tag-2'),
|
||||
Tag(name='Tag 3', slug='tag-3'),
|
||||
)
|
||||
self.configcontext3 = ConfigContext.objects.create(
|
||||
name='Test Config Context 3',
|
||||
weight=300,
|
||||
data={'baz': 789}
|
||||
Tag.objects.bulk_create(tags)
|
||||
|
||||
|
||||
class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConfigContext
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Config Context 4',
|
||||
'data': {'more_foo': True},
|
||||
},
|
||||
{
|
||||
'name': 'Config Context 5',
|
||||
'data': {'more_bar': False},
|
||||
},
|
||||
{
|
||||
'name': 'Config Context 6',
|
||||
'data': {'more_baz': None},
|
||||
},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
config_contexts = (
|
||||
ConfigContext(name='Config Context 1', weight=100, data={'foo': 123}),
|
||||
ConfigContext(name='Config Context 2', weight=200, data={'bar': 456}),
|
||||
ConfigContext(name='Config Context 3', weight=300, data={'baz': 789}),
|
||||
)
|
||||
|
||||
def test_get_configcontext(self):
|
||||
|
||||
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.configcontext1.name)
|
||||
self.assertEqual(response.data['data'], self.configcontext1.data)
|
||||
|
||||
def test_list_configcontexts(self):
|
||||
|
||||
url = reverse('extras-api:configcontext-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_configcontext(self):
|
||||
|
||||
region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
|
||||
region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
|
||||
site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
|
||||
role1 = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
|
||||
role2 = DeviceRole.objects.create(name='Test Role 2', slug='test-role-2')
|
||||
platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1')
|
||||
platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2')
|
||||
tenantgroup1 = TenantGroup(name='Test Tenant Group 1', slug='test-tenant-group-1')
|
||||
tenantgroup1.save()
|
||||
tenantgroup2 = TenantGroup(name='Test Tenant Group 2', slug='test-tenant-group-2')
|
||||
tenantgroup2.save()
|
||||
tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1')
|
||||
tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2')
|
||||
tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1')
|
||||
tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2')
|
||||
|
||||
data = {
|
||||
'name': 'Test Config Context 4',
|
||||
'weight': 1000,
|
||||
'regions': [region1.pk, region2.pk],
|
||||
'sites': [site1.pk, site2.pk],
|
||||
'roles': [role1.pk, role2.pk],
|
||||
'platforms': [platform1.pk, platform2.pk],
|
||||
'tenant_groups': [tenantgroup1.pk, tenantgroup2.pk],
|
||||
'tenants': [tenant1.pk, tenant2.pk],
|
||||
'tags': [tag1.slug, tag2.slug],
|
||||
'data': {'foo': 'XXX'}
|
||||
}
|
||||
|
||||
url = reverse('extras-api:configcontext-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ConfigContext.objects.count(), 4)
|
||||
configcontext4 = ConfigContext.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(configcontext4.name, data['name'])
|
||||
self.assertEqual(region1.pk, data['regions'][0])
|
||||
self.assertEqual(region2.pk, data['regions'][1])
|
||||
self.assertEqual(site1.pk, data['sites'][0])
|
||||
self.assertEqual(site2.pk, data['sites'][1])
|
||||
self.assertEqual(role1.pk, data['roles'][0])
|
||||
self.assertEqual(role2.pk, data['roles'][1])
|
||||
self.assertEqual(platform1.pk, data['platforms'][0])
|
||||
self.assertEqual(platform2.pk, data['platforms'][1])
|
||||
self.assertEqual(tenantgroup1.pk, data['tenant_groups'][0])
|
||||
self.assertEqual(tenantgroup2.pk, data['tenant_groups'][1])
|
||||
self.assertEqual(tenant1.pk, data['tenants'][0])
|
||||
self.assertEqual(tenant2.pk, data['tenants'][1])
|
||||
self.assertEqual(tag1.slug, data['tags'][0])
|
||||
self.assertEqual(tag2.slug, data['tags'][1])
|
||||
self.assertEqual(configcontext4.data, data['data'])
|
||||
|
||||
def test_create_configcontext_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Config Context 4',
|
||||
'data': {'more_foo': True},
|
||||
},
|
||||
{
|
||||
'name': 'Test Config Context 5',
|
||||
'data': {'more_bar': False},
|
||||
},
|
||||
{
|
||||
'name': 'Test Config Context 6',
|
||||
'data': {'more_baz': None},
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('extras-api:configcontext-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ConfigContext.objects.count(), 6)
|
||||
for i in range(0, 3):
|
||||
self.assertEqual(response.data[i]['name'], data[i]['name'])
|
||||
self.assertEqual(response.data[i]['data'], data[i]['data'])
|
||||
|
||||
def test_update_configcontext(self):
|
||||
|
||||
region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
|
||||
region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
|
||||
|
||||
data = {
|
||||
'name': 'Test Config Context X',
|
||||
'weight': 999,
|
||||
'regions': [region1.pk, region2.pk],
|
||||
'data': {'foo': 'XXX'}
|
||||
}
|
||||
|
||||
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(ConfigContext.objects.count(), 3)
|
||||
configcontext1 = ConfigContext.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(configcontext1.name, data['name'])
|
||||
self.assertEqual(configcontext1.weight, data['weight'])
|
||||
self.assertEqual(sorted([r.pk for r in configcontext1.regions.all()]), sorted(data['regions']))
|
||||
self.assertEqual(configcontext1.data, data['data'])
|
||||
|
||||
def test_delete_configcontext(self):
|
||||
|
||||
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(ConfigContext.objects.count(), 2)
|
||||
ConfigContext.objects.bulk_create(config_contexts)
|
||||
|
||||
def test_render_configcontext_for_object(self):
|
||||
|
||||
# Create a Device for which we'll render a config context
|
||||
manufacturer = Manufacturer.objects.create(
|
||||
name='Test Manufacturer',
|
||||
slug='test-manufacturer'
|
||||
)
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Test Device Type'
|
||||
)
|
||||
device_role = DeviceRole.objects.create(
|
||||
name='Test Role',
|
||||
slug='test-role'
|
||||
)
|
||||
site = Site.objects.create(
|
||||
name='Test Site',
|
||||
slug='test-site'
|
||||
)
|
||||
device = Device.objects.create(
|
||||
name='Test Device',
|
||||
device_type=device_type,
|
||||
device_role=device_role,
|
||||
site=site
|
||||
)
|
||||
"""
|
||||
Test rendering config context data for a device.
|
||||
"""
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
site = Site.objects.create(name='Site-1', slug='site-1')
|
||||
device = Device.objects.create(name='Device 1', device_type=devicetype, device_role=devicerole, site=site)
|
||||
|
||||
# Test default config contexts (created at test setup)
|
||||
rendered_context = device.get_config_context()
|
||||
@@ -516,7 +175,7 @@ class ConfigContextTest(APITestCase):
|
||||
|
||||
# Add another context specific to the site
|
||||
configcontext4 = ConfigContext(
|
||||
name='Test Config Context 4',
|
||||
name='Config Context 4',
|
||||
data={'site_data': 'ABC'}
|
||||
)
|
||||
configcontext4.save()
|
||||
@@ -526,7 +185,7 @@ class ConfigContextTest(APITestCase):
|
||||
|
||||
# Override one of the default contexts
|
||||
configcontext5 = ConfigContext(
|
||||
name='Test Config Context 5',
|
||||
name='Config Context 5',
|
||||
weight=2000,
|
||||
data={'foo': 999}
|
||||
)
|
||||
@@ -536,12 +195,9 @@ class ConfigContextTest(APITestCase):
|
||||
self.assertEqual(rendered_context['foo'], 999)
|
||||
|
||||
# Add a context which does NOT match our device and ensure it does not apply
|
||||
site2 = Site.objects.create(
|
||||
name='Test Site 2',
|
||||
slug='test-site-2'
|
||||
)
|
||||
site2 = Site.objects.create(name='Site 2', slug='site-2')
|
||||
configcontext6 = ConfigContext(
|
||||
name='Test Config Context 6',
|
||||
name='Config Context 6',
|
||||
weight=2000,
|
||||
data={'bar': 999}
|
||||
)
|
||||
|
||||
@@ -436,7 +436,6 @@ class ScriptView(PermissionRequiredMixin, View):
|
||||
raise Http404
|
||||
|
||||
def get(self, request, module, name):
|
||||
|
||||
script = self._get_script(module, name)
|
||||
form = script.as_form(initial=request.GET)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
|
||||
from ipam import models
|
||||
from utilities.api import WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
@@ -9,6 +9,7 @@ __all__ = [
|
||||
'NestedPrefixSerializer',
|
||||
'NestedRIRSerializer',
|
||||
'NestedRoleSerializer',
|
||||
'NestedServiceSerializer',
|
||||
'NestedVLANGroupSerializer',
|
||||
'NestedVLANSerializer',
|
||||
'NestedVRFSerializer',
|
||||
@@ -24,7 +25,7 @@ class NestedVRFSerializer(WritableNestedSerializer):
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
model = models.VRF
|
||||
fields = ['id', 'url', 'name', 'rd', 'prefix_count']
|
||||
|
||||
|
||||
@@ -37,7 +38,7 @@ class NestedRIRSerializer(WritableNestedSerializer):
|
||||
aggregate_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RIR
|
||||
model = models.RIR
|
||||
fields = ['id', 'url', 'name', 'slug', 'aggregate_count']
|
||||
|
||||
|
||||
@@ -45,7 +46,7 @@ class NestedAggregateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
model = models.Aggregate
|
||||
fields = ['id', 'url', 'family', 'prefix']
|
||||
|
||||
|
||||
@@ -59,7 +60,7 @@ class NestedRoleSerializer(WritableNestedSerializer):
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
model = models.Role
|
||||
fields = ['id', 'url', 'name', 'slug', 'prefix_count', 'vlan_count']
|
||||
|
||||
|
||||
@@ -68,7 +69,7 @@ class NestedVLANGroupSerializer(WritableNestedSerializer):
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
model = models.VLANGroup
|
||||
fields = ['id', 'url', 'name', 'slug', 'vlan_count']
|
||||
|
||||
|
||||
@@ -76,7 +77,7 @@ class NestedVLANSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
model = models.VLAN
|
||||
fields = ['id', 'url', 'vid', 'name', 'display_name']
|
||||
|
||||
|
||||
@@ -88,7 +89,7 @@ class NestedPrefixSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
model = models.Prefix
|
||||
fields = ['id', 'url', 'family', 'prefix']
|
||||
|
||||
|
||||
@@ -96,10 +97,21 @@ class NestedPrefixSerializer(WritableNestedSerializer):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
|
||||
class NestedIPAddressSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
model = models.IPAddress
|
||||
fields = ['id', 'url', 'family', 'address']
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
#
|
||||
|
||||
class NestedServiceSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.Service
|
||||
fields = ['id', 'url', 'name', 'protocol', 'port']
|
||||
|
||||
@@ -74,12 +74,8 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
filterset_class = filters.PrefixFilterSet
|
||||
|
||||
@swagger_auto_schema(
|
||||
methods=['get', 'post'],
|
||||
responses={
|
||||
200: serializers.AvailablePrefixSerializer(many=True),
|
||||
}
|
||||
)
|
||||
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
||||
@swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
|
||||
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
|
||||
def available_prefixes(self, request, pk=None):
|
||||
@@ -94,10 +90,6 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
|
||||
if request.method == 'POST':
|
||||
|
||||
# Permissions check
|
||||
if not request.user.has_perm('ipam.add_prefix'):
|
||||
raise PermissionDenied()
|
||||
|
||||
# Validate Requested Prefixes' length
|
||||
serializer = serializers.PrefixLengthSerializer(
|
||||
data=request.data if isinstance(request.data, list) else [request.data],
|
||||
@@ -158,13 +150,10 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
methods=['get', 'post'],
|
||||
responses={
|
||||
200: serializers.AvailableIPSerializer(many=True),
|
||||
}
|
||||
)
|
||||
@action(detail=True, url_path='available-ips', methods=['get', 'post'])
|
||||
@swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
|
||||
@swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
|
||||
request_body=serializers.AvailableIPSerializer(many=False))
|
||||
@action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
|
||||
def available_ips(self, request, pk=None):
|
||||
"""
|
||||
@@ -180,10 +169,6 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
# Create the next available IP within the prefix
|
||||
if request.method == 'POST':
|
||||
|
||||
# Permissions check
|
||||
if not request.user.has_perm('ipam.add_ipaddress'):
|
||||
raise PermissionDenied()
|
||||
|
||||
# Normalize to a list of objects
|
||||
requested_ips = request.data if isinstance(request.data, list) else [request.data]
|
||||
|
||||
@@ -276,7 +261,7 @@ class VLANViewSet(CustomFieldModelViewSet):
|
||||
queryset = VLAN.objects.prefetch_related(
|
||||
'site', 'group', 'tenant', 'role', 'tags'
|
||||
).annotate(
|
||||
prefix_count=get_subquery(Prefix, 'role')
|
||||
prefix_count=get_subquery(Prefix, 'vlan')
|
||||
)
|
||||
serializer_class = serializers.VLANSerializer
|
||||
filterset_class = filters.VLANFilterSet
|
||||
|
||||
@@ -618,7 +618,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
if self.instance and self.instance.interface:
|
||||
self.fields['interface'].queryset = Interface.objects.filter(
|
||||
device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
|
||||
)
|
||||
).prefetch_related(
|
||||
'device__primary_ip4',
|
||||
'device__primary_ip6',
|
||||
'virtual_machine__primary_ip4',
|
||||
'virtual_machine__primary_ip6',
|
||||
) # We prefetch the primary address fields to ensure cache invalidation does not balk on the save()
|
||||
else:
|
||||
self.fields['interface'].choices = []
|
||||
|
||||
@@ -676,11 +681,14 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
required=False,
|
||||
label='VRF'
|
||||
)
|
||||
tags = TagField(
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant',
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'status': StaticSelect2(),
|
||||
@@ -775,18 +783,6 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Set interface
|
||||
if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
|
||||
self.instance.interface = Interface.objects.get(
|
||||
device=self.cleaned_data['device'],
|
||||
name=self.cleaned_data['interface_name']
|
||||
)
|
||||
elif self.cleaned_data['virtual_machine'] and self.cleaned_data['interface_name']:
|
||||
self.instance.interface = Interface.objects.get(
|
||||
virtual_machine=self.cleaned_data['virtual_machine'],
|
||||
name=self.cleaned_data['interface_name']
|
||||
)
|
||||
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
# Set as primary for device/VM
|
||||
|
||||
@@ -667,6 +667,9 @@ class ServiceTable(BaseTable):
|
||||
viewname='ipam:service',
|
||||
args=[Accessor('pk')]
|
||||
)
|
||||
parent = tables.LinkColumn(
|
||||
order_by=('device', 'virtual_machine')
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:service_list'
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,11 @@ ADMINS = [
|
||||
# ['John Doe', 'jdoe@example.com'],
|
||||
]
|
||||
|
||||
# URL schemes that are allowed within links in NetBox
|
||||
ALLOWED_URL_SCHEMES = (
|
||||
'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
|
||||
)
|
||||
|
||||
# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same
|
||||
# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
|
||||
BANNER_TOP = ''
|
||||
@@ -132,6 +137,10 @@ EXEMPT_VIEW_PERMISSIONS = [
|
||||
# 'https': 'http://10.10.1.10:1080',
|
||||
# }
|
||||
|
||||
# IP addresses recognized as internal to the system. The debugging toolbar will be available only to clients accessing
|
||||
# NetBox from an internal IP.
|
||||
INTERNAL_IPS = ('127.0.0.1', '::1')
|
||||
|
||||
# Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
|
||||
# https://docs.djangoproject.com/en/stable/topics/logging/
|
||||
LOGGING = {}
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.8.4'
|
||||
VERSION = '2.8.6'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -58,6 +58,9 @@ SECRET_KEY = getattr(configuration, 'SECRET_KEY')
|
||||
|
||||
# Set optional parameters
|
||||
ADMINS = getattr(configuration, 'ADMINS', [])
|
||||
ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', (
|
||||
'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
|
||||
))
|
||||
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
|
||||
BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
|
||||
BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
|
||||
@@ -78,6 +81,7 @@ EMAIL = getattr(configuration, 'EMAIL', {})
|
||||
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
|
||||
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
|
||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||
LOGGING = getattr(configuration, 'LOGGING', {})
|
||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
|
||||
@@ -615,15 +619,6 @@ RQ_QUEUES = {
|
||||
'check_releases': RQ_PARAMS,
|
||||
}
|
||||
|
||||
#
|
||||
# Django debug toolbar
|
||||
#
|
||||
|
||||
INTERNAL_IPS = (
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# NetBox internal settings
|
||||
|
||||
@@ -115,6 +115,16 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
|
||||
'plaintext2': "The two given plaintext values do not match. Please check your input."
|
||||
})
|
||||
|
||||
# Validate uniqueness
|
||||
if Secret.objects.filter(
|
||||
device=self.cleaned_data['device'],
|
||||
role=self.cleaned_data['role'],
|
||||
name=self.cleaned_data['name']
|
||||
).exists():
|
||||
raise forms.ValidationError(
|
||||
"Each secret assigned to a device must have a unique combination of role and name"
|
||||
)
|
||||
|
||||
|
||||
class SecretCSVForm(CustomFieldModelCSVForm):
|
||||
device = CSVModelChoiceField(
|
||||
|
||||
@@ -6,7 +6,7 @@ from rest_framework import status
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
|
||||
from secrets.models import Secret, SecretRole, SessionKey, UserKey
|
||||
from users.models import Token
|
||||
from utilities.testing import APITestCase, create_test_user
|
||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_user
|
||||
from .constants import PRIVATE_KEY, PUBLIC_KEY
|
||||
|
||||
|
||||
@@ -20,107 +20,36 @@ class AppTest(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class SecretRoleTest(APITestCase):
|
||||
class SecretRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
model = SecretRole
|
||||
brief_fields = ['id', 'name', 'secret_count', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Secret Role 4',
|
||||
'slug': 'secret-role-4',
|
||||
},
|
||||
{
|
||||
'name': 'Secret Role 5',
|
||||
'slug': 'secret-role-5',
|
||||
},
|
||||
{
|
||||
'name': 'Secret Role 6',
|
||||
'slug': 'secret-role-6',
|
||||
},
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1')
|
||||
self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2')
|
||||
self.secretrole3 = SecretRole.objects.create(name='Test Secret Role 3', slug='test-secret-role-3')
|
||||
|
||||
def test_get_secretrole(self):
|
||||
|
||||
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.secretrole1.name)
|
||||
|
||||
def test_list_secretroles(self):
|
||||
|
||||
url = reverse('secrets-api:secretrole-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
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', 'secret_count', 'slug', 'url']
|
||||
secret_roles = (
|
||||
SecretRole(name='Secret Role 1', slug='secret-role-1'),
|
||||
SecretRole(name='Secret Role 2', slug='secret-role-2'),
|
||||
SecretRole(name='Secret Role 3', slug='secret-role-3'),
|
||||
)
|
||||
|
||||
def test_create_secretrole(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Secret Role 4',
|
||||
'slug': 'test-secret-role-4',
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secretrole-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(SecretRole.objects.count(), 4)
|
||||
secretrole4 = SecretRole.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(secretrole4.name, data['name'])
|
||||
self.assertEqual(secretrole4.slug, data['slug'])
|
||||
|
||||
def test_create_secretrole_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Secret Role 4',
|
||||
'slug': 'test-secret-role-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test Secret Role 5',
|
||||
'slug': 'test-secret-role-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test Secret Role 6',
|
||||
'slug': 'test-secret-role-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('secrets-api:secretrole-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(SecretRole.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_secretrole(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test SecretRole X',
|
||||
'slug': 'test-secretrole-x',
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(SecretRole.objects.count(), 3)
|
||||
secretrole1 = SecretRole.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(secretrole1.name, data['name'])
|
||||
self.assertEqual(secretrole1.slug, data['slug'])
|
||||
|
||||
def test_delete_secretrole(self):
|
||||
|
||||
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(SecretRole.objects.count(), 2)
|
||||
SecretRole.objects.bulk_create(secret_roles)
|
||||
|
||||
|
||||
# TODO: Standardize SecretTest
|
||||
class SecretTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
<div class="panel-footer noprint">
|
||||
{% if table.rows %}
|
||||
{% if edit_url %}
|
||||
<button type="submit" name="_edit" formaction="{% url edit_url %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
|
||||
<button type="submit" name="_edit" formaction="{% url edit_url %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if delete_url %}
|
||||
<button type="submit" name="_delete" formaction="{% url delete_url %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
|
||||
<button type="submit" name="_delete" formaction="{% url delete_url %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
@@ -3,19 +3,21 @@
|
||||
|
||||
{% block form %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
|
||||
<div class="panel-heading"><strong>Rack Reservation</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">Rack</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{{ obj.rack }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% render_field form.site %}
|
||||
{% render_field form.rack_group %}
|
||||
{% render_field form.rack %}
|
||||
{% render_field form.units %}
|
||||
{% render_field form.user %}
|
||||
{% render_field form.tenant_group %}
|
||||
{% render_field form.tenant %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Tenant Assignment</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.tenant_group %}
|
||||
{% render_field form.tenant %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<code>python3 manage.py migrate</code> from the command line.
|
||||
</p>
|
||||
<p>
|
||||
<i class="fa fa-warning"></i> <strong>Unsupported PostgreSQL version</strong> - Ensure that PostgreSQL version 9.4 or higher is in use. You
|
||||
<i class="fa fa-warning"></i> <strong>Unsupported PostgreSQL version</strong> - Ensure that PostgreSQL version 9.6 or higher is in use. You
|
||||
can check this by connecting to the database using NetBox's credentials and issuing a query for
|
||||
<code>SELECT VERSION()</code>.
|
||||
</p>
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
<li{% if not perms.dcim.view_rackreservation %} class="disabled"{% endif %}>
|
||||
{% if perms.dcim.add_rackreservation %}
|
||||
<div class="buttons pull-right">
|
||||
<a href="{% url 'dcim:rackreservation_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
|
||||
<a href="{% url 'dcim:rackreservation_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
{% render_field model_form.tenant %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Tags</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field model_form.tags %}
|
||||
</div>
|
||||
</div>
|
||||
{% if model_form.custom_fields %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Custom Fields</strong></div>
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<li role="presentation"{% if active_tab == 'prefixes' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:prefix_prefixes' pk=prefix.pk %}">Child Prefixes <span class="badge">{{ prefix.get_child_prefixes.count }}</span></a>
|
||||
</li>
|
||||
{% if perms.ipam.view_ipaddress %}
|
||||
{% if perms.ipam.view_ipaddress and prefix.status != 'container' %}
|
||||
<li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a>
|
||||
</li>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.testing import APITestCase
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
|
||||
|
||||
class AppTest(APITestCase):
|
||||
@@ -15,235 +14,74 @@ class AppTest(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class TenantGroupTest(APITestCase):
|
||||
class TenantGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = TenantGroup
|
||||
brief_fields = ['id', 'name', 'slug', 'tenant_count', 'url']
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.parent_tenant_groups = (
|
||||
TenantGroup(name='Parent Tenant Group 1', slug='parent-tenant-group-1'),
|
||||
TenantGroup(name='Parent Tenant Group 2', slug='parent-tenant-group-2'),
|
||||
)
|
||||
for tenantgroup in self.parent_tenant_groups:
|
||||
tenantgroup.save()
|
||||
|
||||
self.tenant_groups = (
|
||||
TenantGroup(name='Tenant Group 1', slug='tenant-group-1', parent=self.parent_tenant_groups[0]),
|
||||
TenantGroup(name='Tenant Group 2', slug='tenant-group-2', parent=self.parent_tenant_groups[0]),
|
||||
TenantGroup(name='Tenant Group 3', slug='tenant-group-3', parent=self.parent_tenant_groups[0]),
|
||||
)
|
||||
for tenantgroup in self.tenant_groups:
|
||||
tenantgroup.save()
|
||||
|
||||
def test_get_tenantgroup(self):
|
||||
|
||||
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.tenant_groups[0].name)
|
||||
|
||||
def test_list_tenantgroups(self):
|
||||
|
||||
url = reverse('tenancy-api:tenantgroup-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 5)
|
||||
|
||||
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', 'tenant_count', 'url']
|
||||
parent_tenant_groups = (
|
||||
TenantGroup.objects.create(name='Parent Tenant Group 1', slug='parent-tenant-group-1'),
|
||||
TenantGroup.objects.create(name='Parent Tenant Group 2', slug='parent-tenant-group-2'),
|
||||
)
|
||||
|
||||
def test_create_tenantgroup(self):
|
||||
TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0])
|
||||
TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[0])
|
||||
TenantGroup.objects.create(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0])
|
||||
|
||||
data = {
|
||||
'name': 'Tenant Group 4',
|
||||
'slug': 'tenant-group-4',
|
||||
'parent': self.parent_tenant_groups[0].pk,
|
||||
}
|
||||
|
||||
url = reverse('tenancy-api:tenantgroup-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(TenantGroup.objects.count(), 6)
|
||||
tenantgroup4 = TenantGroup.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(tenantgroup4.name, data['name'])
|
||||
self.assertEqual(tenantgroup4.slug, data['slug'])
|
||||
self.assertEqual(tenantgroup4.parent_id, data['parent'])
|
||||
|
||||
def test_create_tenantgroup_bulk(self):
|
||||
|
||||
data = [
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Tenant Group 4',
|
||||
'slug': 'tenant-group-4',
|
||||
'parent': self.parent_tenant_groups[0].pk,
|
||||
'parent': parent_tenant_groups[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Tenant Group 5',
|
||||
'slug': 'tenant-group-5',
|
||||
'parent': self.parent_tenant_groups[0].pk,
|
||||
'parent': parent_tenant_groups[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Tenant Group 6',
|
||||
'slug': 'tenant-group-6',
|
||||
'parent': self.parent_tenant_groups[0].pk,
|
||||
'parent': parent_tenant_groups[1].pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('tenancy-api:tenantgroup-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(TenantGroup.objects.count(), 8)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
class TenantTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Tenant
|
||||
brief_fields = ['id', 'name', 'slug', 'url']
|
||||
|
||||
def test_update_tenantgroup(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
data = {
|
||||
'name': 'Tenant Group X',
|
||||
'slug': 'tenant-group-x',
|
||||
'parent': self.parent_tenant_groups[1].pk,
|
||||
}
|
||||
|
||||
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(TenantGroup.objects.count(), 5)
|
||||
tenantgroup1 = TenantGroup.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(tenantgroup1.name, data['name'])
|
||||
self.assertEqual(tenantgroup1.slug, data['slug'])
|
||||
self.assertEqual(tenantgroup1.parent_id, data['parent'])
|
||||
|
||||
def test_delete_tenantgroup(self):
|
||||
|
||||
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(TenantGroup.objects.count(), 4)
|
||||
|
||||
|
||||
class TenantTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.tenant_groups = (
|
||||
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
|
||||
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
|
||||
)
|
||||
for tenantgroup in self.tenant_groups:
|
||||
tenantgroup.save()
|
||||
|
||||
self.tenants = (
|
||||
Tenant(name='Test Tenant 1', slug='test-tenant-1', group=self.tenant_groups[0]),
|
||||
Tenant(name='Test Tenant 2', slug='test-tenant-2', group=self.tenant_groups[0]),
|
||||
Tenant(name='Test Tenant 3', slug='test-tenant-3', group=self.tenant_groups[0]),
|
||||
)
|
||||
Tenant.objects.bulk_create(self.tenants)
|
||||
|
||||
def test_get_tenant(self):
|
||||
|
||||
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.tenants[0].name)
|
||||
|
||||
def test_list_tenants(self):
|
||||
|
||||
url = reverse('tenancy-api:tenant-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
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']
|
||||
tenant_groups = (
|
||||
TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1'),
|
||||
TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2'),
|
||||
)
|
||||
|
||||
def test_create_tenant(self):
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
|
||||
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[0]),
|
||||
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
data = {
|
||||
'name': 'Test Tenant 4',
|
||||
'slug': 'test-tenant-4',
|
||||
'group': self.tenant_groups[0].pk,
|
||||
}
|
||||
|
||||
url = reverse('tenancy-api:tenant-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Tenant.objects.count(), 4)
|
||||
tenant4 = Tenant.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(tenant4.name, data['name'])
|
||||
self.assertEqual(tenant4.slug, data['slug'])
|
||||
self.assertEqual(tenant4.group_id, data['group'])
|
||||
|
||||
def test_create_tenant_bulk(self):
|
||||
|
||||
data = [
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Test Tenant 4',
|
||||
'slug': 'test-tenant-4',
|
||||
'name': 'Tenant 4',
|
||||
'slug': 'tenant-4',
|
||||
'group': tenant_groups[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Tenant 5',
|
||||
'slug': 'test-tenant-5',
|
||||
'name': 'Tenant 5',
|
||||
'slug': 'tenant-5',
|
||||
'group': tenant_groups[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Tenant 6',
|
||||
'slug': 'test-tenant-6',
|
||||
'name': 'Tenant 6',
|
||||
'slug': 'tenant-6',
|
||||
'group': tenant_groups[1].pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('tenancy-api:tenant-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Tenant.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_tenant(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Tenant X',
|
||||
'slug': 'test-tenant-x',
|
||||
'group': self.tenant_groups[1].pk,
|
||||
}
|
||||
|
||||
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Tenant.objects.count(), 3)
|
||||
tenant1 = Tenant.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(tenant1.name, data['name'])
|
||||
self.assertEqual(tenant1.slug, data['slug'])
|
||||
self.assertEqual(tenant1.group_id, data['group'])
|
||||
|
||||
def test_delete_tenant(self):
|
||||
|
||||
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Tenant.objects.count(), 2)
|
||||
|
||||
@@ -6,14 +6,13 @@ from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
|
||||
from django.db.models import ManyToManyField, ProtectedError
|
||||
from django.http import Http404
|
||||
from django.urls import reverse
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import Field, ModelSerializer, ValidationError
|
||||
from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet
|
||||
from rest_framework.viewsets import ModelViewSet as _ModelViewSet
|
||||
|
||||
from .utils import dict_to_filter_params, dynamic_import
|
||||
|
||||
|
||||
@@ -80,6 +80,70 @@ def unpack_grouped_choices(choices):
|
||||
return unpacked_choices
|
||||
|
||||
|
||||
#
|
||||
# Generic color choices
|
||||
#
|
||||
|
||||
class ColorChoices(ChoiceSet):
|
||||
COLOR_DARK_RED = 'aa1409'
|
||||
COLOR_RED = 'f44336'
|
||||
COLOR_PINK = 'e91e63'
|
||||
COLOR_ROSE = 'ffe4e1'
|
||||
COLOR_FUCHSIA = 'ff66ff'
|
||||
COLOR_PURPLE = '9c27b0'
|
||||
COLOR_DARK_PURPLE = '673ab7'
|
||||
COLOR_INDIGO = '3f51b5'
|
||||
COLOR_BLUE = '2196f3'
|
||||
COLOR_LIGHT_BLUE = '03a9f4'
|
||||
COLOR_CYAN = '00bcd4'
|
||||
COLOR_TEAL = '009688'
|
||||
COLOR_AQUA = '00ffff'
|
||||
COLOR_DARK_GREEN = '2f6a31'
|
||||
COLOR_GREEN = '4caf50'
|
||||
COLOR_LIGHT_GREEN = '8bc34a'
|
||||
COLOR_LIME = 'cddc39'
|
||||
COLOR_YELLOW = 'ffeb3b'
|
||||
COLOR_AMBER = 'ffc107'
|
||||
COLOR_ORANGE = 'ff9800'
|
||||
COLOR_DARK_ORANGE = 'ff5722'
|
||||
COLOR_BROWN = '795548'
|
||||
COLOR_LIGHT_GREY = 'c0c0c0'
|
||||
COLOR_GREY = '9e9e9e'
|
||||
COLOR_DARK_GREY = '607d8b'
|
||||
COLOR_BLACK = '111111'
|
||||
COLOR_WHITE = 'ffffff'
|
||||
|
||||
CHOICES = (
|
||||
(COLOR_DARK_RED, 'Dark red'),
|
||||
(COLOR_RED, 'Red'),
|
||||
(COLOR_PINK, 'Pink'),
|
||||
(COLOR_ROSE, 'Rose'),
|
||||
(COLOR_FUCHSIA, 'Fuchsia'),
|
||||
(COLOR_PURPLE, 'Purple'),
|
||||
(COLOR_DARK_PURPLE, 'Dark purple'),
|
||||
(COLOR_INDIGO, 'Indigo'),
|
||||
(COLOR_BLUE, 'Blue'),
|
||||
(COLOR_LIGHT_BLUE, 'Light blue'),
|
||||
(COLOR_CYAN, 'Cyan'),
|
||||
(COLOR_TEAL, 'Teal'),
|
||||
(COLOR_AQUA, 'Aqua'),
|
||||
(COLOR_DARK_GREEN, 'Dark green'),
|
||||
(COLOR_GREEN, 'Green'),
|
||||
(COLOR_LIGHT_GREEN, 'Light green'),
|
||||
(COLOR_LIME, 'Lime'),
|
||||
(COLOR_YELLOW, 'Yellow'),
|
||||
(COLOR_AMBER, 'Amber'),
|
||||
(COLOR_ORANGE, 'Orange'),
|
||||
(COLOR_DARK_ORANGE, 'Dark orange'),
|
||||
(COLOR_BROWN, 'Brown'),
|
||||
(COLOR_LIGHT_GREY, 'Light grey'),
|
||||
(COLOR_GREY, 'Grey'),
|
||||
(COLOR_DARK_GREY, 'Dark grey'),
|
||||
(COLOR_BLACK, 'Black'),
|
||||
(COLOR_WHITE, 'White'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Button color choices
|
||||
#
|
||||
|
||||
@@ -1,34 +1,3 @@
|
||||
COLOR_CHOICES = (
|
||||
('aa1409', 'Dark red'),
|
||||
('f44336', 'Red'),
|
||||
('e91e63', 'Pink'),
|
||||
('ffe4e1', 'Rose'),
|
||||
('ff66ff', 'Fuschia'),
|
||||
('9c27b0', 'Purple'),
|
||||
('673ab7', 'Dark purple'),
|
||||
('3f51b5', 'Indigo'),
|
||||
('2196f3', 'Blue'),
|
||||
('03a9f4', 'Light blue'),
|
||||
('00bcd4', 'Cyan'),
|
||||
('009688', 'Teal'),
|
||||
('00ffff', 'Aqua'),
|
||||
('2f6a31', 'Dark green'),
|
||||
('4caf50', 'Green'),
|
||||
('8bc34a', 'Light green'),
|
||||
('cddc39', 'Lime'),
|
||||
('ffeb3b', 'Yellow'),
|
||||
('ffc107', 'Amber'),
|
||||
('ff9800', 'Orange'),
|
||||
('ff5722', 'Dark orange'),
|
||||
('795548', 'Brown'),
|
||||
('c0c0c0', 'Light grey'),
|
||||
('9e9e9e', 'Grey'),
|
||||
('607d8b', 'Dark grey'),
|
||||
('111111', 'Black'),
|
||||
('ffffff', 'White'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Filter lookup expressions
|
||||
#
|
||||
|
||||
@@ -7,6 +7,7 @@ import django_filters
|
||||
import yaml
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
|
||||
from django.core.exceptions import MultipleObjectsReturned
|
||||
from django.db.models import Count
|
||||
@@ -14,8 +15,7 @@ from django.forms import BoundField
|
||||
from django.forms.models import fields_for_model
|
||||
from django.urls import reverse
|
||||
|
||||
from .choices import unpack_grouped_choices
|
||||
from .constants import *
|
||||
from .choices import ColorChoices, unpack_grouped_choices
|
||||
from .validators import EnhancedURLValidator
|
||||
|
||||
NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]'
|
||||
@@ -163,7 +163,7 @@ class ColorSelect(forms.Select):
|
||||
option_template_name = 'widgets/colorselect_option.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['choices'] = add_blank_choice(COLOR_CHOICES)
|
||||
kwargs['choices'] = add_blank_choice(ColorChoices)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'netbox-select2-color-picker'
|
||||
|
||||
@@ -244,24 +244,11 @@ class ContentTypeSelect(StaticSelect2):
|
||||
option_template_name = 'widgets/select_contenttype.html'
|
||||
|
||||
|
||||
class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
|
||||
"""
|
||||
MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.delimiter = kwargs.pop('delimiter', ',')
|
||||
super().__init__(*args, **kwargs)
|
||||
class NumericArrayField(SimpleArrayField):
|
||||
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
# Split the delimited string of values into a list
|
||||
if value:
|
||||
value = value[0].split(self.delimiter)
|
||||
return super().optgroups(name, value, attrs)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
# Condense the list of selected choices into a delimited string
|
||||
data = super().value_from_datadict(data, files, name)
|
||||
return self.delimiter.join(data)
|
||||
def to_python(self, value):
|
||||
value = ','.join([str(n) for n in parse_numeric_range(value)])
|
||||
return super().to_python(value)
|
||||
|
||||
|
||||
class APISelect(SelectWithDisabled):
|
||||
@@ -607,15 +594,18 @@ class DynamicModelChoiceMixin:
|
||||
filter = django_filters.ModelChoiceFilter
|
||||
widget = APISelect
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
def _get_initial_value(self, initial_data, field_name):
|
||||
return initial_data.get(field_name)
|
||||
|
||||
def get_bound_field(self, form, field_name):
|
||||
bound_field = BoundField(form, self, field_name)
|
||||
|
||||
# Override initial() to allow passing multiple values
|
||||
bound_field.initial = self._get_initial_value(form.initial, field_name)
|
||||
|
||||
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
|
||||
# will be populated on-demand via the APISelect widget.
|
||||
data = self.prepare_value(bound_field.data or bound_field.initial)
|
||||
data = bound_field.value()
|
||||
if data:
|
||||
filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset)
|
||||
self.queryset = filter.filter(self.queryset, data)
|
||||
@@ -648,12 +638,17 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
|
||||
filter = django_filters.ModelMultipleChoiceFilter
|
||||
widget = APISelectMultiple
|
||||
|
||||
def _get_initial_value(self, initial_data, field_name):
|
||||
# If a QueryDict has been passed as initial form data, get *all* listed values
|
||||
if hasattr(initial_data, 'getlist'):
|
||||
return initial_data.getlist(field_name)
|
||||
return initial_data.get(field_name)
|
||||
|
||||
|
||||
class LaxURLField(forms.URLField):
|
||||
"""
|
||||
Modifies Django's built-in URLField in two ways:
|
||||
1) Allow any valid scheme per RFC 3986 section 3.1
|
||||
2) Remove the requirement for fully-qualified domain names (e.g. http://myserver/ is valid)
|
||||
Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
|
||||
(e.g. http://myserver/ is valid)
|
||||
"""
|
||||
default_validators = [EnhancedURLValidator()]
|
||||
|
||||
|
||||
@@ -84,6 +84,10 @@ class BaseTable(tables.Table):
|
||||
return [name for name in self.sequence if self.columns[name].visible]
|
||||
|
||||
|
||||
#
|
||||
# Table columns
|
||||
#
|
||||
|
||||
class ToggleColumn(tables.CheckBoxColumn):
|
||||
"""
|
||||
Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
|
||||
@@ -129,6 +133,19 @@ class ColorColumn(tables.Column):
|
||||
)
|
||||
|
||||
|
||||
class ColoredLabelColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Render a colored label (e.g. for DeviceRoles).
|
||||
"""
|
||||
template_code = """
|
||||
{% load helpers %}
|
||||
{% if value %}<label class="label" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">{{ value }}</label>{% else %}—{% endif %}
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(template_code=self.template_code, *args, **kwargs)
|
||||
|
||||
|
||||
class TagColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Display a list of tags assigned to the object.
|
||||
|
||||
@@ -10,7 +10,6 @@ from django.utils.html import strip_tags
|
||||
from django.utils.safestring import mark_safe
|
||||
from markdown import markdown
|
||||
|
||||
from utilities.choices import unpack_grouped_choices
|
||||
from utilities.utils import foreground_color
|
||||
|
||||
register = template.Library()
|
||||
@@ -39,6 +38,11 @@ def render_markdown(value):
|
||||
# Strip HTML tags
|
||||
value = strip_tags(value)
|
||||
|
||||
# Sanitize Markdown links
|
||||
schemes = '|'.join(settings.ALLOWED_URL_SCHEMES)
|
||||
pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)'
|
||||
value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
|
||||
|
||||
# Render Markdown
|
||||
html = markdown(value, extensions=['fenced_code', 'tables'])
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from django.contrib.auth.models import Permission, User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.forms.models import model_to_dict
|
||||
from django.test import Client, TestCase as _TestCase, override_settings
|
||||
from django.urls import reverse, NoReverseMatch
|
||||
from netaddr import IPNetwork
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from users.models import Token
|
||||
@@ -57,6 +61,55 @@ class TestCase(_TestCase):
|
||||
expected_status, response.status_code, getattr(response, 'data', 'No data')
|
||||
))
|
||||
|
||||
def assertInstanceEqual(self, instance, data, api=False):
|
||||
"""
|
||||
Compare a model instance to a dictionary, checking that its attribute values match those specified
|
||||
in the dictionary.
|
||||
|
||||
:instance: Python object instance
|
||||
:data: Dictionary of test data used to define the instance
|
||||
:api: Set to True is the data is a JSON representation of the instance
|
||||
"""
|
||||
model_dict = model_to_dict(instance, fields=data.keys())
|
||||
|
||||
for key, value in list(model_dict.items()):
|
||||
|
||||
# TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
|
||||
if key == 'tags':
|
||||
model_dict[key] = ','.join(sorted([tag.name for tag in value]))
|
||||
|
||||
# Convert ManyToManyField to list of instance PKs
|
||||
elif model_dict[key] and type(value) in (list, tuple) and hasattr(value[0], 'pk'):
|
||||
model_dict[key] = [obj.pk for obj in value]
|
||||
|
||||
if api:
|
||||
|
||||
# Replace ContentType numeric IDs with <app_label>.<model>
|
||||
if type(getattr(instance, key)) is ContentType:
|
||||
ct = ContentType.objects.get(pk=value)
|
||||
model_dict[key] = f'{ct.app_label}.{ct.model}'
|
||||
|
||||
# Convert IPNetwork instances to strings
|
||||
if type(value) is IPNetwork:
|
||||
model_dict[key] = str(value)
|
||||
|
||||
else:
|
||||
|
||||
# Convert ArrayFields to CSV strings
|
||||
if type(instance._meta.get_field(key)) is ArrayField:
|
||||
model_dict[key] = ','.join([str(v) for v in value])
|
||||
|
||||
# Omit any dictionary keys which are not instance attributes
|
||||
relevant_data = {
|
||||
k: v for k, v in data.items() if hasattr(instance, k)
|
||||
}
|
||||
|
||||
self.assertDictEqual(model_dict, relevant_data)
|
||||
|
||||
|
||||
#
|
||||
# UI Tests
|
||||
#
|
||||
|
||||
class ModelViewTestCase(TestCase):
|
||||
"""
|
||||
@@ -104,42 +157,6 @@ class ModelViewTestCase(TestCase):
|
||||
else:
|
||||
raise Exception("Invalid action for URL resolution: {}".format(action))
|
||||
|
||||
def assertInstanceEqual(self, instance, data):
|
||||
"""
|
||||
Compare a model instance to a dictionary, checking that its attribute values match those specified
|
||||
in the dictionary.
|
||||
"""
|
||||
model_dict = model_to_dict(instance, fields=data.keys())
|
||||
|
||||
for key in list(model_dict.keys()):
|
||||
|
||||
# TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
|
||||
if key == 'tags':
|
||||
model_dict[key] = ','.join(sorted([tag.name for tag in model_dict['tags']]))
|
||||
|
||||
# Convert ManyToManyField to list of instance PKs
|
||||
elif model_dict[key] and type(model_dict[key]) in (list, tuple) and hasattr(model_dict[key][0], 'pk'):
|
||||
model_dict[key] = [obj.pk for obj in model_dict[key]]
|
||||
|
||||
# Omit any dictionary keys which are not instance attributes
|
||||
relevant_data = {
|
||||
k: v for k, v in data.items() if hasattr(instance, k)
|
||||
}
|
||||
|
||||
self.assertDictEqual(model_dict, relevant_data)
|
||||
|
||||
|
||||
class APITestCase(TestCase):
|
||||
client_class = APIClient
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a superuser and token for API calls.
|
||||
"""
|
||||
self.user = User.objects.create(username='testuser', is_superuser=True)
|
||||
self.token = Token.objects.create(user=self.user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
|
||||
|
||||
|
||||
class ViewTestCases:
|
||||
"""
|
||||
@@ -165,7 +182,7 @@ class ViewTestCases:
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_list_objects_anonymous(self):
|
||||
def test_get_object_anonymous(self):
|
||||
# Make the request as an unauthenticated user
|
||||
self.client.logout()
|
||||
response = self.client.get(self.model.objects.first().get_absolute_url())
|
||||
@@ -488,3 +505,129 @@ class ViewTestCases:
|
||||
TestCase suitable for testing device component models (ConsolePorts, Interfaces, etc.)
|
||||
"""
|
||||
maxDiff = None
|
||||
|
||||
|
||||
#
|
||||
# REST API Tests
|
||||
#
|
||||
|
||||
class APITestCase(TestCase):
|
||||
client_class = APIClient
|
||||
model = None
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a superuser and token for API calls.
|
||||
"""
|
||||
self.user = User.objects.create(username='testuser', is_superuser=True)
|
||||
self.token = Token.objects.create(user=self.user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
|
||||
|
||||
def _get_detail_url(self, instance):
|
||||
viewname = f'{instance._meta.app_label}-api:{instance._meta.model_name}-detail'
|
||||
return reverse(viewname, kwargs={'pk': instance.pk})
|
||||
|
||||
def _get_list_url(self):
|
||||
viewname = f'{self.model._meta.app_label}-api:{self.model._meta.model_name}-list'
|
||||
return reverse(viewname)
|
||||
|
||||
|
||||
class APIViewTestCases:
|
||||
|
||||
class GetObjectViewTestCase(APITestCase):
|
||||
|
||||
def test_get_object(self):
|
||||
"""
|
||||
GET a single object identified by its numeric ID.
|
||||
"""
|
||||
instance = self.model.objects.first()
|
||||
url = self._get_detail_url(instance)
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['id'], instance.pk)
|
||||
|
||||
class ListObjectsViewTestCase(APITestCase):
|
||||
brief_fields = []
|
||||
|
||||
def test_list_objects(self):
|
||||
"""
|
||||
GET a list of objects.
|
||||
"""
|
||||
url = self._get_list_url()
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(len(response.data['results']), self.model.objects.count())
|
||||
|
||||
def test_list_objects_brief(self):
|
||||
"""
|
||||
GET a list of objects using the "brief" parameter.
|
||||
"""
|
||||
url = f'{self._get_list_url()}?brief=1'
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(len(response.data['results']), self.model.objects.count())
|
||||
self.assertEqual(sorted(response.data['results'][0]), self.brief_fields)
|
||||
|
||||
class CreateObjectViewTestCase(APITestCase):
|
||||
create_data = []
|
||||
|
||||
def test_create_object(self):
|
||||
"""
|
||||
POST a single object.
|
||||
"""
|
||||
initial_count = self.model.objects.count()
|
||||
url = self._get_list_url()
|
||||
response = self.client.post(url, self.create_data[0], format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(self.model.objects.count(), initial_count + 1)
|
||||
self.assertInstanceEqual(self.model.objects.get(pk=response.data['id']), self.create_data[0], api=True)
|
||||
|
||||
def test_bulk_create_object(self):
|
||||
"""
|
||||
POST a set of objects in a single request.
|
||||
"""
|
||||
initial_count = self.model.objects.count()
|
||||
url = self._get_list_url()
|
||||
response = self.client.post(url, self.create_data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(self.model.objects.count(), initial_count + len(self.create_data))
|
||||
|
||||
class UpdateObjectViewTestCase(APITestCase):
|
||||
update_data = {}
|
||||
|
||||
def test_update_object(self):
|
||||
"""
|
||||
PATCH a single object identified by its numeric ID.
|
||||
"""
|
||||
instance = self.model.objects.first()
|
||||
url = self._get_detail_url(instance)
|
||||
update_data = self.update_data or getattr(self, 'create_data')[0]
|
||||
response = self.client.patch(url, update_data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
instance.refresh_from_db()
|
||||
self.assertInstanceEqual(instance, self.update_data, api=True)
|
||||
|
||||
class DeleteObjectViewTestCase(APITestCase):
|
||||
|
||||
def test_delete_object(self):
|
||||
"""
|
||||
DELETE a single object identified by its numeric ID.
|
||||
"""
|
||||
instance = self.model.objects.first()
|
||||
url = self._get_detail_url(instance)
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertFalse(self.model.objects.filter(pk=instance.pk).exists())
|
||||
|
||||
class APIViewTestCase(
|
||||
GetObjectViewTestCase,
|
||||
ListObjectsViewTestCase,
|
||||
CreateObjectViewTestCase,
|
||||
UpdateObjectViewTestCase,
|
||||
DeleteObjectViewTestCase
|
||||
):
|
||||
pass
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
|
||||
|
||||
|
||||
class EnhancedURLValidator(URLValidator):
|
||||
"""
|
||||
Extends Django's built-in URLValidator to permit the use of hostnames with no domain extension.
|
||||
Extends Django's built-in URLValidator to permit the use of hostnames with no domain extension and enforce allowed
|
||||
schemes specified in the configuration.
|
||||
"""
|
||||
class AnyURLScheme(object):
|
||||
"""
|
||||
A fake URL list which "contains" all scheme names abiding by the syntax defined in RFC 3986 section 3.1
|
||||
"""
|
||||
def __contains__(self, item):
|
||||
if not item or not re.match(r'^[a-z][0-9a-z+\-.]*$', item.lower()):
|
||||
return False
|
||||
return True
|
||||
|
||||
fqdn_re = URLValidator.hostname_re + URLValidator.domain_re + URLValidator.tld_re
|
||||
host_res = [URLValidator.ipv4_re, URLValidator.ipv6_re, fqdn_re, URLValidator.hostname_re]
|
||||
regex = _lazy_re_compile(
|
||||
r'^(?:[a-z0-9\.\-\+]*)://' # Scheme (previously enforced by AnyURLScheme or schemes kwarg)
|
||||
r'^(?:[a-z0-9\.\-\+]*)://' # Scheme (enforced separately)
|
||||
r'(?:\S+(?::\S*)?@)?' # HTTP basic authentication
|
||||
r'(?:' + '|'.join(host_res) + ')' # IPv4, IPv6, FQDN, or hostname
|
||||
r'(?::\d{2,5})?' # Port number
|
||||
r'(?:[/?#][^\s]*)?' # Path
|
||||
r'\Z', re.IGNORECASE)
|
||||
schemes = AnyURLScheme()
|
||||
schemes = settings.ALLOWED_URL_SCHEMES
|
||||
|
||||
|
||||
class ExclusionValidator(BaseValidator):
|
||||
|
||||
@@ -782,6 +782,8 @@ class BulkEditView(GetReturnURLMixin, View):
|
||||
# TODO: Find a better way to accomplish this
|
||||
if 'device' in request.GET:
|
||||
initial_data['device'] = request.GET.get('device')
|
||||
elif 'device_type' in request.GET:
|
||||
initial_data['device_type'] = request.GET.get('device_type')
|
||||
|
||||
form = self.form(model, initial=initial_data)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Interface
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, TagColumn, ToggleColumn
|
||||
from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
CLUSTERTYPE_ACTIONS = """
|
||||
@@ -28,10 +28,6 @@ VIRTUALMACHINE_STATUS = """
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
"""
|
||||
|
||||
VIRTUALMACHINE_ROLE = """
|
||||
{% if record.role %}<label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>{% else %}—{% endif %}
|
||||
"""
|
||||
|
||||
VIRTUALMACHINE_PRIMARY_IP = """
|
||||
{{ record.primary_ip6.address.ip|default:"" }}
|
||||
{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
|
||||
@@ -132,9 +128,7 @@ class VirtualMachineTable(BaseTable):
|
||||
viewname='virtualization:cluster',
|
||||
args=[Accessor('cluster.pk')]
|
||||
)
|
||||
role = tables.TemplateColumn(
|
||||
template_code=VIRTUALMACHINE_ROLE
|
||||
)
|
||||
role = ColoredLabelColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from django.urls import reverse
|
||||
from netaddr import IPNetwork
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import Interface
|
||||
from ipam.models import IPAddress, VLAN
|
||||
from utilities.testing import APITestCase, disable_warnings
|
||||
from ipam.models import VLAN
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
@@ -20,487 +19,181 @@ class AppTest(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class ClusterTypeTest(APITestCase):
|
||||
class ClusterTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ClusterType
|
||||
brief_fields = ['cluster_count', 'id', 'name', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Cluster Type 4',
|
||||
'slug': 'cluster-type-4',
|
||||
},
|
||||
{
|
||||
'name': 'Cluster Type 5',
|
||||
'slug': 'cluster-type-5',
|
||||
},
|
||||
{
|
||||
'name': 'Cluster Type 6',
|
||||
'slug': 'cluster-type-6',
|
||||
},
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.clustertype1 = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
|
||||
self.clustertype2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
|
||||
self.clustertype3 = ClusterType.objects.create(name='Test Cluster Type 3', slug='test-cluster-type-3')
|
||||
|
||||
def test_get_clustertype(self):
|
||||
|
||||
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.clustertype1.name)
|
||||
|
||||
def test_list_clustertypes(self):
|
||||
|
||||
url = reverse('virtualization-api:clustertype-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
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]),
|
||||
['cluster_count', 'id', 'name', 'slug', 'url']
|
||||
cluster_types = (
|
||||
ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
|
||||
ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
|
||||
ClusterType(name='Cluster Type 3', slug='cluster-type-3'),
|
||||
)
|
||||
ClusterType.objects.bulk_create(cluster_types)
|
||||
|
||||
def test_create_clustertype(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Cluster Type 4',
|
||||
'slug': 'test-cluster-type-4',
|
||||
}
|
||||
class ClusterGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ClusterGroup
|
||||
brief_fields = ['cluster_count', 'id', 'name', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Cluster Group 4',
|
||||
'slug': 'cluster-type-4',
|
||||
},
|
||||
{
|
||||
'name': 'Cluster Group 5',
|
||||
'slug': 'cluster-type-5',
|
||||
},
|
||||
{
|
||||
'name': 'Cluster Group 6',
|
||||
'slug': 'cluster-type-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('virtualization-api:clustertype-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ClusterType.objects.count(), 4)
|
||||
clustertype4 = ClusterType.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(clustertype4.name, data['name'])
|
||||
self.assertEqual(clustertype4.slug, data['slug'])
|
||||
cluster_Groups = (
|
||||
ClusterGroup(name='Cluster Group 1', slug='cluster-type-1'),
|
||||
ClusterGroup(name='Cluster Group 2', slug='cluster-type-2'),
|
||||
ClusterGroup(name='Cluster Group 3', slug='cluster-type-3'),
|
||||
)
|
||||
ClusterGroup.objects.bulk_create(cluster_Groups)
|
||||
|
||||
def test_create_clustertype_bulk(self):
|
||||
|
||||
data = [
|
||||
class ClusterTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Cluster
|
||||
brief_fields = ['id', 'name', 'url', 'virtualmachine_count']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
cluster_types = (
|
||||
ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
|
||||
ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
|
||||
)
|
||||
ClusterType.objects.bulk_create(cluster_types)
|
||||
|
||||
cluster_groups = (
|
||||
ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
|
||||
ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
|
||||
)
|
||||
ClusterGroup.objects.bulk_create(cluster_groups)
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0]),
|
||||
Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0]),
|
||||
Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0]),
|
||||
)
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Test Cluster Type 4',
|
||||
'slug': 'test-cluster-type-4',
|
||||
'name': 'Cluster 4',
|
||||
'type': cluster_types[1].pk,
|
||||
'group': cluster_groups[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Cluster Type 5',
|
||||
'slug': 'test-cluster-type-5',
|
||||
'name': 'Cluster 5',
|
||||
'type': cluster_types[1].pk,
|
||||
'group': cluster_groups[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Cluster Type 6',
|
||||
'slug': 'test-cluster-type-6',
|
||||
'name': 'Cluster 6',
|
||||
'type': cluster_types[1].pk,
|
||||
'group': cluster_groups[1].pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('virtualization-api:clustertype-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ClusterType.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
|
||||
model = VirtualMachine
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
|
||||
def test_update_clustertype(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
|
||||
|
||||
data = {
|
||||
'name': 'Test Cluster Type X',
|
||||
'slug': 'test-cluster-type-x',
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(ClusterType.objects.count(), 3)
|
||||
clustertype1 = ClusterType.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(clustertype1.name, data['name'])
|
||||
self.assertEqual(clustertype1.slug, data['slug'])
|
||||
|
||||
def test_delete_clustertype(self):
|
||||
|
||||
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(ClusterType.objects.count(), 2)
|
||||
|
||||
|
||||
class ClusterGroupTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.clustergroup1 = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
|
||||
self.clustergroup2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2')
|
||||
self.clustergroup3 = ClusterGroup.objects.create(name='Test Cluster Group 3', slug='test-cluster-group-3')
|
||||
|
||||
def test_get_clustergroup(self):
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.clustergroup1.name)
|
||||
|
||||
def test_list_clustergroups(self):
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
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]),
|
||||
['cluster_count', 'id', 'name', 'slug', 'url']
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=clustertype, group=clustergroup),
|
||||
Cluster(name='Cluster 2', type=clustertype, group=clustergroup),
|
||||
)
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
def test_create_clustergroup(self):
|
||||
virtual_machines = (
|
||||
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], local_context_data={'A': 1}),
|
||||
VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}),
|
||||
VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}),
|
||||
)
|
||||
VirtualMachine.objects.bulk_create(virtual_machines)
|
||||
|
||||
data = {
|
||||
'name': 'Test Cluster Group 4',
|
||||
'slug': 'test-cluster-group-4',
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ClusterGroup.objects.count(), 4)
|
||||
clustergroup4 = ClusterGroup.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(clustergroup4.name, data['name'])
|
||||
self.assertEqual(clustergroup4.slug, data['slug'])
|
||||
|
||||
def test_create_clustergroup_bulk(self):
|
||||
|
||||
data = [
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Test Cluster Group 4',
|
||||
'slug': 'test-cluster-group-4',
|
||||
'name': 'Virtual Machine 4',
|
||||
'cluster': clusters[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Cluster Group 5',
|
||||
'slug': 'test-cluster-group-5',
|
||||
'name': 'Virtual Machine 5',
|
||||
'cluster': clusters[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Cluster Group 6',
|
||||
'slug': 'test-cluster-group-6',
|
||||
'name': 'Virtual Machine 6',
|
||||
'cluster': clusters[1].pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ClusterGroup.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_clustergroup(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Cluster Group X',
|
||||
'slug': 'test-cluster-group-x',
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(ClusterGroup.objects.count(), 3)
|
||||
clustergroup1 = ClusterGroup.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(clustergroup1.name, data['name'])
|
||||
self.assertEqual(clustergroup1.slug, data['slug'])
|
||||
|
||||
def test_delete_clustergroup(self):
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(ClusterGroup.objects.count(), 2)
|
||||
|
||||
|
||||
class ClusterTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
|
||||
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
|
||||
|
||||
self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
|
||||
self.cluster2 = Cluster.objects.create(name='Test Cluster 2', type=cluster_type, group=cluster_group)
|
||||
self.cluster3 = Cluster.objects.create(name='Test Cluster 3', type=cluster_type, group=cluster_group)
|
||||
|
||||
def test_get_cluster(self):
|
||||
|
||||
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.cluster1.name)
|
||||
|
||||
def test_list_clusters(self):
|
||||
|
||||
url = reverse('virtualization-api:cluster-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
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', 'virtualmachine_count']
|
||||
)
|
||||
|
||||
def test_create_cluster(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Cluster 4',
|
||||
'type': ClusterType.objects.first().pk,
|
||||
'group': ClusterGroup.objects.first().pk,
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:cluster-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Cluster.objects.count(), 4)
|
||||
cluster4 = Cluster.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(cluster4.name, data['name'])
|
||||
self.assertEqual(cluster4.type.pk, data['type'])
|
||||
self.assertEqual(cluster4.group.pk, data['group'])
|
||||
|
||||
def test_create_cluster_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Cluster 4',
|
||||
'type': ClusterType.objects.first().pk,
|
||||
'group': ClusterGroup.objects.first().pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Cluster 5',
|
||||
'type': ClusterType.objects.first().pk,
|
||||
'group': ClusterGroup.objects.first().pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Cluster 6',
|
||||
'type': ClusterType.objects.first().pk,
|
||||
'group': ClusterGroup.objects.first().pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('virtualization-api:cluster-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Cluster.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_cluster(self):
|
||||
|
||||
cluster_type2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
|
||||
cluster_group2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2')
|
||||
data = {
|
||||
'name': 'Test Cluster X',
|
||||
'type': cluster_type2.pk,
|
||||
'group': cluster_group2.pk,
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Cluster.objects.count(), 3)
|
||||
cluster1 = Cluster.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(cluster1.name, data['name'])
|
||||
self.assertEqual(cluster1.type.pk, data['type'])
|
||||
self.assertEqual(cluster1.group.pk, data['group'])
|
||||
|
||||
def test_delete_cluster(self):
|
||||
|
||||
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Cluster.objects.count(), 2)
|
||||
|
||||
|
||||
class VirtualMachineTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
|
||||
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
|
||||
self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
|
||||
|
||||
self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=self.cluster1)
|
||||
self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=self.cluster1)
|
||||
self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=self.cluster1)
|
||||
self.virtualmachine_with_context_data = VirtualMachine.objects.create(
|
||||
name='VM with context data',
|
||||
cluster=self.cluster1,
|
||||
local_context_data={
|
||||
'A': 1,
|
||||
'B': 2
|
||||
}
|
||||
)
|
||||
|
||||
def test_get_virtualmachine(self):
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.virtualmachine1.name)
|
||||
|
||||
def test_list_virtualmachines(self):
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 4)
|
||||
|
||||
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 = {
|
||||
'name': 'Test Virtual Machine 4',
|
||||
'cluster': self.cluster1.pk,
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VirtualMachine.objects.count(), 5)
|
||||
virtualmachine4 = VirtualMachine.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(virtualmachine4.name, data['name'])
|
||||
self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
|
||||
|
||||
def test_create_virtualmachine_without_cluster(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Virtual Machine 4',
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-list')
|
||||
with disable_warnings('django.request'):
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(VirtualMachine.objects.count(), 4)
|
||||
|
||||
def test_create_virtualmachine_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Virtual Machine 4',
|
||||
'cluster': self.cluster1.pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Virtual Machine 5',
|
||||
'cluster': self.cluster1.pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Virtual Machine 6',
|
||||
'cluster': self.cluster1.pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VirtualMachine.objects.count(), 7)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_virtualmachine(self):
|
||||
|
||||
interface = Interface.objects.create(name='Test Interface 1', virtual_machine=self.virtualmachine1)
|
||||
ip4_address = IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), interface=interface)
|
||||
ip6_address = IPAddress.objects.create(address=IPNetwork('2001:db8::1/64'), interface=interface)
|
||||
|
||||
cluster2 = Cluster.objects.create(
|
||||
name='Test Cluster 2',
|
||||
type=ClusterType.objects.first(),
|
||||
group=ClusterGroup.objects.first()
|
||||
)
|
||||
data = {
|
||||
'name': 'Test Virtual Machine X',
|
||||
'cluster': cluster2.pk,
|
||||
'primary_ip4': ip4_address.pk,
|
||||
'primary_ip6': ip6_address.pk,
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(VirtualMachine.objects.count(), 4)
|
||||
virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(virtualmachine1.name, data['name'])
|
||||
self.assertEqual(virtualmachine1.cluster.pk, data['cluster'])
|
||||
self.assertEqual(virtualmachine1.primary_ip4.pk, data['primary_ip4'])
|
||||
self.assertEqual(virtualmachine1.primary_ip6.pk, data['primary_ip6'])
|
||||
|
||||
def test_delete_virtualmachine(self):
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(VirtualMachine.objects.count(), 3)
|
||||
|
||||
def test_config_context_included_by_default_in_list_view(self):
|
||||
|
||||
"""
|
||||
Check that config context data is included by default in the virtual machines list.
|
||||
"""
|
||||
virtualmachine = VirtualMachine.objects.first()
|
||||
url = reverse('virtualization-api:virtualmachine-list')
|
||||
url = '{}?id={}'.format(url, self.virtualmachine_with_context_data.pk)
|
||||
url = '{}?id={}'.format(url, virtualmachine.pk)
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)
|
||||
|
||||
def test_config_context_excluded(self):
|
||||
|
||||
"""
|
||||
Check that config context data can be excluded by passing ?exclude=config_context.
|
||||
"""
|
||||
url = reverse('virtualization-api:virtualmachine-list') + '?exclude=config_context'
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertFalse('config_context' in response.data['results'][0])
|
||||
|
||||
def test_unique_name_per_cluster_constraint(self):
|
||||
|
||||
"""
|
||||
Check that creating a virtual machine with a duplicate name fails.
|
||||
"""
|
||||
data = {
|
||||
'name': 'Test Virtual Machine 1',
|
||||
'cluster': self.cluster1.pk,
|
||||
'name': 'Virtual Machine 1',
|
||||
'cluster': Cluster.objects.first().pk,
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
# TODO: Standardize InterfaceTest (pending #4721)
|
||||
class InterfaceTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@@ -187,14 +187,14 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
||||
class InterfaceTestCase(
|
||||
ViewTestCases.GetObjectViewTestCase,
|
||||
ViewTestCases.DeviceComponentViewTestCase,
|
||||
ViewTestCases.EditObjectViewTestCase,
|
||||
ViewTestCases.DeleteObjectViewTestCase,
|
||||
ViewTestCases.BulkCreateObjectsViewTestCase,
|
||||
ViewTestCases.BulkEditObjectsViewTestCase,
|
||||
ViewTestCases.BulkDeleteObjectsViewTestCase,
|
||||
):
|
||||
model = Interface
|
||||
|
||||
# Disable inapplicable tests
|
||||
test_list_objects = None
|
||||
test_import_objects = None
|
||||
|
||||
def _get_base_url(self):
|
||||
# Interface belongs to the DCIM app, so we have to override the base URL
|
||||
return 'virtualization:interface_{}'
|
||||
|
||||
Reference in New Issue
Block a user