Compare commits

...

104 Commits

Author SHA1 Message Date
Jeremy Stretch
bac3ace8fc Merge pull request #4762 from netbox-community/develop
Release v2.8.6
2020-06-15 14:45:01 -04:00
Jeremy Stretch
60deb3f0ba Release v2.8.6 2020-06-15 14:37:36 -04:00
Jeremy Stretch
eaaaaec5a5 Fixes #4710: Fix merging of form fields among custom scripts 2020-06-15 14:20:00 -04:00
Jeremy Stretch
5bcf85e57d Closes #4744: Hide IP addresses tab when viewing a container prefix 2020-06-15 13:33:16 -04:00
Jeremy Stretch
1d466d6fd1 Closes #4761: Enable tag assignment during bulk creation of IP addresses 2020-06-15 13:24:34 -04:00
Jeremy Stretch
57cfb4ed7e Fixes #4760: Enable power port template assignment when bulk editing power outlet templates 2020-06-15 13:18:26 -04:00
Jeremy Stretch
9fa4cbdfa5 Correction for #4756 2020-06-15 12:43:08 -04:00
Jeremy Stretch
5af2b3c2f5 Closes #4717: Introduce ALLOWED_URL_SCHEMES configuration parameter to mitigate dangerous hyperlinks 2020-06-15 11:53:47 -04:00
Jeremy Stretch
2e5058c4c9 Fixes #4756: Filter parent group by site when creating rack groups 2020-06-15 10:02:35 -04:00
Jeremy Stretch
9fc4a4f24a Closes #4755: Enable creation of rack reservations directly from navigation menu 2020-06-12 15:11:27 -04:00
Jeremy Stretch
9fd36279ab Fixes #4743: Allow users to create "next available" IPs without needing permission to create prefixes 2020-06-10 16:06:11 -04:00
Jeremy Stretch
40947f8cb2 Merge pull request #4734 from tyler-8/bulk_api_docs
Add example of bulk object creation in documentation
2020-06-10 11:39:44 -04:00
Jeremy Stretch
9abc67bbeb Fixes #4737: Introduce ColoredLabelColumn for consistent display of colored labels 2020-06-10 11:38:23 -04:00
Jeremy Stretch
16cdf3006f Fixes #4736: Add cable trace endpoints for pass-through ports 2020-06-09 15:12:10 -04:00
Jeremy Stretch
15004c654f Add missing API cable trace test for interfaces 2020-06-09 14:47:05 -04:00
Tyler Bigler
062a319a7c Add example of bulk object creation 2020-06-09 13:35:44 -04:00
Jeremy Stretch
ed9ca270a7 Add missing API tests for pass-through port templates 2020-06-09 13:24:07 -04:00
Jeremy Stretch
20ec700045 Changelog for #4674 2020-06-08 17:00:47 -04:00
Jeremy Stretch
ecd3963b7c Merge pull request #4718 from netbox-community/4674-drf_yasg_definitions
Fixes #4674 - Fix available-ips and available-prefixes swagger definitions
2020-06-08 16:59:04 -04:00
Jeremy Stretch
1ea368856b Merge pull request #4728 from netbox-community/4722-api-tests
Closes #4722: Standardize API view tests
2020-06-08 10:16:10 -04:00
Jeremy Stretch
a8077e6ed1 Extend assertInstanceEqual() to accommodate REST API data 2020-06-08 09:47:14 -04:00
Jeremy Stretch
7def37961a Correct exempted test methods on InterfaceTestCase 2020-06-05 16:17:10 -04:00
Jeremy Stretch
4f830c9c22 Fix list_brief tests 2020-06-05 16:09:55 -04:00
Jeremy Stretch
032f87caec Merge branch 'develop' into 4722-api-tests 2020-06-05 15:50:14 -04:00
Jeremy Stretch
e616aad911 Fixes #4725: Fix "brief" rendering of various REST API endpoints 2020-06-05 15:49:06 -04:00
Jeremy Stretch
c2f6f5a7cd Fix ProviderTest 2020-06-05 15:18:18 -04:00
Jeremy Stretch
d3fbaca228 Standardize virtualization API tests 2020-06-05 15:06:08 -04:00
Jeremy Stretch
ae913f14ce Standardize tenancy API tests 2020-06-05 14:30:01 -04:00
Jeremy Stretch
1ee79ee61e Standardize SecretRoleTest 2020-06-05 14:18:38 -04:00
Jeremy Stretch
b5ebfd0b07 Standardize IPAM API tests 2020-06-05 14:09:54 -04:00
Jeremy Stretch
665646707c Standardize extras API tests 2020-06-05 13:41:54 -04:00
Jeremy Stretch
279ae7ea10 Standardize DCIM API tests 2020-06-05 13:23:33 -04:00
Jeremy Stretch
8cc1dc9f1c Fix update data 2020-06-05 10:05:54 -04:00
Jeremy Stretch
86e5a09b01 Optimize test_get_provider_graphs() 2020-06-05 09:36:38 -04:00
Jeremy Stretch
1d5f2fbd11 Correct test method name 2020-06-05 09:19:31 -04:00
Jeremy Stretch
4219691e62 Update circuits API tests to use APIViewTestCases 2020-06-04 16:47:15 -04:00
Jeremy Stretch
4ae1879b87 Introduce APIViewTestCases for standardized API view testing 2020-06-04 16:45:03 -04:00
Jeremy Stretch
d2dce6db25 Merge pull request #4719 from netbox-community/4715-avoid-unnecessary-queries
Fixes #4715: Avoid unnecessary queries in Cable.from_db
2020-06-04 13:13:17 -04:00
Jeremy Stretch
fae115b995 Closes #4698: Improve display of template code for object in admin UI 2020-06-04 13:11:24 -04:00
Sander Steffann
8f9dcf5a97 Avoid unnecessary queries in Cable.from_db 2020-06-04 17:46:09 +02:00
Jeremy Stretch
91ba44cc96 Add local_requirements.txt to .gitignore 2020-06-04 11:44:16 -04:00
Daniel Sheppard
5330914431 #4674 - Correct many=False to many=True on the response serializers 2020-06-04 09:42:00 -05:00
Daniel Sheppard
927c012fc9 #4674 - Fix available-ips and available-prefixes swagger definitions 2020-06-04 09:35:58 -05:00
Jeremy Stretch
56f6698ba5 Fixes #4707: Fix prefix_count population on VLAN API serializer 2020-06-02 13:40:14 -04:00
Jeremy Stretch
edf15532d2 Fixes #4702: Catch IntegrityError exception when adding a non-unique secret 2020-06-01 10:00:32 -04:00
Jeremy Stretch
d23b18beb5 Fixes #4704: Update example template code 2020-06-01 09:40:58 -04:00
Jeremy Stretch
56b7ab1734 Post-release version bump 2020-05-26 16:30:36 -04:00
Jeremy Stretch
68599351aa Merge pull request #4693 from netbox-community/develop
Release v2.8.5
2020-05-26 16:27:36 -04:00
Jeremy Stretch
c9a7527f33 Release v2.8.5 2020-05-26 16:17:01 -04:00
Jeremy Stretch
5f9b25453d Merge pull request #4692 from netbox-community/4525-objectvar-initial-data
Fixes #4525: Allow passing initial data to custom script MultiObjectVar
2020-05-26 15:54:25 -04:00
Jeremy Stretch
ccc31b2c7c Fixes #4525: Allow passing initial data to custom script MultiObjectVar 2020-05-26 15:34:29 -04:00
Jeremy Stretch
e54d441433 Remove "disable plugins" from bug report to prevent irrelevant search results 2020-05-26 10:06:46 -04:00
Jeremy Stretch
88cffca270 Closes #4650: Expose INTERNAL_IPS configuration parameter 2020-05-26 10:01:49 -04:00
Jeremy Stretch
92f49b4711 Closes #4672: Set default color for rack and devices roles 2020-05-26 09:36:27 -04:00
Jeremy Stretch
faf3885775 Merge pull request #4689 from kobayashi/4684-devicetype-import-comment
Fixes #4684: Fix ignored comment when importing DeviceType
2020-05-26 09:12:14 -04:00
Jeremy Stretch
f04340679e Merge branch 'develop' into 4684-devicetype-import-comment 2020-05-26 09:11:50 -04:00
Jeremy Stretch
7f5583c7ae Merge pull request #4690 from kobayashi/4676-docs-default-remote-auth
Closes #4676: Set `False` as default value of REMOTE_AUTH_AUTO_CREATE_USER
2020-05-26 09:07:26 -04:00
Jeremy Stretch
a5785552d9 Changelog for #4651, #4652 2020-05-26 09:05:18 -04:00
Jeremy Stretch
abcd26da43 Merge pull request #4682 from netbox-community/4651-csrf-in-plugintemplateextension
4651: Add `csrf_token` to PluginTemplateExtension context
2020-05-26 09:03:07 -04:00
Jeremy Stretch
4545c15173 Merge branch 'develop' into 4651-csrf-in-plugintemplateextension 2020-05-26 09:02:39 -04:00
Jeremy Stretch
b7cf85e8c8 Merge pull request #4681 from netbox-community/4652-perms-in-plugintemplateextension
4652: Add `perms` to PluginTemplateExtension context
2020-05-26 09:02:08 -04:00
kobayashi
9cde377133 Closes #4676: Set default value of REMOTE_AUTH_AUTO_CREATE_USER as False in docs 2020-05-26 01:26:26 -04:00
kobayashi
74c29b0bb7 Fixes #4684: Fix ignored comment when importing DeviceType 2020-05-26 01:17:10 -04:00
Sander Steffann
ff3b348771 Add csrf_token to PluginTemplateExtension context 2020-05-22 22:28:04 +02:00
Sander Steffann
27700d316f Add perms to PluginTemplateExtension context 2020-05-22 22:24:39 +02:00
Jeremy Stretch
1f5d2520c3 Formatting fix 2020-05-20 10:37:26 -04:00
Jeremy Stretch
d2e1428c75 Closes #4665: Add NEMA L14 and L21 power port/outlet types 2020-05-20 09:36:55 -04:00
Jeremy Stretch
cd236aa886 Closes #4645: Update minimum required version of PostgreSQL to 9.6 2020-05-15 10:11:36 -04:00
Jeremy Stretch
3c8e7e739d Fixes #4649: Fix interface assignment for bulk-imported IP addresses 2020-05-15 09:44:00 -04:00
Jeremy Stretch
a64351279d Fixes #4648: Fix bulk CSV import of child devices 2020-05-15 09:36:16 -04:00
Jeremy Stretch
ba91b3aa2e Fixes #4646: Correct UI link for reports with custom name 2020-05-15 09:13:51 -04:00
Jeremy Stretch
8394ff5537 Fixes #4644: Fix ordering of services table by parent 2020-05-15 09:02:56 -04:00
John Anderson
14744da8f6 fixes #4647 - caching invalidation related to assinging new IP addresses to interfaces 2020-05-15 02:45:48 -04:00
John Anderson
2c2d6c6d47 fixes #3304 - primary IP address caching invalidation 2020-05-15 02:31:45 -04:00
Jeremy Stretch
422eeddbef Post-release version bump 2020-05-13 17:32:27 -04:00
Jeremy Stretch
86755029ef Merge pull request #4642 from netbox-community/develop
Release v2.8.4
2020-05-13 17:31:12 -04:00
Jeremy Stretch
2900013118 Release v2.8.4 2020-05-13 17:24:25 -04:00
Jeremy Stretch
cfe8882f72 Merge pull request #4623 from tyler-8/metrics_docs
Notes on multiprocessing metrics and gunicorn vs uwsgi
2020-05-13 17:17:26 -04:00
Tyler Bigler
29abcbced8 Grammar improvements 2020-05-13 17:13:41 -04:00
Jeremy Stretch
e0ebb8e7d8 Fixes #4617: Restore IP prefix depth notation in list view 2020-05-13 17:08:48 -04:00
Tyler Bigler
96e05fb12d Notes on multiprocessing and gunicorn vs uwsgi 2020-05-13 17:07:32 -04:00
Jeremy Stretch
07fd92cd4c Fixes #4629: Replicate assigned interface when cloning IP addresses 2020-05-13 16:25:22 -04:00
Daniel Sheppard
38d8b0a1ec Merge pull request #4637 from netbox-community/4634-InventoryItemException
#4634 - Correct inventory item table accessor definition on manufacturer column
2020-05-13 10:46:29 -05:00
Daniel Sheppard
fd0be35d99 #4634 - Correct inventory item table accessor definition on manufacturer column 2020-05-13 09:33:48 -05:00
Jeremy Stretch
1461be2004 Fixes #4613: Fix tag assignment on config contexts (regression from #4527) 2020-05-13 10:28:48 -04:00
Jeremy Stretch
569d4ee201 Closes #4632: Extend email configuration parameters to support SSL/TLS 2020-05-13 09:20:24 -04:00
Jeremy Stretch
1d93d9a63a Fixes #4633: Bump django-rq to v2.3.2 to fix ImportError with rq 1.4.0 2020-05-13 08:53:29 -04:00
Daniel Sheppard
41361ce2a2 Fixes: #4618 - Add group creation and correct user creation group syntax 2020-05-11 16:10:23 -05:00
Jeremy Stretch
91e46ceb77 Merge pull request #4616 from kobayashi/4607-token-context-help
Fix: 4607 Missing token context help
2020-05-11 09:21:01 -04:00
weisdd
cea01e037a Fix: incorrect DeviceConnectionsReport in reports.md (#4606)
Since the CONNECTION_STATUS_PLANNED constant is gone from dcim.constants, the DeviceConnectionsReport script is no longer correct.
The suggested fix is based on the fact that console_port.connection_status and power_port.connection_status currently have the following set of values:
* None = A cable is not connected to a Console Server Port or it's connected to a Rear/Front Port;
* False = A cable is connected to a Console Server Port and marked as Planned;
* True = A cable is connected to a Console Server Port and marked as Installed.
2020-05-11 09:14:25 -04:00
kobayashi
465d3ae1af Fix: 4607 Missing token context help 2020-05-09 23:08:14 -04:00
Jeremy Stretch
d5b9722533 Merge pull request #4608 from netbox-community/3226-customfield-manager
Closes #3226: Implement a custom manager for CustomField
2020-05-08 12:55:13 -04:00
Jeremy Stretch
745c9a9c2b Add test for CustomFieldManager.get_for_model() 2020-05-08 12:18:08 -04:00
Jeremy Stretch
e3be5f8468 Remove local caching attempt 2020-05-08 10:05:05 -04:00
Jeremy Stretch
2c19390d7c Introduce CustomFieldManager (WIP) 2020-05-07 17:20:32 -04:00
Jeremy Stretch
da8380c62c Refactor extras.models 2020-05-07 16:59:27 -04:00
Jeremy Stretch
e14e217fcd Fixes #4604: Multi-position rear ports may only be connected to other rear ports 2020-05-07 16:22:04 -04:00
Jeremy Stretch
b7a96a33ef Fixes #4598: Display error message when invalid cable length is specified 2020-05-07 10:34:33 -04:00
Jeremy Stretch
7c6faff405 Post-release version bump 2020-05-06 23:50:41 -04:00
Jeremy Stretch
c507ab30e9 Merge pull request #4594 from netbox-community/develop
Release v2.8.3
2020-05-06 23:49:27 -04:00
Jeremy Stretch
af96ffb3e9 Release v2.8.3 2020-05-06 23:46:52 -04:00
Jeremy Stretch
5c1adf9e37 Fixes #4593: Fix AttributeError exception when viewing object lists as a non-authenticated user 2020-05-06 23:44:06 -04:00
Jeremy Stretch
3711283de5 Extend ViewTestCases to get and list objects as a non-authenticated user 2020-05-06 23:43:46 -04:00
Jeremy Stretch
5dfcca96c8 Post-release version bump 2020-05-06 15:17:06 -04:00
79 changed files with 3361 additions and 6616 deletions

View File

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

@@ -9,6 +9,7 @@
/netbox/static
/venv/
/*.sh
local_requirements.txt
!upgrade.sh
fabfile.py
gunicorn.py

View File

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

View File

@@ -32,3 +32,7 @@ This can be setup by first creating a shared directory and then adding this line
```
environment=prometheus_multiproc_dir=/tmp/prometheus_metrics
```
#### Accuracy
If having accurate long-term metrics in a multiprocess environment is important to you then it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using Netbox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).

View File

@@ -33,7 +33,6 @@ Within each report class, we'll create a number of test methods to execute our r
```
from dcim.choices import DeviceStatusChoices
from dcim.constants import CONNECTION_STATUS_PLANNED
from dcim.models import ConsolePort, Device, PowerPort
from extras.reports import Report
@@ -51,7 +50,7 @@ class DeviceConnectionsReport(Report):
console_port.device,
"No console connection defined for {}".format(console_port.name)
)
elif console_port.connection_status == CONNECTION_STATUS_PLANNED:
elif not console_port.connection_status:
self.log_warning(
console_port.device,
"Console connection for {} marked as planned".format(console_port.name)
@@ -67,7 +66,7 @@ class DeviceConnectionsReport(Report):
for power_port in PowerPort.objects.filter(device=device):
if power_port.connected_endpoint is not None:
connected_ports += 1
if power_port.connection_status == CONNECTION_STATUS_PLANNED:
if not power_port.connection_status:
self.log_warning(
device,
"Power connection for {} marked as planned".format(power_port.name)

View File

@@ -2,18 +2,7 @@
The NetBox API employs token-based authentication. For convenience, cookie authentication can also be used when navigating the browsable API.
## Tokens
A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`.
!!! note
The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access.
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
{!docs/models/users/token.md!}
## Authenticating to the API

View File

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

View File

@@ -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.
---
@@ -108,16 +121,20 @@ The file path to NetBox's documentation. This is used when presenting context-se
## EMAIL
In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` setting:
In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` configuration parameter:
* SERVER - Host name or IP address of the email server (use `localhost` if running locally)
* PORT - TCP port to use for the connection (default: 25)
* USERNAME - Username with which to authenticate
* PASSSWORD - Password with which to authenticate
* TIMEOUT - Amount of time to wait for a connection (seconds)
* FROM_EMAIL - Sender address for emails sent by NetBox
* `SERVER` - Host name or IP address of the email server (use `localhost` if running locally)
* `PORT` - TCP port to use for the connection (default: `25`)
* `USERNAME` - Username with which to authenticate
* `PASSSWORD` - Password with which to authenticate
* `USE_SSL` - Use SSL when connecting to the server (default: `False`). Mutually exclusive with `USE_TLS`.
* `USE_TLS` - Use TLS when connecting to the server (default: `False`). Mutually exclusive with `USE_SSL`.
* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional)
* `SSL_KEYFILE` - Path to the PEM-formatted SSL private key file (optional)
* `TIMEOUT` - Amount of time to wait for a connection, in seconds (default: `10`)
* `FROM_EMAIL` - Sender address for emails sent by NetBox (default: `root@localhost`)
Email is sent from NetBox only for critical events. If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail):
Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail):
```
# python ./manage.py nbshell
@@ -180,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`.
@@ -381,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`.)

View File

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

View File

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

View File

@@ -78,7 +78,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
CentOS users may need to create the `netbox` group first.
```
# adduser --system --group netbox
# groupadd --system netbox
# adduser --system --gid netbox netbox
# chown --recursive netbox /opt/netbox/netbox/media/
```

View File

@@ -0,0 +1,12 @@
## Tokens
A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`.
!!! note
The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access.
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.

View File

@@ -1,5 +1,83 @@
# NetBox v2.8
## 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
* [#4632](https://github.com/netbox-community/netbox/issues/4632) - Extend email configuration parameters to support SSL/TLS
### Bug Fixes
* [#4598](https://github.com/netbox-community/netbox/issues/4598) - Display error message when invalid cable length is specified
* [#4604](https://github.com/netbox-community/netbox/issues/4604) - Multi-position rear ports may only be connected to other rear ports
* [#4607](https://github.com/netbox-community/netbox/issues/4607) - Missing Contextual help for API Tokens
* [#4613](https://github.com/netbox-community/netbox/issues/4613) - Fix tag assignment on config contexts (regression from #4527)
* [#4617](https://github.com/netbox-community/netbox/issues/4617) - Restore IP prefix depth notation in list view
* [#4629](https://github.com/netbox-community/netbox/issues/4629) - Replicate assigned interface when cloning IP addresses
* [#4633](https://github.com/netbox-community/netbox/issues/4633) - Bump django-rq to v2.3.2 to fix ImportError with rq 1.4.0
* [#4634](https://github.com/netbox-community/netbox/issues/4634) - Inventory Item List view exception caused by incorrect accessor definition
---
## v2.8.3 (2020-05-06)
### Bug Fixes
* [#4593](https://github.com/netbox-community/netbox/issues/4593) - Fix AttributeError exception when viewing object lists as a non-authenticated user
---
## v2.8.2 (2020-05-06)
### Enhancements

View File

@@ -1,9 +1,9 @@
from django import forms
from taggit.forms import TagField
from dcim.models import Region, Site
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
TagField,
)
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,23 +9,22 @@ from django.utils.safestring import mark_safe
from mptt.forms import TreeNodeChoiceField
from netaddr import EUI
from netaddr.core import AddrFormatError
from taggit.forms import TagField
from timezone_field import TimeZoneFormField
from circuits.models import Circuit, Provider
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm,
LocalConfigContextFilterForm,
LocalConfigContextFilterForm, TagField,
)
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
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
@@ -364,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(),
@@ -730,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'
@@ -758,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(
@@ -933,6 +931,7 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
model = DeviceType
fields = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'comments',
]
@@ -1227,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,
@@ -1239,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):
@@ -1957,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'
)
@@ -1977,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(
@@ -3659,6 +3693,11 @@ class CableForm(BootstrapMixin, forms.ModelForm):
'type': StaticSelect2,
'length_unit': StaticSelect2,
}
error_messages = {
'length': {
'max_value': 'Maximum length is 32767 (any unit)'
}
}
class CableCSVForm(CSVModelForm):

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

View File

@@ -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({
@@ -2182,23 +2187,29 @@ class Cable(ChangeLoggedModel):
# Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError("Incompatible termination types: {} and {}".format(
self.termination_a_type, self.termination_b_type
))
raise ValidationError(
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
)
# A RearPort with multiple positions must be connected to a component with an equal number of positions
if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
if self.termination_a.positions != self.termination_b.positions:
raise ValidationError(
"{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format(
self.termination_a, self.termination_a.positions,
self.termination_b, self.termination_b.positions
# A RearPort with multiple positions must be connected to a RearPort with an equal number of positions
for term_a, term_b in [
(self.termination_a, self.termination_b),
(self.termination_b, self.termination_a)
]:
if isinstance(term_a, RearPort) and term_a.positions > 1:
if not isinstance(term_b, RearPort):
raise ValidationError(
"Rear ports with multiple positions may only be connected to other rear ports"
)
elif term_a.positions != term_b.positions:
raise ValidationError(
f"{term_a} has {term_a.positions} position(s) but {term_b} has {term_b.positions}. "
f"Both terminations must have the same number of positions."
)
)
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
# A front port cannot be connected to its corresponding rear port
if (

View File

@@ -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 %}
&mdash;
{% 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(
@@ -1195,7 +1178,7 @@ class InventoryItemTable(BaseTable):
args=[Accessor('device.pk')]
)
manufacturer = tables.Column(
accessor=Accessor('manufacturer.name')
accessor=Accessor('manufacturer')
)
discovered = BooleanColumn()

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField
from taggit.forms import TagField as TagField_
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
@@ -142,6 +142,15 @@ class CustomFieldFilterForm(forms.Form):
# Tags
#
class TagField(TagField_):
def widget_attrs(self, widget):
# Apply the "tagfield" CSS class to trigger the special API-based selection widget for tags
return {
'class': 'tagfield'
}
class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
@@ -421,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

View File

@@ -2,7 +2,7 @@
# Generated by Django 1.11 on 2017-04-04 19:58
from django.db import migrations, models
import django.db.models.deletion
import extras.models
import extras.utils
class Migration(migrations.Migration):
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('image', models.ImageField(height_field=b'image_height', upload_to=extras.models.image_upload, width_field=b'image_width')),
('image', models.ImageField(height_field=b'image_height', upload_to=extras.utils.image_upload, width_field=b'image_width')),
('image_height', models.PositiveSmallIntegerField()),
('image_width', models.PositiveSmallIntegerField()),
('name', models.CharField(blank=True, max_length=50)),

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-05-24 15:34
from django.db import migrations, models
import extras.models
import extras.utils
class Migration(migrations.Migration):
@@ -74,7 +74,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='imageattachment',
name='image',
field=models.ImageField(height_field='image_height', upload_to=extras.models.image_upload, width_field='image_width'),
field=models.ImageField(height_field='image_height', upload_to=extras.utils.image_upload, width_field='image_width'),
),
migrations.AlterField(
model_name='topologymap',

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-05-07 21:06
from django.db import migrations
import extras.models.customfields
class Migration(migrations.Migration):
dependencies = [
('extras', '0041_tag_description'),
]
operations = [
migrations.AlterModelManagers(
name='customfield',
managers=[
('objects', extras.models.customfields.CustomFieldManager()),
],
),
]

View File

@@ -0,0 +1,25 @@
from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue
from .models import (
ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult,
Script, Webhook,
)
from .tags import Tag, TaggedItem
__all__ = (
'ConfigContext',
'ConfigContextModel',
'CustomField',
'CustomFieldChoice',
'CustomFieldModel',
'CustomFieldValue',
'CustomLink',
'ExportTemplate',
'Graph',
'ImageAttachment',
'ObjectChange',
'ReportResult',
'Script',
'Tag',
'TaggedItem',
'Webhook',
)

View File

@@ -0,0 +1,308 @@
import logging
from collections import OrderedDict
from datetime import date
from django import forms
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError
from django.db import models
from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
from extras.choices import *
from extras.utils import FeatureQuery
#
# Custom fields
#
class CustomFieldModel(models.Model):
_cf = None
class Meta:
abstract = True
def cache_custom_fields(self):
"""
Cache all custom field values for this instance
"""
self._cf = {
field.name: value for field, value in self.get_custom_fields().items()
}
@property
def cf(self):
"""
Name-based CustomFieldValue accessor for use in templates
"""
if self._cf is None:
self.cache_custom_fields()
return self._cf
def get_custom_fields(self):
"""
Return a dictionary of custom fields for a single object in the form {<field>: value}.
"""
fields = CustomField.objects.get_for_model(self)
# If the object exists, populate its custom fields with values
if hasattr(self, 'pk'):
values = self.custom_field_values.all()
values_dict = {cfv.field_id: cfv.value for cfv in values}
return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
else:
return OrderedDict([(field, None) for field in fields])
class CustomFieldManager(models.Manager):
use_in_migrations = True
def get_for_model(self, model):
"""
Return all CustomFields assigned to the given model.
"""
content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
return self.get_queryset().filter(obj_type=content_type)
class CustomField(models.Model):
obj_type = models.ManyToManyField(
to=ContentType,
related_name='custom_fields',
verbose_name='Object(s)',
limit_choices_to=FeatureQuery('custom_fields'),
help_text='The object(s) to which this field applies.'
)
type = models.CharField(
max_length=50,
choices=CustomFieldTypeChoices,
default=CustomFieldTypeChoices.TYPE_TEXT
)
name = models.CharField(
max_length=50,
unique=True
)
label = models.CharField(
max_length=50,
blank=True,
help_text='Name of the field as displayed to users (if not provided, '
'the field\'s name will be used)'
)
description = models.CharField(
max_length=200,
blank=True
)
required = models.BooleanField(
default=False,
help_text='If true, this field is required when creating new objects '
'or editing an existing object.'
)
filter_logic = models.CharField(
max_length=50,
choices=CustomFieldFilterLogicChoices,
default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
help_text='Loose matches any instance of a given string; exact '
'matches the entire field.'
)
default = models.CharField(
max_length=100,
blank=True,
help_text='Default value for the field. Use "true" or "false" for booleans.'
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Fields with higher weights appear lower in a form.'
)
objects = CustomFieldManager()
class Meta:
ordering = ['weight', 'name']
def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize()
def serialize_value(self, value):
"""
Serialize the given value to a string suitable for storage as a CustomFieldValue
"""
if value is None:
return ''
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
return str(int(bool(value)))
if self.type == CustomFieldTypeChoices.TYPE_DATE:
# Could be date/datetime object or string
try:
return value.strftime('%Y-%m-%d')
except AttributeError:
return value
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
# Could be ModelChoiceField or TypedChoiceField
return str(value.id) if hasattr(value, 'id') else str(value)
return value
def deserialize_value(self, serialized_value):
"""
Convert a string into the object it represents depending on the type of field
"""
if serialized_value == '':
return None
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
return int(serialized_value)
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
return bool(int(serialized_value))
if self.type == CustomFieldTypeChoices.TYPE_DATE:
# Read date as YYYY-MM-DD
return date(*[int(n) for n in serialized_value.split('-')])
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
return self.choices.get(pk=int(serialized_value))
return serialized_value
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
"""
Return a form field suitable for setting a CustomField's value for an object.
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
"""
initial = self.default if set_initial else None
required = self.required if enforce_required else False
# Integer
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
field = forms.IntegerField(required=required, initial=initial)
# Boolean
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
choices = (
(None, '---------'),
(1, 'True'),
(0, 'False'),
)
if initial is not None and initial.lower() in ['true', 'yes', '1']:
initial = 1
elif initial is not None and initial.lower() in ['false', 'no', '0']:
initial = 0
else:
initial = None
field = forms.NullBooleanField(
required=required, initial=initial, widget=StaticSelect2(choices=choices)
)
# Date
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
field = forms.DateField(required=required, initial=initial, widget=DatePicker())
# Select
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
if not required:
choices = add_blank_choice(choices)
# Set the initial value to the PK of the default choice, if any
if set_initial:
default_choice = self.choices.filter(value=self.default).first()
if default_choice:
initial = default_choice.pk
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelect2()
)
# URL
elif self.type == CustomFieldTypeChoices.TYPE_URL:
field = LaxURLField(required=required, initial=initial)
# Text
else:
field = forms.CharField(max_length=255, required=required, initial=initial)
field.model = self
field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
if self.description:
field.help_text = self.description
return field
class CustomFieldValue(models.Model):
field = models.ForeignKey(
to='extras.CustomField',
on_delete=models.CASCADE,
related_name='values'
)
obj_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+'
)
obj_id = models.PositiveIntegerField()
obj = GenericForeignKey(
ct_field='obj_type',
fk_field='obj_id'
)
serialized_value = models.CharField(
max_length=255
)
class Meta:
ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique
unique_together = ('field', 'obj_type', 'obj_id')
def __str__(self):
return '{} {}'.format(self.obj, self.field)
@property
def value(self):
return self.field.deserialize_value(self.serialized_value)
@value.setter
def value(self, value):
self.serialized_value = self.field.serialize_value(value)
def save(self, *args, **kwargs):
# Delete this object if it no longer has a value to store
if self.pk and self.value is None:
self.delete()
else:
super().save(*args, **kwargs)
class CustomFieldChoice(models.Model):
field = models.ForeignKey(
to='extras.CustomField',
on_delete=models.CASCADE,
related_name='choices',
limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT}
)
value = models.CharField(
max_length=100
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Higher weights appear lower in the list'
)
class Meta:
ordering = ['field', 'weight', 'value']
unique_together = ['field', 'value']
def __str__(self):
return self.value
def clean(self):
if self.field.type != CustomFieldTypeChoices.TYPE_SELECT:
raise ValidationError("Custom field choices can only be assigned to selection fields.")
def delete(self, using=None, keep_parents=False):
# When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
pk = self.pk
super().delete(using, keep_parents)
CustomFieldValue.objects.filter(
field__type=CustomFieldTypeChoices.TYPE_SELECT,
serialized_value=str(pk)
).delete()

View File

@@ -1,8 +1,6 @@
import json
from collections import OrderedDict
from datetime import date
from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
@@ -12,37 +10,13 @@ from django.db import models
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
from django.utils.text import slugify
from rest_framework.utils.encoders import JSONEncoder
from taggit.models import TagBase, GenericTaggedItemBase
from utilities.fields import ColorField
from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
from utilities.utils import deepmerge, render_jinja2
from .choices import *
from .constants import *
from .querysets import ConfigContextQuerySet
from .utils import FeatureQuery
__all__ = (
'ConfigContext',
'ConfigContextModel',
'CustomField',
'CustomFieldChoice',
'CustomFieldModel',
'CustomFieldValue',
'CustomLink',
'ExportTemplate',
'Graph',
'ImageAttachment',
'ObjectChange',
'ReportResult',
'Script',
'Tag',
'TaggedItem',
'Webhook',
)
from extras.choices import *
from extras.constants import *
from extras.querysets import ConfigContextQuerySet
from extras.utils import FeatureQuery, image_upload
#
@@ -174,291 +148,6 @@ class Webhook(models.Model):
return json.dumps(context, cls=JSONEncoder)
#
# Custom fields
#
class CustomFieldModel(models.Model):
_cf = None
class Meta:
abstract = True
def cache_custom_fields(self):
"""
Cache all custom field values for this instance
"""
self._cf = {
field.name: value for field, value in self.get_custom_fields().items()
}
@property
def cf(self):
"""
Name-based CustomFieldValue accessor for use in templates
"""
if self._cf is None:
self.cache_custom_fields()
return self._cf
def get_custom_fields(self):
"""
Return a dictionary of custom fields for a single object in the form {<field>: value}.
"""
# Find all custom fields applicable to this type of object
content_type = ContentType.objects.get_for_model(self)
fields = CustomField.objects.filter(obj_type=content_type)
# If the object exists, populate its custom fields with values
if hasattr(self, 'pk'):
values = self.custom_field_values.all()
values_dict = {cfv.field_id: cfv.value for cfv in values}
return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
else:
return OrderedDict([(field, None) for field in fields])
class CustomField(models.Model):
obj_type = models.ManyToManyField(
to=ContentType,
related_name='custom_fields',
verbose_name='Object(s)',
limit_choices_to=FeatureQuery('custom_fields'),
help_text='The object(s) to which this field applies.'
)
type = models.CharField(
max_length=50,
choices=CustomFieldTypeChoices,
default=CustomFieldTypeChoices.TYPE_TEXT
)
name = models.CharField(
max_length=50,
unique=True
)
label = models.CharField(
max_length=50,
blank=True,
help_text='Name of the field as displayed to users (if not provided, '
'the field\'s name will be used)'
)
description = models.CharField(
max_length=200,
blank=True
)
required = models.BooleanField(
default=False,
help_text='If true, this field is required when creating new objects '
'or editing an existing object.'
)
filter_logic = models.CharField(
max_length=50,
choices=CustomFieldFilterLogicChoices,
default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
help_text='Loose matches any instance of a given string; exact '
'matches the entire field.'
)
default = models.CharField(
max_length=100,
blank=True,
help_text='Default value for the field. Use "true" or "false" for booleans.'
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Fields with higher weights appear lower in a form.'
)
class Meta:
ordering = ['weight', 'name']
def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize()
def serialize_value(self, value):
"""
Serialize the given value to a string suitable for storage as a CustomFieldValue
"""
if value is None:
return ''
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
return str(int(bool(value)))
if self.type == CustomFieldTypeChoices.TYPE_DATE:
# Could be date/datetime object or string
try:
return value.strftime('%Y-%m-%d')
except AttributeError:
return value
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
# Could be ModelChoiceField or TypedChoiceField
return str(value.id) if hasattr(value, 'id') else str(value)
return value
def deserialize_value(self, serialized_value):
"""
Convert a string into the object it represents depending on the type of field
"""
if serialized_value == '':
return None
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
return int(serialized_value)
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
return bool(int(serialized_value))
if self.type == CustomFieldTypeChoices.TYPE_DATE:
# Read date as YYYY-MM-DD
return date(*[int(n) for n in serialized_value.split('-')])
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
return self.choices.get(pk=int(serialized_value))
return serialized_value
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
"""
Return a form field suitable for setting a CustomField's value for an object.
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
"""
initial = self.default if set_initial else None
required = self.required if enforce_required else False
# Integer
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
field = forms.IntegerField(required=required, initial=initial)
# Boolean
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
choices = (
(None, '---------'),
(1, 'True'),
(0, 'False'),
)
if initial is not None and initial.lower() in ['true', 'yes', '1']:
initial = 1
elif initial is not None and initial.lower() in ['false', 'no', '0']:
initial = 0
else:
initial = None
field = forms.NullBooleanField(
required=required, initial=initial, widget=StaticSelect2(choices=choices)
)
# Date
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
field = forms.DateField(required=required, initial=initial, widget=DatePicker())
# Select
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
if not required:
choices = add_blank_choice(choices)
# Set the initial value to the PK of the default choice, if any
if set_initial:
default_choice = self.choices.filter(value=self.default).first()
if default_choice:
initial = default_choice.pk
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelect2()
)
# URL
elif self.type == CustomFieldTypeChoices.TYPE_URL:
field = LaxURLField(required=required, initial=initial)
# Text
else:
field = forms.CharField(max_length=255, required=required, initial=initial)
field.model = self
field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
if self.description:
field.help_text = self.description
return field
class CustomFieldValue(models.Model):
field = models.ForeignKey(
to='extras.CustomField',
on_delete=models.CASCADE,
related_name='values'
)
obj_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+'
)
obj_id = models.PositiveIntegerField()
obj = GenericForeignKey(
ct_field='obj_type',
fk_field='obj_id'
)
serialized_value = models.CharField(
max_length=255
)
class Meta:
ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique
unique_together = ('field', 'obj_type', 'obj_id')
def __str__(self):
return '{} {}'.format(self.obj, self.field)
@property
def value(self):
return self.field.deserialize_value(self.serialized_value)
@value.setter
def value(self, value):
self.serialized_value = self.field.serialize_value(value)
def save(self, *args, **kwargs):
# Delete this object if it no longer has a value to store
if self.pk and self.value is None:
self.delete()
else:
super().save(*args, **kwargs)
class CustomFieldChoice(models.Model):
field = models.ForeignKey(
to='extras.CustomField',
on_delete=models.CASCADE,
related_name='choices',
limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT}
)
value = models.CharField(
max_length=100
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Higher weights appear lower in the list'
)
class Meta:
ordering = ['field', 'weight', 'value']
unique_together = ['field', 'value']
def __str__(self):
return self.value
def clean(self):
if self.field.type != CustomFieldTypeChoices.TYPE_SELECT:
raise ValidationError("Custom field choices can only be assigned to selection fields.")
def delete(self, using=None, keep_parents=False):
# When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
pk = self.pk
super().delete(using, keep_parents)
CustomFieldValue.objects.filter(
field__type=CustomFieldTypeChoices.TYPE_SELECT,
serialized_value=str(pk)
).delete()
#
# Custom links
#
@@ -663,20 +352,6 @@ class ExportTemplate(models.Model):
# Image attachments
#
def image_upload(instance, filename):
path = 'image-attachments/'
# Rename the file to the provided name, if any. Attempt to preserve the file extension.
extension = filename.rsplit('.')[-1].lower()
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
filename = '.'.join([instance.name, extension])
elif instance.name:
filename = instance.name
return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
class ImageAttachment(models.Model):
"""
An uploaded image which is associated with an object.
@@ -1038,44 +713,3 @@ class ObjectChange(models.Model):
self.object_repr,
self.object_data,
)
#
# Tags
#
# TODO: figure out a way around this circular import for ObjectChange
from utilities.models import ChangeLoggedModel # noqa: E402
class Tag(TagBase, ChangeLoggedModel):
color = ColorField(
default='9e9e9e'
)
description = models.CharField(
max_length=200,
blank=True,
)
def get_absolute_url(self):
return reverse('extras:tag', args=[self.slug])
def slugify(self, tag, i=None):
# Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
slug = slugify(tag, allow_unicode=True)
if i is not None:
slug += "_%d" % i
return slug
class TaggedItem(GenericTaggedItemBase):
tag = models.ForeignKey(
to=Tag,
related_name="%(app_label)s_%(class)s_items",
on_delete=models.CASCADE
)
class Meta:
index_together = (
("content_type", "object_id")
)

View File

@@ -0,0 +1,45 @@
from django.db import models
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
#
# Tags
#
class Tag(TagBase, ChangeLoggedModel):
color = ColorField(
default=ColorChoices.COLOR_GREY
)
description = models.CharField(
max_length=200,
blank=True,
)
def get_absolute_url(self):
return reverse('extras:tag', args=[self.slug])
def slugify(self, tag, i=None):
# Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
slug = slugify(tag, allow_unicode=True)
if i is not None:
slug += "_%d" % i
return slug
class TaggedItem(GenericTaggedItemBase):
tag = models.ForeignKey(
to=Tag,
related_name="%(app_label)s_%(class)s_items",
on_delete=models.CASCADE
)
class Meta:
index_together = (
("content_type", "object_id")
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -99,6 +99,19 @@ class CustomFieldTest(TestCase):
cf.delete()
class CustomFieldManagerTest(TestCase):
def setUp(self):
content_type = ContentType.objects.get_for_model(Site)
custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
custom_field.save()
custom_field.obj_type.set([content_type])
def test_get_for_model(self):
self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1)
self.assertEqual(CustomField.objects.get_for_model(VirtualMachine).count(), 0)
class CustomFieldAPITest(APITestCase):
@classmethod

View File

@@ -22,6 +22,22 @@ def is_taggable(obj):
return False
def image_upload(instance, filename):
"""
Return a path for uploading image attchments.
"""
path = 'image-attachments/'
# Rename the file to the provided name, if any. Attempt to preserve the file extension.
extension = filename.rsplit('.')[-1].lower()
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
filename = '.'.join([instance.name, extension])
elif instance.name:
filename = instance.name
return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
@deconstructible
class FeatureQuery:
"""

View File

@@ -124,9 +124,12 @@ class ConfigContextView(PermissionRequiredMixin, View):
# Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']:
format = request.GET.get('format')
request.user.config.set('extras.configcontext.format', format, commit=True)
else:
if request.user.is_authenticated:
request.user.config.set('extras.configcontext.format', format, commit=True)
elif request.user.is_authenticated:
format = request.user.config.get('extras.configcontext.format', 'json')
else:
format = 'json'
return render(request, 'extras/configcontext.html', {
'configcontext': configcontext,
@@ -181,9 +184,12 @@ class ObjectConfigContextView(View):
# Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']:
format = request.GET.get('format')
request.user.config.set('extras.configcontext.format', format, commit=True)
else:
if request.user.is_authenticated:
request.user.config.set('extras.configcontext.format', format, commit=True)
elif request.user.is_authenticated:
format = request.user.config.get('extras.configcontext.format', 'json')
else:
format = 'json'
return render(request, 'extras/object_configcontext.html', {
model_name: obj,
@@ -430,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)

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator
from taggit.forms import TagField
from dcim.models import Device, Interface, Rack, Region, Site
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
TagField,
)
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
@@ -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

View File

@@ -640,7 +640,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
'dns_name', 'description',
]
clone_fields = [
'vrf', 'tenant', 'status', 'role', 'description',
'vrf', 'tenant', 'status', 'role', 'description', 'interface',
]
STATUS_CLASS_MAP = {

View File

@@ -378,6 +378,8 @@ class PrefixTable(BaseTable):
verbose_name='Pool'
)
add_prefetch = False
class Meta(BaseTable.Meta):
model = Prefix
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description')
@@ -665,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

View File

@@ -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 = ''
@@ -108,6 +113,8 @@ EMAIL = {
'PORT': 25,
'USERNAME': '',
'PASSWORD': '',
'USE_SSL': False,
'USE_TLS': False,
'TIMEOUT': 10, # seconds
'FROM_EMAIL': '',
}
@@ -130,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 = {}

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.8.2'
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)
@@ -246,12 +250,16 @@ if SESSION_FILE_PATH is not None:
#
EMAIL_HOST = EMAIL.get('SERVER')
EMAIL_PORT = EMAIL.get('PORT', 25)
EMAIL_HOST_USER = EMAIL.get('USERNAME')
EMAIL_HOST_PASSWORD = EMAIL.get('PASSWORD')
EMAIL_PORT = EMAIL.get('PORT', 25)
EMAIL_SSL_CERTFILE = EMAIL.get('SSL_CERTFILE')
EMAIL_SSL_KEYFILE = EMAIL.get('SSL_KEYFILE')
EMAIL_SUBJECT_PREFIX = '[NetBox] '
EMAIL_USE_SSL = EMAIL.get('USE_SSL', False)
EMAIL_USE_TLS = EMAIL.get('USE_TLS', False)
EMAIL_TIMEOUT = EMAIL.get('TIMEOUT', 10)
SERVER_EMAIL = EMAIL.get('FROM_EMAIL')
EMAIL_SUBJECT_PREFIX = '[NetBox] '
#
@@ -611,15 +619,6 @@ RQ_QUEUES = {
'check_releases': RQ_PARAMS,
}
#
# Django debug toolbar
#
INTERNAL_IPS = (
'127.0.0.1',
'::1',
)
#
# NetBox internal settings

View File

@@ -292,9 +292,9 @@ $(document).ready(function() {
});
// API backed tags
var tags = $('#id_tags');
var tags = $('#id_tags.tagfield');
if (tags.length > 0 && tags.val().length > 0){
tags = $('#id_tags').val().split(/,\s*/);
tags = $('#id_tags.tagfield').val().split(/,\s*/);
} else {
tags = [];
}
@@ -306,8 +306,8 @@ $(document).ready(function() {
}
});
// Replace the django issued text input with a select element
$('#id_tags').replaceWith('<select name="tags" id="id_tags" class="form-control"></select>');
$('#id_tags').select2({
$('#id_tags.tagfield').replaceWith('<select name="tags" id="id_tags" class="form-control tagfield"></select>');
$('#id_tags.tagfield').select2({
tags: true,
data: tag_objs,
multiple: true,
@@ -354,14 +354,14 @@ $(document).ready(function() {
}
}
});
$('#id_tags').closest('form').submit(function(event){
$('#id_tags.tagfield').closest('form').submit(function(event){
// django-taggit can only accept a single comma seperated string value
var value = $('#id_tags').val();
var value = $('#id_tags.tagfield').val();
if (value.length > 0){
var final_tags = value.join(', ');
$('#id_tags').val(null).trigger('change');
$('#id_tags.tagfield').val(null).trigger('change');
var option = new Option(final_tags, final_tags, true, true);
$('#id_tags').append(option).trigger('change');
$('#id_tags.tagfield').append(option).trigger('change');
}
});

View File

@@ -1,11 +1,11 @@
from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA
from django import forms
from taggit.forms import TagField
from dcim.models import Device
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
TagField,
)
from utilities.forms import (
APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
@@ -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(

View File

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

View File

@@ -10,9 +10,23 @@
<label class="col-md-3 control-label" for="id_length">{{ form.length.label }}</label>
<div class="col-md-5">
{{ form.length }}
{% if form.length.errors %}
<ul>
{% for error in form.length.errors %}
<li class="text-danger">{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="col-md-4">
{{ form.length_unit }}
{% if form.length_unit.errors %}
<ul>
{% for error in form.length_unit.errors %}
<li class="text-danger">{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
{% block content %}
<div class="pull-right noprint">
{% block buttons %}{% endblock %}
{% if table_config_form %}
{% if request.user.is_authenticated and table_config_form %}
<button type="button" class="btn btn-default" data-toggle="modal" data-target="#tableconfig" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
{% endif %}
{% if permissions.add and 'add' in action_buttons %}

View File

@@ -1,8 +1,8 @@
from django import forms
from taggit.forms import TagField
from extras.forms import (
AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm,
TagField,
)
from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,9 +50,12 @@ def get_paginate_count(request):
if 'per_page' in request.GET:
try:
per_page = int(request.GET.get('per_page'))
request.user.config.set('pagination.per_page', per_page, commit=True)
if request.user.is_authenticated:
request.user.config.set('pagination.per_page', per_page, commit=True)
return per_page
except ValueError:
pass
return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
if request.user.is_authenticated:
return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
return settings.PAGINATE_COUNT

View File

@@ -1,6 +1,5 @@
import django_tables2 as tables
from django.core.exceptions import FieldDoesNotExist
from django.db.models import ForeignKey
from django.db.models.fields.related import RelatedField
from django.utils.safestring import mark_safe
from django_tables2.data import TableQuerysetData
@@ -9,7 +8,13 @@ from django_tables2.data import TableQuerysetData
class BaseTable(tables.Table):
"""
Default table for object lists
:param add_prefetch: By default, modify the queryset passed to the table upon initialization to automatically
prefetch related data. Set this to False if it's necessary to avoid modifying the queryset (e.g. to
accommodate PrefixQuerySet.annotate_depth()).
"""
add_prefetch = True
class Meta:
attrs = {
'class': 'table table-hover table-headings',
@@ -50,7 +55,7 @@ class BaseTable(tables.Table):
self.sequence.append('actions')
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched
if isinstance(self.data, TableQuerysetData):
if self.add_prefetch and isinstance(self.data, TableQuerysetData):
model = getattr(self.Meta, 'model')
prefetch_fields = []
for column in self.columns:
@@ -79,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.
@@ -124,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 %}&mdash;{% 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.

View File

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

View File

@@ -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:
"""
@@ -164,6 +181,13 @@ class ViewTestCases:
response = self.client.get(instance.get_absolute_url())
self.assertHttpStatus(response, 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
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())
self.assertHttpStatus(response, 200)
class CreateObjectViewTestCase(ModelViewTestCase):
"""
Create a single new instance.
@@ -287,6 +311,13 @@ class ViewTestCases:
self.assertHttpStatus(response, 200)
self.assertEqual(response.get('Content-Type'), 'text/csv')
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_list_objects_anonymous(self):
# Make the request as an unauthenticated user
self.client.logout()
response = self.client.get(self._get_url('list'))
self.assertHttpStatus(response, 200)
class BulkCreateObjectsViewTestCase(ModelViewTestCase):
"""
Create multiple instances using a single form. Expects the creation of three new instances by default.
@@ -474,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

View File

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

View File

@@ -3,6 +3,7 @@ import sys
from copy import deepcopy
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db import transaction, IntegrityError
@@ -13,6 +14,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.template import loader
from django.template.exceptions import TemplateDoesNotExist
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.html import escape
from django.utils.http import is_safe_url
from django.utils.safestring import mark_safe
@@ -164,7 +166,10 @@ class ObjectListView(View):
permissions[action] = request.user.has_perm(perm_name)
# Construct the table based on the user's permissions
columns = request.user.config.get(f"tables.{self.table.__name__}.columns")
if request.user.is_authenticated:
columns = request.user.config.get(f"tables.{self.table.__name__}.columns")
else:
columns = None
table = self.table(self.queryset, columns=columns)
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk')
@@ -188,6 +193,7 @@ class ObjectListView(View):
return render(request, self.template_name, context)
@method_decorator(login_required)
def post(self, request):
# Update the user's table configuration
@@ -776,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)

View File

@@ -1,6 +1,5 @@
from django import forms
from django.core.exceptions import ValidationError
from taggit.forms import TagField
from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
@@ -8,6 +7,7 @@ from dcim.forms import INTERFACE_MODE_HELP_TEXT
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
TagField,
)
from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm

View File

@@ -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 %}&mdash;{% 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
)

View File

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

View File

@@ -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_{}'

View File

@@ -6,7 +6,7 @@ django-filter==2.2.0
django-mptt==0.11.0
django-pglocks==1.0.4
django-prometheus==2.0.0
django-rq==2.3.1
django-rq==2.3.2
django-tables2==2.3.1
django-taggit==1.2.0
django-taggit-serializer==0.1.7