Merge branch 'develop-2.7' into 822-bulk-import-of-device-components

This commit is contained in:
Jeremy Stretch 2019-12-05 15:26:40 -05:00 committed by GitHub
commit 0c866561d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
76 changed files with 2301 additions and 619 deletions

4
.gitignore vendored
View File

@ -12,5 +12,9 @@
fabfile.py
*.swp
gunicorn_config.py
gunicorn.conf
netbox.log
netbox.pid
.DS_Store
.vscode
.coverage

View File

@ -10,6 +10,7 @@ python:
install:
- pip install -r requirements.txt
- pip install pycodestyle
- pip install coverage
before_script:
- psql --version
- psql -U postgres -c 'SELECT version();'

View File

@ -54,10 +54,6 @@ djangorestframework
# https://github.com/axnsan12/drf-yasg
drf-yasg[validation]
# Python interface to the graphviz graph rendering utility
# https://github.com/xflr6/graphviz
graphviz
# Simple markup language for rendering HTML
# https://github.com/Python-Markdown/markdown
# py-gfm requires Markdown<3.0

22
contrib/gunicorn.conf Normal file
View File

@ -0,0 +1,22 @@
# Bind is the ip and port that the Netbox WSGI should bind to
#
bind='127.0.0.1:8001'
# Workers is the number of workers that GUnicorn should spawn.
# Workers should be: cores * 2 + 1. So if you have 8 cores, it would be 17.
#
workers=3
# Threads
# The number of threads for handling requests
#
threads=3
# Timeout is the timeout between gunicorn receiving a request and returning a response (or failing with a 500 error)
#
timeout=120
# ErrorLog
# ErrorLog is the logfile for the ErrorLog
#
errorlog='/opt/netbox/netbox.log'

24
contrib/netbox-rq.service Normal file
View File

@ -0,0 +1,24 @@
[Unit]
Description=Netbox RQ Worker
Documentation=https://netbox.readthedocs.io/en/stable/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/sysconfig/netbox.env
User=www-data
Group=www-data
WorkingDirectory=${WorkingDirectory}
ExecStart=/usr/bin/python3 ${WorkingDirectory}/netbox/manage.py rqworker
Restart=on-failure
RestartSec=30
PrivateTmp=true
[Install]
WantedBy=multi-user.target

15
contrib/netbox.env Normal file
View File

@ -0,0 +1,15 @@
# Name is the Process Name
#
Name = 'Netbox'
# ConfigPath is the path to the gunicorn config file.
#
ConfigPath=/opt/netbox/gunicorn.conf
# WorkingDirectory is the Working Directory for Netbox.
#
WorkingDirectory=/opt/netbox/
# PidPath is the path to the pid for the netbox WSGI
#
PidPath=/opt/netbox/netbox.pid

24
contrib/netbox.service Normal file
View File

@ -0,0 +1,24 @@
[Unit]
Description=Netbox WSGI
Documentation=https://netbox.readthedocs.io/en/stable/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/sysconfig/netbox.env
User=www-data
Group=www-data
PIDFile=${PidPath}
WorkingDirectory=${WorkingDirectory}
ExecStart=/usr/local/bin/gunicorn --pid ${PidPath} --pythonpath ${WorkingDirectory}/netbox --config ${ConfigPath} netbox.wsgi
Restart=on-failure
RestartSec=30
PrivateTmp=true
[Install]
WantedBy=multi-user.target

24
contrib/netbox@.service Normal file
View File

@ -0,0 +1,24 @@
[Unit]
Description=Netbox WSGI
Documentation=https://netbox.readthedocs.io/en/stable/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/sysconfig/netbox.%i.env
User=www-data
Group=www-data
PIDFile=${PidPath}
WorkingDirectory=${WorkingDirectory}
ExecStart=/usr/local/bin/gunicorn --pid ${PidPath} --pythonpath ${WorkingDirectory}/netbox --config ${ConfigPath} netbox.wsgi
Restart=on-failure
RestartSec=30
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View File

