diff --git a/.gitignore b/.gitignore index 36c6d3fa8..2183b50a3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,9 @@ fabfile.py *.swp gunicorn_config.py +gunicorn.conf +netbox.log +netbox.pid .DS_Store .vscode +.coverage diff --git a/.travis.yml b/.travis.yml index 29fa87b64..872121c21 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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();' diff --git a/base_requirements.txt b/base_requirements.txt index f0f6cfe38..ca3f4ba6f 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -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 diff --git a/contrib/gunicorn.conf b/contrib/gunicorn.conf new file mode 100644 index 000000000..9ecf81008 --- /dev/null +++ b/contrib/gunicorn.conf @@ -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' \ No newline at end of file diff --git a/contrib/netbox-rq.service b/contrib/netbox-rq.service new file mode 100644 index 000000000..4b364d6bc --- /dev/null +++ b/contrib/netbox-rq.service @@ -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 diff --git a/contrib/netbox.env b/contrib/netbox.env new file mode 100644 index 000000000..c64cc587a --- /dev/null +++ b/contrib/netbox.env @@ -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 \ No newline at end of file diff --git a/contrib/netbox.service b/contrib/netbox.service new file mode 100644 index 000000000..76fb0e8ac --- /dev/null +++ b/contrib/netbox.service @@ -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 \ No newline at end of file diff --git a/contrib/netbox@.service b/contrib/netbox@.service new file mode 100644 index 000000000..8616ccc52 --- /dev/null +++ b/contrib/netbox@.service @@ -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 \ No newline at end of file diff --git a/docs/additional-features/topology-maps.md b/docs/additional-features/topology-maps.md deleted file mode 100644 index 21bbe404d..000000000 --- a/docs/additional-features/topology-maps.md +++ /dev/null @@ -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. diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index 92b2fbfb8..dd7492cb4 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -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 = { - 'HOST': 'localhost', - 'PORT': 6379, - 'PASSWORD': '', - 'DATABASE': 0, - 'CACHE_DATABASE': 1, - 'DEFAULT_TIMEOUT': 300, - 'SSL': False, + '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: - 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. --- diff --git a/docs/installation/2-netbox.md b/docs/installation/2-netbox.md index 7bae23d77..f9ca46468 100644 --- a/docs/installation/2-netbox.md +++ b/docs/installation/2-netbox.md @@ -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 = { - 'HOST': 'localhost', - 'PORT': 6379, - 'PASSWORD': '', - 'DATABASE': 0, - 'CACHE_DATABASE': 1, - 'DEFAULT_TIMEOUT': 300, - 'SSL': False, + '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, + } } ``` diff --git a/docs/installation/3-http-daemon.md b/docs/installation/3-http-daemon.md index 9c29fc979..a3a186aeb 100644 --- a/docs/installation/3-http-daemon.md +++ b/docs/installation/3-http-daemon.md @@ -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. diff --git a/docs/installation/index.md b/docs/installation/index.md index 54daa62e3..4962eb7d0 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -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. diff --git a/docs/installation/migrating-to-systemd.md b/docs/installation/migrating-to-systemd.md new file mode 100644 index 000000000..8f207c6ad --- /dev/null +++ b/docs/installation/migrating-to-systemd.md @@ -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 +``` \ No newline at end of file diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 85af66536..e2744e9c9 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -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. \ No newline at end of file diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 10b4ba75c..e44a306fe 120000 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -1 +1 @@ -version-2.6.md \ No newline at end of file +version-2.7.md \ No newline at end of file diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md new file mode 100644 index 000000000..e51cdb644 --- /dev/null +++ b/docs/release-notes/version-2.7.md @@ -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` diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 495709268..95ee15bbe 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -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() diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 12774e4be..6540cebaf 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -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']), diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py new file mode 100644 index 000000000..c9637965b --- /dev/null +++ b/netbox/dcim/choices.py @@ -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) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 034911a26..2e2285b14 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -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 diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 8bcf0a0fc..6204e0b1a 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -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', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index da7b583a6..156b0627a 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -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): diff --git a/netbox/dcim/migrations/0076_console_port_types.py b/netbox/dcim/migrations/0076_console_port_types.py new file mode 100644 index 000000000..844b32283 --- /dev/null +++ b/netbox/dcim/migrations/0076_console_port_types.py @@ -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), + ), + ] diff --git a/netbox/dcim/migrations/0077_power_types.py b/netbox/dcim/migrations/0077_power_types.py new file mode 100644 index 000000000..702bd837b --- /dev/null +++ b/netbox/dcim/migrations/0077_power_types.py @@ -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), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 45b241d42..6fd008d0a 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -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: diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index a9339b938..4851ada5c 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -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): diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 6e34b8ae9..74457af0e 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -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): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 712134f6b..33bdc6318 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -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//', views.DeviceTypeView.as_view(), name='devicetype'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2e59223e4..d0d20a911 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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): diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index f99848b1b..fd26d4445 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -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'], - } diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 8cbddc860..7c533a5b4 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -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 # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index f4968d004..50a54d3fe 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -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) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 526db20a2..3164464f8 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -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 ".