@ -1,17 +0,0 @@
# Topology Maps
NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps.
Each topology map is associated with a site. A site can have multiple topology maps, which might each illustrate a different aspect of its infrastructure (for example, production versus backend infrastructure).
To define the scope of a topology map, decide which devices you want to include. The map will only include interface connections with both points terminated on an included device. Specify the devices to include in the **device patterns** field by entering a list of [regular expressions](https://en.wikipedia.org/wiki/Regular_expression) matching device names. For example, if you wanted to include "mgmt-switch1" through "mgmt-switch99", you might use the regex `mgmt-switch\d+`.
Each line of the **device patterns** field represents a hierarchical layer within the topology map. For example, you might map a traditional network with core, distribution, and access tiers like this:
```
core-switch-[abcd]
dist-switch\d
access-switch\d+;oob-switch\d+
```
Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those.

View File

@ -25,7 +25,7 @@ NetBox requires access to a PostgreSQL database service to store data. This serv
Example:
```
```python
DATABASE = {
'NAME': 'netbox', # Database name
'USER': 'netbox', # PostgreSQL username
@ -42,40 +42,48 @@ DATABASE = {
[Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of
NetBox since the introduction of webhooks in version 2.4, it is required starting in 2.6 to support NetBox's caching
functionality (as well as other planned features).
functionality (as well as other planned features). In 2.7, the connection settings were broken down into two sections for
webhooks and caching, allowing the user to connect to different Redis instances/databases per feature.
Redis is configured using a configuration setting similar to `DATABASE`:
Redis is configured using a configuration setting similar to `DATABASE` and these settings are the same for both of the `webhooks` and `caching` subsections:
* `HOST` - Name or IP address of the Redis server (use `localhost` if running locally)
* `PORT` - TCP port of the Redis service; leave blank for default port (6379)
* `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID for webhooks
* `CACHE_DATABASE` - Numeric database ID for caching
* `DATABASE` - Numeric database ID
* `DEFAULT_TIMEOUT` - Connection timeout in seconds
* `SSL` - Use SSL connection to Redis
Example:
```
```python
REDIS = {
'webhooks': {
'HOST': 'redis.example.com',
'PORT': 1234,
'PASSWORD': 'foobar',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
},
'caching': {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 0,
'CACHE_DATABASE': 1,
'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
}
```
!!! note:
If you were using these settings in a prior release with webhooks, the `DATABASE` setting remains the same but
an additional `CACHE_DATABASE` setting has been added with a default value of 1 to support the caching backend. The
`DATABASE` setting will be renamed in a future release of NetBox to better relay the meaning of the setting.
If you are upgrading from a version prior to v2.7, please note that the Redis connection configuration settings have
changed. Manual modification to bring the `REDIS` section inline with the above specification is necessary
!!! warning:
It is highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result in webhook
processing data being lost in cache flushing events.
It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the
same Redis instance for both may result in webhook processing data being lost during cache flushing events.
---

View File

@ -5,14 +5,14 @@ This section of the documentation discusses installing and configuring the NetBo
**Ubuntu**
```no-highlight
# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev redis-server zlib1g-dev
# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev redis-server zlib1g-dev
```
**CentOS**
```no-highlight
# yum install -y epel-release
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config redis
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config redis
# easy_install-3.6 pip
# ln -s /usr/bin/python36 /usr/bin/python3
```
@ -139,13 +139,22 @@ Redis is a in-memory key-value store required as part of the NetBox installation
```python
REDIS = {
'webhooks': {
'HOST': 'redis.example.com',
'PORT': 1234,
'PASSWORD': 'foobar',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
},
'caching': {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 0,
'CACHE_DATABASE': 1,
'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
}
```

View File

@ -1,4 +1,4 @@
We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence.
We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll use systemd to enable service persistence.
!!! info
For the sake of brevity, only Ubuntu 18.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed.
@ -107,7 +107,7 @@ Install gunicorn:
# pip3 install gunicorn
```
Save the following configuration in the root netbox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`. More info on `max_requests` can be found in the [gunicorn docs](https://docs.gunicorn.org/en/stable/settings.html#max-requests).
Save the following configuration in the root NetBox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`. More info on `max_requests` can be found in the [gunicorn docs](https://docs.gunicorn.org/en/stable/settings.html#max-requests).
```no-highlight
command = '/usr/bin/gunicorn'
@ -119,32 +119,99 @@ max_requests = 5000
max_requests_jitter = 500
```
# supervisord Installation
# systemd configuration
Install supervisor:
Copy or link contrib/netbox.service and contrib/netbox-rq.service to /etc/systemd/system/netbox.service and /etc/systemd/system/netbox-rq.service
```no-highlight
# apt-get install -y supervisor
# cp contrib/netbox.service to /etc/systemd/system/netbox.service
# cp contrib/netbox-rq.service to /etc/systemd/system/netbox-rq.service
```
Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
Edit /etc/systemd/system/netbox.service and /etc/systemd/system/netbox-rq.service. Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`). If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`:
```no-highlight
[program:netbox]
command = gunicorn -c /opt/netbox/gunicorn_config.py netbox.wsgi
directory = /opt/netbox/netbox/
user = www-data
[program:netbox-rqworker]
command = python3 /opt/netbox/netbox/manage.py rqworker
directory = /opt/netbox/netbox/
user = www-data
/usr/local/bin/gunicorn --pid ${PidPath} --pythonpath ${WorkingDirectory}/netbox --config ${ConfigPath} netbox.wsgi
```
Then, restart the supervisor service to detect and run the gunicorn service:
```no-highlight
User=www-data
Group=www-data
```
Copy contrib/netbox.env to /etc/sysconfig/netbox.env
```no-highlight
# service supervisor restart
# cp contrib/netbox.env to /etc/sysconfig/netbox.env
```
Edit /etc/sysconfig/netbox.env and change the settings as required. Update the `WorkingDirectory` variable if needed.
```no-highlight
# Name is the Process Name
#
Name = 'Netbox'
# ConfigPath is the path to the gunicorn config file.
#
ConfigPath=/opt/netbox/gunicorn.conf
# WorkingDirectory is the Working Directory for Netbox.
#
WorkingDirectory=/opt/netbox/
# PidPath is the path to the pid for the netbox WSGI
#
PidPath=/var/run/netbox.pid
```
Copy contrib/gunicorn.conf to gunicorn.conf
```no-highlight
# cp contrib/gunicorn.conf to gunicorn.conf
```
Edit gunicorn.conf and change the settings as required.
```
# Bind is the ip and port that the Netbox WSGI should bind to
#
bind='127.0.0.1:8001'
# Workers is the number of workers that GUnicorn should spawn.
# Workers should be: cores * 2 + 1. So if you have 8 cores, it would be 17.
#
workers=3
# Threads
# The number of threads for handling requests
# Threads should be: cores * 2 + 1. So if you have 4 cores, it would be 9.
#
threads=3
# Timeout is the timeout between gunicorn receiving a request and returning a response (or failing with a 500 error)
#
timeout=120
# ErrorLog
# ErrorLog is the logfile for the ErrorLog
#
errorlog='/opt/netbox/netbox.log'
```
Then, restart the systemd daemon service to detect the netbox service and start the netbox service:
```no-highlight
# systemctl daemon-reload
# systemctl start netbox.service
# systemctl enable netbox.service
```
If using webhooks, also start the Redis worker:
```no-highlight
# systemctl start netbox-rq.service
# systemctl enable netbox-rq.service
```
At this point, you should be able to connect to the nginx HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running.

View File

@ -12,3 +12,5 @@ The following sections detail how to set up a new instance of NetBox:
If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md).
NetBox v2.5 and later requires Python 3.5 or higher. Please see the instructions for [migrating to Python 3](migrating-to-python3.md) if you are still using Python 2.
Netbox v2.5.9 and later moved to using systemd instead of supervisord. Please see the instructions for [migrating to systemd](migrating-to-systemd.md) if you are still using supervisord.

View File

@ -0,0 +1,105 @@
# Migration
Migration is not required, as supervisord will still continue to function.
## Ubuntu
### Remove supervisord:
```no-highlight
# apt-get remove -y supervisord
```
### systemd configuration:
Copy or link contrib/netbox.service and contrib/netbox-rq.service to /etc/systemd/system/netbox.service and /etc/systemd/system/netbox-rq.service
```no-highlight
# cp contrib/netbox.service /etc/systemd/system/netbox.service
# cp contrib/netbox-rq.service /etc/systemd/system/netbox-rq.service
```
Edit /etc/systemd/system/netbox.service and /etc/systemd/system/netbox-rq.service. Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`). If using CentOS/RHEL. Change the username from `www-data` to `nginx` or `apache`:
```no-highlight
/usr/local/bin/gunicorn --pid ${PidPath} --pythonpath ${WorkingDirectory}/netbox --config ${ConfigPath} netbox.wsgi
```
```no-highlight
User=www-data
Group=www-data
```
Copy contrib/netbox.env to /etc/sysconfig/netbox.env
```no-highlight
# cp contrib/netbox.env /etc/sysconfig/netbox.env
```
Edit /etc/sysconfig/netbox.env and change the settings as required. Update the `WorkingDirectory` variable if needed.
```no-highlight
# Name is the Process Name
#
Name = 'Netbox'
# ConfigPath is the path to the gunicorn config file.
#
ConfigPath=/opt/netbox/gunicorn.conf
# WorkingDirectory is the Working Directory for Netbox.
#
WorkingDirectory=/opt/netbox/
# PidPath is the path to the pid for the netbox WSGI
#
PidPath=/var/run/netbox.pid
```
Copy contrib/gunicorn.conf to gunicorn.conf
```no-highlight
# cp contrib/gunicorn.conf to gunicorn.conf
```
Edit gunicorn.conf and change the settings as required.
```
# Bind is the ip and port that the Netbox WSGI should bind to
#
bind='127.0.0.1:8001'
# Workers is the number of workers that GUnicorn should spawn.
# Workers should be: cores * 2 + 1. So if you have 8 cores, it would be 17.
#
workers=3
# Threads
# The number of threads for handling requests
#
threads=3
# Timeout is the timeout between gunicorn receiving a request and returning a response (or failing with a 500 error)
#
timeout=120
# ErrorLog
# ErrorLog is the logfile for the ErrorLog
#
errorlog='/opt/netbox/netbox.log'
```
Then, restart the systemd daemon service to detect the netbox service and start the netbox service:
```no-highlight
# systemctl daemon-reload
# systemctl start netbox.service
# systemctl enable netbox.service
```
If using webhooks, also start the Redis worker:
```no-highlight
# systemctl start netbox-rq.service
# systemctl enable netbox-rq.service
```

View File

@ -84,14 +84,17 @@ This script:
# Restart the WSGI Service
Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `supervisorctl`:
Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `systemctl:
```no-highlight
# sudo supervisorctl restart netbox
# sudo systemctl restart netbox
```
If using webhooks, also restart the Redis worker:
```no-highlight
# sudo supervisorctl restart netbox-rqworker
# sudo systemctl restart netbox-rqworker
```
!!! note
It's possible you are still using supervisord instead of the linux native systemd. If you are still using supervisord you can restart the services by either restarting supervisord or by using supervisorctl to restart netbox.

View File

@ -1 +1 @@
version-2.6.md
version-2.7.md

View File

@ -0,0 +1,100 @@
# v2.7.0 (FUTURE)
## New Features
### Enhanced Device Type Import ([#451](https://github.com/netbox-community/netbox/issues/451))
NetBox now supports the import of device types and related component templates using a YAML- or JSON-based definition.
For example, the following will create a new device type with four network interfaces, two power ports, and a console
port:
```yaml
manufacturer: Acme
model: Packet Shooter 9000
slug: packet-shooter-9000
u_height: 1
interfaces:
- name: ge-0/0/0
type: 1000base-t
- name: ge-0/0/1
type: 1000base-t
- name: ge-0/0/2
type: 1000base-t
- name: ge-0/0/3
type: 1000base-t
power-ports:
- name: PSU0
- name: PSU1
console-ports:
- name: Console
```
This new functionality replaces the existing CSV-based import form, which did not allow for component template import.
## Changes
### Topology Maps Removed ([#2745](https://github.com/netbox-community/netbox/issues/2745))
The topology maps feature has been removed to help focus NetBox development efforts.
### Redis Configuration ([#3282](https://github.com/netbox-community/netbox/issues/3282))
v2.6.0 introduced caching and added the `CACHE_DATABASE` option to the existing `REDIS` database configuration section.
This did not however, allow for using two different Redis connections for the seperate caching and webhooks features.
This change separates the Redis connection configurations in the `REDIS` section into distinct `webhooks` and `caching` subsections.
This requires modification of the `REDIS` section of the `configuration.py` file as follows:
Old Redis configuration:
```python
REDIS = {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 0,
'CACHE_DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
```
New Redis configuration:
```python
REDIS = {
'webhooks': {
'HOST': 'redis.example.com',
'PORT': 1234,
'PASSWORD': 'foobar',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
},
'caching': {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
}
```
Note that `CACHE_DATABASE` has been removed and the connection settings have been duplicated for both `webhooks` and `caching`.
This allows the user to make use of separate Redis instances and/or databases if desired.
Full connection details are required in both sections, even if they are the same.
## Enhancements
* [#1865](https://github.com/digitalocean/netbox/issues/1865) - Add console port and console server port types
* [#2902](https://github.com/digitalocean/netbox/issues/2902) - Replace supervisord with systemd
* [#3455](https://github.com/digitalocean/netbox/issues/3455) - Add tenant assignment to cluster
* [#3538](https://github.com/digitalocean/netbox/issues/3538) -
## API Changes
* Introduced `/api/extras/scripts/` endpoint for retrieving and executing custom scripts
* dcim.ConsolePort: Added field `type`
* dcim.ConsolePortTemplate: Added field `type`
* dcim.ConsoleServerPort: Added field `type`
* dcim.ConsoleServerPortTemplate: Added field `type`
* virtualization.Cluster: Added field `tenant`

View File

@ -4,6 +4,7 @@ from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.choices import *
from dcim.constants import *
from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
@ -200,30 +201,46 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
class ConsolePortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=ConsolePortTypes.CHOICES,
required=False
)
class Meta:
model = ConsolePortTemplate
fields = ['id', 'device_type', 'name']
fields = ['id', 'device_type', 'name', 'type']
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=ConsolePortTypes.CHOICES,
required=False
)
class Meta:
model = ConsoleServerPortTemplate
fields = ['id', 'device_type', 'name']
fields = ['id', 'device_type', 'name', 'type']
class PowerPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=PowerPortTypes.CHOICES,
required=False
)
class Meta:
model = PowerPortTemplate
fields = ['id', 'device_type', 'name', 'maximum_draw', 'allocated_draw']
fields = ['id', 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw']
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=PowerOutletTypes.CHOICES,
required=False
)
power_port = PowerPortTemplateSerializer(
required=False
)
@ -235,18 +252,16 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerOutletTemplate
fields = ['id', 'device_type', 'name', 'power_port', 'feed_leg']
fields = ['id', 'device_type', 'name', 'type', 'power_port', 'feed_leg']
class InterfaceTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
# TODO: Remove in v2.7 (backward-compatibility for form_factor)
form_factor = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
class Meta:
model = InterfaceTemplate
fields = ['id', 'device_type', 'name', 'type', 'form_factor', 'mgmt_only']
fields = ['id', 'device_type', 'name', 'type', 'mgmt_only']
class RearPortTemplateSerializer(ValidatedModelSerializer):
@ -372,32 +387,44 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=ConsolePortTypes.CHOICES,
required=False
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = ConsoleServerPort
fields = [
'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
'cable', 'tags',
'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
'connection_status', 'cable', 'tags',
]
class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=ConsolePortTypes.CHOICES,
required=False
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = ConsolePort
fields = [
'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
'cable', 'tags',
'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
'connection_status', 'cable', 'tags',
]
class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=PowerOutletTypes.CHOICES,
required=False
)
power_port = NestedPowerPortSerializer(
required=False
)
@ -416,20 +443,24 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class Meta:
model = PowerOutlet
fields = [
'id', 'device', 'name', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
'id', 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
]
class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=PowerPortTypes.CHOICES,
required=False
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = PowerPort
fields = [
'id', 'device', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
'id', 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
]
@ -437,8 +468,6 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
# TODO: Remove in v2.7 (backward-compatibility for form_factor)
form_factor = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
@ -454,9 +483,9 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class Meta:
model = Interface
fields = [
'id', 'device', 'name', 'type', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode',
'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
'id', 'device', 'name', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan',
'tagged_vlans', 'tags', 'count_ipaddresses',
]
# TODO: This validation should be handled by Interface.clean()

View File

@ -42,16 +42,20 @@ from .exceptions import MissingFilterException
class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(Cable, ['length_unit', 'status', 'termination_a_type', 'termination_b_type', 'type']),
(ConsolePort, ['connection_status']),
(ConsolePort, ['type', 'connection_status']),
(ConsolePortTemplate, ['type']),
(ConsoleServerPort, ['type']),
(ConsoleServerPortTemplate, ['type']),
(Device, ['face', 'status']),
(DeviceType, ['subdevice_role']),
(FrontPort, ['type']),
(FrontPortTemplate, ['type']),
(Interface, ['type', 'mode']),
(InterfaceTemplate, ['type']),
(PowerOutlet, ['feed_leg']),
(PowerOutletTemplate, ['feed_leg']),
(PowerPort, ['connection_status']),
(PowerOutlet, ['type', 'feed_leg']),
(PowerOutletTemplate, ['type', 'feed_leg']),
(PowerPort, ['type', 'connection_status']),
(PowerPortTemplate, ['type']),
(Rack, ['outer_unit', 'status', 'type', 'width']),
(RearPort, ['type']),
(RearPortTemplate, ['type']),

594
netbox/dcim/choices.py Normal file
View File

@ -0,0 +1,594 @@
from .constants import *
#
# Console port type values
#
class ConsolePortTypes:
"""
ConsolePort/ConsoleServerPort.type slugs
"""
TYPE_DE9 = 'de-9'
TYPE_DB25 = 'db-25'
TYPE_RJ45 = 'rj-45'
TYPE_USB_A = 'usb-a'
TYPE_USB_B = 'usb-b'
TYPE_USB_C = 'usb-c'
TYPE_USB_MINI_A = 'usb-mini-a'
TYPE_USB_MINI_B = 'usb-mini-b'
TYPE_USB_MICRO_A = 'usb-micro-a'
TYPE_USB_MICRO_B = 'usb-micro-b'
TYPE_OTHER = 'other'
CHOICES = (
('Serial', (
(TYPE_DE9, 'DE-9'),
(TYPE_DB25, 'DB-25'),
(TYPE_RJ45, 'RJ-45'),
)),
('USB', (
(TYPE_USB_A, 'USB Type A'),
(TYPE_USB_B, 'USB Type B'),
(TYPE_USB_C, 'USB Type C'),
(TYPE_USB_MINI_A, 'USB Mini A'),
(TYPE_USB_MINI_B, 'USB Mini B'),
(TYPE_USB_MICRO_A, 'USB Micro A'),
(TYPE_USB_MICRO_B, 'USB Micro B'),
)),
('Other', (
(TYPE_OTHER, 'Other'),
)),
)
#
# Power port types
#
class PowerPortTypes:
# TODO: Add more power port types
# IEC 60320
TYPE_IEC_C6 = 'iec-60320-c6'
TYPE_IEC_C8 = 'iec-60320-c8'
TYPE_IEC_C14 = 'iec-60320-c14'
TYPE_IEC_C16 = 'iec-60320-c16'
TYPE_IEC_C20 = 'iec-60320-c20'
# IEC 60309
TYPE_IEC_PNE4H = 'iec-60309-p-n-e-4h'
TYPE_IEC_PNE6H = 'iec-60309-p-n-e-6h'
TYPE_IEC_PNE9H = 'iec-60309-p-n-e-9h'
TYPE_IEC_2PE4H = 'iec-60309-2p-e-4h'
TYPE_IEC_2PE6H = 'iec-60309-2p-e-6h'
TYPE_IEC_2PE9H = 'iec-60309-2p-e-9h'
TYPE_IEC_3PE4H = 'iec-60309-3p-e-4h'
TYPE_IEC_3PE6H = 'iec-60309-3p-e-6h'
TYPE_IEC_3PE9H = 'iec-60309-3p-e-9h'
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# NEMA non-locking
TYPE_NEMA_515P = 'nema-5-15p'
TYPE_NEMA_520P = 'nema-5-20p'
TYPE_NEMA_530P = 'nema-5-30p'
TYPE_NEMA_550P = 'nema-5-50p'
TYPE_NEMA_615P = 'nema-6-15p'
TYPE_NEMA_620P = 'nema-6-20p'
TYPE_NEMA_630P = 'nema-6-30p'
TYPE_NEMA_650P = 'nema-6-50p'
# NEMA locking
TYPE_NEMA_L515P = 'nema-l5-15p'
TYPE_NEMA_L520P = 'nema-l5-20p'
TYPE_NEMA_L530P = 'nema-l5-30p'
TYPE_NEMA_L615P = 'nema-l5-50p'
TYPE_NEMA_L620P = 'nema-l6-20p'
TYPE_NEMA_L630P = 'nema-l6-30p'
TYPE_NEMA_L650P = 'nema-l6-50p'
CHOICES = (
('IEC 60320', (
(TYPE_IEC_C6, 'C6'),
(TYPE_IEC_C8, 'C8'),
(TYPE_IEC_C14, 'C14'),
(TYPE_IEC_C16, 'C16'),
(TYPE_IEC_C20, 'C20'),
)),
('IEC 60309', (
(TYPE_IEC_PNE4H, 'P+N+E 4H'),
(TYPE_IEC_PNE6H, 'P+N+E 6H'),
(TYPE_IEC_PNE9H, 'P+N+E 9H'),
(TYPE_IEC_2PE4H, '2P+E 4H'),
(TYPE_IEC_2PE6H, '2P+E 6H'),
(TYPE_IEC_2PE9H, '2P+E 9H'),
(TYPE_IEC_3PE4H, '3P+E 4H'),
(TYPE_IEC_3PE6H, '3P+E 6H'),
(TYPE_IEC_3PE9H, '3P+E 9H'),
(TYPE_IEC_3PNE4H, '3P+N+E 4H'),
(TYPE_IEC_3PNE6H, '3P+N+E 6H'),
(TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)),
('NEMA (Non-locking)', (
(TYPE_NEMA_515P, 'NEMA 5-15P'),
(TYPE_NEMA_520P, 'NEMA 5-20P'),
(TYPE_NEMA_530P, 'NEMA 5-30P'),
(TYPE_NEMA_550P, 'NEMA 5-50P'),
(TYPE_NEMA_615P, 'NEMA 6-15P'),
(TYPE_NEMA_620P, 'NEMA 6-20P'),
(TYPE_NEMA_630P, 'NEMA 6-30P'),
(TYPE_NEMA_650P, 'NEMA 6-50P'),
)),
('NEMA (Locking)', (
(TYPE_NEMA_L515P, 'NEMA L5-15P'),
(TYPE_NEMA_L520P, 'NEMA L5-20P'),
(TYPE_NEMA_L530P, 'NEMA L5-30P'),
(TYPE_NEMA_L615P, 'NEMA L6-15P'),
(TYPE_NEMA_L620P, 'NEMA L6-20P'),
(TYPE_NEMA_L630P, 'NEMA L6-30P'),
(TYPE_NEMA_L650P, 'NEMA L6-50P'),
)),
)
#
# Power outlet types
#
class PowerOutletTypes:
# TODO: Add more power outlet types
# IEC 60320
TYPE_IEC_C5 = 'iec-60320-c5'
TYPE_IEC_C7 = 'iec-60320-c7'
TYPE_IEC_C13 = 'iec-60320-c13'
TYPE_IEC_C15 = 'iec-60320-c15'
TYPE_IEC_C19 = 'iec-60320-c19'
# IEC 60309
TYPE_IEC_PNE4H = 'iec-60309-p-n-e-4h'
TYPE_IEC_PNE6H = 'iec-60309-p-n-e-6h'
TYPE_IEC_PNE9H = 'iec-60309-p-n-e-9h'
TYPE_IEC_2PE4H = 'iec-60309-2p-e-4h'
TYPE_IEC_2PE6H = 'iec-60309-2p-e-6h'
TYPE_IEC_2PE9H = 'iec-60309-2p-e-9h'
TYPE_IEC_3PE4H = 'iec-60309-3p-e-4h'
TYPE_IEC_3PE6H = 'iec-60309-3p-e-6h'
TYPE_IEC_3PE9H = 'iec-60309-3p-e-9h'
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# NEMA non-locking
TYPE_NEMA_515R = 'nema-5-15r'
TYPE_NEMA_520R = 'nema-5-20r'
TYPE_NEMA_530R = 'nema-5-30r'
TYPE_NEMA_550R = 'nema-5-50r'
TYPE_NEMA_615R = 'nema-6-15r'
TYPE_NEMA_620R = 'nema-6-20r'
TYPE_NEMA_630R = 'nema-6-30r'
TYPE_NEMA_650R = 'nema-6-50r'
# NEMA locking
TYPE_NEMA_L515R = 'nema-l5-15r'
TYPE_NEMA_L520R = 'nema-l5-20r'
TYPE_NEMA_L530R = 'nema-l5-30r'
TYPE_NEMA_L615R = 'nema-l5-50r'
TYPE_NEMA_L620R = 'nema-l6-20r'
TYPE_NEMA_L630R = 'nema-l6-30r'
TYPE_NEMA_L650R = 'nema-l6-50r'
CHOICES = (
('IEC 60320', (
(TYPE_IEC_C5, 'C5'),
(TYPE_IEC_C7, 'C7'),
(TYPE_IEC_C13, 'C13'),
(TYPE_IEC_C15, 'C15'),
(TYPE_IEC_C19, 'C19'),
)),
('IEC 60309', (
(TYPE_IEC_PNE4H, 'P+N+E 4H'),
(TYPE_IEC_PNE6H, 'P+N+E 6H'),
(TYPE_IEC_PNE9H, 'P+N+E 9H'),
(TYPE_IEC_2PE4H, '2P+E 4H'),
(TYPE_IEC_2PE6H, '2P+E 6H'),
(TYPE_IEC_2PE9H, '2P+E 9H'),
(TYPE_IEC_3PE4H, '3P+E 4H'),
(TYPE_IEC_3PE6H, '3P+E 6H'),
(TYPE_IEC_3PE9H, '3P+E 9H'),
(TYPE_IEC_3PNE4H, '3P+N+E 4H'),
(TYPE_IEC_3PNE6H, '3P+N+E 6H'),
(TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)),
('NEMA (Non-locking)', (
(TYPE_NEMA_515R, 'NEMA 5-15R'),
(TYPE_NEMA_520R, 'NEMA 5-20R'),
(TYPE_NEMA_530R, 'NEMA 5-30R'),
(TYPE_NEMA_550R, 'NEMA 5-50R'),
(TYPE_NEMA_615R, 'NEMA 6-15R'),
(TYPE_NEMA_620R, 'NEMA 6-20R'),
(TYPE_NEMA_630R, 'NEMA 6-30R'),
(TYPE_NEMA_650R, 'NEMA 6-50R'),
)),
('NEMA (Locking)', (
(TYPE_NEMA_L515R, 'NEMA L5-15R'),
(TYPE_NEMA_L520R, 'NEMA L5-20R'),
(TYPE_NEMA_L530R, 'NEMA L5-30R'),
(TYPE_NEMA_L615R, 'NEMA L6-15R'),
(TYPE_NEMA_L620R, 'NEMA L6-20R'),
(TYPE_NEMA_L630R, 'NEMA L6-30R'),
(TYPE_NEMA_L650R, 'NEMA L6-50R'),
)),
)
#
# Interface type values
#
class InterfaceTypes:
"""
Interface.type slugs
"""
# Virtual
TYPE_VIRTUAL = 'virtual'
TYPE_LAG = 'lag'
# Ethernet
TYPE_100ME_FIXED = '100base-tx'
TYPE_1GE_FIXED = '1000base-t'
TYPE_1GE_GBIC = '1000base-x-gbic'
TYPE_1GE_SFP = '1000base-x-sfp'
TYPE_2GE_FIXED = '2.5gbase-t'
TYPE_5GE_FIXED = '5gbase-t'
TYPE_10GE_FIXED = '10gbase-t'
TYPE_10GE_CX4 = '10gbase-cx4'
TYPE_10GE_SFP_PLUS = '10gbase-x-sfpp'
TYPE_10GE_XFP = '10gbase-x-xfp'
TYPE_10GE_XENPAK = '10gbase-x-xenpak'
TYPE_10GE_X2 = '10gbase-x-x2'
TYPE_25GE_SFP28 = '25gbase-x-sfp28'
TYPE_40GE_QSFP_PLUS = '40gbase-x-qsfpp'
TYPE_50GE_QSFP28 = '50gbase-x-sfp28'
TYPE_100GE_CFP = '100gbase-x-cfp'
TYPE_100GE_CFP2 = '100gbase-x-cfp2'
TYPE_100GE_CFP4 = '100gbase-x-cfp4'
TYPE_100GE_CPAK = '100gbase-x-cpak'
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp'
# Wireless
TYPE_80211A = 'ieee802.11a'
TYPE_80211G = 'ieee802.11g'
TYPE_80211N = 'ieee802.11n'
TYPE_80211AC = 'ieee802.11ac'
TYPE_80211AD = 'ieee802.11ad'
# Cellular
TYPE_GSM = 'gsm'
TYPE_CDMA = 'cdma'
TYPE_LTE = 'lte'
# SONET
TYPE_SONET_OC3 = 'sonet-oc3'
TYPE_SONET_OC12 = 'sonet-oc12'
TYPE_SONET_OC48 = 'sonet-oc48'
TYPE_SONET_OC192 = 'sonet-oc192'
TYPE_SONET_OC768 = 'sonet-oc768'
TYPE_SONET_OC1920 = 'sonet-oc1920'
TYPE_SONET_OC3840 = 'sonet-oc3840'
# Fibrechannel
TYPE_1GFC_SFP = '1gfc-sfp'
TYPE_2GFC_SFP = '2gfc-sfp'
TYPE_4GFC_SFP = '4gfc-sfp'
TYPE_8GFC_SFP_PLUS = '8gfc-sfpp'
TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
TYPE_32GFC_SFP28 = '32gfc-sfp28'
TYPE_128GFC_QSFP28 = '128gfc-sfp28'
# InfiniBand
TYPE_INFINIBAND_SDR = 'inifiband-sdr'
TYPE_INFINIBAND_DDR = 'inifiband-ddr'
TYPE_INFINIBAND_QDR = 'inifiband-qdr'
TYPE_INFINIBAND_FDR10 = 'inifiband-fdr10'
TYPE_INFINIBAND_FDR = 'inifiband-fdr'
TYPE_INFINIBAND_EDR = 'inifiband-edr'
TYPE_INFINIBAND_HDR = 'inifiband-hdr'
TYPE_INFINIBAND_NDR = 'inifiband-ndr'
TYPE_INFINIBAND_XDR = 'inifiband-xdr'
# Serial
TYPE_T1 = 't1'
TYPE_E1 = 'e1'
TYPE_T3 = 't3'
TYPE_E3 = 'e3'
# Stacking
TYPE_STACKWISE = 'cisco-stackwise'
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
TYPE_FLEXSTACK = 'cisco-flexstack'
TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus'
TYPE_JUNIPER_VCP = 'juniper-vcp'
TYPE_SUMMITSTACK = 'extreme-summitstack'
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
TYPE_SUMMITSTACK256 = 'extreme-summitstack-256'
TYPE_SUMMITSTACK512 = 'extreme-summitstack-512'
# Other
TYPE_OTHER = 'other'
TYPE_CHOICES = (
(
'Virtual interfaces',
(
(TYPE_VIRTUAL, 'Virtual'),
(TYPE_LAG, 'Link Aggregation Group (LAG)'),
),
),
(
'Ethernet (fixed)',
(
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
(TYPE_10GE_FIXED, '10GBASE-T (10GE)'),
(TYPE_10GE_CX4, '10GBASE-CX4 (10GE)'),
)
),
(
'Ethernet (modular)',
(
(TYPE_1GE_GBIC, 'GBIC (1GE)'),
(TYPE_1GE_SFP, 'SFP (1GE)'),
(TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'),
(TYPE_10GE_XFP, 'XFP (10GE)'),
(TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
(TYPE_10GE_X2, 'X2 (10GE)'),
(TYPE_25GE_SFP28, 'SFP28 (25GE)'),
(TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'),
(TYPE_50GE_QSFP28, 'QSFP28 (50GE)'),
(TYPE_100GE_CFP, 'CFP (100GE)'),
(TYPE_100GE_CFP2, 'CFP2 (100GE)'),
(TYPE_200GE_CFP2, 'CFP2 (200GE)'),
(TYPE_100GE_CFP4, 'CFP4 (100GE)'),
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
)
),
(
'Wireless',
(
(TYPE_80211A, 'IEEE 802.11a'),
(TYPE_80211G, 'IEEE 802.11b/g'),
(TYPE_80211N, 'IEEE 802.11n'),
(TYPE_80211AC, 'IEEE 802.11ac'),
(TYPE_80211AD, 'IEEE 802.11ad'),
)
),
(
'Cellular',
(
(TYPE_GSM, 'GSM'),
(TYPE_CDMA, 'CDMA'),
(TYPE_LTE, 'LTE'),
)
),
(
'SONET',
(
(TYPE_SONET_OC3, 'OC-3/STM-1'),
(TYPE_SONET_OC12, 'OC-12/STM-4'),
(TYPE_SONET_OC48, 'OC-48/STM-16'),
(TYPE_SONET_OC192, 'OC-192/STM-64'),
(TYPE_SONET_OC768, 'OC-768/STM-256'),
(TYPE_SONET_OC1920, 'OC-1920/STM-640'),
(TYPE_SONET_OC3840, 'OC-3840/STM-1234'),
)
),
(
'FibreChannel',
(
(TYPE_1GFC_SFP, 'SFP (1GFC)'),
(TYPE_2GFC_SFP, 'SFP (2GFC)'),
(TYPE_4GFC_SFP, 'SFP (4GFC)'),
(TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'),
(TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'),
(TYPE_32GFC_SFP28, 'SFP28 (32GFC)'),
(TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'),
)
),
(
'InfiniBand',
(
(TYPE_INFINIBAND_SDR, 'SDR (2 Gbps)'),
(TYPE_INFINIBAND_DDR, 'DDR (4 Gbps)'),
(TYPE_INFINIBAND_QDR, 'QDR (8 Gbps)'),
(TYPE_INFINIBAND_FDR10, 'FDR10 (10 Gbps)'),
(TYPE_INFINIBAND_FDR, 'FDR (13.5 Gbps)'),
(TYPE_INFINIBAND_EDR, 'EDR (25 Gbps)'),
(TYPE_INFINIBAND_HDR, 'HDR (50 Gbps)'),
(TYPE_INFINIBAND_NDR, 'NDR (100 Gbps)'),
(TYPE_INFINIBAND_XDR, 'XDR (250 Gbps)'),
)
),
(
'Serial',
(
(TYPE_T1, 'T1 (1.544 Mbps)'),
(TYPE_E1, 'E1 (2.048 Mbps)'),
(TYPE_T3, 'T3 (45 Mbps)'),
(TYPE_E3, 'E3 (34 Mbps)'),
)
),
(
'Stacking',
(
(TYPE_STACKWISE, 'Cisco StackWise'),
(TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
(TYPE_FLEXSTACK, 'Cisco FlexStack'),
(TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'),
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
(TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'),
(TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'),
)
),
(
'Other',
(
(TYPE_OTHER, 'Other'),
)
),
)
@classmethod
def slug_to_integer(cls, slug):
"""
Provide backward-compatible mapping of the type slug to integer.
"""
return {
# Slug: integer
cls.TYPE_VIRTUAL: IFACE_TYPE_VIRTUAL,
cls.TYPE_LAG: IFACE_TYPE_LAG,
cls.TYPE_100ME_FIXED: IFACE_TYPE_100ME_FIXED,
cls.TYPE_1GE_FIXED: IFACE_TYPE_1GE_FIXED,
cls.TYPE_1GE_GBIC: IFACE_TYPE_1GE_GBIC,
cls.TYPE_1GE_SFP: IFACE_TYPE_1GE_SFP,
cls.TYPE_2GE_FIXED: IFACE_TYPE_2GE_FIXED,
cls.TYPE_5GE_FIXED: IFACE_TYPE_5GE_FIXED,
cls.TYPE_10GE_FIXED: IFACE_TYPE_10GE_FIXED,
cls.TYPE_10GE_CX4: IFACE_TYPE_10GE_CX4,
cls.TYPE_10GE_SFP_PLUS: IFACE_TYPE_10GE_SFP_PLUS,
cls.TYPE_10GE_XFP: IFACE_TYPE_10GE_XFP,
cls.TYPE_10GE_XENPAK: IFACE_TYPE_10GE_XENPAK,
cls.TYPE_10GE_X2: IFACE_TYPE_10GE_X2,
cls.TYPE_25GE_SFP28: IFACE_TYPE_25GE_SFP28,
cls.TYPE_40GE_QSFP_PLUS: IFACE_TYPE_40GE_QSFP_PLUS,
cls.TYPE_50GE_QSFP28: IFACE_TYPE_50GE_QSFP28,
cls.TYPE_100GE_CFP: IFACE_TYPE_100GE_CFP,
cls.TYPE_100GE_CFP2: IFACE_TYPE_100GE_CFP2,
cls.TYPE_100GE_CFP4: IFACE_TYPE_100GE_CFP4,
cls.TYPE_100GE_CPAK: IFACE_TYPE_100GE_CPAK,
cls.TYPE_100GE_QSFP28: IFACE_TYPE_100GE_QSFP28,
cls.TYPE_200GE_CFP2: IFACE_TYPE_200GE_CFP2,
cls.TYPE_200GE_QSFP56: IFACE_TYPE_200GE_QSFP56,
cls.TYPE_400GE_QSFP_DD: IFACE_TYPE_400GE_QSFP_DD,
cls.TYPE_80211A: IFACE_TYPE_80211A,
cls.TYPE_80211G: IFACE_TYPE_80211G,
cls.TYPE_80211N: IFACE_TYPE_80211N,
cls.TYPE_80211AC: IFACE_TYPE_80211AC,
cls.TYPE_80211AD: IFACE_TYPE_80211AD,
cls.TYPE_GSM: IFACE_TYPE_GSM,
cls.TYPE_CDMA: IFACE_TYPE_CDMA,
cls.TYPE_LTE: IFACE_TYPE_LTE,
cls.TYPE_SONET_OC3: IFACE_TYPE_SONET_OC3,
cls.TYPE_SONET_OC12: IFACE_TYPE_SONET_OC12,
cls.TYPE_SONET_OC48: IFACE_TYPE_SONET_OC48,
cls.TYPE_SONET_OC192: IFACE_TYPE_SONET_OC192,
cls.TYPE_SONET_OC768: IFACE_TYPE_SONET_OC768,
cls.TYPE_SONET_OC1920: IFACE_TYPE_SONET_OC1920,
cls.TYPE_SONET_OC3840: IFACE_TYPE_SONET_OC3840,
cls.TYPE_1GFC_SFP: IFACE_TYPE_1GFC_SFP,
cls.TYPE_2GFC_SFP: IFACE_TYPE_2GFC_SFP,
cls.TYPE_4GFC_SFP: IFACE_TYPE_4GFC_SFP,
cls.TYPE_8GFC_SFP_PLUS: IFACE_TYPE_8GFC_SFP_PLUS,
cls.TYPE_16GFC_SFP_PLUS: IFACE_TYPE_16GFC_SFP_PLUS,
cls.TYPE_32GFC_SFP28: IFACE_TYPE_32GFC_SFP28,
cls.TYPE_128GFC_QSFP28: IFACE_TYPE_128GFC_QSFP28,
cls.TYPE_INFINIBAND_SDR: IFACE_TYPE_INFINIBAND_SDR,
cls.TYPE_INFINIBAND_DDR: IFACE_TYPE_INFINIBAND_DDR,
cls.TYPE_INFINIBAND_QDR: IFACE_TYPE_INFINIBAND_QDR,
cls.TYPE_INFINIBAND_FDR10: IFACE_TYPE_INFINIBAND_FDR10,
cls.TYPE_INFINIBAND_FDR: IFACE_TYPE_INFINIBAND_FDR,
cls.TYPE_INFINIBAND_EDR: IFACE_TYPE_INFINIBAND_EDR,
cls.TYPE_INFINIBAND_HDR: IFACE_TYPE_INFINIBAND_HDR,
cls.TYPE_INFINIBAND_NDR: IFACE_TYPE_INFINIBAND_NDR,
cls.TYPE_INFINIBAND_XDR: IFACE_TYPE_INFINIBAND_XDR,
cls.TYPE_T1: IFACE_TYPE_T1,
cls.TYPE_E1: IFACE_TYPE_E1,
cls.TYPE_T3: IFACE_TYPE_T3,
cls.TYPE_E3: IFACE_TYPE_E3,
cls.TYPE_STACKWISE: IFACE_TYPE_STACKWISE,
cls.TYPE_STACKWISE_PLUS: IFACE_TYPE_STACKWISE_PLUS,
cls.TYPE_FLEXSTACK: IFACE_TYPE_FLEXSTACK,
cls.TYPE_FLEXSTACK_PLUS: IFACE_TYPE_FLEXSTACK_PLUS,
cls.TYPE_JUNIPER_VCP: IFACE_TYPE_JUNIPER_VCP,
cls.TYPE_SUMMITSTACK: IFACE_TYPE_SUMMITSTACK,
cls.TYPE_SUMMITSTACK128: IFACE_TYPE_SUMMITSTACK128,
cls.TYPE_SUMMITSTACK256: IFACE_TYPE_SUMMITSTACK256,
cls.TYPE_SUMMITSTACK512: IFACE_TYPE_SUMMITSTACK512,
}.get(slug)
#
# Port type values
#
class PortTypes:
"""
FrontPort/RearPort.type slugs
"""
TYPE_8P8C = '8p8c'
TYPE_110_PUNCH = '110-punch'
TYPE_BNC = 'bnc'
TYPE_ST = 'st'
TYPE_SC = 'sc'
TYPE_SC_APC = 'sc-apc'
TYPE_FC = 'fc'
TYPE_LC = 'lc'
TYPE_LC_APC = 'lc-apc'
TYPE_MTRJ = 'mtrj'
TYPE_MPO = 'mpo'
TYPE_LSH = 'lsh'
TYPE_LSH_APC = 'lsh-apc'
TYPE_CHOICES = (
(
'Copper',
(
(TYPE_8P8C, '8P8C'),
(TYPE_110_PUNCH, '110 Punch'),
(TYPE_BNC, 'BNC'),
),
),
(
'Fiber Optic',
(
(TYPE_FC, 'FC'),
(TYPE_LC, 'LC'),
(TYPE_LC_APC, 'LC/APC'),
(TYPE_LSH, 'LSH'),
(TYPE_LSH_APC, 'LSH/APC'),
(TYPE_MPO, 'MPO'),
(TYPE_MTRJ, 'MTRJ'),
(TYPE_SC, 'SC'),
(TYPE_SC_APC, 'SC/APC'),
(TYPE_ST, 'ST'),
)
)
)
@classmethod
def slug_to_integer(cls, slug):
"""
Provide backward-compatible mapping of the type slug to integer.
"""
return {
# Slug: integer
cls.TYPE_8P8C: PORT_TYPE_8P8C,
cls.TYPE_110_PUNCH: PORT_TYPE_8P8C,
cls.TYPE_BNC: PORT_TYPE_BNC,
cls.TYPE_ST: PORT_TYPE_ST,
cls.TYPE_SC: PORT_TYPE_SC,
cls.TYPE_SC_APC: PORT_TYPE_SC_APC,
cls.TYPE_FC: PORT_TYPE_FC,
cls.TYPE_LC: PORT_TYPE_LC,
cls.TYPE_LC_APC: PORT_TYPE_LC_APC,
cls.TYPE_MTRJ: PORT_TYPE_MTRJ,
cls.TYPE_MPO: PORT_TYPE_MPO,
cls.TYPE_LSH: PORT_TYPE_LSH,
cls.TYPE_LSH_APC: PORT_TYPE_LSH_APC,
}.get(slug)

View File

@ -1,4 +1,3 @@
# Rack types
RACK_TYPE_2POST = 100
RACK_TYPE_4POST = 200
@ -58,7 +57,10 @@ SUBDEVICE_ROLE_CHOICES = (
(SUBDEVICE_ROLE_CHILD, 'Child'),
)
# Interface types
#
# Numeric interface types
#
# Virtual
IFACE_TYPE_VIRTUAL = 0
IFACE_TYPE_LAG = 200
@ -114,15 +116,15 @@ IFACE_TYPE_16GFC_SFP_PLUS = 3160
IFACE_TYPE_32GFC_SFP28 = 3320
IFACE_TYPE_128GFC_QSFP28 = 3400
# InfiniBand
IFACE_FF_INFINIBAND_SDR = 7010
IFACE_FF_INFINIBAND_DDR = 7020
IFACE_FF_INFINIBAND_QDR = 7030
IFACE_FF_INFINIBAND_FDR10 = 7040
IFACE_FF_INFINIBAND_FDR = 7050
IFACE_FF_INFINIBAND_EDR = 7060
IFACE_FF_INFINIBAND_HDR = 7070
IFACE_FF_INFINIBAND_NDR = 7080
IFACE_FF_INFINIBAND_XDR = 7090
IFACE_TYPE_INFINIBAND_SDR = 7010
IFACE_TYPE_INFINIBAND_DDR = 7020
IFACE_TYPE_INFINIBAND_QDR = 7030
IFACE_TYPE_INFINIBAND_FDR10 = 7040
IFACE_TYPE_INFINIBAND_FDR = 7050
IFACE_TYPE_INFINIBAND_EDR = 7060
IFACE_TYPE_INFINIBAND_HDR = 7070
IFACE_TYPE_INFINIBAND_NDR = 7080
IFACE_TYPE_INFINIBAND_XDR = 7090
# Serial
IFACE_TYPE_T1 = 4000
IFACE_TYPE_E1 = 4010
@ -229,15 +231,15 @@ IFACE_TYPE_CHOICES = [
[
'InfiniBand',
[
[IFACE_FF_INFINIBAND_SDR, 'SDR (2 Gbps)'],
[IFACE_FF_INFINIBAND_DDR, 'DDR (4 Gbps)'],
[IFACE_FF_INFINIBAND_QDR, 'QDR (8 Gbps)'],
[IFACE_FF_INFINIBAND_FDR10, 'FDR10 (10 Gbps)'],
[IFACE_FF_INFINIBAND_FDR, 'FDR (13.5 Gbps)'],
[IFACE_FF_INFINIBAND_EDR, 'EDR (25 Gbps)'],
[IFACE_FF_INFINIBAND_HDR, 'HDR (50 Gbps)'],
[IFACE_FF_INFINIBAND_NDR, 'NDR (100 Gbps)'],
[IFACE_FF_INFINIBAND_XDR, 'XDR (250 Gbps)'],
[IFACE_TYPE_INFINIBAND_SDR, 'SDR (2 Gbps)'],
[IFACE_TYPE_INFINIBAND_DDR, 'DDR (4 Gbps)'],
[IFACE_TYPE_INFINIBAND_QDR, 'QDR (8 Gbps)'],
[IFACE_TYPE_INFINIBAND_FDR10, 'FDR10 (10 Gbps)'],
[IFACE_TYPE_INFINIBAND_FDR, 'FDR (13.5 Gbps)'],
[IFACE_TYPE_INFINIBAND_EDR, 'EDR (25 Gbps)'],
[IFACE_TYPE_INFINIBAND_HDR, 'HDR (50 Gbps)'],
[IFACE_TYPE_INFINIBAND_NDR, 'NDR (100 Gbps)'],
[IFACE_TYPE_INFINIBAND_XDR, 'XDR (250 Gbps)'],
]
],
[
@ -384,7 +386,8 @@ CONNECTION_STATUS_CHOICES = [
# Cable endpoint types
CABLE_TERMINATION_TYPES = [
'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination',
'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport',
'circuittermination',
]
# Cable types

View File

@ -11,6 +11,7 @@ from utilities.filters import (
TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster
from .choices import *
from .constants import *
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
@ -346,28 +347,28 @@ class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = ConsolePortTemplate
fields = ['id', 'name']
fields = ['id', 'name', 'type']
class ConsoleServerPortTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = ConsoleServerPortTemplate
fields = ['id', 'name']
fields = ['id', 'name', 'type']
class PowerPortTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = PowerPortTemplate
fields = ['id', 'name', 'maximum_draw', 'allocated_draw']
fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw']
class PowerOutletTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = PowerOutletTemplate
fields = ['id', 'name', 'feed_leg']
fields = ['id', 'name', 'type', 'feed_leg']
class InterfaceTemplateFilter(DeviceTypeComponentFilterSet):
@ -641,6 +642,10 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
class ConsolePortFilter(DeviceComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=ConsolePortTypes.CHOICES,
null_value=None
)
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
@ -653,6 +658,10 @@ class ConsolePortFilter(DeviceComponentFilterSet):
class ConsoleServerPortFilter(DeviceComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=ConsolePortTypes.CHOICES,
null_value=None
)
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
@ -665,6 +674,10 @@ class ConsoleServerPortFilter(DeviceComponentFilterSet):
class PowerPortFilter(DeviceComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PowerPortTypes.CHOICES,
null_value=None
)
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
@ -677,6 +690,10 @@ class PowerPortFilter(DeviceComponentFilterSet):
class PowerOutletFilter(DeviceComponentFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PowerOutletTypes.CHOICES,
null_value=None
)
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',

View File

@ -23,9 +23,10 @@ from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField,
SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
from .choices import *
from .constants import *
from .models import (
Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
@ -828,29 +829,17 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
}
class DeviceTypeCSVForm(forms.ModelForm):
class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(),
required=True,
to_field_name='name',
help_text='Manufacturer name',
error_messages={
'invalid_choice': 'Manufacturer not found.',
}
)
subdevice_role = CSVChoiceField(
choices=SUBDEVICE_ROLE_CHOICES,
required=False,
help_text='Parent/child status'
to_field_name='name'
)
class Meta:
model = DeviceType
fields = DeviceType.csv_headers
help_texts = {
'model': 'Model name',
'slug': 'URL-friendly slug',
}
fields = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
]
class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
@ -953,7 +942,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsolePortTemplate
fields = [
'device_type', 'name',
'device_type', 'name', 'type',
]
widgets = {
'device_type': forms.HiddenInput(),
@ -964,6 +953,10 @@ class ConsolePortTemplateCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField(
choices=ConsolePortTypes.CHOICES,
widget=StaticSelect2()
)
class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
@ -971,7 +964,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsoleServerPortTemplate
fields = [
'device_type', 'name',
'device_type', 'name', 'type',
]
widgets = {
'device_type': forms.HiddenInput(),
@ -982,6 +975,10 @@ class ConsoleServerPortTemplateCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypes.CHOICES),
widget=StaticSelect2()
)
class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
@ -989,7 +986,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPortTemplate
fields = [
'device_type', 'name', 'maximum_draw', 'allocated_draw',
'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw',
]
widgets = {
'device_type': forms.HiddenInput(),
@ -1000,6 +997,10 @@ class PowerPortTemplateCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypes.CHOICES),
required=False
)
maximum_draw = forms.IntegerField(
min_value=1,
required=False,
@ -1017,7 +1018,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerOutletTemplate
fields = [
'device_type', 'name', 'power_port', 'feed_leg',
'device_type', 'name', 'type', 'power_port', 'feed_leg',
]
widgets = {
'device_type': forms.HiddenInput(),
@ -1038,6 +1039,10 @@ class PowerOutletTemplateCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypes.CHOICES),
required=False
)
power_port = forms.ModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False
@ -1232,6 +1237,139 @@ class DeviceBayTemplateCreateForm(ComponentForm):
)
#
# Component template import forms
#
class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm):
def __init__(self, device_type, data=None, *args, **kwargs):
# Must pass the parent DeviceType on form initialization
data.update({
'device_type': device_type.pk,
})
super().__init__(data, *args, **kwargs)
def clean_device_type(self):
data = self.cleaned_data['device_type']
# Limit fields referencing other components to the parent DeviceType
for field_name, field in self.fields.items():
if isinstance(field, forms.ModelChoiceField) and field_name != 'device_type':
field.queryset = field.queryset.filter(device_type=data)
return data
class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = ConsolePortTemplate
fields = [
'device_type', 'name', 'type',
]
class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = ConsoleServerPortTemplate
fields = [
'device_type', 'name', 'type',
]
class PowerPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = PowerPortTemplate
fields = [
'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw',
]
class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
power_port = forms.ModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
to_field_name='name',
required=False
)
class Meta:
model = PowerOutletTemplate
fields = [
'device_type', 'name', 'type', 'power_port', 'feed_leg',
]
class InterfaceTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
choices=InterfaceTypes.TYPE_CHOICES
)
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'name', 'type', 'mgmt_only',
]
def clean_type(self):
# Convert slug value to field integer value
slug = self.cleaned_data['type']
return InterfaceTypes.slug_to_integer(slug)
class FrontPortTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
choices=PortTypes.TYPE_CHOICES
)
rear_port = forms.ModelChoiceField(
queryset=RearPortTemplate.objects.all(),
to_field_name='name',
required=False
)
class Meta:
model = FrontPortTemplate
fields = [
'device_type', 'name', 'type', 'rear_port', 'rear_port_position',
]
def clean_type(self):
# Convert slug value to field integer value
slug = self.cleaned_data['type']
return PortTypes.slug_to_integer(slug)
class RearPortTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
choices=PortTypes.TYPE_CHOICES
)
class Meta:
model = RearPortTemplate
fields = [
'device_type', 'name', 'type', 'positions',
]
def clean_type(self):
# Convert slug value to field integer value
slug = self.cleaned_data['type']
return PortTypes.slug_to_integer(slug)
class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = DeviceBayTemplate
fields = [
'device_type', 'name',
]
#
# Device roles
#
@ -1933,7 +2071,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsolePort
fields = [
'device', 'name', 'description', 'tags',
'device', 'name', 'type', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@ -1944,6 +2082,11 @@ class ConsolePortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypes.CHOICES),
required=False,
widget=StaticSelect2()
)
description = forms.CharField(
max_length=100,
required=False
@ -1980,7 +2123,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsoleServerPort
fields = [
'device', 'name', 'description', 'tags',
'device', 'name', 'type', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@ -1991,6 +2134,11 @@ class ConsoleServerPortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypes.CHOICES),
required=False,
widget=StaticSelect2()
)
description = forms.CharField(
max_length=100,
required=False
@ -2005,6 +2153,11 @@ class ConsoleServerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditF
queryset=ConsoleServerPort.objects.all(),
widget=forms.MultipleHiddenInput()
)
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypes.CHOICES),
required=False,
widget=StaticSelect2()
)
description = forms.CharField(
max_length=100,
required=False
@ -2057,7 +2210,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPort
fields = [
'device', 'name', 'maximum_draw', 'allocated_draw', 'description', 'tags',
'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@ -2068,6 +2221,11 @@ class PowerPortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypes.CHOICES),
required=False,
widget=StaticSelect2()
)
maximum_draw = forms.IntegerField(
min_value=1,
required=False,
@ -2118,7 +2276,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerOutlet
fields = [
'device', 'name', 'power_port', 'feed_leg', 'description', 'tags',
'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@ -2138,6 +2296,11 @@ class PowerOutletCreateForm(ComponentForm):
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypes.CHOICES),
required=False,
widget=StaticSelect2()
)
power_port = forms.ModelChoiceField(
queryset=PowerPort.objects.all(),
required=False
@ -2217,6 +2380,10 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
queryset=PowerOutlet.objects.all(),
widget=forms.MultipleHiddenInput()
)
type = forms.ChoiceField(
choices=PowerOutletTypes.CHOICES,
required=False
)
feed_leg = forms.ChoiceField(
choices=add_blank_choice(POWERFEED_LEG_CHOICES),
required=False,
@ -2232,7 +2399,7 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class Meta:
nullable_fields = [
'feed_leg', 'power_port', 'description',
'type', 'feed_leg', 'power_port', 'description',
]
def __init__(self, *args, **kwargs):

View File

@ -0,0 +1,33 @@
# Generated by Django 2.2.6 on 2019-10-30 17:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0075_cable_devices'),
]
operations = [
migrations.AddField(
model_name='consoleport',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='consoleporttemplate',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='consoleserverport',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='type',
field=models.CharField(blank=True, max_length=50),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 2.2.6 on 2019-11-06 19:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0076_console_port_types'),
]
operations = [
migrations.AddField(
model_name='poweroutlet',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='powerport',
name='type',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='powerporttemplate',
name='type',
field=models.CharField(blank=True, max_length=50),
),
]

View File

@ -20,6 +20,7 @@ from utilities.fields import ColorField
from utilities.managers import NaturalOrderingManager
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object, to_meters
from .choices import *
from .constants import *
from .exceptions import LoopDetected
from .fields import ASNField, MACAddressField
@ -1014,6 +1015,11 @@ class ConsolePortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypes.CHOICES,
blank=True
)
objects = NaturalOrderingManager()
@ -1027,7 +1033,8 @@ class ConsolePortTemplate(ComponentTemplateModel):
def instantiate(self, device):
return ConsolePort(
device=device,
name=self.name
name=self.name,
type=self.type
)
@ -1043,6 +1050,11 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypes.CHOICES,
blank=True
)
objects = NaturalOrderingManager()
@ -1056,7 +1068,8 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
def instantiate(self, device):
return ConsoleServerPort(
device=device,
name=self.name
name=self.name,
type=self.type
)
@ -1072,6 +1085,11 @@ class PowerPortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
type = models.CharField(
max_length=50,
choices=PowerPortTypes.CHOICES,
blank=True
)
maximum_draw = models.PositiveSmallIntegerField(
blank=True,
null=True,
@ -1115,6 +1133,11 @@ class PowerOutletTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
type = models.CharField(
max_length=50,
choices=PowerOutletTypes.CHOICES,
blank=True
)
power_port = models.ForeignKey(
to='dcim.PowerPortTemplate',
on_delete=models.SET_NULL,
@ -1189,22 +1212,6 @@ class InterfaceTemplate(ComponentTemplateModel):
def __str__(self):
return self.name
# TODO: Remove in v2.7
@property
def form_factor(self):
"""
Backward-compatibility for form_factor
"""
return self.type
# TODO: Remove in v2.7
@form_factor.setter
def form_factor(self, value):
"""
Backward-compatibility for form_factor
"""
self.type = value
def instantiate(self, device):
return Interface(
device=device,
@ -1862,6 +1869,11 @@ class ConsolePort(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypes.CHOICES,
blank=True
)
connected_endpoint = models.OneToOneField(
to='dcim.ConsoleServerPort',
on_delete=models.SET_NULL,
@ -1877,7 +1889,7 @@ class ConsolePort(CableTermination, ComponentModel):
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'description']
csv_headers = ['device', 'name', 'type', 'description']
class Meta:
ordering = ['device', 'name']
@ -1893,6 +1905,7 @@ class ConsolePort(CableTermination, ComponentModel):
return (
self.device.identifier,
self.name,
self.type,
self.description,
)
@ -1913,6 +1926,11 @@ class ConsoleServerPort(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypes.CHOICES,
blank=True
)
connection_status = models.NullBooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True
@ -1921,7 +1939,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'description']
csv_headers = ['device', 'name', 'type', 'description']
class Meta:
unique_together = ['device', 'name']
@ -1936,6 +1954,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
return (
self.device.identifier,
self.name,
self.type,
self.description,
)
@ -1956,6 +1975,11 @@ class PowerPort(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
type = models.CharField(
max_length=50,
choices=PowerPortTypes.CHOICES,
blank=True
)
maximum_draw = models.PositiveSmallIntegerField(
blank=True,
null=True,
@ -1990,7 +2014,7 @@ class PowerPort(CableTermination, ComponentModel):
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'maximum_draw', 'allocated_draw', 'description']
csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
class Meta:
ordering = ['device', 'name']
@ -2006,6 +2030,7 @@ class PowerPort(CableTermination, ComponentModel):
return (
self.device.identifier,
self.name,
self.get_type_display(),
self.maximum_draw,
self.allocated_draw,
self.description,
@ -2093,6 +2118,11 @@ class PowerOutlet(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
type = models.CharField(
max_length=50,
choices=PowerOutletTypes.CHOICES,
blank=True
)
power_port = models.ForeignKey(
to='dcim.PowerPort',
on_delete=models.SET_NULL,
@ -2114,7 +2144,7 @@ class PowerOutlet(CableTermination, ComponentModel):
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'power_port', 'feed_leg', 'description']
csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description']
class Meta:
unique_together = ['device', 'name']
@ -2129,6 +2159,7 @@ class PowerOutlet(CableTermination, ComponentModel):
return (
self.device.identifier,
self.name,
self.get_type_display(),
self.power_port.name if self.power_port else None,
self.get_feed_leg_display(),
self.description,
@ -2350,22 +2381,6 @@ class Interface(CableTermination, ComponentModel):
object_data=serialize_object(self)
)
# TODO: Remove in v2.7
@property
def form_factor(self):
"""
Backward-compatibility for form_factor
"""
return self.type
# TODO: Remove in v2.7
@form_factor.setter
def form_factor(self, value):
"""
Backward-compatibility for form_factor
"""
self.type = value
@property
def connected_endpoint(self):
if self._connected_interface:

View File

@ -422,7 +422,7 @@ class ConsolePortTemplateTable(BaseTable):
class Meta(BaseTable.Meta):
model = ConsolePortTemplate
fields = ('pk', 'name', 'actions')
fields = ('pk', 'name', 'type', 'actions')
empty_text = "None"
@ -468,7 +468,7 @@ class PowerPortTemplateTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerPortTemplate
fields = ('pk', 'name', 'maximum_draw', 'allocated_draw', 'actions')
fields = ('pk', 'name', 'type', 'maximum_draw', 'allocated_draw', 'actions')
empty_text = "None"
@ -491,7 +491,7 @@ class PowerOutletTemplateTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerOutletTemplate
fields = ('pk', 'name', 'power_port', 'feed_leg', 'actions')
fields = ('pk', 'name', 'type', 'power_port', 'feed_leg', 'actions')
empty_text = "None"
@ -711,7 +711,7 @@ class ConsolePortTable(BaseTable):
class Meta(BaseTable.Meta):
model = ConsolePort
fields = ('name',)
fields = ('name', 'type')
class ConsoleServerPortTable(BaseTable):
@ -725,14 +725,14 @@ class PowerPortTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerPort
fields = ('name',)
fields = ('name', 'type')
class PowerOutletTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerOutlet
fields = ('name', 'description')
fields = ('name', 'type', 'description')
class InterfaceTable(BaseTable):

View File

@ -3,10 +3,12 @@ import urllib.parse
from django.test import Client, TestCase
from django.urls import reverse
from dcim.constants import CABLE_TYPE_CAT6, IFACE_TYPE_1GE_FIXED
from dcim.choices import *
from dcim.constants import *
from dcim.models import (
Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup,
RackReservation, RackRole, Site, Region, VirtualChassis,
Cable, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
FrontPortTemplate, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerPortTemplate,
PowerOutletTemplate, Rack, RackGroup, RackReservation, RackRole, RearPortTemplate, Site, Region, VirtualChassis,
)
from utilities.testing import create_test_user
@ -221,6 +223,148 @@ class DeviceTypeTestCase(TestCase):
response = self.client.get(devicetype.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_devicetype_import(self):
IMPORT_DATA = """
manufacturer: Generic
model: TEST-1000
slug: test-1000
u_height: 2
console-ports:
- name: Console Port 1
type: de-9
- name: Console Port 2
type: de-9
- name: Console Port 3
type: de-9
console-server-ports:
- name: Console Server Port 1
type: rj-45
- name: Console Server Port 2
type: rj-45
- name: Console Server Port 3
type: rj-45
power-ports:
- name: Power Port 1
type: iec-60320-c14
- name: Power Port 2
type: iec-60320-c14
- name: Power Port 3
type: iec-60320-c14
power-outlets:
- name: Power Outlet 1
type: iec-60320-c13
power_port: Power Port 1
feed_leg: 1
- name: Power Outlet 2
type: iec-60320-c13
power_port: Power Port 1
feed_leg: 1
- name: Power Outlet 3
type: iec-60320-c13
power_port: Power Port 1
feed_leg: 1
interfaces:
- name: Interface 1
type: 1000base-t
mgmt_only: true
- name: Interface 2
type: 1000base-t
- name: Interface 3
type: 1000base-t
rear-ports:
- name: Rear Port 1
type: 8p8c
- name: Rear Port 2
type: 8p8c
- name: Rear Port 3
type: 8p8c
front-ports:
- name: Front Port 1
type: 8p8c
rear_port: Rear Port 1
- name: Front Port 2
type: 8p8c
rear_port: Rear Port 2
- name: Front Port 3
type: 8p8c
rear_port: Rear Port 3
device-bays:
- name: Device Bay 1
- name: Device Bay 2
- name: Device Bay 3
"""
# Create the manufacturer
Manufacturer(name='Generic', slug='generic').save()
# Authenticate as user with necessary permissions
user = create_test_user(username='testuser2', permissions=[
'dcim.view_devicetype',
'dcim.add_devicetype',
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_devicebaytemplate',
])
self.client.force_login(user)
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('dcim:devicetype_import'), data=form_data, follow=True)
self.assertEqual(response.status_code, 200)
dt = DeviceType.objects.get(model='TEST-1000')
# Verify all of the components were created
self.assertEqual(dt.consoleport_templates.count(), 3)
cp1 = ConsolePortTemplate.objects.first()
self.assertEqual(cp1.name, 'Console Port 1')
self.assertEqual(cp1.type, ConsolePortTypes.TYPE_DE9)
self.assertEqual(dt.consoleserverport_templates.count(), 3)
csp1 = ConsoleServerPortTemplate.objects.first()
self.assertEqual(csp1.name, 'Console Server Port 1')
self.assertEqual(csp1.type, ConsolePortTypes.TYPE_RJ45)
self.assertEqual(dt.powerport_templates.count(), 3)
pp1 = PowerPortTemplate.objects.first()
self.assertEqual(pp1.name, 'Power Port 1')
self.assertEqual(pp1.type, PowerPortTypes.TYPE_IEC_C14)
self.assertEqual(dt.poweroutlet_templates.count(), 3)
po1 = PowerOutletTemplate.objects.first()
self.assertEqual(po1.name, 'Power Outlet 1')
self.assertEqual(po1.type, PowerOutletTypes.TYPE_IEC_C13)
self.assertEqual(po1.power_port, pp1)
self.assertEqual(po1.feed_leg, POWERFEED_LEG_A)
self.assertEqual(dt.interface_templates.count(), 3)
iface1 = InterfaceTemplate.objects.first()
self.assertEqual(iface1.name, 'Interface 1')
self.assertEqual(iface1.type, IFACE_TYPE_1GE_FIXED)
self.assertTrue(iface1.mgmt_only)
self.assertEqual(dt.rearport_templates.count(), 3)
rp1 = RearPortTemplate.objects.first()
self.assertEqual(rp1.name, 'Rear Port 1')
self.assertEqual(dt.frontport_templates.count(), 3)
fp1 = FrontPortTemplate.objects.first()
self.assertEqual(fp1.name, 'Front Port 1')
self.assertEqual(fp1.rear_port, rp1)
self.assertEqual(fp1.rear_port_position, 1)
self.assertEqual(dt.device_bay_templates.count(), 3)
db1 = DeviceBayTemplate.objects.first()
self.assertEqual(db1.name, 'Device Bay 1')
class DeviceRoleTestCase(TestCase):

View File

@ -82,7 +82,7 @@ urlpatterns = [
# Device types
path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
path(r'device-types/import/', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'),
path(r'device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
path(r'device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),

View File

@ -1,3 +1,4 @@
from collections import OrderedDict
import re
from django.conf import settings
@ -17,7 +18,7 @@ from django.views.generic import View
from circuits.models import Circuit
from extras.constants import GRAPH_TYPE_DEVICE, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from extras.models import Graph, TopologyMap
from extras.models import Graph
from extras.views import ObjectConfigContextView
from ipam.models import Prefix, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
@ -26,7 +27,7 @@ from utilities.paginator import EnhancedPaginator
from utilities.utils import csv_format
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
ObjectDeleteView, ObjectEditView, ObjectListView,
ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
@ -208,14 +209,12 @@ class SiteView(PermissionRequiredMixin, View):
'vm_count': VirtualMachine.objects.filter(cluster__site=site).count(),
}
rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
topology_maps = TopologyMap.objects.filter(site=site)
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_SITE).exists()
return render(request, 'dcim/site.html', {
'site': site,
'stats': stats,
'rack_groups': rack_groups,
'topology_maps': topology_maps,
'show_graphs': show_graphs,
})
@ -659,11 +658,31 @@ class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
default_return_url = 'dcim:devicetype_list'
class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_devicetype'
model_form = forms.DeviceTypeCSVForm
table = tables.DeviceTypeTable
default_return_url = 'dcim:devicetype_list'
class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView):
permission_required = [
'dcim.add_devicetype',
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_devicebaytemplate',
]
model = DeviceType
model_form = forms.DeviceTypeImportForm
related_object_forms = OrderedDict((
('console-ports', forms.ConsolePortTemplateImportForm),
('console-server-ports', forms.ConsoleServerPortTemplateImportForm),
('power-ports', forms.PowerPortTemplateImportForm),
('power-outlets', forms.PowerOutletTemplateImportForm),
('interfaces', forms.InterfaceTemplateImportForm),
('rear-ports', forms.RearPortTemplateImportForm),
('front-ports', forms.FrontPortTemplateImportForm),
('device-bays', forms.DeviceBayTemplateImportForm),
))
default_return_url = 'dcim:devicetype_import'
class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):

View File

@ -3,7 +3,7 @@ from django.contrib import admin
from netbox.admin import admin_site
from utilities.forms import LaxURLField
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, TopologyMap, Webhook
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, Webhook
def order_content_types(field):
@ -164,15 +164,3 @@ class ExportTemplateAdmin(admin.ModelAdmin):
'content_type',
]
form = ExportTemplateForm
#
# Topology maps
#
@admin.register(TopologyMap, site=admin_site)
class TopologyMapAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'site']
prepopulated_fields = {
'slug': ['name'],
}

View File

@ -10,8 +10,7 @@ from dcim.api.nested_serializers import (
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
from extras.constants import *
from extras.models import (
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
Tag
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
)
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup
@ -69,18 +68,6 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
]
#
# Topology maps
#
class TopologyMapSerializer(ValidatedModelSerializer):
site = NestedSiteSerializer()
class Meta:
model = TopologyMap
fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
#
# Tags
#
@ -213,6 +200,52 @@ class ReportDetailSerializer(ReportSerializer):
result = ReportResultSerializer()
#
# Scripts
#
class ScriptSerializer(serializers.Serializer):
id = serializers.SerializerMethodField(read_only=True)
name = serializers.SerializerMethodField(read_only=True)
description = serializers.SerializerMethodField(read_only=True)
vars = serializers.SerializerMethodField(read_only=True)
def get_id(self, instance):
return '{}.{}'.format(instance.__module__, instance.__name__)
def get_name(self, instance):
return getattr(instance.Meta, 'name', instance.__name__)
def get_description(self, instance):
return getattr(instance.Meta, 'description', '')
def get_vars(self, instance):
return {
k: v.__class__.__name__ for k, v in instance._get_vars().items()
}
class ScriptInputSerializer(serializers.Serializer):
data = serializers.JSONField()
commit = serializers.BooleanField()
class ScriptLogMessageSerializer(serializers.Serializer):
status = serializers.SerializerMethodField(read_only=True)
message = serializers.SerializerMethodField(read_only=True)
def get_status(self, instance):
return LOG_LEVEL_CODES.get(instance[0])
def get_message(self, instance):
return instance[1]
class ScriptOutputSerializer(serializers.Serializer):
log = ScriptLogMessageSerializer(many=True, read_only=True)
output = serializers.CharField(read_only=True)
#
# Change logging
#

View File

@ -26,9 +26,6 @@ router.register(r'graphs', views.GraphViewSet)
# Export templates
router.register(r'export-templates', views.ExportTemplateViewSet)
# Topology maps
router.register(r'topology-maps', views.TopologyMapViewSet)
# Tags
router.register(r'tags', views.TagViewSet)
@ -41,6 +38,9 @@ router.register(r'config-contexts', views.ConfigContextViewSet)
# Reports
router.register(r'reports', views.ReportViewSet, basename='report')
# Scripts
router.register(r'scripts', views.ScriptViewSet, basename='script')
# Change logging
router.register(r'object-changes', views.ObjectChangeViewSet)

View File

@ -2,8 +2,8 @@ from collections import OrderedDict
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.http import Http404
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
@ -11,10 +11,10 @@ from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from extras import filters
from extras.models import (
ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
Tag,
ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
)
from extras.reports import get_report, get_reports
from extras.scripts import get_script, get_scripts
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
from . import serializers
@ -115,34 +115,6 @@ class ExportTemplateViewSet(ModelViewSet):
filterset_class = filters.ExportTemplateFilter
#
# Topology maps
#
class TopologyMapViewSet(ModelViewSet):
queryset = TopologyMap.objects.prefetch_related('site')
serializer_class = serializers.TopologyMapSerializer
filterset_class = filters.TopologyMapFilter
@action(detail=True)
def render(self, request, pk):
tmap = get_object_or_404(TopologyMap, pk=pk)
img_format = 'png'
try:
data = tmap.render(img_format=img_format)
except Exception as e:
return HttpResponse(
"There was an error generating the requested graph: %s" % e
)
response = HttpResponse(data, content_type='image/{}'.format(img_format))
response['Content-Disposition'] = 'inline; filename="{}.{}"'.format(tmap.slug, img_format)
return response
#
# Tags
#
@ -252,6 +224,56 @@ class ReportViewSet(ViewSet):
return Response(serializer.data)
#
# Scripts
#
class ScriptViewSet(ViewSet):
permission_classes = [IsAuthenticatedOrLoginNotRequired]
_ignore_model_permissions = True
exclude_from_schema = True
lookup_value_regex = '[^/]+' # Allow dots
def _get_script(self, pk):
module_name, script_name = pk.split('.')
script = get_script(module_name, script_name)
if script is None:
raise Http404
return script
def list(self, request):
flat_list = []
for script_list in get_scripts().values():
flat_list.extend(script_list.values())
serializer = serializers.ScriptSerializer(flat_list, many=True, context={'request': request})
return Response(serializer.data)
def retrieve(self, request, pk):
script = self._get_script(pk)
serializer = serializers.ScriptSerializer(script, context={'request': request})
return Response(serializer.data)
def post(self, request, pk):
"""
Run a Script identified as "<module>.<script>".
"""
script = self._get_script(pk)()
input_serializer = serializers.ScriptInputSerializer(data=request.data)
if input_serializer.is_valid():
output = script.run(input_serializer.data['data'])
script.output = output
output_serializer = serializers.ScriptOutputSerializer(script)
return Response(output_serializer.data)
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
#
# Change logging
#

View File

@ -21,11 +21,11 @@ class ExtrasConfig(AppConfig):
)
try:
rs = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DATABASE,
password=settings.REDIS_PASSWORD or None,
ssl=settings.REDIS_SSL,
host=settings.WEBHOOKS_REDIS_HOST,
port=settings.WEBHOOKS_REDIS_PORT,
db=settings.WEBHOOKS_REDIS_DATABASE,
password=settings.WEBHOOKS_REDIS_PASSWORD or None,
ssl=settings.WEBHOOKS_REDIS_SSL,
)
rs.ping()
except redis.exceptions.ConnectionError:

View File

@ -136,16 +136,6 @@ TEMPLATE_LANGUAGE_CHOICES = (
(TEMPLATE_LANGUAGE_JINJA2, 'Jinja2'),
)
# Topology map types
TOPOLOGYMAP_TYPE_NETWORK = 1
TOPOLOGYMAP_TYPE_CONSOLE = 2
TOPOLOGYMAP_TYPE_POWER = 3
TOPOLOGYMAP_TYPE_CHOICES = (
(TOPOLOGYMAP_TYPE_NETWORK, 'Network'),
(TOPOLOGYMAP_TYPE_CONSOLE, 'Console'),
(TOPOLOGYMAP_TYPE_POWER, 'Power'),
)
# Change log actions
OBJECTCHANGE_ACTION_CREATE = 1
OBJECTCHANGE_ACTION_UPDATE = 2

View File

@ -5,7 +5,7 @@ from django.db.models import Q
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from .constants import *
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag, TopologyMap
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
class CustomFieldFilter(django_filters.Filter):
@ -103,24 +103,6 @@ class TagFilter(django_filters.FilterSet):
)
class TopologyMapFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='site',
queryset=Site.objects.all(),
label='Site',
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
class Meta:
model = TopologyMap
fields = ['name', 'slug']
class ConfigContextFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',

View File

@ -0,0 +1,16 @@
# Generated by Django 2.2 on 2019-08-09 01:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0027_webhook_additional_headers'),
]
operations = [
migrations.DeleteModel(
name='TopologyMap',
),
]

View File

@ -1,23 +1,20 @@
from collections import OrderedDict
from datetime import date
import graphviz
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.core.validators import ValidationError
from django.db import models
from django.db.models import F, Q
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
from jinja2 import Environment
from taggit.models import TagBase, GenericTaggedItemBase
from dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.fields import ColorField
from utilities.utils import deepmerge, foreground_color, model_names_to_filter_dict
from utilities.utils import deepmerge, model_names_to_filter_dict
from .constants import *
from .querysets import ConfigContextQuerySet
@ -531,154 +528,6 @@ class ExportTemplate(models.Model):
return response
#
# Topology maps
#
class TopologyMap(models.Model):
name = models.CharField(
max_length=50,
unique=True
)
slug = models.SlugField(
unique=True
)
type = models.PositiveSmallIntegerField(
choices=TOPOLOGYMAP_TYPE_CHOICES,
default=TOPOLOGYMAP_TYPE_NETWORK
)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
related_name='topology_maps',
blank=True,
null=True
)
device_patterns = models.TextField(
help_text='Identify devices to include in the diagram using regular '
'expressions, one per line. Each line will result in a new '
'tier of the drawing. Separate multiple regexes within a '
'line using semicolons. Devices will be rendered in the '
'order they are defined.'
)
description = models.CharField(
max_length=100,
blank=True
)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
@property
def device_sets(self):
if not self.device_patterns:
return None
return [line.strip() for line in self.device_patterns.split('\n')]
def render(self, img_format='png'):
from dcim.models import Device
# Construct the graph
if self.type == TOPOLOGYMAP_TYPE_NETWORK:
G = graphviz.Graph
else:
G = graphviz.Digraph
self.graph = G()
self.graph.graph_attr['ranksep'] = '1'
seen = set()
for i, device_set in enumerate(self.device_sets):
subgraph = G(name='sg{}'.format(i))
subgraph.graph_attr['rank'] = 'same'
subgraph.graph_attr['directed'] = 'true'
# Add a pseudonode for each device_set to enforce hierarchical layout
subgraph.node('set{}'.format(i), label='', shape='none', width='0')
if i:
self.graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
# Add each device to the graph
devices = []
for query in device_set.strip(';').split(';'): # Split regexes on semicolons
devices += Device.objects.filter(name__regex=query).prefetch_related('device_role')
# Remove duplicate devices
devices = [d for d in devices if d.id not in seen]
seen.update([d.id for d in devices])
for d in devices:
bg_color = '#{}'.format(d.device_role.color)
fg_color = '#{}'.format(foreground_color(d.device_role.color))
subgraph.node(d.name, style='filled', fillcolor=bg_color, fontcolor=fg_color, fontname='sans')
# Add an invisible connection to each successive device in a set to enforce horizontal order
for j in range(0, len(devices) - 1):
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
self.graph.subgraph(subgraph)
# Compile list of all devices
device_superset = Q()
for device_set in self.device_sets:
for query in device_set.split(';'): # Split regexes on semicolons
device_superset = device_superset | Q(name__regex=query)
devices = Device.objects.filter(*(device_superset,))
# Draw edges depending on graph type
if self.type == TOPOLOGYMAP_TYPE_NETWORK:
self.add_network_connections(devices)
elif self.type == TOPOLOGYMAP_TYPE_CONSOLE:
self.add_console_connections(devices)
elif self.type == TOPOLOGYMAP_TYPE_POWER:
self.add_power_connections(devices)
return self.graph.pipe(format=img_format)
def add_network_connections(self, devices):
from circuits.models import CircuitTermination
from dcim.models import Interface
# Add all interface connections to the graph
connected_interfaces = Interface.objects.prefetch_related(
'_connected_interface__device'
).filter(
Q(device__in=devices) | Q(_connected_interface__device__in=devices),
_connected_interface__isnull=False,
pk__lt=F('_connected_interface')
)
for interface in connected_interfaces:
style = 'solid' if interface.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(interface.device.name, interface.connected_endpoint.device.name, style=style)
# Add all circuits to the graph
for termination in CircuitTermination.objects.filter(term_side='A', connected_endpoint__device__in=devices):
peer_termination = termination.get_peer_termination()
if (peer_termination is not None and peer_termination.interface is not None and
peer_termination.interface.device in devices):
self.graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
def add_console_connections(self, devices):
from dcim.models import ConsolePort
# Add all console connections to the graph
for cp in ConsolePort.objects.filter(device__in=devices, connected_endpoint__device__in=devices):
style = 'solid' if cp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(cp.connected_endpoint.device.name, cp.device.name, style=style)
def add_power_connections(self, devices):
from dcim.models import PowerPort
# Add all power connections to the graph
for pp in PowerPort.objects.filter(device__in=devices, _connected_poweroutlet__device__in=devices):
style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(pp.connected_endpoint.device.name, pp.device.name, style=style)
#
# Image attachments
#

View File

@ -242,16 +242,21 @@ class BaseScript:
def __str__(self):
return getattr(self.Meta, 'name', self.__class__.__name__)
def _get_vars(self):
@classmethod
def module(cls):
return cls.__module__
@classmethod
def _get_vars(cls):
vars = OrderedDict()
# Infer order from Meta.field_order (Python 3.5 and lower)
field_order = getattr(self.Meta, 'field_order', [])
field_order = getattr(cls.Meta, 'field_order', [])
for name in field_order:
vars[name] = getattr(self, name)
vars[name] = getattr(cls, name)
# Default to order of declaration on class
for name, attr in self.__class__.__dict__.items():
for name, attr in cls.__dict__.items():
if name not in vars and issubclass(attr.__class__, ScriptVariable):
vars[name] = attr
@ -382,14 +387,18 @@ def run_script(script, data, files, commit=True):
return output, execution_time
def get_scripts():
def get_scripts(use_names=False):
"""
Return a dict of dicts mapping all scripts to their modules. Set use_names to True to use each module's human-
defined name in place of the actual module name.
"""
scripts = OrderedDict()
# Iterate through all modules within the reports path. These are the user-created files in which reports are
# defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
module = importer.find_module(module_name).load_module(module_name)
if hasattr(module, 'name'):
if use_names and hasattr(module, 'name'):
module_name = module.name
module_scripts = OrderedDict()
for name, cls in inspect.getmembers(module, is_script):
@ -397,3 +406,13 @@ def get_scripts():
scripts[module_name] = module_scripts
return scripts
def get_script(module_name, script_name):
"""
Retrieve a script class by module and name. Returns None if the script does not exist.
"""
scripts = get_scripts()
module = scripts.get(module_name)
if module:
return module.get(script_name)

View File

@ -3,8 +3,10 @@ from django.urls import reverse
from rest_framework import status
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
from extras.api.views import ScriptViewSet
from extras.constants import GRAPH_TYPE_SITE
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from tenancy.models import Tenant, TenantGroup
from utilities.testing import APITestCase
@ -520,3 +522,68 @@ class ConfigContextTest(APITestCase):
configcontext6.sites.add(site2)
rendered_context = device.get_config_context()
self.assertEqual(rendered_context['bar'], 456)
class ScriptTest(APITestCase):
class TestScript(Script):
class Meta:
name = "Test script"
var1 = StringVar()
var2 = IntegerVar()
var3 = BooleanVar()
def run(self, data):
self.log_info(data['var1'])
self.log_success(data['var2'])
self.log_failure(data['var3'])
return 'Script complete'
def get_test_script(self, *args):
return self.TestScript
def setUp(self):
super().setUp()
# Monkey-patch the API viewset's _get_script method to return our test script above
ScriptViewSet._get_script = self.get_test_script
def test_get_script(self):
url = reverse('extras-api:script-detail', kwargs={'pk': None})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.TestScript.Meta.name)
self.assertEqual(response.data['vars']['var1'], 'StringVar')
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
def test_run_script(self):
script_data = {
'var1': 'FooBar',
'var2': 123,
'var3': False,
}
data = {
'data': script_data,
'commit': True,
}
url = reverse('extras-api:script-detail', kwargs={'pk': None})
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['log'][0]['status'], 'info')
self.assertEqual(response.data['log'][0]['message'], script_data['var1'])
self.assertEqual(response.data['log'][1]['status'], 'success')
self.assertEqual(response.data['log'][1]['message'], script_data['var2'])
self.assertEqual(response.data['log'][2]['status'], 'failure')
self.assertEqual(response.data['log'][2]['message'], script_data['var3'])
self.assertEqual(response.data['output'], 'Script complete')

View File

@ -375,7 +375,7 @@ class ScriptListView(PermissionRequiredMixin, View):
def get(self, request):
return render(request, 'extras/script_list.html', {
'scripts': get_scripts(),
'scripts': get_scripts(use_names=True),
})

View File

@ -27,14 +27,25 @@ DATABASE = {
SECRET_KEY = ''
# Redis database settings. The Redis database is used for caching and background processing such as webhooks
# Seperate sections for webhooks and caching allow for connecting to seperate Redis instances/datbases if desired.
# Full connection details are required in both sections, even if they are the same.
REDIS = {
'webhooks': {
'HOST': 'redis.example.com',
'PORT': 1234,
'PASSWORD': 'foobar',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
},
'caching': {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 0,
'CACHE_DATABASE': 1,
'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
}

View File

@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup
#
VERSION = '2.6.8-dev'
VERSION = '2.7-beta1'
# Hostname
HOSTNAME = platform.node()
@ -118,13 +118,30 @@ DATABASES = {
# Redis
#
REDIS_HOST = REDIS.get('HOST', 'localhost')
REDIS_PORT = REDIS.get('PORT', 6379)
REDIS_PASSWORD = REDIS.get('PASSWORD', '')
REDIS_DATABASE = REDIS.get('DATABASE', 0)
REDIS_CACHE_DATABASE = REDIS.get('CACHE_DATABASE', 1)
REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300)
REDIS_SSL = REDIS.get('SSL', False)
if 'webhooks' not in REDIS:
raise ImproperlyConfigured(
"REDIS section in configuration.py is missing webhooks subsection."
)
if 'caching' not in REDIS:
raise ImproperlyConfigured(
"REDIS section in configuration.py is missing caching subsection."
)
WEBHOOKS_REDIS = REDIS.get('webhooks', {})
WEBHOOKS_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost')
WEBHOOKS_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379)
WEBHOOKS_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '')
WEBHOOKS_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0)
WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300)
WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False)
CACHING_REDIS = REDIS.get('caching', {})
CACHING_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost')
CACHING_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379)
CACHING_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '')
CACHING_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0)
CACHING_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300)
CACHING_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False)
#
@ -341,15 +358,20 @@ if LDAP_CONFIG is not None:
# Caching
#
if REDIS_SSL:
if CACHING_REDIS_SSL:
REDIS_CACHE_CON_STRING = 'rediss://'
else:
REDIS_CACHE_CON_STRING = 'redis://'
if REDIS_PASSWORD:
REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, REDIS_PASSWORD)
if CACHING_REDIS_PASSWORD:
REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD)
REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(REDIS_CACHE_CON_STRING, REDIS_HOST, REDIS_PORT, REDIS_CACHE_DATABASE)
REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(
REDIS_CACHE_CON_STRING,
CACHING_REDIS_HOST,
CACHING_REDIS_PORT,
CACHING_REDIS_DATABASE
)
if not CACHE_TIMEOUT:
CACHEOPS_ENABLED = False
@ -468,12 +490,12 @@ SWAGGER_SETTINGS = {
RQ_QUEUES = {
'default': {
'HOST': REDIS_HOST,
'PORT': REDIS_PORT,
'DB': REDIS_DATABASE,
'PASSWORD': REDIS_PASSWORD,
'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT,
'SSL': REDIS_SSL,
'HOST': WEBHOOKS_REDIS_HOST,
'PORT': WEBHOOKS_REDIS_PORT,
'DB': WEBHOOKS_REDIS_DATABASE,
'PASSWORD': WEBHOOKS_REDIS_PASSWORD,
'DEFAULT_TIMEOUT': WEBHOOKS_REDIS_DEFAULT_TIMEOUT,
'SSL': WEBHOOKS_REDIS_SSL,
}
}

View File

@ -21,7 +21,7 @@ from dcim.tables import (
CableTable, DeviceDetailTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable,
VirtualChassisTable,
)
from extras.models import ObjectChange, ReportResult, TopologyMap
from extras.models import ObjectChange, ReportResult
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
@ -245,7 +245,6 @@ class HomeView(View):
return render(request, self.template_name, {
'search_form': SearchForm(),
'stats': stats,
'topology_maps': TopologyMap.objects.filter(site__isnull=True),
'report_results': ReportResult.objects.order_by('-created')[:10],
'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:50]
})

View File

@ -628,6 +628,7 @@
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>Type</th>
<th>Description</th>
<th>Cable</th>
<th colspan="2">Connection</th>
@ -687,6 +688,7 @@
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>Type</th>
<th>Input/Leg</th>
<th>Description</th>
<th>Cable</th>

View File

@ -1,4 +1,4 @@
{% extends 'utilities/obj_import.html' %}
{% extends 'utilities/obj_bulk_import.html' %}
{% block tabs %}
{% include 'dcim/inc/device_import_header.html' %}

View File

@ -1,4 +1,4 @@
{% extends 'utilities/obj_import.html' %}
{% extends 'utilities/obj_bulk_import.html' %}
{% block tabs %}
{% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %}

View File

@ -4,6 +4,12 @@
<td>
<i class="fa fa-fw fa-keyboard-o"></i> {{ cp }}
</td>
{# Type #}
<td>
{% if cp.type %}{{ cp.get_type_display }}{% else %}&mdash;{% endif %}
</td>
<td></td>
{# Description #}

View File

@ -14,6 +14,11 @@
<i class="fa fa-fw fa-keyboard-o"></i> {{ csp }}
</td>
{# Type #}
<td>
{% if csp.type %}{{ csp.get_type_display }}{% else %}&mdash;{% endif %}
</td>
{# Description #}
<td>
{{ csp.description|placeholder }}

View File

@ -14,6 +14,11 @@
<i class="fa fa-fw fa-bolt"></i> {{ po }}
</td>
{# Type #}
<td>
{{ po.get_type_display }}
</td>
{# Input/leg #}
<td>
{% if po.power_port %}

View File

@ -5,6 +5,11 @@
<i class="fa fa-fw fa-bolt"></i> {{ pp }}
</td>
{# Type #}
<td>
{{ pp.get_type_display }}
</td>
{# Current draw #}
<td>
{% if pp.allocated_draw %}

View File

@ -285,25 +285,6 @@
</div>
{% endif %}
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Topology Maps</strong>
</div>
{% if topology_maps %}
<table class="table table-hover panel-body">
{% for tm in topology_maps %}
<tr>
<td><i class="fa fa-fw fa-map-o"></i> <a href="{% url 'extras-api:topologymap-render' pk=tm.pk %}" target="_blank">{{ tm }}</a></td>
<td>{{ tm.description }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="panel-body text-muted">
None
</div>
{% endif %}
</div>
</div>
</div>
{% include 'inc/modal.html' with modal_name='graphs' %}

View File

@ -19,7 +19,7 @@
{% for class_name, script in module_scripts.items %}
<tr>
<td>
<a href="{% url 'extras:script' module=module name=class_name %}" name="script.{{ class_name }}"><strong>{{ script }}</strong></a>
<a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}"><strong>{{ script }}</strong></a>
</td>
<td>{{ script.Meta.description }}</td>
</tr>

View File

@ -259,29 +259,6 @@
</div>
</div>
<div class="col-sm-6 col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Global Topology Maps</strong>
</div>
{% if topology_maps and perms.extras.view_topologymap %}
<table class="table table-hover panel-body">
{% for tm in topology_maps %}
<tr>
<td><i class="fa fa-fw fa-map-o"></i> <a href="{% url 'extras-api:topologymap-render' pk=tm.pk %}" target="_blank">{{ tm }}</a></td>
<td>{{ tm.description }}</td>
</tr>
{% endfor %}
</table>
{% elif perms.extras.view_topologymap %}
<div class="panel-body text-muted">
None found
</div>
{% else %}
<div class="panel-body text-muted">
<i class="fa fa-lock"></i> No permission
</div>
{% endif %}
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Reports</strong>

View File

@ -16,7 +16,7 @@
<div id="navbar" class="navbar-collapse collapse">
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
<ul class="nav navbar-nav">
<li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/regions/,/tenancy/,/extras/tags/,/extras/reports/' %} active{% endif %}">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
<ul class="dropdown-menu">
<li class="dropdown-header">Sites</li>
@ -39,47 +39,6 @@
<a href="{% url 'dcim:region_list' %}">Regions</a>
</li>
<li class="divider"></li>
<li class="dropdown-header">Tenancy</li>
<li{% if not perms.tenancy.view_tenant %} class="disabled"{% endif %}>
{% if perms.tenancy.add_tenant %}
<div class="buttons pull-right">
<a href="{% url 'tenancy:tenant_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a href="{% url 'tenancy:tenant_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'tenancy:tenant_list' %}">Tenants</a>
</li>
<li{% if not perms.tenancy.view_tenantgroup %} class="disabled"{% endif %}>
{% if perms.tenancy.add_tenantgroup %}
<div class="buttons pull-right">
<a href="{% url 'tenancy:tenantgroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a href="{% url 'tenancy:tenantgroup_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'tenancy:tenantgroup_list' %}">Tenant Groups</a>
</li>
<li class="divider"></li>
<li class="dropdown-header">Miscellaneous</li>
<li{% if not perms.extras.view_tag %} class="disabled"{% endif %}>
<a href="{% url 'extras:tag_list' %}">Tags</a>
</li>
<li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}>
<a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
</li>
<li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
<a href="{% url 'extras:script_list' %}">Scripts</a>
</li>
<li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}>
<a href="{% url 'extras:report_list' %}">Reports</a>
</li>
<li{% if not perms.extras.view_objectchange %} class="disabled"{% endif %}>
<a href="{% url 'extras:objectchange_list' %}">Changelog</a>
</li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/dcim/rack' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Racks <span class="caret"></span></a>
<ul class="dropdown-menu">
<li class="dropdown-header">Racks</li>
<li{% if not perms.dcim.view_rack %} class="disabled"{% endif %}>
{% if perms.dcim.add_rack %}
@ -114,9 +73,34 @@
<li{% if not perms.dcim.view_rackreservation %} class="disabled"{% endif %}>
<a href="{% url 'dcim:rackreservation_list' %}">Reservations</a>
</li>
<li class="divider"></li>
<li class="dropdown-header">Tenancy</li>
<li{% if not perms.tenancy.view_tenant %} class="disabled"{% endif %}>
{% if perms.tenancy.add_tenant %}
<div class="buttons pull-right">
<a href="{% url 'tenancy:tenant_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a href="{% url 'tenancy:tenant_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'tenancy:tenant_list' %}">Tenants</a>
</li>
<li{% if not perms.tenancy.view_tenantgroup %} class="disabled"{% endif %}>
{% if perms.tenancy.add_tenantgroup %}
<div class="buttons pull-right">
<a href="{% url 'tenancy:tenantgroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a href="{% url 'tenancy:tenantgroup_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'tenancy:tenantgroup_list' %}">Tenant Groups</a>
</li>
<li class="divider"></li>
<li class="dropdown-header">Tags</li>
<li{% if not perms.extras.view_tag %} class="disabled"{% endif %}>
<a href="{% url 'extras:tag_list' %}">Tags</a>
</li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/virtual-chassis,/dcim/manufacturers/,/dcim/platforms/,/dcim/cable,-connections/,/dcim/inventory-items/' %} active{% endif %}">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
<ul class="dropdown-menu">
<li class="dropdown-header">Devices</li>
@ -201,7 +185,7 @@
</li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/ipam/' %} active{% endif %}">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">IPAM <span class="caret"></span></a>
<ul class="dropdown-menu">
<li class="dropdown-header">IP Addresses</li>
@ -292,7 +276,7 @@
</li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/virtualization/' %} active{% endif %}">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Virtualization <span class="caret"></span></a>
<ul class="dropdown-menu">
<li class="dropdown-header">Virtual Machines</li>
@ -336,7 +320,7 @@
</li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/circuits/' %} active{% endif %}">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>
<ul class="dropdown-menu">
<li class="dropdown-header">Circuits</li>
@ -371,7 +355,7 @@
</li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/dcim/power' %} active{% endif %}">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Power <span class="caret"></span></a>
<ul class="dropdown-menu">
<li class="dropdown-header">Power</li>
@ -395,7 +379,7 @@
</li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/secrets/' %} active{% endif %}">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Secrets <span class="caret"></span></a>
<ul class="dropdown-menu">
<li class="dropdown-header">Secrets</li>
@ -418,6 +402,26 @@
</li>
</ul>
</li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Other <span class="caret"></span></a>
<ul class="dropdown-menu">
<li class="dropdown-header">Logging</li>
<li{% if not perms.extras.view_objectchange %} class="disabled"{% endif %}>
<a href="{% url 'extras:objectchange_list' %}">Changelog</a>
</li>
<li class="divider"></li>
<li class="dropdown-header">Miscellaneous</li>
<li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}>
<a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
</li>
<li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
<a href="{% url 'extras:script_list' %}">Scripts</a>
</li>
<li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}>
<a href="{% url 'extras:report_list' %}">Reports</a>
</li>
</ul>
</li>
</ul>
{% endif %}
<ul class="nav navbar-nav navbar-right">

View File

@ -1,4 +1,4 @@
{% extends 'utilities/obj_import.html' %}
{% extends 'utilities/obj_bulk_import.html' %}
{% load static %}
{% block content %}

View File

@ -142,6 +142,10 @@
<h2><a href="{% url 'virtualization:virtualmachine_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.virtualmachine_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.virtualmachine_count }}</a></h2>
<p>Virtual machines</p>
</div>
<div class="col-md-4 text-center">
<h2><a href="{% url 'virtualization:cluster_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.cluster_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.cluster_count }}</a></h2>
<p>Clusters</p>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,60 @@
{% extends '_base.html' %}
{% load helpers %}
{% load form_helpers %}
{% block content %}
<h1>{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}</h1>
{% block tabs %}{% endblock %}
<div class="row">
<div class="col-md-7">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<form action="" method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<div class="col-md-12 text-right">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %}
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
{% endif %}
</div>
</div>
</form>
</div>
<div class="col-md-5">
{% if fields %}
<h4 class="text-center">CSV Format</h4>
<table class="table">
<tr>
<th>Field</th>
<th>Required</th>
<th>Description</th>
</tr>
{% for name, field in fields.items %}
<tr>
<td><code>{{ name }}</code></td>
<td>{% if field.required %}<i class="glyphicon glyphicon-ok" title="Required"></i>{% endif %}</td>
<td>
{{ field.help_text|default:field.label }}
{% if field.choices %}
<br /><small class="text-muted">Choices: {{ field|example_choices }}</small>
{% elif field|widget_type == 'dateinput' %}
<br /><small class="text-muted">Format: YYYY-MM-DD</small>
{% elif field|widget_type == 'checkboxinput' %}
<br /><small class="text-muted">Specify "true" or "false"</small>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -6,7 +6,7 @@
<h1>{% block title %}{{ obj_type|bettertitle }} Import{% endblock %}</h1>
{% block tabs %}{% endblock %}
<div class="row">
<div class="col-md-7">
<div class="col-md-8 col-md-offset-2">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
@ -15,12 +15,13 @@
</div>
</div>
{% endif %}
<form action="" method="post" class="form">
<form action="" method="post" class="form form-horizontal">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<div class="col-md-12 text-right">
<button type="submit" class="btn btn-primary">Submit</button>
<button type="submit" name="_create" class="btn btn-primary">Submit</button>
<button type="submit" name="_addanother" class="btn btn-primary">Submit and Import Another</button>
{% if return_url %}
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
{% endif %}
@ -28,33 +29,5 @@
</div>
</form>
</div>
<div class="col-md-5">
{% if fields %}
<h4 class="text-center">CSV Format</h4>
<table class="table">
<tr>
<th>Field</th>
<th>Required</th>
<th>Description</th>
</tr>
{% for name, field in fields.items %}
<tr>
<td><code>{{ name }}</code></td>
<td>{% if field.required %}<i class="glyphicon glyphicon-ok" title="Required"></i>{% endif %}</td>
<td>
{{ field.help_text|default:field.label }}
{% if field.choices %}
<br /><small class="text-muted">Choices: {{ field|example_choices }}</small>
{% elif field|widget_type == 'dateinput' %}
<br /><small class="text-muted">Format: YYYY-MM-DD</small>
{% elif field|widget_type == 'checkboxinput' %}
<br /><small class="text-muted">Specify "true" or "false"</small>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -83,6 +83,16 @@
{% endif %}
</td>
</tr>
<tr>
<td>Tenant</td>
<td>
{% if cluster.tenant %}
<a href="{{ cluster.tenant.get_absolute_url }}">{{ cluster.tenant }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Site</td>
<td>

View File

@ -8,6 +8,7 @@
{% render_field form.name %}
{% render_field form.type %}
{% render_field form.group %}
{% render_field form.tenant %}
{% render_field form.site %}
</div>
</div>

View File

@ -31,11 +31,12 @@ class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer):
virtualmachine_count = serializers.IntegerField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True)
vrf_count = serializers.IntegerField(read_only=True)
cluster_count = serializers.IntegerField(read_only=True)
class Meta:
model = Tenant
fields = [
'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count',
'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count',
'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count',
]

View File

@ -9,7 +9,7 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, Cluster
from . import filters, forms, tables
from .models import Tenant, TenantGroup
@ -80,6 +80,7 @@ class TenantView(PermissionRequiredMixin, View):
'vlan_count': VLAN.objects.filter(tenant=tenant).count(),
'circuit_count': Circuit.objects.filter(tenant=tenant).count(),
'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(),
'cluster_count': Cluster.objects.filter(tenant=tenant).count(),
}
return render(request, 'tenancy/tenant.html', {

View File

@ -2,6 +2,7 @@ import csv
import json
import re
from io import StringIO
import yaml
from django import forms
from django.conf import settings
@ -722,3 +723,41 @@ class BulkEditForm(forms.Form):
# Copy any nullable fields defined in Meta
if hasattr(self.Meta, 'nullable_fields'):
self.nullable_fields = self.Meta.nullable_fields
class ImportForm(BootstrapMixin, forms.Form):
"""
Generic form for creating an object from JSON/YAML data
"""
data = forms.CharField(
widget=forms.Textarea,
help_text="Enter object data in JSON or YAML format."
)
format = forms.ChoiceField(
choices=(
('json', 'JSON'),
('yaml', 'YAML')
),
initial='yaml'
)
def clean(self):
data = self.cleaned_data['data']
format = self.cleaned_data['format']
# Process JSON/YAML data
if format == 'json':
try:
self.cleaned_data['data'] = json.loads(data)
except json.decoder.JSONDecodeError as err:
raise forms.ValidationError({
'data': "Invalid JSON data: {}".format(err)
})
else:
try:
self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader)
except yaml.scanner.ScannerError as err:
raise forms.ValidationError({
'data': "Invalid YAML data: {}".format(err)
})

View File

@ -1,4 +1,6 @@
import json
import sys
import yaml
from copy import deepcopy
from django.conf import settings
@ -24,10 +26,11 @@ from django_tables2 import RequestConfig
from extras.models import CustomField, CustomFieldValue, ExportTemplate
from extras.querysets import CustomFieldQueryset
from utilities.exceptions import AbortTransaction
from utilities.forms import BootstrapMixin, CSVDataField
from utilities.utils import csv_format
from .error_handlers import handle_protectederror
from .forms import ConfirmationForm
from .forms import ConfirmationForm, ImportForm
from .paginator import EnhancedPaginator
@ -394,6 +397,106 @@ class BulkCreateView(GetReturnURLMixin, View):
})
class ObjectImportView(GetReturnURLMixin, View):
"""
Import a single object (YAML or JSON format).
"""
model = None
model_form = None
related_object_forms = dict()
template_name = 'utilities/obj_import.html'
def get(self, request):
form = ImportForm()
return render(request, self.template_name, {
'form': form,
'obj_type': self.model._meta.verbose_name,
'return_url': self.get_return_url(request),
})
def post(self, request):
form = ImportForm(request.POST)
if form.is_valid():
# Initialize model form
data = form.cleaned_data['data']
model_form = self.model_form(data)
# Assign default values for any fields which were not specified. We have to do this manually because passing
# 'initial=' to the form on initialization merely sets default values for the widgets. Since widgets are not
# used for YAML/JSON import, we first bind the imported data normally, then update the form's data with the
# applicable field defaults as needed prior to form validation.
for field_name, field in model_form.fields.items():
if field_name not in data and hasattr(field, 'initial'):
model_form.data[field_name] = field.initial
if model_form.is_valid():
try:
with transaction.atomic():
# Save the primary object
obj = model_form.save()
# Iterate through the related object forms (if any), validating and saving each instance.
for field_name, related_object_form in self.related_object_forms.items():
for i, rel_obj_data in enumerate(data.get(field_name, list())):
f = related_object_form(obj, rel_obj_data)
for subfield_name, field in f.fields.items():
if subfield_name not in rel_obj_data and hasattr(field, 'initial'):
f.data[subfield_name] = field.initial
if f.is_valid():
f.save()
else:
# Replicate errors on the related object form to the primary form for display
for subfield_name, errors in f.errors.items():
for err in errors:
err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err)
model_form.add_error(None, err_msg)
raise AbortTransaction()
except AbortTransaction:
pass
if not model_form.errors:
messages.success(request, mark_safe('Imported object: <a href="{}">{}</a>'.format(
obj.get_absolute_url(), obj
)))
if '_addanother' in request.POST:
return redirect(request.get_full_path())
return_url = form.cleaned_data.get('return_url')
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
return redirect(return_url)
else:
return redirect(self.get_return_url(request, obj))
else:
# Replicate model form errors for display
for field, errors in model_form.errors.items():
for err in errors:
if field == '__all__':
form.add_error(None, err)
else:
form.add_error(None, "{}: {}".format(field, err))
return render(request, self.template_name, {
'form': form,
'obj_type': self.model._meta.verbose_name,
'return_url': self.get_return_url(request),
})
class BulkImportView(GetReturnURLMixin, View):
"""
Import objects in bulk (CSV format).
@ -405,7 +508,7 @@ class BulkImportView(GetReturnURLMixin, View):
"""
model_form = None
table = None
template_name = 'utilities/obj_import.html'
template_name = 'utilities/obj_bulk_import.html'
widget_attrs = {}
def _import_form(self, *args, **kwargs):

View File

@ -38,6 +38,7 @@ class ClusterGroupSerializer(ValidatedModelSerializer):
class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
type = NestedClusterTypeSerializer()
group = NestedClusterGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
site = NestedSiteSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True)
@ -46,7 +47,7 @@ class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer):
class Meta:
model = Cluster
fields = [
'id', 'name', 'type', 'group', 'site', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'device_count', 'virtualmachine_count',
]

View File

@ -41,7 +41,7 @@ class ClusterGroupViewSet(ModelViewSet):
class ClusterViewSet(CustomFieldModelViewSet):
queryset = Cluster.objects.prefetch_related(
'type', 'group', 'site', 'tags'
'type', 'group', 'tenant', 'site', 'tags'
).annotate(
device_count=get_subquery(Device, 'cluster'),
virtualmachine_count=get_subquery(VirtualMachine, 'cluster')

View File

@ -6,6 +6,7 @@ from netaddr.core import AddrFormatError
from dcim.models import DeviceRole, Interface, Platform, Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filtersets import TenancyFilterSet
from tenancy.models import Tenant
from utilities.filters import (
MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
)
@ -56,6 +57,10 @@ class ClusterFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
to_field_name='slug',
label='Cluster type (slug)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label="Tenant (ID)"
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',

View File

@ -86,7 +86,7 @@ class ClusterForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = Cluster
fields = [
'name', 'type', 'group', 'site', 'comments', 'tags',
'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags',
]
widgets = {
'type': APISelect(
@ -128,6 +128,15 @@ class ClusterCSVForm(forms.ModelForm):
'invalid_choice': 'Invalid site name.',
}
)
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
to_field_name='name',
required=False,
help_text='Name of assigned tenant',
error_messages={
'invalid_choice': 'Invalid tenant name'
}
)
class Meta:
model = Cluster
@ -153,6 +162,10 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
api_url="/api/virtualization/cluster-groups/"
)
)
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
required=False
)
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
@ -166,7 +179,7 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
class Meta:
nullable_fields = [
'group', 'site', 'comments',
'group', 'site', 'comments', 'tenant',
]
@ -193,6 +206,15 @@ class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
null_option=True,
)
)
tenant = FilterChoiceField(
queryset=Tenant.objects.all(),
null_label='-- None --',
required=False,
widget=APISelectMultiple(
api_url="/api/tenancy/tenants/",
null_option=True,
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',

View File

@ -0,0 +1,18 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0001_initial'),
('virtualization', '0009_custom_tag_models'),
]
operations = [
migrations.AddField(
model_name='cluster',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='tenancy.Tenant'),
),
]

View File

@ -103,6 +103,13 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
blank=True,
null=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='clusters',
blank=True,
null=True
)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.PROTECT,
@ -150,6 +157,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
self.type.name,
self.group.name if self.group else None,
self.site.name if self.site else None,
self.tenant.name if self.tenant else None,
self.comments,
)

View File

@ -84,13 +84,14 @@ class ClusterGroupTable(BaseTable):
class ClusterTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
device_count = tables.Column(accessor=Accessor('devices.count'), orderable=False, verbose_name='Devices')
vm_count = tables.Column(accessor=Accessor('virtual_machines.count'), orderable=False, verbose_name='VMs')
class Meta(BaseTable.Meta):
model = Cluster
fields = ('pk', 'name', 'type', 'group', 'site', 'device_count', 'vm_count')
fields = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
#

View File

@ -96,7 +96,7 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ClusterListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'virtualization.view_cluster'
queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant')
table = tables.ClusterTable
filter = filters.ClusterFilter
filter_form = forms.ClusterFilterForm

View File

@ -12,7 +12,6 @@ django-taggit-serializer==0.1.7
django-timezone-field==3.0
djangorestframework==3.9.4
drf-yasg[validation]==1.16.0
graphviz==0.10.1
Jinja2==2.10.1
Markdown==2.6.11
netaddr==0.7.19

View File

@ -41,13 +41,21 @@ sed -i -e "s/ALLOWED_HOSTS = \[\]/ALLOWED_HOSTS = \['*'\]/g" $CONFIG
sed -i -e "s/SECRET_KEY = ''/SECRET_KEY = 'netboxci'/g" $CONFIG
# Run NetBox tests
./netbox/manage.py test netbox/
coverage run --source="netbox/" netbox/manage.py test netbox/
RC=$?
if [[ $RC != 0 ]]; then
echo -e "\n$(info) one or more tests failed, failing build."
EXIT=$RC
fi
# Show code coverage report
coverage report --skip-covered --omit *migrations*
RC=$?
if [[ $RC != 0 ]]; then
echo -e "\n$(info) failed to generate code coverage report."
EXIT=$RC
fi
# Show build duration
END=$(date +%s)
echo "$(info) exiting with code $EXIT after $(($END - $START)) seconds."