Merge pull request #5454 from netbox-community/develop-2.10

Stage v2.10 release
This commit is contained in:
Jeremy Stretch 2020-12-14 14:19:14 -05:00 committed by GitHub
commit 872ba89cad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
461 changed files with 45696 additions and 21458 deletions

View File

@ -5,7 +5,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [3.6, 3.7] python-version: [3.6, 3.7, 3.8]
services: services:
redis: redis:
image: redis image: redis

View File

@ -15,8 +15,6 @@ categories for discussions:
* **Ideas** - Ideas for new functionality that isn't yet ready for a formal * **Ideas** - Ideas for new functionality that isn't yet ready for a formal
feature request feature request
* **Q&A** - Request help with installing or using NetBox * **Q&A** - Request help with installing or using NetBox
* **Show and tell** - Share a plugin, script, or something else you've made
using NetBox
### Mailing List ### Mailing List

View File

@ -1,26 +1,69 @@
# Custom Fields # Custom Fields
Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. Each model in NetBox is represented in the database as a discrete table, and each attribute of a model exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows.
However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data. However, some users might want to store additional object attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number correlating it with an internal support system record. This is certainly a legitimate use for NetBox, but it's not a common enough need to warrant including a field for _every_ NetBox installation. Instead, you can create a custom field to hold this data.
Custom fields must be created through the admin UI under Extras > Custom Fields. To create a new custom field, select the object(s) to which you want it to apply, and the type of field it will be. NetBox supports six field types: Within the database, custom fields are stored as JSON data directly alongside each object. This alleviates the need for complex queries when retrieving objects.
* Free-form text (up to 255 characters) ## Creating Custom Fields
* Integer
* Boolean (true/false)
* Date
* URL
* Selection
Assign the field a name. This should be a simple database-friendly string, e.g. `tps_report`. You may optionally assign the field a human-friendly label (e.g. "TPS report") as well; the label will be displayed on forms. If a description is provided, it will appear beneath the field in a form. Custom fields must be created through the admin UI under Extras > Custom Fields. NetBox supports six types of custom field:
Marking the field as required will require the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields. (The default value has no effect for selection fields.) * Text: Free-form text (up to 255 characters)
* Integer: A whole number (positive or negative)
* Boolean: True or false
* Date: A date in ISO 8601 format (YYYY-MM-DD)
* URL: This will be presented as a link in the web UI
* Selection: A selection of one of several pre-defined custom choices
When creating a selection field, you should create at least two choices. These choices will be arranged first by weight, with lower weights appearing higher in the list, and then alphabetically. Each custom field must have a name; this should be a simple database-friendly string, e.g. `tps_report`. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form.
## Using Custom Fields Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields.
When a single object is edited, the form will include any custom fields which have been defined for the object type. These fields are included in the "Custom Fields" panel. On the backend, each custom field value is saved separately from the core object as an independent database call, so it's best to avoid adding too many custom fields per object. The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely.
When editing multiple objects, custom field values are saved in bulk. There is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field. A custom field must be assigned to one or object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields.
### Custom Field Validation
NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type:
* Text: Regular expression (optional)
* Integer: Minimum and/or maximum value (optional)
* Selection: Must exactly match one of the prescribed choices
### Custom Selection Fields
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
If a default value is specified for a selection field, it must exactly match one of the provided choices.
## Custom Fields and the REST API
When retrieving an object via the REST API, all of its custom data will be included within the `custom_fields` attribute. For example, below is the partial output of a site with two custom fields defined:
```json
{
"id": 123,
"url": "http://localhost:8000/api/dcim/sites/123/",
"name": "Raleigh 42",
...
"custom_fields": {
"deployed": "2018-06-19",
"site_code": "US-NC-RAL42"
},
...
```
To set or change these values, simply include nested JSON data. For example:
```json
{
"name": "New Site",
"slug": "new-site",
"custom_fields": {
"deployed": "2019-03-24"
}
}
```

View File

@ -1,30 +0,0 @@
# Graphs
!!! warning
Native support for embedded graphs is due to be removed in NetBox v2.10. It will likely be superseded by a plugin providing similar functionality.
NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters:
* **Type:** Site, device, provider, or interface. This determines in which view the graph will be displayed.
* **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name.
* **Name:** The title to display above the graph.
* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`.
* **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`.
Graph names and links can be rendered using Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/).
## Examples
You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this:
```
https://my.nms.local/graphs/?node={{ obj.device.name }}&interface={{ obj.name }}&duration=5m
```
You can define several graphs to provide multiple contexts when viewing an object. For example:
```
https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m
https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=24h
https://my.nms.local/graphs/?type=errors&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m
```

View File

@ -185,7 +185,7 @@ To delete an object, simply call `delete()` on its instance. This will return a
>>> vlan >>> vlan
<VLAN: 123 (BetterName)> <VLAN: 123 (BetterName)>
>>> vlan.delete() >>> vlan.delete()
(1, {'extras.CustomFieldValue': 0, 'ipam.VLAN': 1}) (1, {'ipam.VLAN': 1})
``` ```
To delete multiple objects at once, call `delete()` on a filtered queryset. It's a good idea to always sanity-check the count of selected objects _before_ deleting them. To delete multiple objects at once, call `delete()` on a filtered queryset. It's a good idea to always sanity-check the count of selected objects _before_ deleting them.
@ -194,9 +194,9 @@ To delete multiple objects at once, call `delete()` on a filtered queryset. It's
>>> Device.objects.filter(name__icontains='test').count() >>> Device.objects.filter(name__icontains='test').count()
27 27
>>> Device.objects.filter(name__icontains='test').delete() >>> Device.objects.filter(name__icontains='test').delete()
(35, {'extras.CustomFieldValue': 0, 'dcim.DeviceBay': 0, 'secrets.Secret': 0, (35, {'dcim.DeviceBay': 0, 'secrets.Secret': 0, 'dcim.InterfaceConnection': 4,
'dcim.InterfaceConnection': 4, 'extras.ImageAttachment': 0, 'dcim.Device': 27, 'extras.ImageAttachment': 0, 'dcim.Device': 27, 'dcim.Interface': 4,
'dcim.Interface': 4, 'dcim.ConsolePort': 0, 'dcim.PowerPort': 0}) 'dcim.ConsolePort': 0, 'dcim.PowerPort': 0})
``` ```
!!! warning !!! warning

View File

@ -15,3 +15,4 @@
--- ---
{!docs/models/ipam/vrf.md!} {!docs/models/ipam/vrf.md!}
{!docs/models/ipam/routetarget.md!}

View File

@ -55,7 +55,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
## Supported Python Versions ## Supported Python Versions
NetBox supports Python 3.6 and 3.7 environments currently. (Support for Python 3.5 was removed in NetBox v2.8.) NetBox supports Python 3.6, 3.7, and 3.8 environments currently. (Support for Python 3.5 was removed in NetBox v2.8.)
## Getting Started ## Getting Started

View File

@ -5,30 +5,27 @@ This section entails the installation and configuration of a local PostgreSQL da
!!! warning !!! warning
NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** currently supported. NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** currently supported.
The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
## Installation ## Installation
#### Ubuntu #### Ubuntu
If a recent enough version of PostgreSQL is not available through your distribution's package manager, you'll need to install it from an official [PostgreSQL repository](https://wiki.postgresql.org/wiki/Apt). Install the PostgreSQL server and client development libraries using `apt`.
```no-highlight ```no-highlight
# apt-get update sudo apt update
# apt-get install -y postgresql libpq-dev sudo apt install -y postgresql libpq-dev
``` ```
#### CentOS #### CentOS
CentOS 7 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6, however you may opt to install a more recent version. PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/).
```no-highlight ```no-highlight
# yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm sudo yum install -y postgresql-server libpq-devel
# yum install -y postgresql96 postgresql96-server postgresql96-devel sudo postgresql-setup --initdb
# /usr/pgsql-9.6/bin/postgresql96-setup initdb
``` ```
CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/9.6/data/pg_hba.conf`. For example: CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below:
```no-highlight ```no-highlight
host all all 127.0.0.1/32 md5 host all all 127.0.0.1/32 md5
@ -38,8 +35,8 @@ host all all ::1/128 md5
Then, start the service and enable it to run at boot: Then, start the service and enable it to run at boot:
```no-highlight ```no-highlight
# systemctl start postgresql-9.6 sudo systemctl start postgresql
# systemctl enable postgresql-9.6 sudo systemctl enable postgresql
``` ```
## Database Creation ## Database Creation
@ -50,8 +47,8 @@ At a minimum, we need to create a database for NetBox and assign it a username a
**Do not use the password from the example.** Choose a strong, random password to ensure secure database authentication for your NetBox installation. **Do not use the password from the example.** Choose a strong, random password to ensure secure database authentication for your NetBox installation.
```no-highlight ```no-highlight
# sudo -u postgres psql $ sudo -u postgres psql
psql (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1)) psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
Type "help" for help. Type "help" for help.
postgres=# CREATE DATABASE netbox; postgres=# CREATE DATABASE netbox;
@ -68,13 +65,16 @@ postgres=# \q
You can verify that authentication works issuing the following command and providing the configured password. (Replace `localhost` with your database server if using a remote database.) You can verify that authentication works issuing the following command and providing the configured password. (Replace `localhost` with your database server if using a remote database.)
```no-highlight ```no-highlight
# psql --username netbox --password --host localhost netbox $ psql --username netbox --password --host localhost netbox
Password for user netbox: Password for user netbox:
psql (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1)) psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help. Type "help" for help.
netbox=> \conninfo
You are connected to database "netbox" as user "netbox" on host "localhost" (address "127.0.0.1") at port "5432".
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
netbox=> \q netbox=> \q
``` ```
If successful, you will enter a `netbox` prompt. Type `\q` to exit. If successful, you will enter a `netbox` prompt. Type `\conninfo` to confirm your connection, or type `\q` to exit.

View File

@ -10,16 +10,15 @@
### Ubuntu ### Ubuntu
```no-highlight ```no-highlight
# apt-get install -y redis-server sudo apt install -y redis-server
``` ```
### CentOS ### CentOS
```no-highlight ```no-highlight
# yum install -y epel-release sudo yum install -y redis
# yum install -y redis sudo systemctl start redis
# systemctl start redis sudo systemctl enable redis
# systemctl enable redis
``` ```
You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/redis/redis.conf`, however in most cases the default configuration is sufficient. You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/redis/redis.conf`, however in most cases the default configuration is sufficient.

View File

@ -7,40 +7,39 @@ This section of the documentation discusses installing and configuring the NetBo
Begin by installing all system packages required by NetBox and its dependencies. Begin by installing all system packages required by NetBox and its dependencies.
!!! note !!! note
NetBox v2.8.0 and later require Python 3.6 or 3.7. This documentation assumes Python 3.6. NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. This documentation assumes Python 3.6.
### Ubuntu ### Ubuntu
```no-highlight ```no-highlight
# apt-get install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev sudo apt install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
``` ```
### CentOS ### CentOS
```no-highlight ```no-highlight
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config
# easy_install-3.6 pip
``` ```
Before continuing with either platform, update pip (Python's package management tool) to its latest release: Before continuing with either platform, update pip (Python's package management tool) to its latest release:
```no-highlight ```no-highlight
# pip3 install --upgrade pip sudo pip3 install --upgrade pip
``` ```
## Download NetBox ## Download NetBox
This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and decompressing the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by re-pulling the `master` branch. This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and extracting the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by re-pulling the `master` branch.
### Option A: Download a Release Archive ### Option A: Download a Release Archive
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root. Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root.
```no-highlight ```no-highlight
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz $ sudo wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt $ sudo tar -xzf vX.Y.Z.tar.gz -C /opt
# ln -s /opt/netbox-X.Y.Z/ /opt/netbox $ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
# ls -l /opt | grep netbox $ ls -l /opt | grep netbox
lrwxrwxrwx 1 root root 13 Jul 20 13:44 netbox -> netbox-2.9.0/ lrwxrwxrwx 1 root root 13 Jul 20 13:44 netbox -> netbox-2.9.0/
drwxr-xr-x 2 root root 4096 Jul 20 13:44 netbox-2.9.0 drwxr-xr-x 2 root root 4096 Jul 20 13:44 netbox-2.9.0
``` ```
@ -53,7 +52,7 @@ drwxr-xr-x 2 root root 4096 Jul 20 13:44 netbox-2.9.0
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`. Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
```no-highlight ```no-highlight
# mkdir -p /opt/netbox/ && cd /opt/netbox/ sudo mkdir -p /opt/netbox/ && cd /opt/netbox/
``` ```
If `git` is not already installed, install it: If `git` is not already installed, install it:
@ -61,19 +60,19 @@ If `git` is not already installed, install it:
#### Ubuntu #### Ubuntu
```no-highlight ```no-highlight
# apt-get install -y git sudo apt install -y git
``` ```
#### CentOS #### CentOS
```no-highlight ```no-highlight
# yum install -y git sudo yum install -y git
``` ```
Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.) Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.)
```no-highlight ```no-highlight
# git clone -b master https://github.com/netbox-community/netbox.git . $ sudo git clone -b master https://github.com/netbox-community/netbox.git .
Cloning into '.'... Cloning into '.'...
remote: Counting objects: 1994, done. remote: Counting objects: 1994, done.
remote: Compressing objects: 100% (150/150), done. remote: Compressing objects: 100% (150/150), done.
@ -93,16 +92,16 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
#### Ubuntu #### Ubuntu
``` ```
# adduser --system --group netbox sudo adduser --system --group netbox
# chown --recursive netbox /opt/netbox/netbox/media/ sudo chown --recursive netbox /opt/netbox/netbox/media/
``` ```
#### CentOS #### CentOS
``` ```
# groupadd --system netbox sudo groupadd --system netbox
# adduser --system -g netbox netbox sudo adduser --system -g netbox netbox
# chown --recursive netbox /opt/netbox/netbox/media/ sudo chown --recursive netbox /opt/netbox/netbox/media/
``` ```
## Configuration ## Configuration
@ -110,8 +109,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. This file will hold all of your local configuration parameters. Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. This file will hold all of your local configuration parameters.
```no-highlight ```no-highlight
# cd /opt/netbox/netbox/netbox/ cd /opt/netbox/netbox/netbox/
# cp configuration.example.py configuration.py sudo cp configuration.example.py configuration.py
``` ```
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](/configuration/), but only the following four are required for new installations: Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](/configuration/), but only the following four are required for new installations:
@ -154,7 +153,7 @@ DATABASE = {
Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](/configuration/required-settings/#redis) for more detail on individual parameters. Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](/configuration/required-settings/#redis) for more detail on individual parameters.
Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique database ID. Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID.
```python ```python
REDIS = { REDIS = {
@ -182,7 +181,7 @@ This parameter must be assigned a randomly-generated key employed as a salt for
A simple Python script named `generate_secret_key.py` is provided in the parent directory to assist in generating a suitable key: A simple Python script named `generate_secret_key.py` is provided in the parent directory to assist in generating a suitable key:
```no-highlight ```no-highlight
# python3 ../generate_secret_key.py python3 ../generate_secret_key.py
``` ```
!!! warning !!! warning
@ -199,7 +198,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will
The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device. The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
```no-highlight ```no-highlight
# echo napalm >> /opt/netbox/local_requirements.txt sudo echo napalm >> /opt/netbox/local_requirements.txt
``` ```
### Remote File Storage ### Remote File Storage
@ -207,7 +206,7 @@ The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to
By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](/configuration/optional-settings/#storage_backend) in `configuration.py`. By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](/configuration/optional-settings/#storage_backend) in `configuration.py`.
```no-highlight ```no-highlight
# echo django-storages >> /opt/netbox/local_requirements.txt sudo echo django-storages >> /opt/netbox/local_requirements.txt
``` ```
## Run the Upgrade Script ## Run the Upgrade Script
@ -220,7 +219,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
* Aggregate static resource files on disk * Aggregate static resource files on disk
```no-highlight ```no-highlight
# /opt/netbox/upgrade.sh sudo /opt/netbox/upgrade.sh
``` ```
!!! note !!! note
@ -231,7 +230,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script: NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script:
```no-highlight ```no-highlight
# source /opt/netbox/venv/bin/activate source /opt/netbox/venv/bin/activate
``` ```
Once the virtual environment has been activated, you should notice the string `(venv)` prepended to your console prompt. Once the virtual environment has been activated, you should notice the string `(venv)` prepended to your console prompt.
@ -239,8 +238,8 @@ Once the virtual environment has been activated, you should notice the string `(
Next, we'll create a superuser account using the `createsuperuser` Django management command (via `manage.py`). Specifying an email address for the user is not required, but be sure to use a very strong password. Next, we'll create a superuser account using the `createsuperuser` Django management command (via `manage.py`). Specifying an email address for the user is not required, but be sure to use a very strong password.
```no-highlight ```no-highlight
(venv) # cd /opt/netbox/netbox (venv) $ cd /opt/netbox/netbox
(venv) # python3 manage.py createsuperuser (venv) $ python3 manage.py createsuperuser
Username: admin Username: admin
Email address: admin@example.com Email address: admin@example.com
Password: Password:
@ -250,28 +249,33 @@ Superuser created successfully.
## Test the Application ## Test the Application
At this point, we should be able to run NetBox. We can check by starting a development instance: At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance:
```no-highlight ```no-highlight
(venv) # python3 manage.py runserver 0.0.0.0:8000 --insecure (venv) $ python3 manage.py runserver 0.0.0.0:8000 --insecure
Performing system checks... Performing system checks...
System check identified no issues (0 silenced). System check identified no issues (0 silenced).
November 28, 2018 - 09:33:45 November 17, 2020 - 16:08:13
Django version 2.0.9, using settings 'netbox.settings' Django version 3.1.3, using settings 'netbox.settings'
Starting development server at http://0.0.0.0:8000/ Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C. Quit the server with CONTROL-C.
``` ```
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.** Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page.
!!! warning
The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.**
!!! warning !!! warning
If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected. If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.
Note that the initial UI will be locked down for non-authenticated users. Note that the initial user interface will be locked down for non-authenticated users.
![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_ui_guest.png) ![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_ui_guest.png)
Try logging in as the super user we just created. Once authenticated, you'll be able to access all areas of the UI: Try logging in using the superuser account we just created. Once authenticated, you'll be able to access all areas of the UI:
![NetBox UI as seen by an administrator](../media/installation/netbox_ui_admin.png) ![NetBox UI as seen by an administrator](../media/installation/netbox_ui_admin.png)
Type `Ctrl+c` to stop the development server.

View File

@ -1,32 +1,31 @@
# Gunicorn # Gunicorn
Like most Django applications, NetBox runs as a [WSGI application](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) behind an HTTP server. This documentation shows how to install and configure [gunicorn](http://gunicorn.org/) for this role, however other WSGIs are available and should work similarly well. Like most Django applications, NetBox runs as a [WSGI application](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) behind an HTTP server. This documentation shows how to install and configure [gunicorn](http://gunicorn.org/) (which is automatically installed with NetBox) for this role, however other WSGI servers are available and should work similarly well. [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) is a popular alternative.
## Configuration ## Configuration
NetBox ships with a default configuration file for gunicorn. To use it, copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file rather than pointing to it directly to ensure that any changes to it do not get overwritten by a future upgrade.) NetBox ships with a default configuration file for gunicorn. To use it, copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file rather than pointing to it directly to ensure that any local changes to it do not get overwritten by a future upgrade.)
```no-highlight ```no-highlight
# cd /opt/netbox sudo cp /opt/netbox/contrib/gunicorn.py /opt/netbox/gunicorn.py
# cp contrib/gunicorn.py /opt/netbox/gunicorn.py
``` ```
While this default configuration should suffice for most initial installations, you may wish to edit this file to change the bound IP address and/or port number, or to make performance-related adjustments. See [the Gunicorn documentation](https://docs.gunicorn.org/en/stable/configure.html) for the available configuration parameters. While the provided configuration should suffice for most initial installations, you may wish to edit this file to change the bound IP address and/or port number, or to make performance-related adjustments. See [the Gunicorn documentation](https://docs.gunicorn.org/en/stable/configure.html) for the available configuration parameters.
## systemd Setup ## systemd Setup
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd dameon: We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd dameon:
```no-highlight ```no-highlight
# cp contrib/*.service /etc/systemd/system/ sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/
# systemctl daemon-reload sudo systemctl daemon-reload
``` ```
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time: Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
```no-highlight ```no-highlight
# systemctl start netbox netbox-rq sudo systemctl start netbox netbox-rq
# systemctl enable netbox netbox-rq sudo systemctl enable netbox netbox-rq
``` ```
You can use the command `systemctl status netbox` to verify that the WSGI service is running: You can use the command `systemctl status netbox` to verify that the WSGI service is running:
@ -35,15 +34,19 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic
# systemctl status netbox.service # systemctl status netbox.service
● netbox.service - NetBox WSGI Service ● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago Active: active (running) since Tue 2020-11-17 16:18:23 UTC; 3min 35s ago
Docs: https://netbox.readthedocs.io/en/stable/ Docs: https://netbox.readthedocs.io/en/stable/
Main PID: 11993 (gunicorn) Main PID: 22836 (gunicorn)
Tasks: 6 (limit: 2362) Tasks: 6 (limit: 2345)
Memory: 339.3M
CGroup: /system.slice/netbox.service CGroup: /system.slice/netbox.service
├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... ├─22836 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid>
├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... ├─22854 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid>
├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... ├─22855 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid>
... ...
``` ```
!!! note
If the NetBox service fails to start, issue the command `journalctl -eu netbox` to check for log messages that may indicate the problem.
Once you've verified that the WSGI workers are up and running, move on to HTTP server setup. Once you've verified that the WSGI workers are up and running, move on to HTTP server setup.

View File

@ -1,9 +1,9 @@
# HTTP Server Setup # HTTP Server Setup
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4), though any HTTP server which supports WSGI should be compatible. This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
!!! info !!! info
For the sake of brevity, only Ubuntu 18.04 instructions are provided here, these tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed. For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
## Obtain an SSL Certificate ## Obtain an SSL Certificate
@ -12,7 +12,7 @@ To enable HTTPS access to NetBox, you'll need a valid SSL certificate. You can p
The command below can be used to generate a self-signed certificate for testing purposes, however it is strongly recommended to use a certificate from a trusted authority in production. Two files will be created: the public certificate (`netbox.crt`) and the private key (`netbox.key`). The certificate is published to the world, whereas the private key must be kept secret at all times. The command below can be used to generate a self-signed certificate for testing purposes, however it is strongly recommended to use a certificate from a trusted authority in production. Two files will be created: the public certificate (`netbox.crt`) and the private key (`netbox.key`). The certificate is published to the world, whereas the private key must be kept secret at all times.
```no-highlight ```no-highlight
# openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/netbox.key \ -keyout /etc/ssl/private/netbox.key \
-out /etc/ssl/certs/netbox.crt -out /etc/ssl/certs/netbox.crt
``` ```
@ -26,27 +26,26 @@ The above command will prompt you for additional details of the certificate; all
Begin by installing nginx: Begin by installing nginx:
```no-highlight ```no-highlight
# apt-get install -y nginx sudo apt install -y nginx
``` ```
Once nginx is installed, copy the nginx configuration file provided by NetBox to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.) Once nginx is installed, copy the nginx configuration file provided by NetBox to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.)
```no-highlight ```no-highlight
# cp /opt/netbox/contrib/nginx.conf /etc/nginx/sites-available/netbox sudo cp /opt/netbox/contrib/nginx.conf /etc/nginx/sites-available/netbox
``` ```
Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created. Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
```no-highlight ```no-highlight
# cd /etc/nginx/sites-enabled/ sudo rm /etc/nginx/sites-enabled/default
# rm default sudo ln -s /etc/nginx/sites-available/netbox /etc/nginx/sites-enabled/netbox
# ln -s /etc/nginx/sites-available/netbox
``` ```
Finally, restart the `nginx` service to use the new configuration. Finally, restart the `nginx` service to use the new configuration.
```no-highlight ```no-highlight
# service nginx restart sudo systemctl restart nginx
``` ```
### Option B: Apache ### Option B: Apache
@ -54,26 +53,26 @@ Finally, restart the `nginx` service to use the new configuration.
Begin by installing Apache: Begin by installing Apache:
```no-highlight ```no-highlight
# apt-get install -y apache2 sudo apt install -y apache2
``` ```
Next, copy the default configuration file to `/etc/apache2/sites-available/`. Be sure to modify the `ServerName` parameter appropriately. Next, copy the default configuration file to `/etc/apache2/sites-available/`. Be sure to modify the `ServerName` parameter appropriately.
```no-highlight ```no-highlight
# cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf sudo cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf
``` ```
Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache: Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache:
```no-highlight ```no-highlight
# a2enmod ssl proxy proxy_http headers sudo a2enmod ssl proxy proxy_http headers
# a2ensite netbox sudo a2ensite netbox
# service apache2 restart sudo systemctl restart apache2
``` ```
## Confirm Connectivity ## Confirm Connectivity
At this point, you should be able to connect to the HTTP service at the server name or IP address you provided. At this point, you should be able to connect to the HTTPS service at the server name or IP address you provided.
!!! info !!! info
Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment. Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment.
@ -91,5 +90,5 @@ If you are unable to connect to the HTTP server, check that:
If you are able to connect but receive a 502 (bad gateway) error, check the following: If you are able to connect but receive a 502 (bad gateway) error, check the following:
* The WSGI worker processes (gunicorn) are running (`systemctl status netbox` should show a status of "active (running)") * The WSGI worker processes (gunicorn) are running (`systemctl status netbox` should show a status of "active (running)")
* nginx/Apache is configured to connect to the port on which gunicorn is listening (default is 8001). * Nginx/Apache is configured to connect to the port on which gunicorn is listening (default is 8001).
* SELinux is not preventing the reverse proxy connection. You may need to allow HTTP network connections with the command `setsebool -P httpd_can_network_connect 1` * SELinux is not preventing the reverse proxy connection. You may need to allow HTTP network connections with the command `setsebool -P httpd_can_network_connect 1`

View File

@ -9,13 +9,13 @@ This guide explains how to implement LDAP authentication using an external serve
On Ubuntu: On Ubuntu:
```no-highlight ```no-highlight
# apt-get install -y libldap2-dev libsasl2-dev libssl-dev sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
``` ```
On CentOS: On CentOS:
```no-highlight ```no-highlight
# yum install -y openldap-devel sudo yum install -y openldap-devel
``` ```
### Install django-auth-ldap ### Install django-auth-ldap
@ -23,15 +23,14 @@ On CentOS:
Activate the Python virtual environment and install the `django-auth-ldap` package using pip: Activate the Python virtual environment and install the `django-auth-ldap` package using pip:
```no-highlight ```no-highlight
# cd /opt/netbox/ source /opt/netbox/venv/bin/activate
# source venv/bin/activate pip3 install django-auth-ldap
(venv) # pip3 install django-auth-ldap
``` ```
Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment: Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
```no-highlight ```no-highlight
(venv) # echo django-auth-ldap >> local_requirements.txt sudo echo django-auth-ldap >> /opt/netbox/local_requirements.txt
``` ```
## Configuration ## Configuration
@ -42,7 +41,7 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend' REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
``` ```
Next, create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/). Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
### General Server Configuration ### General Server Configuration

View File

@ -1,5 +1,7 @@
# Installation # Installation
The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.2. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
The following sections detail how to set up a new instance of NetBox: The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md) 1. [PostgreSQL database](1-postgresql.md)
@ -9,6 +11,14 @@ The following sections detail how to set up a new instance of NetBox:
5. [HTTP server](5-http-server.md) 5. [HTTP server](5-http-server.md)
6. [LDAP authentication](6-ldap.md) (optional) 6. [LDAP authentication](6-ldap.md) (optional)
## Requirements
| Dependency | Minimum Version |
|------------|-----------------|
| Python | 3.6 |
| PostgreSQL | 9.6 |
| Redis | 4.0 |
Below is a simplified overview of the NetBox application stack for reference: Below is a simplified overview of the NetBox application stack for reference:
![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_application_stack.png) ![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_application_stack.png)

View File

@ -2,7 +2,7 @@
## Review the Release Notes ## Review the Release Notes
Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect. Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
## Update Dependencies to Required Versions ## Update Dependencies to Required Versions
@ -14,7 +14,7 @@ NetBox v2.9.0 and later requires the following:
| PostgreSQL | 9.6 | | PostgreSQL | 9.6 |
| Redis | 4.0 | | Redis | 4.0 |
## Install the Latest Code ## Install the Latest Release
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository.
@ -25,47 +25,36 @@ Download the [latest stable release](https://github.com/netbox-community/netbox/
Download and extract the latest version: Download and extract the latest version:
```no-highlight ```no-highlight
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt sudo tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/ sudo ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox
# ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox
``` ```
Copy the 'configuration.py' you created when first installing to the new version: Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if present) from the current installation to the new version:
```no-highlight ```no-highlight
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py sudo cp /opt/netbox-X.Y.Z/local_requirements.txt /opt/netbox/
``` sudo cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
Copy your local requirements file if used:
```no-highlight
# cp /opt/netbox-X.Y.Z/local_requirements.txt /opt/netbox/local_requirements.txt
```
Also copy the LDAP configuration if using LDAP:
```no-highlight
# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py
``` ```
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.) Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
```no-highlight ```no-highlight
# cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/ sudo cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/
``` ```
Also make sure to copy over any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.) Also make sure to copy or link any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.)
```no-highlight ```no-highlight
# cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/ sudo cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/
# cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/ sudo cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/
``` ```
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well: If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```no-highlight ```no-highlight
# cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/gunicorn.py sudo cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/
``` ```
### Option B: Clone the Git Repository ### Option B: Clone the Git Repository
@ -73,10 +62,9 @@ If you followed the original installation guide to set up gunicorn, be sure to c
This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch: This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch:
```no-highlight ```no-highlight
# cd /opt/netbox cd /opt/netbox
# git checkout master sudo git checkout master
# git pull origin master sudo git pull origin master
# git status
``` ```
## Run the Upgrade Script ## Run the Upgrade Script
@ -84,10 +72,10 @@ This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most
Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script: Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script:
```no-highlight ```no-highlight
# ./upgrade.sh sudo ./upgrade.sh
``` ```
This script: This script performs the following actions:
* Destroys and rebuilds the Python virtual environment * Destroys and rebuilds the Python virtual environment
* Installs all required Python packages (listed in `requirements.txt`) * Installs all required Python packages (listed in `requirements.txt`)
@ -99,23 +87,20 @@ This script:
* Clears all cached data to prevent conflicts with the new release * Clears all cached data to prevent conflicts with the new release
!!! note !!! note
It's possible that the upgrade script will display a notice warning of unreflected database migrations: If the upgrade script prompts a warning about unreflected database migrations, this indicates that some change has
been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are
Your models have changes that are not yet reflected in a migration, and so won't be applied. intentionally modifying the database schema.
Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them.
This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are intentionally modifying the database schema.
## Restart the NetBox Services ## Restart the NetBox Services
!!! warning !!! warning
If you are upgrading from an installation that does not use a Python virtual environment, you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference. If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference.
Finally, restart the gunicorn and RQ services: Finally, restart the gunicorn and RQ services:
```no-highlight ```no-highlight
# sudo systemctl restart netbox netbox-rq sudo systemctl restart netbox netbox-rq
``` ```
!!! note !!! note
It's possible you are still using supervisord instead of systemd. If so, please see the instructions for [migrating to systemd](migrating-to-systemd.md). If upgrading from an installation that uses supervisord, please see the instructions for [migrating to systemd](migrating-to-systemd.md). The use of supervisord is no longer supported.

View File

@ -0,0 +1,5 @@
# Route Targets
A route target is a particular type of [extended BGP community](https://tools.ietf.org/html/rfc4360#section-4) used to control the redistribution of routes among VRF tables in a network. Route targets can be assigned to individual VRFs in NetBox as import or export targets (or both) to model this exchange in an L3VPN. Each route target must be given a unique name, which should be in a format prescribed by [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), similar to a VR route distinguisher.
Each route target can optionally be assigned to a tenant, and may have tags assigned to it.

View File

@ -10,3 +10,5 @@ By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This b
!!! note !!! note
Enforcement of unique IP space can be toggled for global table (non-VRF prefixes) using the `ENFORCE_GLOBAL_UNIQUE` configuration setting. Enforcement of unique IP space can be toggled for global table (non-VRF prefixes) using the `ENFORCE_GLOBAL_UNIQUE` configuration setting.
Each VRF may have one or more import and/or export route targets applied to it. Route targets are used to control the exchange of routes (prefixes) among VRFs in L3VPNs.

View File

@ -1 +1 @@
version-2.9.md version-2.10.md

View File

@ -0,0 +1,176 @@
# NetBox v2.10
## v2.10-beta3 (FUTURE)
### Enhancements
* [#5411](https://github.com/netbox-community/netbox/issues/5411) - Include cable tags in trace view
### Bug Fixes
* [#5417](https://github.com/netbox-community/netbox/issues/5417) - Fix exception when viewing a device installed within a device bay
---
## v2.10-beta2 (2020-12-03)
### Enhancements
* [#5274](https://github.com/netbox-community/netbox/issues/5274) - Add REST API support for custom fields
* [#5399](https://github.com/netbox-community/netbox/issues/5399) - Show options for cable endpoint types during bulk import
### Bug Fixes
* [#5176](https://github.com/netbox-community/netbox/issues/5176) - Enforce content type restrictions when creating objects via the REST API
* [#5358](https://github.com/netbox-community/netbox/issues/5358) - Fix user table configuration for VM interfaces
* [#5374](https://github.com/netbox-community/netbox/issues/5374) - Fix exception thrown when tracing mid-point
* [#5376](https://github.com/netbox-community/netbox/issues/5376) - Correct invalid custom field filter logic values
* [#5395](https://github.com/netbox-community/netbox/issues/5395) - Fix cable tracing for rear ports with no corresponding front port
### Other Changes
* [#4711](https://github.com/netbox-community/netbox/issues/4711) - Renamed Webhook `obj_type` to `content_types`
---
## v2.10-beta1 (2020-11-17)
**NOTE:** This release completely removes support for embedded graphs.
**NOTE:** The Django templating language (DTL) is no longer supported for export templates. Ensure that all export templates use Jinja2 before upgrading.
### New Features
#### Route Targets ([#259](https://github.com/netbox-community/netbox/issues/259))
This release introduces support for modeling L3VPN route targets, which can be used to control the redistribution of advertised prefixes among VRFs. Each VRF may be assigned one or more route targets in the import and/or export direction. Like VRFs, route targets may be assigned to tenants and support tag assignment.
#### REST API Bulk Deletion ([#3436](https://github.com/netbox-community/netbox/issues/3436))
The REST API now supports the bulk deletion of objects of the same type in a single request. Send a `DELETE` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request:
```no-highlight
curl -s -X DELETE \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
http://netbox/api/dcim/sites/ \
--data '[{"id": 10}, {"id": 11}, {"id": 12}]'
```
#### REST API Bulk Update ([#4882](https://github.com/netbox-community/netbox/issues/4882))
Similar to bulk deletion, the REST API also now supports bulk updates. Send a `PUT` or `PATCH` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object and the attribute(s) to be updated. For example, to set a description for sites with IDs 10 and 11, issue the following request:
```no-highlight
curl -s -X PATCH \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
http://netbox/api/dcim/sites/ \
--data '[{"id": 10, "description": "Foo"}, {"id": 11, "description": "Bar"}]'
```
#### Reimplementation of Custom Fields ([#4878](https://github.com/netbox-community/netbox/issues/4878))
NetBox v2.10 introduces a completely overhauled approach to custom fields. Whereas previous versions used CustomFieldValue instances to store values, custom field data is now stored directly on each model instance as JSON data and may be accessed using the `cf` property:
```python
>>> site = Site.objects.first()
>>> site.cf
{'site_code': 'US-RAL01'}
>>> site.cf['foo'] = 'ABC'
>>> site.full_clean()
>>> site.save()
>>> site = Site.objects.first()
>>> site.cf
{'foo': 'ABC', 'site_code': 'US-RAL01'}
```
Additionally, custom selection field choices are now defined on the CustomField model within the admin UI, which greatly simplifies working with choice values.
#### Improved Cable Trace Performance ([#4900](https://github.com/netbox-community/netbox/issues/4900))
All end-to-end cable paths are now cached using the new CablePath backend model. This allows NetBox to now immediately return the complete path originating from any endpoint directly from the database, rather than having to trace each cable recursively. It also resolves some systemic validation issues present in the original implementation.
**Note:** As part of this change, cable traces will no longer traverse circuits: A circuit termination will be considered the origin or destination of an end-to-end path.
### Enhancements
* [#609](https://github.com/netbox-community/netbox/issues/609) - Add min/max value and regex validation for custom fields
* [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines
* [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI
* [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the use of multiple port numbers when defining a service
* [#4897](https://github.com/netbox-community/netbox/issues/4897) - Allow filtering by content type identified as `<app>.<model>` string
* [#4918](https://github.com/netbox-community/netbox/issues/4918) - Add a REST API endpoint (`/api/status/`) which returns NetBox's current operational status
* [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view
* [#4967](https://github.com/netbox-community/netbox/issues/4967) - Support tenant assignment for aggregates
* [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields
* [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom field support for cables, power panels, rack reservations, and virtual chassis
* [#5154](https://github.com/netbox-community/netbox/issues/5154) - The web interface now consumes the entire browser window
* [#5190](https://github.com/netbox-community/netbox/issues/5190) - Add a REST API endpoint for retrieving content types (`/api/extras/content-types/`)
### Other Changes
* [#1846](https://github.com/netbox-community/netbox/issues/1846) - Enable MPTT for InventoryItem hierarchy
* [#2755](https://github.com/netbox-community/netbox/issues/2755) - Switched from Font Awesome/Glyphicons to Material Design icons
* [#4349](https://github.com/netbox-community/netbox/issues/4349) - Dropped support for embedded graphs
* [#4360](https://github.com/netbox-community/netbox/issues/4360) - Dropped support for the Django template language from export templates
* [#4941](https://github.com/netbox-community/netbox/issues/4941) - `commit` argument is now required argument in a custom script's `run()` method
* [#5011](https://github.com/netbox-community/netbox/issues/5011) - Standardized name field lengths across all models
* [#5139](https://github.com/netbox-community/netbox/issues/5139) - Omit utilization statistics from RIR list
* [#5225](https://github.com/netbox-community/netbox/issues/5225) - Circuit termination port speed is now an optional field
### REST API Changes
* Added support for `PUT`, `PATCH`, and `DELETE` operations on list endpoints (bulk update and delete)
* Added the `/extras/content-types/` endpoint for Django ContentTypes
* Added the `/extras/custom-fields/` endpoint for custom fields
* Removed the `/extras/_custom_field_choices/` endpoint (replaced by new custom fields endpoint)
* Added the `/status/` endpoint to convey NetBox's current status
* circuits.CircuitTermination:
* Added the `/trace/` endpoint
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* `port_speed` may now be null
* dcim.Cable: Added `custom_fields`
* dcim.ConsolePort:
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* dcim.ConsoleServerPort:
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* dcim.FrontPort:
* Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths
* Added `cable_peer` and `cable_peer_type`
* dcim.Interface:
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning
* dcim.PowerFeed:
* Added the `/trace/` endpoint
* Added fields `connected_endpoint`, `connected_endpoint_type`, `connected_endpoint_reachable`, `cable_peer`, and `cable_peer_type`
* dcim.PowerOutlet:
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* dcim.PowerPanel: Added `custom_fields`
* dcim.PowerPort
* Replaced `connection_status` with `connected_endpoint_reachable` (boolean)
* Added `cable_peer` and `cable_peer_type`
* Removed `connection_status` from nested serializer
* dcim.RackReservation: Added `custom_fields`
* dcim.RearPort:
* Replaced the `/trace/` endpoint with `/paths/`, which returns a list of cable paths
* Added `cable_peer` and `cable_peer_type`
* dcim.VirtualChassis: Added `custom_fields`
* extras.ExportTemplate: The `template_language` field has been removed
* extras.Graph: This API endpoint has been removed (see #4349)
* extras.ImageAttachment: Filtering by `content_type` now takes a string in the form `<app>.<model>`
* extras.ObjectChange: Filtering by `changed_object_type` now takes a string in the form `<app>.<model>`
* ipam.Aggregate: Added `tenant` field
* ipam.RouteTarget: New endpoint
* ipam.Service: Renamed `port` to `ports`; now holds a list of one or more port numbers
* ipam.VRF: Added `import_targets` and `export_targets` fields
* secrets.Secret: Removed `device` field; replaced with `assigned_object` generic foreign key. This may represent either a device or a virtual machine. Assign an object by setting `assigned_object_type` and `assigned_object_id`.

View File

@ -468,16 +468,16 @@ http://netbox/api/dcim/sites/ \
] ]
``` ```
### Modifying an Object ### Updating an Object
To modify an object which has already been created, make a `PATCH` request to the model's _detail_ endpoint specifying its unique numeric ID. Include any data which you wish to update on the object. As with object creation, the `Authorization` and `Content-Type` headers must also be specified. To modify an object which has already been created, make a `PATCH` request to the model's _detail_ endpoint specifying its unique numeric ID. Include any data which you wish to update on the object. As with object creation, the `Authorization` and `Content-Type` headers must also be specified.
```no-highlight ```no-highlight
curl -s -X PATCH \ curl -s -X PATCH \
> -H "Authorization: Token $TOKEN" \ -H "Authorization: Token $TOKEN" \
> -H "Content-Type: application/json" \ -H "Content-Type: application/json" \
> http://netbox/api/ipam/prefixes/18691/ \ http://netbox/api/ipam/prefixes/18691/ \
> --data '{"status": "reserved"}' | jq '.' --data '{"status": "reserved"}' | jq '.'
``` ```
```json ```json
@ -515,6 +515,23 @@ curl -s -X PATCH \
!!! note "PUT versus PATCH" !!! note "PUT versus PATCH"
The NetBox REST API support the use of either `PUT` or `PATCH` to modify an existing object. The difference is that a `PUT` request requires the user to specify a _complete_ representation of the object being modified, whereas a `PATCH` request need include only the attributes that are being updated. For most purposes, using `PATCH` is recommended. The NetBox REST API support the use of either `PUT` or `PATCH` to modify an existing object. The difference is that a `PUT` request requires the user to specify a _complete_ representation of the object being modified, whereas a `PATCH` request need include only the attributes that are being updated. For most purposes, using `PATCH` is recommended.
### Updating Multiple Objects
Multiple objects can be updated simultaneously by issuing a `PUT` or `PATCH` request to a model's list endpoint with a list of dictionaries specifying the numeric ID of each object to be deleted and the attributes to be updated. For example, to update sites with IDs 10 and 11 to a status of "active", issue the following request:
```no-highlight
curl -s -X PATCH \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
http://netbox/api/dcim/sites/ \
--data '[{"id": 10, "status": "active"}, {"id": 11, "status": "active"}]'
```
Note that there is no requirement for the attributes to be identical among objects. For instance, it's possible to update the status of one site along with the name of another in the same request.
!!! note
The bulk update of objects is an all-or-none operation, meaning that if NetBox fails to successfully update any of the specified objects (e.g. due a validation error), the entire operation will be aborted and none of the objects will be updated.
### Deleting an Object ### Deleting an Object
To delete an object from NetBox, make a `DELETE` request to the model's _detail_ endpoint specifying its unique numeric ID. The `Authorization` header must be included to specify an authorization token, however this type of request does not support passing any data in the body. To delete an object from NetBox, make a `DELETE` request to the model's _detail_ endpoint specifying its unique numeric ID. The `Authorization` header must be included to specify an authorization token, however this type of request does not support passing any data in the body.
@ -529,3 +546,18 @@ Note that `DELETE` requests do not return any data: If successful, the API will
!!! note !!! note
You can run `curl` with the verbose (`-v`) flag to inspect the HTTP response codes. You can run `curl` with the verbose (`-v`) flag to inspect the HTTP response codes.
### Deleting Multiple Objects
NetBox supports the simultaneous deletion of multiple objects of the same type by issuing a `DELETE` request to the model's list endpoint with a list of dictionaries specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request:
```no-highlight
curl -s -X DELETE \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
http://netbox/api/dcim/sites/ \
--data '[{"id": 10}, {"id": 11}, {"id": 12}]'
```
!!! note
The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted.

View File

@ -49,7 +49,6 @@ nav:
- Custom Links: 'additional-features/custom-links.md' - Custom Links: 'additional-features/custom-links.md'
- Custom Scripts: 'additional-features/custom-scripts.md' - Custom Scripts: 'additional-features/custom-scripts.md'
- Export Templates: 'additional-features/export-templates.md' - Export Templates: 'additional-features/export-templates.md'
- Graphs: 'additional-features/graphs.md'
- NAPALM: 'additional-features/napalm.md' - NAPALM: 'additional-features/napalm.md'
- Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md'
- Reports: 'additional-features/reports.md' - Reports: 'additional-features/reports.md'
@ -76,6 +75,7 @@ nav:
- User Preferences: 'development/user-preferences.md' - User Preferences: 'development/user-preferences.md'
- Release Checklist: 'development/release-checklist.md' - Release Checklist: 'development/release-checklist.md'
- Release Notes: - Release Notes:
- Version 2.10: 'release-notes/version-2.10.md'
- Version 2.9: 'release-notes/version-2.9.md' - Version 2.9: 'release-notes/version-2.9.md'
- Version 2.8: 'release-notes/version-2.8.md' - Version 2.8: 'release-notes/version-2.8.md'
- Version 2.7: 'release-notes/version-2.7.md' - Version 2.7: 'release-notes/version-2.7.md'

View File

@ -1,7 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from utilities.api import WritableNestedSerializer from netbox.api import WritableNestedSerializer
__all__ = [ __all__ = [
'NestedCircuitSerializer', 'NestedCircuitSerializer',

View File

@ -3,11 +3,11 @@ from rest_framework import serializers
from circuits.choices import CircuitStatusChoices from circuits.choices import CircuitStatusChoices
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
from dcim.api.serializers import ConnectedEndpointSerializer from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer from extras.api.serializers import TaggedObjectSerializer
from netbox.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
from .nested_serializers import * from .nested_serializers import *
@ -67,7 +67,7 @@ class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
] ]
class CircuitTerminationSerializer(ConnectedEndpointSerializer): class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer() circuit = NestedCircuitSerializer()
site = NestedSiteSerializer() site = NestedSiteSerializer()
@ -77,5 +77,6 @@ class CircuitTerminationSerializer(ConnectedEndpointSerializer):
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable'
] ]

View File

@ -1,4 +1,4 @@
from utilities.api import OrderedDefaultRouter from netbox.api import OrderedDefaultRouter
from . import views from . import views

View File

@ -1,15 +1,11 @@
from django.db.models import Prefetch from django.db.models import Prefetch
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from circuits import filters from circuits import filters
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
from extras.api.serializers import RenderedGraphSerializer from dcim.api.views import PathEndpointMixin
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph from netbox.api.views import ModelViewSet
from utilities.api import ModelViewSet
from utilities.utils import get_subquery from utilities.utils import get_subquery
from . import serializers from . import serializers
@ -33,16 +29,6 @@ class ProviderViewSet(CustomFieldModelViewSet):
serializer_class = serializers.ProviderSerializer serializer_class = serializers.ProviderSerializer
filterset_class = filters.ProviderFilterSet filterset_class = filters.ProviderFilterSet
@action(detail=True)
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular provider.
"""
provider = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='provider')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider})
return Response(serializer.data)
# #
# Circuit Types # Circuit Types
@ -62,9 +48,7 @@ class CircuitTypeViewSet(ModelViewSet):
class CircuitViewSet(CustomFieldModelViewSet): class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related( Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')),
'site', 'connected_endpoint__device'
)),
'type', 'tenant', 'provider', 'type', 'tenant', 'provider',
).prefetch_related('tags') ).prefetch_related('tags')
serializer_class = serializers.CircuitSerializer serializer_class = serializers.CircuitSerializer
@ -75,9 +59,9 @@ class CircuitViewSet(CustomFieldModelViewSet):
# Circuit Terminations # Circuit Terminations
# #
class CircuitTerminationViewSet(ModelViewSet): class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
queryset = CircuitTermination.objects.prefetch_related( queryset = CircuitTermination.objects.prefetch_related(
'circuit', 'site', 'connected_endpoint__device', 'cable' 'circuit', 'site', '_path__destination', 'cable'
) )
serializer_class = serializers.CircuitTerminationSerializer serializer_class = serializers.CircuitTerminationSerializer
filterset_class = filters.CircuitTerminationFilterSet filterset_class = filters.CircuitTerminationFilterSet

View File

@ -23,6 +23,15 @@ class CircuitStatusChoices(ChoiceSet):
(STATUS_DECOMMISSIONED, 'Decommissioned'), (STATUS_DECOMMISSIONED, 'Decommissioned'),
) )
CSS_CLASSES = {
STATUS_DEPROVISIONING: 'warning',
STATUS_ACTIVE: 'success',
STATUS_PLANNED: 'info',
STATUS_PROVISIONING: 'primary',
STATUS_OFFLINE: 'danger',
STATUS_DECOMMISSIONED: 'default',
}
# #
# CircuitTerminations # CircuitTerminations

View File

@ -1,8 +1,9 @@
import django_filters import django_filters
from django.db.models import Q from django.db.models import Q
from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet
from dcim.models import Region, Site from dcim.models import Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet from tenancy.filters import TenancyFilterSet
from utilities.filters import ( from utilities.filters import (
BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
@ -18,7 +19,7 @@ __all__ = (
) )
class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -72,7 +73,7 @@ class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet): class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -144,7 +145,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr
).distinct() ).distinct()
class CircuitTerminationFilterSet(BaseFilterSet): class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',

View File

@ -0,0 +1,22 @@
import django.core.serializers.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0019_nullbooleanfield_to_booleanfield'),
]
operations = [
migrations.AddField(
model_name='circuit',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
),
migrations.AddField(
model_name='provider',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
),
]

View File

@ -0,0 +1,49 @@
import sys
from django.db import migrations, models
import django.db.models.deletion
def cache_cable_peers(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType')
Cable = apps.get_model('dcim', 'Cable')
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
if 'test' not in sys.argv:
print(f"\n Updating circuit termination cable peers...", flush=True)
ct = ContentType.objects.get_for_model(CircuitTermination)
for cable in Cable.objects.filter(termination_a_type=ct):
CircuitTermination.objects.filter(pk=cable.termination_a_id).update(
_cable_peer_type_id=cable.termination_b_type_id,
_cable_peer_id=cable.termination_b_id
)
for cable in Cable.objects.filter(termination_b_type=ct):
CircuitTermination.objects.filter(pk=cable.termination_b_id).update(
_cable_peer_type_id=cable.termination_a_type_id,
_cable_peer_id=cable.termination_a_id
)
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('circuits', '0020_custom_field_data'),
]
operations = [
migrations.AddField(
model_name='circuittermination',
name='_cable_peer_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='circuittermination',
name='_cable_peer_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
),
migrations.RunPython(
code=cache_cable_peers,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,26 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0121_cablepath'),
('circuits', '0021_cache_cable_peer'),
]
operations = [
migrations.AddField(
model_name='circuittermination',
name='_path',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
),
migrations.RemoveField(
model_name='circuittermination',
name='connected_endpoint',
),
migrations.RemoveField(
model_name='circuittermination',
name='connection_status',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1 on 2020-10-09 17:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0022_cablepath'),
]
operations = [
migrations.AlterField(
model_name='circuittermination',
name='port_speed',
field=models.PositiveIntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 3.1 on 2020-10-15 19:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0023_circuittermination_port_speed_optional'),
]
operations = [
migrations.AlterField(
model_name='circuit',
name='cid',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='circuittype',
name='name',
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='circuittype',
name='slug',
field=models.SlugField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='provider',
name='name',
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='provider',
name='slug',
field=models.SlugField(max_length=100, unique=True),
),
]

View File

@ -1,11 +1,9 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from dcim.constants import CONNECTION_STATUS_CHOICES
from dcim.fields import ASNField from dcim.fields import ASNField
from dcim.models import CableTermination from dcim.models import CableTermination, PathEndpoint
from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
@ -22,17 +20,18 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Provider(ChangeLoggedModel, CustomFieldModel): class Provider(ChangeLoggedModel, CustomFieldModel):
""" """
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
stores information pertinent to the user's relationship with the Provider. stores information pertinent to the user's relationship with the Provider.
""" """
name = models.CharField( name = models.CharField(
max_length=50, max_length=100,
unique=True unique=True
) )
slug = models.SlugField( slug = models.SlugField(
max_length=100,
unique=True unique=True
) )
asn = ASNField( asn = ASNField(
@ -61,11 +60,6 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
@ -105,10 +99,11 @@ class CircuitType(ChangeLoggedModel):
"Long Haul," "Metro," or "Out-of-Band". "Long Haul," "Metro," or "Out-of-Band".
""" """
name = models.CharField( name = models.CharField(
max_length=50, max_length=100,
unique=True unique=True
) )
slug = models.SlugField( slug = models.SlugField(
max_length=100,
unique=True unique=True
) )
description = models.CharField( description = models.CharField(
@ -145,7 +140,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
in Kbps. in Kbps.
""" """
cid = models.CharField( cid = models.CharField(
max_length=50, max_length=100,
verbose_name='Circuit ID' verbose_name='Circuit ID'
) )
provider = models.ForeignKey( provider = models.ForeignKey(
@ -186,11 +181,6 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
objects = CircuitQuerySet.as_manager() objects = CircuitQuerySet.as_manager()
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
@ -202,15 +192,6 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
] ]
STATUS_CLASS_MAP = {
CircuitStatusChoices.STATUS_DEPROVISIONING: 'warning',
CircuitStatusChoices.STATUS_ACTIVE: 'success',
CircuitStatusChoices.STATUS_PLANNED: 'info',
CircuitStatusChoices.STATUS_PROVISIONING: 'primary',
CircuitStatusChoices.STATUS_OFFLINE: 'danger',
CircuitStatusChoices.STATUS_DECOMMISSIONED: 'default',
}
class Meta: class Meta:
ordering = ['provider', 'cid'] ordering = ['provider', 'cid']
unique_together = ['provider', 'cid'] unique_together = ['provider', 'cid']
@ -235,7 +216,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
) )
def get_status_class(self): def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status) return CircuitStatusChoices.CSS_CLASSES.get(self.status)
def _get_termination(self, side): def _get_termination(self, side):
for ct in self.terminations.all(): for ct in self.terminations.all():
@ -252,7 +233,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
return self._get_termination('Z') return self._get_termination('Z')
class CircuitTermination(CableTermination): class CircuitTermination(PathEndpoint, CableTermination):
circuit = models.ForeignKey( circuit = models.ForeignKey(
to='circuits.Circuit', to='circuits.Circuit',
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -268,20 +249,10 @@ class CircuitTermination(CableTermination):
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='circuit_terminations' related_name='circuit_terminations'
) )
connected_endpoint = models.OneToOneField(
to='dcim.Interface',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True,
null=True
)
port_speed = models.PositiveIntegerField( port_speed = models.PositiveIntegerField(
verbose_name='Port speed (Kbps)' verbose_name='Port speed (Kbps)',
blank=True,
null=True
) )
upstream_speed = models.PositiveIntegerField( upstream_speed = models.PositiveIntegerField(
blank=True, blank=True,

View File

@ -2,13 +2,9 @@ import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
from .models import Circuit, CircuitType, Provider from .models import Circuit, CircuitType, Provider
STATUS_LABEL = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
# #
# Providers # Providers
@ -64,9 +60,7 @@ class CircuitTable(BaseTable):
viewname='circuits:provider', viewname='circuits:provider',
args=[Accessor('provider__slug')] args=[Accessor('provider__slug')]
) )
status = tables.TemplateColumn( status = ChoiceFieldColumn()
template_code=STATUS_LABEL
)
tenant = tables.TemplateColumn( tenant = tables.TemplateColumn(
template_code=COL_TENANT template_code=COL_TENANT
) )

View File

@ -1,11 +1,8 @@
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from circuits.choices import * from circuits.choices import *
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.models import Site from dcim.models import Site
from extras.models import Graph
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
@ -35,6 +32,9 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
'slug': 'provider-6', 'slug': 'provider-6',
}, },
] ]
bulk_update_data = {
'asn': 1234,
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -46,27 +46,6 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
) )
Provider.objects.bulk_create(providers) Provider.objects.bulk_create(providers)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_provider_graphs(self):
"""
Test retrieval of Graphs assigned to Providers.
"""
provider = self.model.objects.first()
ct = ContentType.objects.get(app_label='circuits', model='provider')
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
self.add_permissions('circuits.view_provider')
url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=provider-1&foo=1')
class CircuitTypeTest(APIViewTestCases.APIViewTestCase): class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
model = CircuitType model = CircuitType
@ -85,6 +64,9 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
'slug': 'circuit-type-6', 'slug': 'circuit-type-6',
}, },
) )
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -100,6 +82,9 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
class CircuitTest(APIViewTestCases.APIViewTestCase): class CircuitTest(APIViewTestCases.APIViewTestCase):
model = Circuit model = Circuit
brief_fields = ['cid', 'id', 'url'] brief_fields = ['cid', 'id', 'url']
bulk_update_data = {
'status': 'planned',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -168,10 +153,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
circuit_terminations = ( circuit_terminations = (
CircuitTermination(circuit=circuits[0], site=sites[0], port_speed=100000, term_side=SIDE_A), CircuitTermination(circuit=circuits[0], site=sites[0], term_side=SIDE_A),
CircuitTermination(circuit=circuits[0], site=sites[1], port_speed=100000, term_side=SIDE_Z), CircuitTermination(circuit=circuits[0], site=sites[1], term_side=SIDE_Z),
CircuitTermination(circuit=circuits[1], site=sites[0], port_speed=100000, term_side=SIDE_A), CircuitTermination(circuit=circuits[1], site=sites[0], term_side=SIDE_A),
CircuitTermination(circuit=circuits[1], site=sites[1], port_speed=100000, term_side=SIDE_Z), CircuitTermination(circuit=circuits[1], site=sites[1], term_side=SIDE_Z),
) )
CircuitTermination.objects.bulk_create(circuit_terminations) CircuitTermination.objects.bulk_create(circuit_terminations)
@ -189,3 +174,7 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
'port_speed': 200000, 'port_speed': 200000,
}, },
] ]
cls.bulk_update_data = {
'port_speed': 123456
}

View File

@ -3,7 +3,7 @@ from django.test import TestCase
from circuits.choices import * from circuits.choices import *
from circuits.filters import * from circuits.filters import *
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.models import Region, Site from dcim.models import Cable, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -50,8 +50,8 @@ class ProviderTestCase(TestCase):
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
CircuitTermination.objects.bulk_create(( CircuitTermination.objects.bulk_create((
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000), CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A', port_speed=1000), CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A'),
)) ))
def test_id(self): def test_id(self):
@ -176,9 +176,9 @@ class CircuitTestCase(TestCase):
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
circuit_terminations = (( circuit_terminations = ((
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000), CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=1000), CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'),
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=1000), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'),
)) ))
CircuitTermination.objects.bulk_create(circuit_terminations) CircuitTermination.objects.bulk_create(circuit_terminations)
@ -286,6 +286,8 @@ class CircuitTerminationTestCase(TestCase):
)) ))
CircuitTermination.objects.bulk_create(circuit_terminations) CircuitTermination.objects.bulk_create(circuit_terminations)
Cable(termination_a=circuit_terminations[0], termination_b=circuit_terminations[1]).save()
def test_term_side(self): def test_term_side(self):
params = {'term_side': 'A'} params = {'term_side': 'A'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
@ -313,3 +315,13 @@ class CircuitTerminationTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'site': [sites[0].slug, sites[1].slug]} params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_cabled(self):
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)

View File

@ -1,6 +1,6 @@
from django.urls import path from django.urls import path
from dcim.views import CableCreateView, CableTraceView from dcim.views import CableCreateView, PathTraceView
from extras.views import ObjectChangeLogView from extras.views import ObjectChangeLogView
from . import views from . import views
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import Circuit, CircuitTermination, CircuitType, Provider
@ -45,6 +45,6 @@ urlpatterns = [
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
path('circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), path('circuit-terminations/<int:pk>/trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
] ]

View File

@ -3,13 +3,10 @@ from django.db import transaction
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from extras.models import Graph from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_subquery from utilities.utils import get_subquery
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables from . import filters, forms, tables
from .choices import CircuitTerminationSideChoices from .choices import CircuitTerminationSideChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import Circuit, CircuitTermination, CircuitType, Provider
@ -19,7 +16,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
# Providers # Providers
# #
class ProviderListView(ObjectListView): class ProviderListView(generic.ObjectListView):
queryset = Provider.objects.annotate( queryset = Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider') count_circuits=get_subquery(Circuit, 'provider')
) )
@ -28,18 +25,15 @@ class ProviderListView(ObjectListView):
table = tables.ProviderTable table = tables.ProviderTable
class ProviderView(ObjectView): class ProviderView(generic.ObjectView):
queryset = Provider.objects.all() queryset = Provider.objects.all()
def get(self, request, slug): def get_extra_context(self, request, instance):
provider = get_object_or_404(self.queryset, slug=slug)
circuits = Circuit.objects.restrict(request.user, 'view').filter( circuits = Circuit.objects.restrict(request.user, 'view').filter(
provider=provider provider=instance
).prefetch_related( ).prefetch_related(
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
).annotate_sites() ).annotate_sites()
show_graphs = Graph.objects.filter(type__model='provider').exists()
circuits_table = tables.CircuitTable(circuits) circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('provider') circuits_table.columns.hide('provider')
@ -50,30 +44,28 @@ class ProviderView(ObjectView):
} }
RequestConfig(request, paginate).configure(circuits_table) RequestConfig(request, paginate).configure(circuits_table)
return render(request, 'circuits/provider.html', { return {
'provider': provider,
'circuits_table': circuits_table, 'circuits_table': circuits_table,
'show_graphs': show_graphs, }
})
class ProviderEditView(ObjectEditView): class ProviderEditView(generic.ObjectEditView):
queryset = Provider.objects.all() queryset = Provider.objects.all()
model_form = forms.ProviderForm model_form = forms.ProviderForm
template_name = 'circuits/provider_edit.html' template_name = 'circuits/provider_edit.html'
class ProviderDeleteView(ObjectDeleteView): class ProviderDeleteView(generic.ObjectDeleteView):
queryset = Provider.objects.all() queryset = Provider.objects.all()
class ProviderBulkImportView(BulkImportView): class ProviderBulkImportView(generic.BulkImportView):
queryset = Provider.objects.all() queryset = Provider.objects.all()
model_form = forms.ProviderCSVForm model_form = forms.ProviderCSVForm
table = tables.ProviderTable table = tables.ProviderTable
class ProviderBulkEditView(BulkEditView): class ProviderBulkEditView(generic.BulkEditView):
queryset = Provider.objects.annotate( queryset = Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider') count_circuits=get_subquery(Circuit, 'provider')
) )
@ -82,7 +74,7 @@ class ProviderBulkEditView(BulkEditView):
form = forms.ProviderBulkEditForm form = forms.ProviderBulkEditForm
class ProviderBulkDeleteView(BulkDeleteView): class ProviderBulkDeleteView(generic.BulkDeleteView):
queryset = Provider.objects.annotate( queryset = Provider.objects.annotate(
count_circuits=get_subquery(Circuit, 'provider') count_circuits=get_subquery(Circuit, 'provider')
) )
@ -94,29 +86,29 @@ class ProviderBulkDeleteView(BulkDeleteView):
# Circuit Types # Circuit Types
# #
class CircuitTypeListView(ObjectListView): class CircuitTypeListView(generic.ObjectListView):
queryset = CircuitType.objects.annotate( queryset = CircuitType.objects.annotate(
circuit_count=get_subquery(Circuit, 'type') circuit_count=get_subquery(Circuit, 'type')
) )
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
class CircuitTypeEditView(ObjectEditView): class CircuitTypeEditView(generic.ObjectEditView):
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeForm model_form = forms.CircuitTypeForm
class CircuitTypeDeleteView(ObjectDeleteView): class CircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
class CircuitTypeBulkImportView(BulkImportView): class CircuitTypeBulkImportView(generic.BulkImportView):
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeCSVForm model_form = forms.CircuitTypeCSVForm
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
class CircuitTypeBulkDeleteView(BulkDeleteView): class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate( queryset = CircuitType.objects.annotate(
circuit_count=get_subquery(Circuit, 'type') circuit_count=get_subquery(Circuit, 'type')
) )
@ -127,7 +119,7 @@ class CircuitTypeBulkDeleteView(BulkDeleteView):
# Circuits # Circuits
# #
class CircuitListView(ObjectListView): class CircuitListView(generic.ObjectListView):
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations' 'provider', 'type', 'tenant', 'terminations'
).annotate_sites() ).annotate_sites()
@ -136,52 +128,52 @@ class CircuitListView(ObjectListView):
table = tables.CircuitTable table = tables.CircuitTable
class CircuitView(ObjectView): class CircuitView(generic.ObjectView):
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group') queryset = Circuit.objects.all()
def get(self, request, pk): def get_extra_context(self, request, instance):
circuit = get_object_or_404(self.queryset, pk=pk)
# A-side termination
termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
'site__region', 'connected_endpoint__device' 'site__region'
).filter( ).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
).first() ).first()
if termination_a and termination_a.connected_endpoint: if termination_a and termination_a.connected_endpoint:
termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view') termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
# Z-side termination
termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
'site__region', 'connected_endpoint__device' 'site__region'
).filter( ).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
).first() ).first()
if termination_z and termination_z.connected_endpoint: if termination_z and termination_z.connected_endpoint:
termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view') termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
return render(request, 'circuits/circuit.html', { return {
'circuit': circuit,
'termination_a': termination_a, 'termination_a': termination_a,
'termination_z': termination_z, 'termination_z': termination_z,
}) }
class CircuitEditView(ObjectEditView): class CircuitEditView(generic.ObjectEditView):
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
model_form = forms.CircuitForm model_form = forms.CircuitForm
template_name = 'circuits/circuit_edit.html' template_name = 'circuits/circuit_edit.html'
class CircuitDeleteView(ObjectDeleteView): class CircuitDeleteView(generic.ObjectDeleteView):
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
class CircuitBulkImportView(BulkImportView): class CircuitBulkImportView(generic.BulkImportView):
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
model_form = forms.CircuitCSVForm model_form = forms.CircuitCSVForm
table = tables.CircuitTable table = tables.CircuitTable
class CircuitBulkEditView(BulkEditView): class CircuitBulkEditView(generic.BulkEditView):
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations' 'provider', 'type', 'tenant', 'terminations'
) )
@ -190,7 +182,7 @@ class CircuitBulkEditView(BulkEditView):
form = forms.CircuitBulkEditForm form = forms.CircuitBulkEditForm
class CircuitBulkDeleteView(BulkDeleteView): class CircuitBulkDeleteView(generic.BulkDeleteView):
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations' 'provider', 'type', 'tenant', 'terminations'
) )
@ -198,7 +190,7 @@ class CircuitBulkDeleteView(BulkDeleteView):
table = tables.CircuitTable table = tables.CircuitTable
class CircuitSwapTerminations(ObjectEditView): class CircuitSwapTerminations(generic.ObjectEditView):
""" """
Swap the A and Z terminations of a circuit. Swap the A and Z terminations of a circuit.
""" """
@ -271,7 +263,7 @@ class CircuitSwapTerminations(ObjectEditView):
# Circuit terminations # Circuit terminations
# #
class CircuitTerminationEditView(ObjectEditView): class CircuitTerminationEditView(generic.ObjectEditView):
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationForm model_form = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html' template_name = 'circuits/circuittermination_edit.html'
@ -285,5 +277,5 @@ class CircuitTerminationEditView(ObjectEditView):
return obj.circuit.get_absolute_url() return obj.circuit.get_absolute_url()
class CircuitTerminationDeleteView(ObjectDeleteView): class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()

View File

@ -1,8 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from dcim.constants import CONNECTION_STATUS_CHOICES
from dcim import models from dcim import models
from utilities.api import ChoiceField, WritableNestedSerializer from netbox.api import WritableNestedSerializer
__all__ = [ __all__ = [
'NestedCableSerializer', 'NestedCableSerializer',
@ -228,51 +227,46 @@ class NestedDeviceSerializer(WritableNestedSerializer):
class NestedConsoleServerPortSerializer(WritableNestedSerializer): class NestedConsoleServerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer(read_only=True) device = NestedDeviceSerializer(read_only=True)
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta: class Meta:
model = models.ConsoleServerPort model = models.ConsoleServerPort
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] fields = ['id', 'url', 'device', 'name', 'cable']
class NestedConsolePortSerializer(WritableNestedSerializer): class NestedConsolePortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer(read_only=True) device = NestedDeviceSerializer(read_only=True)
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta: class Meta:
model = models.ConsolePort model = models.ConsolePort
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] fields = ['id', 'url', 'device', 'name', 'cable']
class NestedPowerOutletSerializer(WritableNestedSerializer): class NestedPowerOutletSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer(read_only=True) device = NestedDeviceSerializer(read_only=True)
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta: class Meta:
model = models.PowerOutlet model = models.PowerOutlet
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] fields = ['id', 'url', 'device', 'name', 'cable']
class NestedPowerPortSerializer(WritableNestedSerializer): class NestedPowerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer(read_only=True) device = NestedDeviceSerializer(read_only=True)
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta: class Meta:
model = models.PowerPort model = models.PowerPort
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] fields = ['id', 'url', 'device', 'name', 'cable']
class NestedInterfaceSerializer(WritableNestedSerializer): class NestedInterfaceSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True) device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta: class Meta:
model = models.Interface model = models.Interface
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] fields = ['id', 'url', 'device', 'name', 'cable']
class NestedRearPortSerializer(WritableNestedSerializer): class NestedRearPortSerializer(WritableNestedSerializer):
@ -305,10 +299,11 @@ class NestedDeviceBaySerializer(WritableNestedSerializer):
class NestedInventoryItemSerializer(WritableNestedSerializer): class NestedInventoryItemSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer(read_only=True) device = NestedDeviceSerializer(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta: class Meta:
model = models.InventoryItem model = models.InventoryItem
fields = ['id', 'url', 'device', 'name'] fields = ['id', 'url', 'device', 'name', '_depth']
# #

View File

@ -7,37 +7,57 @@ from rest_framework.validators import UniqueTogetherValidator
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import ( from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis, VirtualChassis,
) )
from dcim.utils import decompile_path_node
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer from extras.api.serializers import TaggedObjectSerializer
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
from ipam.models import VLAN from ipam.models import VLAN
from netbox.api import (
ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer,
WritableNestedSerializer,
)
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
from utilities.api import ( from utilities.api import get_serializer_for_model
ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer,
WritableNestedSerializer, get_serializer_for_model,
)
from virtualization.api.nested_serializers import NestedClusterSerializer from virtualization.api.nested_serializers import NestedClusterSerializer
from .nested_serializers import * from .nested_serializers import *
class CableTerminationSerializer(serializers.ModelSerializer):
cable_peer_type = serializers.SerializerMethodField(read_only=True)
cable_peer = serializers.SerializerMethodField(read_only=True)
def get_cable_peer_type(self, obj):
if obj._cable_peer is not None:
return f'{obj._cable_peer._meta.app_label}.{obj._cable_peer._meta.model_name}'
return None
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_cable_peer(self, obj):
"""
Return the appropriate serializer for the cable termination model.
"""
if obj._cable_peer is not None:
serializer = get_serializer_for_model(obj._cable_peer, prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj._cable_peer, context=context).data
return None
class ConnectedEndpointSerializer(ValidatedModelSerializer): class ConnectedEndpointSerializer(ValidatedModelSerializer):
connected_endpoint_type = serializers.SerializerMethodField(read_only=True) connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
connected_endpoint = serializers.SerializerMethodField(read_only=True) connected_endpoint = serializers.SerializerMethodField(read_only=True)
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
def get_connected_endpoint_type(self, obj): def get_connected_endpoint_type(self, obj):
if hasattr(obj, 'connected_endpoint') and obj.connected_endpoint is not None: if obj._path is not None and obj._path.destination is not None:
return '{}.{}'.format( return f'{obj._path.destination._meta.app_label}.{obj._path.destination._meta.model_name}'
obj.connected_endpoint._meta.app_label,
obj.connected_endpoint._meta.model_name
)
return None return None
@swagger_serializer_method(serializer_or_field=serializers.DictField) @swagger_serializer_method(serializer_or_field=serializers.DictField)
@ -45,14 +65,17 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer):
""" """
Return the appropriate serializer for the type of connected object. Return the appropriate serializer for the type of connected object.
""" """
if getattr(obj, 'connected_endpoint', None) is None: if obj._path is not None and obj._path.destination is not None:
serializer = get_serializer_for_model(obj._path.destination, prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj._path.destination, context=context).data
return None return None
serializer = get_serializer_for_model(obj.connected_endpoint, prefix='Nested') @swagger_serializer_method(serializer_or_field=serializers.BooleanField)
context = {'request': self.context['request']} def get_connected_endpoint_reachable(self, obj):
data = serializer(obj.connected_endpoint, context=context).data if obj._path is not None:
return obj._path.is_active
return data return None
# #
@ -168,7 +191,7 @@ class RackUnitSerializer(serializers.Serializer):
occupied = serializers.BooleanField(read_only=True) occupied = serializers.BooleanField(read_only=True)
class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
rack = NestedRackSerializer() rack = NestedRackSerializer()
user = NestedUserSerializer() user = NestedUserSerializer()
@ -176,7 +199,7 @@ class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags'] fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', 'custom_fields']
class RackElevationDetailFilterSerializer(serializers.Serializer): class RackElevationDetailFilterSerializer(serializers.Serializer):
@ -452,7 +475,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
method = serializers.DictField() method = serializers.DictField()
class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
@ -465,12 +488,12 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSeria
class Meta: class Meta:
model = ConsoleServerPort model = ConsoleServerPort
fields = [ fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type',
'connected_endpoint', 'connection_status', 'cable', 'tags', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
] ]
class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
@ -483,12 +506,12 @@ class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer)
class Meta: class Meta:
model = ConsolePort model = ConsolePort
fields = [ fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type',
'connected_endpoint', 'connection_status', 'cable', 'tags', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
] ]
class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
@ -511,12 +534,13 @@ class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer)
class Meta: class Meta:
model = PowerOutlet model = PowerOutlet
fields = [ fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable',
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags',
] ]
class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
@ -529,12 +553,13 @@ class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
class Meta: class Meta:
model = PowerPort model = PowerPort
fields = [ fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable',
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags',
] ]
class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer): class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices) type = ChoiceField(choices=InterfaceTypeChoices)
@ -554,32 +579,26 @@ class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
model = Interface model = Interface
fields = [ fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'cable', 'cable_peer', 'cable_peer_type',
'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
'count_ipaddresses',
] ]
# TODO: This validation should be handled by Interface.clean()
def validate(self, data): def validate(self, data):
# All associated VLANs be global or assigned to the parent device's site. # Validate many-to-many VLAN assignments
device = self.instance.device if self.instance else data.get('device') device = self.instance.device if self.instance else data.get('device')
untagged_vlan = data.get('untagged_vlan')
if untagged_vlan and untagged_vlan.site not in [device.site, None]:
raise serializers.ValidationError({
'untagged_vlan': "VLAN {} must belong to the same site as the interface's parent device, or it must be "
"global.".format(untagged_vlan)
})
for vlan in data.get('tagged_vlans', []): for vlan in data.get('tagged_vlans', []):
if vlan.site not in [device.site, None]: if vlan.site not in [device.site, None]:
raise serializers.ValidationError({ raise serializers.ValidationError({
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must " 'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, or "
"be global.".format(vlan) f"it must be global."
}) })
return super().validate(data) return super().validate(data)
class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
@ -587,7 +606,10 @@ class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class Meta: class Meta:
model = RearPort model = RearPort
fields = ['id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags'] fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer',
'cable_peer_type', 'tags',
]
class FrontPortRearPortSerializer(WritableNestedSerializer): class FrontPortRearPortSerializer(WritableNestedSerializer):
@ -601,7 +623,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'name', 'label'] fields = ['id', 'url', 'name', 'label']
class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
@ -612,7 +634,7 @@ class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
model = FrontPort model = FrontPort
fields = [ fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable',
'tags', 'cable_peer', 'cable_peer_type', 'tags',
] ]
@ -636,12 +658,13 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
# Provide a default value to satisfy UniqueTogetherValidator # Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta: class Meta:
model = InventoryItem model = InventoryItem
fields = [ fields = [
'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag',
'discovered', 'description', 'tags', 'discovered', 'description', 'tags', '_depth',
] ]
@ -649,7 +672,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
# Cables # Cables
# #
class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
termination_a_type = ContentTypeField( termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
@ -667,6 +690,7 @@ class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
fields = [ fields = [
'id', 'url', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'id', 'url', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', 'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
'custom_fields',
] ]
def _get_termination(self, obj, side): def _get_termination(self, obj, side):
@ -706,6 +730,49 @@ class TracedCableSerializer(serializers.ModelSerializer):
] ]
class CablePathSerializer(serializers.ModelSerializer):
origin_type = ContentTypeField(read_only=True)
origin = serializers.SerializerMethodField(read_only=True)
destination_type = ContentTypeField(read_only=True)
destination = serializers.SerializerMethodField(read_only=True)
path = serializers.SerializerMethodField(read_only=True)
class Meta:
model = CablePath
fields = [
'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', 'is_split',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_origin(self, obj):
"""
Return the appropriate serializer for the origin.
"""
serializer = get_serializer_for_model(obj.origin, prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj.origin, context=context).data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_destination(self, obj):
"""
Return the appropriate serializer for the destination, if any.
"""
if obj.destination_id is not None:
serializer = get_serializer_for_model(obj.destination, prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj.destination, context=context).data
return None
@swagger_serializer_method(serializer_or_field=serializers.ListField)
def get_path(self, obj):
ret = []
for node in obj.get_path():
serializer = get_serializer_for_model(node, prefix='Nested')
context = {'request': self.context['request']}
ret.append(serializer(node, context=context).data)
return ret
# #
# Interface connections # Interface connections
# #
@ -713,37 +780,43 @@ class TracedCableSerializer(serializers.ModelSerializer):
class InterfaceConnectionSerializer(ValidatedModelSerializer): class InterfaceConnectionSerializer(ValidatedModelSerializer):
interface_a = serializers.SerializerMethodField() interface_a = serializers.SerializerMethodField()
interface_b = NestedInterfaceSerializer(source='connected_endpoint') interface_b = NestedInterfaceSerializer(source='connected_endpoint')
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = Interface model = Interface
fields = ['interface_a', 'interface_b', 'connection_status'] fields = ['interface_a', 'interface_b', 'connected_endpoint_reachable']
@swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer) @swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer)
def get_interface_a(self, obj): def get_interface_a(self, obj):
context = {'request': self.context['request']} context = {'request': self.context['request']}
return NestedInterfaceSerializer(instance=obj, context=context).data return NestedInterfaceSerializer(instance=obj, context=context).data
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
def get_connected_endpoint_reachable(self, obj):
if obj._path is not None:
return obj._path.is_active
return None
# #
# Virtual chassis # Virtual chassis
# #
class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer(required=False) master = NestedDeviceSerializer(required=False)
member_count = serializers.IntegerField(read_only=True) member_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = VirtualChassis model = VirtualChassis
fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'member_count'] fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
# #
# Power panels # Power panels
# #
class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
site = NestedSiteSerializer() site = NestedSiteSerializer()
rack_group = NestedRackGroupSerializer( rack_group = NestedRackGroupSerializer(
@ -755,10 +828,15 @@ class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class Meta: class Meta:
model = PowerPanel model = PowerPanel
fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'powerfeed_count'] fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'custom_fields', 'powerfeed_count']
class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class PowerFeedSerializer(
TaggedObjectSerializer,
CableTerminationSerializer,
ConnectedEndpointSerializer,
CustomFieldModelSerializer
):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer() power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer( rack = NestedRackSerializer(
@ -788,5 +866,7 @@ class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
model = PowerFeed model = PowerFeed
fields = [ fields = [
'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'cable', 'max_utilization', 'comments', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
'last_updated',
] ]

View File

@ -1,4 +1,4 @@
from utilities.api import OrderedDefaultRouter from netbox.api import OrderedDefaultRouter
from . import views from . import views

View File

@ -17,21 +17,20 @@ from rest_framework.viewsets import GenericViewSet, ViewSet
from circuits.models import Circuit from circuits.models import Circuit
from dcim import filters from dcim import filters
from dcim.models import ( from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis, VirtualChassis,
) )
from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
from extras.models import Graph
from ipam.models import Prefix, VLAN from ipam.models import Prefix, VLAN
from utilities.api import ( from netbox.api.views import ModelViewSet
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
) from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata
from utilities.api import get_serializer_for_model
from utilities.utils import get_subquery from utilities.utils import get_subquery
from utilities.metadata import ContentTypeMetadata
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import serializers from . import serializers
from .exceptions import MissingFilterException from .exceptions import MissingFilterException
@ -47,7 +46,7 @@ class DCIMRootView(APIRootView):
# Mixins # Mixins
class CableTraceMixin(object): class PathEndpointMixin(object):
@action(detail=True, url_path='trace') @action(detail=True, url_path='trace')
def trace(self, request, pk): def trace(self, request, pk):
@ -59,7 +58,10 @@ class CableTraceMixin(object):
# Initialize the path array # Initialize the path array
path = [] path = []
for near_end, cable, far_end in obj.trace()[0]: for near_end, cable, far_end in obj.trace():
if near_end is None:
# Split paths
break
# Serialize each object # Serialize each object
serializer_a = get_serializer_for_model(near_end, prefix='Nested') serializer_a = get_serializer_for_model(near_end, prefix='Nested')
@ -79,6 +81,20 @@ class CableTraceMixin(object):
return Response(path) return Response(path)
class PassThroughPortMixin(object):
@action(detail=True, url_path='paths')
def paths(self, request, pk):
"""
Return all CablePaths which traverse a given pass-through port.
"""
obj = get_object_or_404(self.queryset, pk=pk)
cablepaths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin', 'destination')
serializer = serializers.CablePathSerializer(cablepaths, context={'request': request}, many=True)
return Response(serializer.data)
# #
# Regions # Regions
# #
@ -113,16 +129,6 @@ class SiteViewSet(CustomFieldModelViewSet):
serializer_class = serializers.SiteSerializer serializer_class = serializers.SiteSerializer
filterset_class = filters.SiteFilterSet filterset_class = filters.SiteFilterSet
@action(detail=True)
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular site.
"""
site = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='site')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site})
return Response(serializer.data)
# #
# Rack groups # Rack groups
@ -363,17 +369,6 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
return serializers.DeviceWithConfigContextSerializer return serializers.DeviceWithConfigContextSerializer
@action(detail=True)
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular Device.
"""
device = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='device')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device})
return Response(serializer.data)
@swagger_auto_schema( @swagger_auto_schema(
manual_parameters=[ manual_parameters=[
Parameter( Parameter(
@ -494,59 +489,47 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
# Device components # Device components
# #
class ConsolePortViewSet(CableTraceMixin, ModelViewSet): class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
queryset = ConsolePort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
serializer_class = serializers.ConsolePortSerializer serializer_class = serializers.ConsolePortSerializer
filterset_class = filters.ConsolePortFilterSet filterset_class = filters.ConsolePortFilterSet
class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet): class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
queryset = ConsoleServerPort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') queryset = ConsoleServerPort.objects.prefetch_related(
'device', '_path__destination', 'cable', '_cable_peer', 'tags'
)
serializer_class = serializers.ConsoleServerPortSerializer serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filters.ConsoleServerPortFilterSet filterset_class = filters.ConsoleServerPortFilterSet
class PowerPortViewSet(CableTraceMixin, ModelViewSet): class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
queryset = PowerPort.objects.prefetch_related( queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable', 'tags'
)
serializer_class = serializers.PowerPortSerializer serializer_class = serializers.PowerPortSerializer
filterset_class = filters.PowerPortFilterSet filterset_class = filters.PowerPortFilterSet
class PowerOutletViewSet(CableTraceMixin, ModelViewSet): class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
queryset = PowerOutlet.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags') queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
serializer_class = serializers.PowerOutletSerializer serializer_class = serializers.PowerOutletSerializer
filterset_class = filters.PowerOutletFilterSet filterset_class = filters.PowerOutletFilterSet
class InterfaceViewSet(CableTraceMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
queryset = Interface.objects.prefetch_related( queryset = Interface.objects.prefetch_related(
'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags' 'device', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
).filter(
device__isnull=False
) )
serializer_class = serializers.InterfaceSerializer serializer_class = serializers.InterfaceSerializer
filterset_class = filters.InterfaceFilterSet filterset_class = filters.InterfaceFilterSet
@action(detail=True)
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular interface.
"""
interface = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='interface')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface})
return Response(serializer.data)
class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
class FrontPortViewSet(CableTraceMixin, ModelViewSet):
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
serializer_class = serializers.FrontPortSerializer serializer_class = serializers.FrontPortSerializer
filterset_class = filters.FrontPortFilterSet filterset_class = filters.FrontPortFilterSet
class RearPortViewSet(CableTraceMixin, ModelViewSet): class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
serializer_class = serializers.RearPortSerializer serializer_class = serializers.RearPortSerializer
filterset_class = filters.RearPortFilterSet filterset_class = filters.RearPortFilterSet
@ -569,32 +552,26 @@ class InventoryItemViewSet(ModelViewSet):
# #
class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet): class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = ConsolePort.objects.prefetch_related( queryset = ConsolePort.objects.prefetch_related('device', '_path').filter(
'device', 'connected_endpoint__device' _path__destination_id__isnull=False
).filter(
connected_endpoint__isnull=False
) )
serializer_class = serializers.ConsolePortSerializer serializer_class = serializers.ConsolePortSerializer
filterset_class = filters.ConsoleConnectionFilterSet filterset_class = filters.ConsoleConnectionFilterSet
class PowerConnectionViewSet(ListModelMixin, GenericViewSet): class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = PowerPort.objects.prefetch_related( queryset = PowerPort.objects.prefetch_related('device', '_path').filter(
'device', 'connected_endpoint__device' _path__destination_id__isnull=False
).filter(
_connected_poweroutlet__isnull=False
) )
serializer_class = serializers.PowerPortSerializer serializer_class = serializers.PowerPortSerializer
filterset_class = filters.PowerConnectionFilterSet filterset_class = filters.PowerConnectionFilterSet
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = Interface.objects.prefetch_related( queryset = Interface.objects.prefetch_related('device', '_path').filter(
'device', '_connected_interface__device'
).filter(
# Avoid duplicate connections by only selecting the lower PK in a connected pair # Avoid duplicate connections by only selecting the lower PK in a connected pair
_connected_interface__isnull=False, _path__destination_id__isnull=False,
pk__lt=F('_connected_interface') pk__lt=F('_path__destination_id')
) )
serializer_class = serializers.InterfaceConnectionSerializer serializer_class = serializers.InterfaceConnectionSerializer
filterset_class = filters.InterfaceConnectionFilterSet filterset_class = filters.InterfaceConnectionFilterSet
@ -643,8 +620,10 @@ class PowerPanelViewSet(ModelViewSet):
# Power feeds # Power feeds
# #
class PowerFeedViewSet(CustomFieldModelViewSet): class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack', 'tags') queryset = PowerFeed.objects.prefetch_related(
'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags'
)
serializer_class = serializers.PowerFeedSerializer serializer_class = serializers.PowerFeedSerializer
filterset_class = filters.PowerFeedFilterSet filterset_class = filters.PowerFeedFilterSet
@ -699,7 +678,7 @@ class ConnectedDeviceViewSet(ViewSet):
device__name=peer_device_name, device__name=peer_device_name,
name=peer_interface_name name=peer_interface_name
) )
local_interface = peer_interface._connected_interface local_interface = peer_interface.connected_endpoint
if local_interface is None: if local_interface is None:
return Response() return Response()

View File

@ -21,6 +21,14 @@ class SiteStatusChoices(ChoiceSet):
(STATUS_RETIRED, 'Retired'), (STATUS_RETIRED, 'Retired'),
) )
CSS_CLASSES = {
STATUS_PLANNED: 'info',
STATUS_STAGING: 'primary',
STATUS_ACTIVE: 'success',
STATUS_DECOMMISSIONING: 'warning',
STATUS_RETIRED: 'danger',
}
# #
# Racks # Racks
@ -74,6 +82,14 @@ class RackStatusChoices(ChoiceSet):
(STATUS_DEPRECATED, 'Deprecated'), (STATUS_DEPRECATED, 'Deprecated'),
) )
CSS_CLASSES = {
STATUS_RESERVED: 'warning',
STATUS_AVAILABLE: 'success',
STATUS_PLANNED: 'info',
STATUS_ACTIVE: 'primary',
STATUS_DEPRECATED: 'danger',
}
class RackDimensionUnitChoices(ChoiceSet): class RackDimensionUnitChoices(ChoiceSet):
@ -147,6 +163,16 @@ class DeviceStatusChoices(ChoiceSet):
(STATUS_DECOMMISSIONING, 'Decommissioning'), (STATUS_DECOMMISSIONING, 'Decommissioning'),
) )
CSS_CLASSES = {
STATUS_OFFLINE: 'warning',
STATUS_ACTIVE: 'success',
STATUS_PLANNED: 'info',
STATUS_STAGED: 'primary',
STATUS_FAILED: 'danger',
STATUS_INVENTORY: 'default',
STATUS_DECOMMISSIONING: 'warning',
}
# #
# ConsolePorts # ConsolePorts
@ -969,6 +995,12 @@ class CableStatusChoices(ChoiceSet):
(STATUS_DECOMMISSIONING, 'Decommissioning'), (STATUS_DECOMMISSIONING, 'Decommissioning'),
) )
CSS_CLASSES = {
STATUS_CONNECTED: 'success',
STATUS_PLANNED: 'info',
STATUS_DECOMMISSIONING: 'warning',
}
class CableLengthUnitChoices(ChoiceSet): class CableLengthUnitChoices(ChoiceSet):
@ -1003,6 +1035,13 @@ class PowerFeedStatusChoices(ChoiceSet):
(STATUS_FAILED, 'Failed'), (STATUS_FAILED, 'Failed'),
) )
CSS_CLASSES = {
STATUS_OFFLINE: 'warning',
STATUS_ACTIVE: 'success',
STATUS_PLANNED: 'info',
STATUS_FAILED: 'danger',
}
class PowerFeedTypeChoices(ChoiceSet): class PowerFeedTypeChoices(ChoiceSet):
@ -1014,6 +1053,11 @@ class PowerFeedTypeChoices(ChoiceSet):
(TYPE_REDUNDANT, 'Redundant'), (TYPE_REDUNDANT, 'Redundant'),
) )
CSS_CLASSES = {
TYPE_PRIMARY: 'success',
TYPE_REDUNDANT: 'info',
}
class PowerFeedSupplyChoices(ChoiceSet): class PowerFeedSupplyChoices(ChoiceSet):

View File

@ -59,12 +59,6 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
# Cabling and connections # Cabling and connections
# #
# Console/power/interface connection statuses
CONNECTION_STATUS_CHOICES = [
[False, 'Not Connected'],
[True, 'Connected'],
]
# Cable endpoint types # Cable endpoint types
CABLE_TERMINATION_MODELS = Q( CABLE_TERMINATION_MODELS = Q(
Q(app_label='circuits', model__in=( Q(app_label='circuits', model__in=(
@ -83,12 +77,13 @@ CABLE_TERMINATION_MODELS = Q(
) )
COMPATIBLE_TERMINATION_TYPES = { COMPATIBLE_TERMINATION_TYPES = {
'circuittermination': ['interface', 'frontport', 'rearport', 'circuittermination'],
'consoleport': ['consoleserverport', 'frontport', 'rearport'], 'consoleport': ['consoleserverport', 'frontport', 'rearport'],
'consoleserverport': ['consoleport', 'frontport', 'rearport'], 'consoleserverport': ['consoleport', 'frontport', 'rearport'],
'powerport': ['poweroutlet', 'powerfeed'],
'poweroutlet': ['powerport'],
'interface': ['interface', 'circuittermination', 'frontport', 'rearport'], 'interface': ['interface', 'circuittermination', 'frontport', 'rearport'],
'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], 'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
'powerfeed': ['powerport'],
'poweroutlet': ['powerport'],
'powerport': ['poweroutlet', 'powerfeed'],
'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], 'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
'circuittermination': ['interface', 'frontport', 'rearport', 'circuittermination'],
} }

View File

@ -1,14 +0,0 @@
class LoopDetected(Exception):
"""
A loop has been detected while tracing a cable path.
"""
pass
class CableTraceSplit(Exception):
"""
A cable trace cannot be completed because a RearPort maps to multiple FrontPorts and
we don't know which one to follow.
"""
def __init__(self, termination, *args, **kwargs):
self.termination = termination

View File

@ -1,9 +1,11 @@
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models from django.db import models
from netaddr import AddrFormatError, EUI, mac_unix_expanded from netaddr import AddrFormatError, EUI, mac_unix_expanded
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
from .lookups import PathContains
class ASNField(models.BigIntegerField): class ASNField(models.BigIntegerField):
@ -50,3 +52,15 @@ class MACAddressField(models.Field):
if not value: if not value:
return None return None
return str(self.to_python(value)) return str(self.to_python(value))
class PathField(ArrayField):
"""
An ArrayField which holds a set of objects, each identified by a (type, ID) tuple.
"""
def __init__(self, **kwargs):
kwargs['base_field'] = models.CharField(max_length=40)
super().__init__(**kwargs)
PathField.register_lookup(PathContains)

View File

@ -1,7 +1,8 @@
import django_filters import django_filters
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Count
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet from tenancy.filters import TenancyFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
@ -23,6 +24,7 @@ from .models import (
__all__ = ( __all__ = (
'CableFilterSet', 'CableFilterSet',
'CableTerminationFilterSet',
'ConsoleConnectionFilterSet', 'ConsoleConnectionFilterSet',
'ConsolePortFilterSet', 'ConsolePortFilterSet',
'ConsolePortTemplateFilterSet', 'ConsolePortTemplateFilterSet',
@ -40,6 +42,7 @@ __all__ = (
'InterfaceTemplateFilterSet', 'InterfaceTemplateFilterSet',
'InventoryItemFilterSet', 'InventoryItemFilterSet',
'ManufacturerFilterSet', 'ManufacturerFilterSet',
'PathEndpointFilterSet',
'PlatformFilterSet', 'PlatformFilterSet',
'PowerConnectionFilterSet', 'PowerConnectionFilterSet',
'PowerFeedFilterSet', 'PowerFeedFilterSet',
@ -77,7 +80,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description'] fields = ['id', 'name', 'slug', 'description']
class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -176,7 +179,7 @@ class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'color'] fields = ['id', 'name', 'slug', 'color']
class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -322,7 +325,7 @@ class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'description'] fields = ['id', 'name', 'slug', 'description']
class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -501,7 +504,7 @@ class DeviceFilterSet(
BaseFilterSet, BaseFilterSet,
TenancyFilterSet, TenancyFilterSet,
LocalConfigContextFilterSet, LocalConfigContextFilterSet,
CustomFieldFilterSet, CustomFieldModelFilterSet,
CreatedUpdatedFilterSet CreatedUpdatedFilterSet
): ):
q = django_filters.CharFilter( q = django_filters.CharFilter(
@ -746,71 +749,76 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
) )
class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet): class CableTerminationFilterSet(django_filters.FilterSet):
type = django_filters.MultipleChoiceFilter(
choices=ConsolePortTypeChoices,
null_value=None
)
cabled = django_filters.BooleanFilter( cabled = django_filters.BooleanFilter(
field_name='cable', field_name='cable',
lookup_expr='isnull', lookup_expr='isnull',
exclude=True exclude=True
) )
class PathEndpointFilterSet(django_filters.FilterSet):
connected = django_filters.BooleanFilter(
method='filter_connected'
)
def filter_connected(self, queryset, name, value):
if value:
return queryset.filter(_path__is_active=True)
else:
return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False))
class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=ConsolePortTypeChoices,
null_value=None
)
class Meta: class Meta:
model = ConsolePort model = ConsolePort
fields = ['id', 'name', 'description', 'connection_status'] fields = ['id', 'name', 'description']
class ConsoleServerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): class ConsoleServerPortFilterSet(
BaseFilterSet,
DeviceComponentFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet
):
type = django_filters.MultipleChoiceFilter( type = django_filters.MultipleChoiceFilter(
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
null_value=None null_value=None
) )
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
exclude=True
)
class Meta: class Meta:
model = ConsoleServerPort model = ConsoleServerPort
fields = ['id', 'name', 'description', 'connection_status'] fields = ['id', 'name', 'description']
class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
type = django_filters.MultipleChoiceFilter( type = django_filters.MultipleChoiceFilter(
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
null_value=None null_value=None
) )
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
exclude=True
)
class Meta: class Meta:
model = PowerPort model = PowerPort
fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status'] fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description']
class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet): class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
type = django_filters.MultipleChoiceFilter( type = django_filters.MultipleChoiceFilter(
choices=PowerOutletTypeChoices, choices=PowerOutletTypeChoices,
null_value=None null_value=None
) )
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
exclude=True
)
class Meta: class Meta:
model = PowerOutlet model = PowerOutlet
fields = ['id', 'name', 'feed_leg', 'description', 'connection_status'] fields = ['id', 'name', 'feed_leg', 'description']
class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet): class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -827,11 +835,6 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
field_name='pk', field_name='pk',
label='Device (ID)', label='Device (ID)',
) )
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
exclude=True
)
kind = django_filters.CharFilter( kind = django_filters.CharFilter(
method='filter_kind', method='filter_kind',
label='Kind of interface', label='Kind of interface',
@ -858,7 +861,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
class Meta: class Meta:
model = Interface model = Interface
fields = ['id', 'name', 'connection_status', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] fields = ['id', 'name', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
def filter_device(self, queryset, name, value): def filter_device(self, queryset, name, value):
try: try:
@ -908,24 +911,14 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
}.get(value, queryset.none()) }.get(value, queryset.none())
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
exclude=True
)
class Meta: class Meta:
model = FrontPort model = FrontPort
fields = ['id', 'name', 'type', 'description'] fields = ['id', 'name', 'type', 'description']
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet): class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
cabled = django_filters.BooleanFilter(
field_name='cable',
lookup_expr='isnull',
exclude=True
)
class Meta: class Meta:
model = RearPort model = RearPort
@ -1133,7 +1126,20 @@ class CableFilterSet(BaseFilterSet):
return queryset return queryset
class ConsoleConnectionFilterSet(BaseFilterSet): class ConnectionFilterSet:
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(device_id__in=value)
class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(
method='filter_site', method='filter_site',
label='Site (slug)', label='Site (slug)',
@ -1148,23 +1154,10 @@ class ConsoleConnectionFilterSet(BaseFilterSet):
class Meta: class Meta:
model = ConsolePort model = ConsolePort
fields = ['name', 'connection_status'] fields = ['name']
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(connected_endpoint__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(
Q(**{'{}__in'.format(name): value}) |
Q(**{'connected_endpoint__{}__in'.format(name): value})
)
class PowerConnectionFilterSet(BaseFilterSet): class PowerConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(
method='filter_site', method='filter_site',
label='Site (slug)', label='Site (slug)',
@ -1179,23 +1172,10 @@ class PowerConnectionFilterSet(BaseFilterSet):
class Meta: class Meta:
model = PowerPort model = PowerPort
fields = ['name', 'connection_status'] fields = ['name']
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(_connected_poweroutlet__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(
Q(**{'{}__in'.format(name): value}) |
Q(**{'_connected_poweroutlet__{}__in'.format(name): value})
)
class InterfaceConnectionFilterSet(BaseFilterSet): class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(
method='filter_site', method='filter_site',
label='Site (slug)', label='Site (slug)',
@ -1210,23 +1190,7 @@ class InterfaceConnectionFilterSet(BaseFilterSet):
class Meta: class Meta:
model = Interface model = Interface
fields = ['connection_status'] fields = []
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(device__site__slug=value) |
Q(_connected_interface__device__site__slug=value)
)
def filter_device(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(
Q(**{'{}__in'.format(name): value}) |
Q(**{'_connected_interface__{}__in'.format(name): value})
)
class PowerPanelFilterSet(BaseFilterSet): class PowerPanelFilterSet(BaseFilterSet):
@ -1278,7 +1242,13 @@ class PowerPanelFilterSet(BaseFilterSet):
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): class PowerFeedFilterSet(
BaseFilterSet,
CableTerminationFilterSet,
PathEndpointFilterSet,
CustomFieldModelFilterSet,
CreatedUpdatedFilterSet
):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',

View File

@ -22,9 +22,10 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ColorSelect, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm,
DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
@ -88,13 +89,12 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form):
) )
class InterfaceCommonForm: class InterfaceCommonForm(forms.Form):
def clean(self): def clean(self):
super().clean() super().clean()
# Validate VLAN assignments parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
tagged_vlans = self.cleaned_data['tagged_vlans'] tagged_vlans = self.cleaned_data['tagged_vlans']
# Untagged interfaces cannot be assigned tagged VLANs # Untagged interfaces cannot be assigned tagged VLANs
@ -109,13 +109,13 @@ class InterfaceCommonForm:
# Validate tagged VLANs; must be a global VLAN or in the same site # Validate tagged VLANs; must be a global VLAN or in the same site
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED: elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED:
valid_sites = [None, self.cleaned_data['device'].site] valid_sites = [None, self.cleaned_data[parent_field].site]
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
if invalid_vlans: if invalid_vlans:
raise forms.ValidationError({ raise forms.ValidationError({
'tagged_vlans': "The tagged VLANs ({}) must belong to the same site as the interface's parent " 'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
"device/VM, or they must be global".format(', '.join(invalid_vlans)) f"the interface's parent device/VM, or they must be global"
}) })
@ -721,7 +721,7 @@ class RackElevationFilterForm(RackFilterForm):
# Rack reservations # Rack reservations
# #
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
region = DynamicModelChoiceField( region = DynamicModelChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
required=False, required=False,
@ -2688,7 +2688,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model) tag = TagFilterField(model)
class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm):
untagged_vlan = DynamicModelChoiceField( untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
@ -3361,6 +3361,13 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
queryset=Device.objects.all(), queryset=Device.objects.all(),
display_field='display_name' display_field='display_name'
) )
parent = DynamicModelChoiceField(
queryset=InventoryItem.objects.all(),
required=False,
query_params={
'device_id': '$device'
}
)
manufacturer = DynamicModelChoiceField( manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False required=False
@ -3373,7 +3380,8 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = InventoryItem model = InventoryItem
fields = [ fields = [
'name', 'label', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
'tags',
] ]
@ -3382,6 +3390,13 @@ class InventoryItemCreateForm(ComponentCreateForm):
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False required=False
) )
parent = DynamicModelChoiceField(
queryset=InventoryItem.objects.all(),
required=False,
query_params={
'device_id': '$device'
}
)
part_id = forms.CharField( part_id = forms.CharField(
max_length=50, max_length=50,
required=False, required=False,
@ -3396,8 +3411,8 @@ class InventoryItemCreateForm(ComponentCreateForm):
required=False, required=False,
) )
field_order = ( field_order = (
'device', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag',
'tags', 'description', 'tags',
) )
@ -3472,7 +3487,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
# Cables # Cables
# #
class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
""" """
Base form for connecting a Cable to a Device component Base form for connecting a Cable to a Device component
""" """
@ -3609,7 +3624,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
) )
class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm):
termination_b_provider = DynamicModelChoiceField( termination_b_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
label='Provider', label='Provider',
@ -3663,7 +3678,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
return getattr(self.cleaned_data['termination_b_id'], 'pk', None) return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
termination_b_region = DynamicModelChoiceField( termination_b_region = DynamicModelChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
label='Region', label='Region',
@ -3720,7 +3735,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
return getattr(self.cleaned_data['termination_b_id'], 'pk', None) return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
class CableForm(BootstrapMixin, forms.ModelForm): class CableForm(BootstrapMixin, CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
required=False required=False
@ -3750,10 +3765,9 @@ class CableCSVForm(CSVModelForm):
to_field_name='name', to_field_name='name',
help_text='Side A device' help_text='Side A device'
) )
side_a_type = CSVModelChoiceField( side_a_type = CSVContentTypeField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS, limit_choices_to=CABLE_TERMINATION_MODELS,
to_field_name='model',
help_text='Side A type' help_text='Side A type'
) )
side_a_name = forms.CharField( side_a_name = forms.CharField(
@ -3766,10 +3780,9 @@ class CableCSVForm(CSVModelForm):
to_field_name='name', to_field_name='name',
help_text='Side B device' help_text='Side B device'
) )
side_b_type = CSVModelChoiceField( side_b_type = CSVContentTypeField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS, limit_choices_to=CABLE_TERMINATION_MODELS,
to_field_name='model',
help_text='Side B type' help_text='Side B type'
) )
side_b_name = forms.CharField( side_b_name = forms.CharField(
@ -3803,58 +3816,36 @@ class CableCSVForm(CSVModelForm):
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'), 'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
} }
# TODO: Merge the clean() methods for either end def _clean_side(self, side):
def clean_side_a_name(self): """
Derive a Cable's A/B termination objects.
device = self.cleaned_data.get('side_a_device') :param side: 'a' or 'b'
content_type = self.cleaned_data.get('side_a_type') """
name = self.cleaned_data.get('side_a_name') assert side in 'ab', f"Invalid side designation: {side}"
device = self.cleaned_data.get(f'side_{side}_device')
content_type = self.cleaned_data.get(f'side_{side}_type')
name = self.cleaned_data.get(f'side_{side}_name')
if not device or not content_type or not name: if not device or not content_type or not name:
return None return None
model = content_type.model_class() model = content_type.model_class()
try: try:
termination_object = model.objects.get( termination_object = model.objects.get(device=device, name=name)
device=device,
name=name
)
if termination_object.cable is not None: if termination_object.cable is not None:
raise forms.ValidationError( raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
"Side A: {} {} is already connected".format(device, termination_object)
)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise forms.ValidationError( raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
"A side termination not found: {} {}".format(device, name)
)
self.instance.termination_a = termination_object setattr(self.instance, f'termination_{side}', termination_object)
return termination_object return termination_object
def clean_side_a_name(self):
return self._clean_side('a')
def clean_side_b_name(self): def clean_side_b_name(self):
return self._clean_side('b')
device = self.cleaned_data.get('side_b_device')
content_type = self.cleaned_data.get('side_b_type')
name = self.cleaned_data.get('side_b_name')
if not device or not content_type or not name:
return None
model = content_type.model_class()
try:
termination_object = model.objects.get(
device=device,
name=name
)
if termination_object.cable is not None:
raise forms.ValidationError(
"Side B: {} {} is already connected".format(device, termination_object)
)
except ObjectDoesNotExist:
raise forms.ValidationError(
"B side termination not found: {} {}".format(device, name)
)
self.instance.termination_b = termination_object
return termination_object
def clean_length_unit(self): def clean_length_unit(self):
# Avoid trying to save as NULL # Avoid trying to save as NULL
@ -4063,7 +4054,7 @@ class DeviceSelectionForm(forms.Form):
) )
class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm): class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
region = DynamicModelChoiceField( region = DynamicModelChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
required=False, required=False,
@ -4126,7 +4117,7 @@ class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
return instance return instance
class VirtualChassisForm(BootstrapMixin, forms.ModelForm): class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm):
master = forms.ModelChoiceField( master = forms.ModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
@ -4316,7 +4307,7 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Power panels # Power panels
# #
class PowerPanelForm(BootstrapMixin, forms.ModelForm): class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
region = DynamicModelChoiceField( region = DynamicModelChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
required=False, required=False,

10
netbox/dcim/lookups.py Normal file
View File

@ -0,0 +1,10 @@
from django.contrib.postgres.fields.array import ArrayContains
from dcim.utils import object_to_path_node
class PathContains(ArrayContains):
def get_prep_lookup(self):
self.rhs = [object_to_path_node(self.rhs)]
return super().get_prep_lookup()

View File

View File

@ -0,0 +1,88 @@
from django.core.management.base import BaseCommand
from django.core.management.color import no_style
from django.db import connection
from circuits.models import CircuitTermination
from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
from dcim.signals import create_cablepath
ENDPOINT_MODELS = (
CircuitTermination,
ConsolePort,
ConsoleServerPort,
Interface,
PowerFeed,
PowerOutlet,
PowerPort
)
class Command(BaseCommand):
help = "Generate any missing cable paths among all cable termination objects in NetBox"
def add_arguments(self, parser):
parser.add_argument(
"--force", action='store_true', dest='force',
help="Force recalculation of all existing cable paths"
)
parser.add_argument(
"--no-input", action='store_true', dest='no_input',
help="Do not prompt user for any input/confirmation"
)
def draw_progress_bar(self, percentage):
"""
Draw a simple progress bar 20 increments wide illustrating the specified percentage.
"""
bar_size = int(percentage / 5)
self.stdout.write(f"\r [{'#' * bar_size}{' ' * (20-bar_size)}] {int(percentage)}%", ending='')
def handle(self, *model_names, **options):
# If --force was passed, first delete all existing CablePaths
if options['force']:
cable_paths = CablePath.objects.all()
paths_count = cable_paths.count()
# Prompt the user to confirm recalculation of all paths
if paths_count and not options['no_input']:
self.stdout.write(self.style.ERROR("WARNING: Forcing recalculation of all cable paths."))
self.stdout.write(
f"This will delete and recalculate all {paths_count} existing cable paths. Are you sure?"
)
confirmation = input("Type yes to confirm: ")
if confirmation != 'yes':
self.stdout.write(self.style.SUCCESS("Aborting"))
return
# Delete all existing CablePath instances
self.stdout.write(f"Deleting {paths_count} existing cable paths...")
deleted_count, _ = CablePath.objects.all().delete()
self.stdout.write((self.style.SUCCESS(f' Deleted {deleted_count} paths')))
# Reinitialize the model's PK sequence
self.stdout.write(f'Resetting database sequence for CablePath model')
sequence_sql = connection.ops.sequence_reset_sql(no_style(), [CablePath])
with connection.cursor() as cursor:
for sql in sequence_sql:
cursor.execute(sql)
# Retrace paths
for model in ENDPOINT_MODELS:
origins = model.objects.filter(cable__isnull=False)
if not options['force']:
origins = origins.filter(_path__isnull=True)
origins_count = origins.count()
if not origins_count:
self.stdout.write(f'Found no missing {model._meta.verbose_name} paths; skipping')
continue
self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
i = 0
for i, obj in enumerate(origins, start=1):
create_cablepath(obj)
if not i % 100:
self.draw_progress_bar(i * 100 / origins_count)
self.draw_progress_bar(100)
self.stdout.write(self.style.SUCCESS(f'\n Retraced {i} {model._meta.verbose_name_plural}'))
self.stdout.write(self.style.SUCCESS('Finished.'))

View File

@ -0,0 +1,60 @@
import django.core.serializers.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0116_rearport_max_positions'),
]
operations = [
# Original CustomFieldModels
migrations.AddField(
model_name='device',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
),
migrations.AddField(
model_name='devicetype',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
),
migrations.AddField(
model_name='powerfeed',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
),
migrations.AddField(
model_name='rack',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
),
migrations.AddField(
model_name='site',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
),
# Added under #5146
migrations.AddField(
model_name='cable',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
),
migrations.AddField(
model_name='powerpanel',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
),
migrations.AddField(
model_name='rackreservation',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
),
migrations.AddField(
model_name='virtualchassis',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder),
),
]

View File

@ -0,0 +1,44 @@
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('dcim', '0117_custom_field_data'),
]
operations = [
# The MPTT will be rebuilt in the following migration. Using dummy values for now.
migrations.AddField(
model_name='inventoryitem',
name='level',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='inventoryitem',
name='lft',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='inventoryitem',
name='rght',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='inventoryitem',
name='tree_id',
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
# Convert ForeignKey to TreeForeignKey
migrations.AlterField(
model_name='inventoryitem',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.inventoryitem'),
),
]

View File

@ -0,0 +1,26 @@
from django.db import migrations
import mptt
import mptt.managers
def rebuild_mptt(apps, schema_editor):
manager = mptt.managers.TreeManager()
InventoryItem = apps.get_model('dcim', 'InventoryItem')
manager.model = InventoryItem
mptt.register(InventoryItem)
manager.contribute_to_class(InventoryItem, 'objects')
manager.rebuild()
class Migration(migrations.Migration):
dependencies = [
('dcim', '0118_inventoryitem_mptt'),
]
operations = [
migrations.RunPython(
code=rebuild_mptt,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,141 @@
import sys
from django.db import migrations, models
import django.db.models.deletion
def cache_cable_peers(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType')
Cable = apps.get_model('dcim', 'Cable')
ConsolePort = apps.get_model('dcim', 'ConsolePort')
ConsoleServerPort = apps.get_model('dcim', 'ConsoleServerPort')
PowerPort = apps.get_model('dcim', 'PowerPort')
PowerOutlet = apps.get_model('dcim', 'PowerOutlet')
Interface = apps.get_model('dcim', 'Interface')
FrontPort = apps.get_model('dcim', 'FrontPort')
RearPort = apps.get_model('dcim', 'RearPort')
PowerFeed = apps.get_model('dcim', 'PowerFeed')
models = (
ConsolePort,
ConsoleServerPort,
PowerPort,
PowerOutlet,
Interface,
FrontPort,
RearPort,
PowerFeed
)
if 'test' not in sys.argv:
print("\n", end="")
for model in models:
if 'test' not in sys.argv:
print(f" Updating {model._meta.verbose_name} cable peers...", flush=True)
ct = ContentType.objects.get_for_model(model)
for cable in Cable.objects.filter(termination_a_type=ct):
model.objects.filter(pk=cable.termination_a_id).update(
_cable_peer_type_id=cable.termination_b_type_id,
_cable_peer_id=cable.termination_b_id
)
for cable in Cable.objects.filter(termination_b_type=ct):
model.objects.filter(pk=cable.termination_b_id).update(
_cable_peer_type_id=cable.termination_a_type_id,
_cable_peer_id=cable.termination_a_id
)
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0119_inventoryitem_mptt_rebuild'),
]
operations = [
migrations.AddField(
model_name='consoleport',
name='_cable_peer_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='consoleport',
name='_cable_peer_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
),
migrations.AddField(
model_name='consoleserverport',
name='_cable_peer_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='consoleserverport',
name='_cable_peer_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
),
migrations.AddField(
model_name='frontport',
name='_cable_peer_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='frontport',
name='_cable_peer_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
),
migrations.AddField(
model_name='interface',
name='_cable_peer_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='interface',
name='_cable_peer_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
),
migrations.AddField(
model_name='powerfeed',
name='_cable_peer_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='powerfeed',
name='_cable_peer_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
),
migrations.AddField(
model_name='poweroutlet',
name='_cable_peer_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='poweroutlet',
name='_cable_peer_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
),
migrations.AddField(
model_name='powerport',
name='_cable_peer_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='powerport',
name='_cable_peer_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
),
migrations.AddField(
model_name='rearport',
name='_cable_peer_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='rearport',
name='_cable_peer_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='contenttypes.contenttype'),
),
migrations.RunPython(
code=cache_cable_peers,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,108 @@
import dcim.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0120_cache_cable_peer'),
]
operations = [
migrations.CreateModel(
name='CablePath',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('origin_id', models.PositiveIntegerField()),
('destination_id', models.PositiveIntegerField(blank=True, null=True)),
('path', dcim.fields.PathField(base_field=models.CharField(max_length=40), size=None)),
('is_active', models.BooleanField(default=False)),
('is_split', models.BooleanField(default=False)),
('destination_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
('origin_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
],
options={
'unique_together': {('origin_type', 'origin_id')},
},
),
migrations.AddField(
model_name='consoleport',
name='_path',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
),
migrations.AddField(
model_name='consoleserverport',
name='_path',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
),
migrations.AddField(
model_name='interface',
name='_path',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
),
migrations.AddField(
model_name='powerfeed',
name='_path',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
),
migrations.AddField(
model_name='poweroutlet',
name='_path',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
),
migrations.AddField(
model_name='powerport',
name='_path',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.cablepath'),
),
migrations.RemoveField(
model_name='consoleport',
name='connected_endpoint',
),
migrations.RemoveField(
model_name='consoleport',
name='connection_status',
),
migrations.RemoveField(
model_name='consoleserverport',
name='connection_status',
),
migrations.RemoveField(
model_name='interface',
name='_connected_circuittermination',
),
migrations.RemoveField(
model_name='interface',
name='_connected_interface',
),
migrations.RemoveField(
model_name='interface',
name='connection_status',
),
migrations.RemoveField(
model_name='powerfeed',
name='connected_endpoint',
),
migrations.RemoveField(
model_name='powerfeed',
name='connection_status',
),
migrations.RemoveField(
model_name='poweroutlet',
name='connection_status',
),
migrations.RemoveField(
model_name='powerport',
name='_connected_powerfeed',
),
migrations.RemoveField(
model_name='powerport',
name='_connected_poweroutlet',
),
migrations.RemoveField(
model_name='powerport',
name='connection_status',
),
]

View File

@ -0,0 +1,98 @@
# Generated by Django 3.1 on 2020-10-15 19:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0121_cablepath'),
]
operations = [
migrations.AlterField(
model_name='devicerole',
name='name',
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='devicerole',
name='slug',
field=models.SlugField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='devicetype',
name='model',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='devicetype',
name='slug',
field=models.SlugField(max_length=100),
),
migrations.AlterField(
model_name='manufacturer',
name='name',
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='manufacturer',
name='slug',
field=models.SlugField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='powerfeed',
name='name',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='powerpanel',
name='name',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='rack',
name='name',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='rackgroup',
name='name',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='rackgroup',
name='slug',
field=models.SlugField(max_length=100),
),
migrations.AlterField(
model_name='rackrole',
name='name',
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='rackrole',
name='slug',
field=models.SlugField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='region',
name='name',
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='region',
name='slug',
field=models.SlugField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='site',
name='name',
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='site',
name='slug',
field=models.SlugField(max_length=100, unique=True),
),
]

View File

@ -1,3 +1,4 @@
from .cables import *
from .device_component_templates import * from .device_component_templates import *
from .device_components import * from .device_components import *
from .devices import * from .devices import *
@ -8,6 +9,7 @@ from .sites import *
__all__ = ( __all__ = (
'BaseInterface', 'BaseInterface',
'Cable', 'Cable',
'CablePath',
'CableTermination', 'CableTermination',
'ConsolePort', 'ConsolePort',
'ConsolePortTemplate', 'ConsolePortTemplate',

View File

@ -0,0 +1,495 @@
from collections import defaultdict
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models
from django.db.models import Sum
from django.urls import reverse
from taggit.managers import TaggableManager
from dcim.choices import *
from dcim.constants import *
from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
from extras.utils import extras_features
from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters
from .devices import Device
from .device_components import FrontPort, RearPort
__all__ = (
'Cable',
'CablePath',
)
#
# Cables
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Cable(ChangeLoggedModel, CustomFieldModel):
"""
A physical connection between two endpoints.
"""
termination_a_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_a_id = models.PositiveIntegerField()
termination_a = GenericForeignKey(
ct_field='termination_a_type',
fk_field='termination_a_id'
)
termination_b_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_b_id = models.PositiveIntegerField()
termination_b = GenericForeignKey(
ct_field='termination_b_type',
fk_field='termination_b_id'
)
type = models.CharField(
max_length=50,
choices=CableTypeChoices,
blank=True
)
status = models.CharField(
max_length=50,
choices=CableStatusChoices,
default=CableStatusChoices.STATUS_CONNECTED
)
label = models.CharField(
max_length=100,
blank=True
)
color = ColorField(
blank=True
)
length = models.PositiveSmallIntegerField(
blank=True,
null=True
)
length_unit = models.CharField(
max_length=50,
choices=CableLengthUnitChoices,
blank=True,
)
# Stores the normalized length (in meters) for database ordering
_abs_length = models.DecimalField(
max_digits=10,
decimal_places=4,
blank=True,
null=True
)
# Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
# their associated Devices.
_termination_a_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
_termination_b_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit',
]
class Meta:
ordering = ['pk']
unique_together = (
('termination_a_type', 'termination_a_id'),
('termination_b_type', 'termination_b_id'),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# A copy of the PK to be used by __str__ in case the object is deleted
self._pk = self.pk
# Cache the original status so we can check later if it's been changed
self._orig_status = self.status
@classmethod
def from_db(cls, db, field_names, values):
"""
Cache the original A and B terminations of existing Cable instances for later reference inside clean().
"""
instance = super().from_db(db, field_names, values)
instance._orig_termination_a_type_id = instance.termination_a_type_id
instance._orig_termination_a_id = instance.termination_a_id
instance._orig_termination_b_type_id = instance.termination_b_type_id
instance._orig_termination_b_id = instance.termination_b_id
return instance
def __str__(self):
return self.label or '#{}'.format(self._pk)
def get_absolute_url(self):
return reverse('dcim:cable', args=[self.pk])
def clean(self):
from circuits.models import CircuitTermination
super().clean()
# Validate that termination A exists
if not hasattr(self, 'termination_a_type'):
raise ValidationError('Termination A type has not been specified')
try:
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
})
# Validate that termination B exists
if not hasattr(self, 'termination_b_type'):
raise ValidationError('Termination B type has not been specified')
try:
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
})
# If editing an existing Cable instance, check that neither termination has been modified.
if self.pk:
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
if (
self.termination_a_type_id != self._orig_termination_a_type_id or
self.termination_a_id != self._orig_termination_a_id
):
raise ValidationError({
'termination_a': err_msg
})
if (
self.termination_b_type_id != self._orig_termination_b_type_id or
self.termination_b_id != self._orig_termination_b_id
):
raise ValidationError({
'termination_b': err_msg
})
type_a = self.termination_a_type.model
type_b = self.termination_b_type.model
# Validate interface types
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_a.get_type_display()
)
})
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_b.get_type_display()
)
})
# Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError(
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
)
# Check that two connected RearPorts have the same number of positions (if both are >1)
if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
if self.termination_a.positions > 1 and self.termination_b.positions > 1:
if self.termination_a.positions != self.termination_b.positions:
raise ValidationError(
f"{self.termination_a} has {self.termination_a.positions} position(s) but "
f"{self.termination_b} has {self.termination_b.positions}. "
f"Both terminations must have the same number of positions (if greater than one)."
)
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
# A front port cannot be connected to its corresponding rear port
if (
type_a in ['frontport', 'rearport'] and
type_b in ['frontport', 'rearport'] and
(
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
getattr(self.termination_b, 'rear_port', None) == self.termination_a
)
):
raise ValidationError("A front port cannot be connected to it corresponding rear port")
# Check for an existing Cable connected to either termination object
if self.termination_a.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_a, self.termination_a.cable_id
))
if self.termination_b.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_b, self.termination_b.cable_id
))
# Validate length and length_unit
if self.length is not None and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length")
elif self.length is None:
self.length_unit = ''
def save(self, *args, **kwargs):
# Store the given length (if any) in meters for use in database ordering
if self.length and self.length_unit:
self._abs_length = to_meters(self.length, self.length_unit)
else:
self._abs_length = None
# Store the parent Device for the A and B terminations (if applicable) to enable filtering
if hasattr(self.termination_a, 'device'):
self._termination_a_device = self.termination_a.device
if hasattr(self.termination_b, 'device'):
self._termination_b_device = self.termination_b.device
super().save(*args, **kwargs)
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
self._pk = self.pk
def to_csv(self):
return (
'{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
self.termination_a_id,
'{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
self.termination_b_id,
self.get_type_display(),
self.get_status_display(),
self.label,
self.color,
self.length,
self.length_unit,
)
def get_status_class(self):
return CableStatusChoices.CSS_CLASSES.get(self.status)
def get_compatible_types(self):
"""
Return all termination types compatible with termination A.
"""
if self.termination_a is None:
return
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
class CablePath(models.Model):
"""
A CablePath instance represents the physical path from an origin to a destination, including all intermediate
elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do
not terminate on a PathEndpoint).
`path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the
path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following
topology:
1 2 3
Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B
This path would be expressed as:
CablePath(
origin = Interface A
destination = Interface B
path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3]
)
`is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of
"connected".
"""
origin_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
related_name='+'
)
origin_id = models.PositiveIntegerField()
origin = GenericForeignKey(
ct_field='origin_type',
fk_field='origin_id'
)
destination_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
destination_id = models.PositiveIntegerField(
blank=True,
null=True
)
destination = GenericForeignKey(
ct_field='destination_type',
fk_field='destination_id'
)
path = PathField()
is_active = models.BooleanField(
default=False
)
is_split = models.BooleanField(
default=False
)
class Meta:
unique_together = ('origin_type', 'origin_id')
def __str__(self):
status = ' (active)' if self.is_active else ' (split)' if self.is_split else ''
return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# Record a direct reference to this CablePath on its originating object
model = self.origin._meta.model
model.objects.filter(pk=self.origin.pk).update(_path=self.pk)
@property
def segment_count(self):
total_length = 1 + len(self.path) + (1 if self.destination else 0)
return int(total_length / 3)
@classmethod
def from_origin(cls, origin):
"""
Create a new CablePath instance as traced from the given path origin.
"""
if origin is None or origin.cable is None:
return None
destination = None
path = []
position_stack = []
is_active = True
is_split = False
node = origin
while node.cable is not None:
if node.cable.status != CableStatusChoices.STATUS_CONNECTED:
is_active = False
# Follow the cable to its far-end termination
path.append(object_to_path_node(node.cable))
peer_termination = node.get_cable_peer()
# Follow a FrontPort to its corresponding RearPort
if isinstance(peer_termination, FrontPort):
path.append(object_to_path_node(peer_termination))
node = peer_termination.rear_port
if node.positions > 1:
position_stack.append(peer_termination.rear_port_position)
path.append(object_to_path_node(node))
# Follow a RearPort to its corresponding FrontPort (if any)
elif isinstance(peer_termination, RearPort):
path.append(object_to_path_node(peer_termination))
# Determine the peer FrontPort's position
if peer_termination.positions == 1:
position = 1
elif position_stack:
position = position_stack.pop()
else:
# No position indicated: path has split, so we stop at the RearPort
is_split = True
break
try:
node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position)
path.append(object_to_path_node(node))
except ObjectDoesNotExist:
# No corresponding FrontPort found for the RearPort
break
# Anything else marks the end of the path
else:
destination = peer_termination
break
if destination is None:
is_active = False
return cls(
origin=origin,
destination=destination,
path=path,
is_active=is_active,
is_split=is_split
)
def get_path(self):
"""
Return the path as a list of prefetched objects.
"""
# Compile a list of IDs to prefetch for each type of model in the path
to_prefetch = defaultdict(list)
for node in self.path:
ct_id, object_id = decompile_path_node(node)
to_prefetch[ct_id].append(object_id)
# Prefetch path objects using one query per model type. Prefetch related devices where appropriate.
prefetched = {}
for ct_id, object_ids in to_prefetch.items():
model_class = ContentType.objects.get_for_id(ct_id).model_class()
queryset = model_class.objects.filter(pk__in=object_ids)
if hasattr(model_class, 'device'):
queryset = queryset.prefetch_related('device')
prefetched[ct_id] = {
obj.id: obj for obj in queryset
}
# Replicate the path using the prefetched objects.
path = []
for node in self.path:
ct_id, object_id = decompile_path_node(node)
path.append(prefetched[ct_id][object_id])
return path
def get_total_length(self):
"""
Return the sum of the length of each cable in the path.
"""
cable_ids = [
# Starting from the first element, every third element in the path should be a Cable
decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3)
]
return Cable.objects.filter(id__in=cable_ids).aggregate(total=Sum('_abs_length'))['total']
def get_split_nodes(self):
"""
Return all available next segments in a split cable path.
"""
rearport = path_node_to_object(self.path[-1])
return FrontPort.objects.filter(rear_port=rearport)

View File

@ -1,20 +1,20 @@
import logging from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Sum from django.db.models import Sum
from django.urls import reverse from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.exceptions import CableTraceSplit
from dcim.fields import MACAddressField from dcim.fields import MACAddressField
from extras.models import ObjectChange, TaggedItem from extras.models import ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.fields import NaturalOrderingField from utilities.fields import NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
@ -30,6 +30,7 @@ __all__ = (
'FrontPort', 'FrontPort',
'Interface', 'Interface',
'InventoryItem', 'InventoryItem',
'PathEndpoint',
'PowerOutlet', 'PowerOutlet',
'PowerPort', 'PowerPort',
'RearPort', 'RearPort',
@ -37,6 +38,9 @@ __all__ = (
class ComponentModel(models.Model): class ComponentModel(models.Model):
"""
An abstract model inherited by any model which has a parent Device.
"""
device = models.ForeignKey( device = models.ForeignKey(
to='dcim.Device', to='dcim.Device',
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -91,6 +95,14 @@ class ComponentModel(models.Model):
class CableTermination(models.Model): class CableTermination(models.Model):
"""
An abstract model inherited by all models to which a Cable can terminate (certain device components, PowerFeed, and
CircuitTermination instances). The `cable` field indicates the Cable instance which is terminated to this instance.
`_cable_peer` is a GenericForeignKey used to cache the far-end CableTermination on the local instance; this is a
shortcut to referencing `cable.termination_b`, for example. `_cable_peer` is set or cleared by the receivers in
dcim.signals when a Cable instance is created or deleted, respectively.
"""
cable = models.ForeignKey( cable = models.ForeignKey(
to='dcim.Cable', to='dcim.Cable',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -98,6 +110,21 @@ class CableTermination(models.Model):
blank=True, blank=True,
null=True null=True
) )
_cable_peer_type = models.ForeignKey(
to=ContentType,
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
_cable_peer_id = models.PositiveIntegerField(
blank=True,
null=True
)
_cable_peer = GenericForeignKey(
ct_field='_cable_peer_type',
fk_field='_cable_peer_id'
)
# Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted. # Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted.
_cabled_as_a = GenericRelation( _cabled_as_a = GenericRelation(
@ -114,138 +141,57 @@ class CableTermination(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def trace(self):
"""
Return three items: the traceable portion of a cable path, the termination points where it splits (if any), and
the remaining positions on the position stack (if any). Splits occur when the trace is initiated from a midpoint
along a path which traverses a RearPort. In cases where the originating endpoint is unknown, it is not possible
to know which corresponding FrontPort to follow. Remaining positions occur when tracing a path that traverses
a FrontPort without traversing a RearPort again.
The path is a list representing a complete cable path, with each individual segment represented as a
three-tuple:
[
(termination A, cable, termination B),
(termination C, cable, termination D),
(termination E, cable, termination F)
]
"""
endpoint = self
path = []
position_stack = []
def get_peer_port(termination):
from circuits.models import CircuitTermination
# Map a front port to its corresponding rear port
if isinstance(termination, FrontPort):
# Retrieve the corresponding RearPort from database to ensure we have an up-to-date instance
peer_port = RearPort.objects.get(pk=termination.rear_port.pk)
# Don't use the stack for RearPorts with a single position. Only remember the position at
# many-to-one points so we can select the correct FrontPort when we reach the corresponding
# one-to-many point.
if peer_port.positions > 1:
position_stack.append(termination)
return peer_port
# Map a rear port/position to its corresponding front port
elif isinstance(termination, RearPort):
if termination.positions > 1:
# Can't map to a FrontPort without a position if there are multiple options
if not position_stack:
raise CableTraceSplit(termination)
front_port = position_stack.pop()
position = front_port.rear_port_position
# Validate the position
if position not in range(1, termination.positions + 1):
raise Exception("Invalid position for {} ({} positions): {})".format(
termination, termination.positions, position
))
else:
# Don't use the stack for RearPorts with a single position. The only possible position is 1.
position = 1
try:
peer_port = FrontPort.objects.get(
rear_port=termination,
rear_port_position=position,
)
return peer_port
except ObjectDoesNotExist:
return None
# Follow a circuit to its other termination
elif isinstance(termination, CircuitTermination):
peer_termination = termination.get_peer_termination()
if peer_termination is None:
return None
return peer_termination
# Termination is not a pass-through port
else:
return None
logger = logging.getLogger('netbox.dcim.cable.trace')
logger.debug("Tracing cable from {} {}".format(self.parent, self))
while endpoint is not None:
# No cable connected; nothing to trace
if not endpoint.cable:
path.append((endpoint, None, None))
logger.debug("No cable connected")
return path, None, position_stack
# Check for loops
if endpoint.cable in [segment[1] for segment in path]:
logger.debug("Loop detected!")
return path, None, position_stack
# Record the current segment in the path
far_end = endpoint.get_cable_peer()
path.append((endpoint, endpoint.cable, far_end))
logger.debug("{}[{}] --- Cable {} ---> {}[{}]".format(
endpoint.parent, endpoint, endpoint.cable.pk, far_end.parent, far_end
))
# Get the peer port of the far end termination
try:
endpoint = get_peer_port(far_end)
except CableTraceSplit as e:
return path, e.termination.frontports.all(), position_stack
if endpoint is None:
return path, None, position_stack
def get_cable_peer(self): def get_cable_peer(self):
if self.cable is None: return self._cable_peer
return None
if self._cabled_as_a.exists():
return self.cable.termination_b
if self._cabled_as_b.exists():
return self.cable.termination_a
def get_path_endpoints(self):
class PathEndpoint(models.Model):
""" """
Return all endpoints of paths which traverse this object. An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically,
these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, PowerFeed, and CircuitTermination.
`_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
`connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any.
""" """
endpoints = [] _path = models.ForeignKey(
to='dcim.CablePath',
on_delete=models.SET_NULL,
null=True,
blank=True
)
# Get the far end of the last path segment class Meta:
path, split_ends, position_stack = self.trace() abstract = True
endpoint = path[-1][2]
if split_ends is not None:
for termination in split_ends:
endpoints.extend(termination.get_path_endpoints())
elif endpoint is not None:
endpoints.append(endpoint)
return endpoints def trace(self):
if self._path is None:
return []
# Construct the complete path
path = [self, *self._path.get_path()]
while (len(path) + 1) % 3:
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a RearPort)
path.append(None)
path.append(self._path.destination)
# Return the path as a list of three-tuples (A termination, cable, B termination)
return list(zip(*[iter(path)] * 3))
@property
def path(self):
return self._path
@property
def connected_endpoint(self):
"""
Caching accessor for the attached CablePath's destination (if any)
"""
if not hasattr(self, '_connected_endpoint'):
self._connected_endpoint = self._path.destination if self._path else None
return self._connected_endpoint
# #
@ -253,7 +199,7 @@ class CableTermination(models.Model):
# #
@extras_features('export_templates', 'webhooks') @extras_features('export_templates', 'webhooks')
class ConsolePort(CableTermination, ComponentModel): class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
""" """
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
""" """
@ -263,18 +209,6 @@ class ConsolePort(CableTermination, ComponentModel):
blank=True, blank=True,
help_text='Physical port type' help_text='Physical port type'
) )
connected_endpoint = models.OneToOneField(
to='dcim.ConsoleServerPort',
on_delete=models.SET_NULL,
related_name='connected_endpoint',
blank=True,
null=True
)
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'label', 'type', 'description'] csv_headers = ['device', 'name', 'label', 'type', 'description']
@ -301,7 +235,7 @@ class ConsolePort(CableTermination, ComponentModel):
# #
@extras_features('webhooks') @extras_features('webhooks')
class ConsoleServerPort(CableTermination, ComponentModel): class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
""" """
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
""" """
@ -311,11 +245,6 @@ class ConsoleServerPort(CableTermination, ComponentModel):
blank=True, blank=True,
help_text='Physical port type' help_text='Physical port type'
) )
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'label', 'type', 'description'] csv_headers = ['device', 'name', 'label', 'type', 'description']
@ -342,7 +271,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
# #
@extras_features('export_templates', 'webhooks') @extras_features('export_templates', 'webhooks')
class PowerPort(CableTermination, ComponentModel): class PowerPort(CableTermination, PathEndpoint, ComponentModel):
""" """
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
""" """
@ -364,25 +293,6 @@ class PowerPort(CableTermination, ComponentModel):
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
help_text="Allocated power draw (watts)" help_text="Allocated power draw (watts)"
) )
_connected_poweroutlet = models.OneToOneField(
to='dcim.PowerOutlet',
on_delete=models.SET_NULL,
related_name='connected_endpoint',
blank=True,
null=True
)
_connected_powerfeed = models.OneToOneField(
to='dcim.PowerFeed',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description'] csv_headers = ['device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description']
@ -413,51 +323,18 @@ class PowerPort(CableTermination, ComponentModel):
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)." 'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
}) })
@property
def connected_endpoint(self):
"""
Return the connected PowerOutlet, if it exists, or the connected PowerFeed, if it exists. We have to check for
ObjectDoesNotExist in case the referenced object has been deleted from the database.
"""
try:
if self._connected_poweroutlet:
return self._connected_poweroutlet
except ObjectDoesNotExist:
pass
try:
if self._connected_powerfeed:
return self._connected_powerfeed
except ObjectDoesNotExist:
pass
return None
@connected_endpoint.setter
def connected_endpoint(self, value):
# TODO: Fix circular import
from . import PowerFeed
if value is None:
self._connected_poweroutlet = None
self._connected_powerfeed = None
elif isinstance(value, PowerOutlet):
self._connected_poweroutlet = value
self._connected_powerfeed = None
elif isinstance(value, PowerFeed):
self._connected_poweroutlet = None
self._connected_powerfeed = value
else:
raise ValueError(
"Connected endpoint must be a PowerOutlet or PowerFeed, not {}.".format(type(value))
)
def get_power_draw(self): def get_power_draw(self):
""" """
Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort. Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
""" """
# Calculate aggregate draw of all child power outlets if no numbers have been defined manually # Calculate aggregate draw of all child power outlets if no numbers have been defined manually
if self.allocated_draw is None and self.maximum_draw is None: if self.allocated_draw is None and self.maximum_draw is None:
poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet)
outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True) outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate( utilization = PowerPort.objects.filter(
_cable_peer_type=poweroutlet_ct,
_cable_peer_id__in=outlet_ids
).aggregate(
maximum_draw_total=Sum('maximum_draw'), maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'), allocated_draw_total=Sum('allocated_draw'),
) )
@ -469,10 +346,13 @@ class PowerPort(CableTermination, ComponentModel):
} }
# Calculate per-leg aggregates for three-phase feeds # Calculate per-leg aggregates for three-phase feeds
if self._connected_powerfeed and self._connected_powerfeed.phase == PowerFeedPhaseChoices.PHASE_3PHASE: if getattr(self._cable_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE:
for leg, leg_name in PowerOutletFeedLegChoices: for leg, leg_name in PowerOutletFeedLegChoices:
outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True) outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate( utilization = PowerPort.objects.filter(
_cable_peer_type=poweroutlet_ct,
_cable_peer_id__in=outlet_ids
).aggregate(
maximum_draw_total=Sum('maximum_draw'), maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'), allocated_draw_total=Sum('allocated_draw'),
) )
@ -499,7 +379,7 @@ class PowerPort(CableTermination, ComponentModel):
# #
@extras_features('webhooks') @extras_features('webhooks')
class PowerOutlet(CableTermination, ComponentModel): class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
""" """
A physical power outlet (output) within a Device which provides power to a PowerPort. A physical power outlet (output) within a Device which provides power to a PowerPort.
""" """
@ -522,11 +402,6 @@ class PowerOutlet(CableTermination, ComponentModel):
blank=True, blank=True,
help_text="Phase (for three-phase feeds)" help_text="Phase (for three-phase feeds)"
) )
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'] csv_headers = ['device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description']
@ -602,8 +477,8 @@ class BaseInterface(models.Model):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@extras_features('graphs', 'export_templates', 'webhooks') @extras_features('export_templates', 'webhooks')
class Interface(CableTermination, ComponentModel, BaseInterface): class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
""" """
A network interface within a Device. A physical Interface can connect to exactly one other Interface. A network interface within a Device. A physical Interface can connect to exactly one other Interface.
""" """
@ -614,25 +489,6 @@ class Interface(CableTermination, ComponentModel, BaseInterface):
max_length=100, max_length=100,
blank=True blank=True
) )
_connected_interface = models.OneToOneField(
to='self',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
_connected_circuittermination = models.OneToOneField(
to='circuits.CircuitTermination',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True,
null=True
)
lag = models.ForeignKey( lag = models.ForeignKey(
to='self', to='self',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -736,42 +592,6 @@ class Interface(CableTermination, ComponentModel, BaseInterface):
"device, or it must be global".format(self.untagged_vlan) "device, or it must be global".format(self.untagged_vlan)
}) })
@property
def connected_endpoint(self):
"""
Return the connected Interface, if it exists, or the connected CircuitTermination, if it exists. We have to
check for ObjectDoesNotExist in case the referenced object has been deleted from the database.
"""
try:
if self._connected_interface:
return self._connected_interface
except ObjectDoesNotExist:
pass
try:
if self._connected_circuittermination:
return self._connected_circuittermination
except ObjectDoesNotExist:
pass
return None
@connected_endpoint.setter
def connected_endpoint(self, value):
from circuits.models import CircuitTermination
if value is None:
self._connected_interface = None
self._connected_circuittermination = None
elif isinstance(value, Interface):
self._connected_interface = value
self._connected_circuittermination = None
elif isinstance(value, CircuitTermination):
self._connected_interface = None
self._connected_circuittermination = value
else:
raise ValueError(
"Connected endpoint must be an Interface or CircuitTermination, not {}.".format(type(value))
)
@property @property
def parent(self): def parent(self):
return self.device return self.device
@ -975,17 +795,18 @@ class DeviceBay(ComponentModel):
# #
@extras_features('export_templates', 'webhooks') @extras_features('export_templates', 'webhooks')
class InventoryItem(ComponentModel): class InventoryItem(MPTTModel, ComponentModel):
""" """
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
InventoryItems are used only for inventory purposes. InventoryItems are used only for inventory purposes.
""" """
parent = models.ForeignKey( parent = TreeForeignKey(
to='self', to='self',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='child_items', related_name='child_items',
blank=True, blank=True,
null=True null=True,
db_index=True
) )
manufacturer = models.ForeignKey( manufacturer = models.ForeignKey(
to='dcim.Manufacturer', to='dcim.Manufacturer',
@ -1020,6 +841,8 @@ class InventoryItem(ComponentModel):
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = TreeManager()
csv_headers = [ csv_headers = [
'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
] ]

View File

@ -2,9 +2,8 @@ from collections import OrderedDict
import yaml import yaml
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import F, ProtectedError from django.db.models import F, ProtectedError
@ -20,12 +19,10 @@ from extras.utils import extras_features
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters
from .device_components import * from .device_components import *
__all__ = ( __all__ = (
'Cable',
'Device', 'Device',
'DeviceRole', 'DeviceRole',
'DeviceType', 'DeviceType',
@ -45,10 +42,11 @@ class Manufacturer(ChangeLoggedModel):
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
""" """
name = models.CharField( name = models.CharField(
max_length=50, max_length=100,
unique=True unique=True
) )
slug = models.SlugField( slug = models.SlugField(
max_length=100,
unique=True unique=True
) )
description = models.CharField( description = models.CharField(
@ -99,9 +97,11 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
related_name='device_types' related_name='device_types'
) )
model = models.CharField( model = models.CharField(
max_length=50 max_length=100
)
slug = models.SlugField(
max_length=100
) )
slug = models.SlugField()
part_number = models.CharField( part_number = models.CharField(
max_length=50, max_length=50,
blank=True, blank=True,
@ -135,11 +135,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
@ -259,6 +254,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
return yaml.dump(dict(data), sort_keys=False) return yaml.dump(dict(data), sort_keys=False)
def clean(self): def clean(self):
super().clean()
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
# room to expand within their racks. This validation will impose a very high performance penalty when there are # room to expand within their racks. This validation will impose a very high performance penalty when there are
@ -349,10 +345,11 @@ class DeviceRole(ChangeLoggedModel):
virtual machines as well. virtual machines as well.
""" """
name = models.CharField( name = models.CharField(
max_length=50, max_length=100,
unique=True unique=True
) )
slug = models.SlugField( slug = models.SlugField(
max_length=100,
unique=True unique=True
) )
color = ColorField( color = ColorField(
@ -399,8 +396,8 @@ class Platform(ChangeLoggedModel):
unique=True unique=True
) )
slug = models.SlugField( slug = models.SlugField(
unique=True, max_length=100,
max_length=100 unique=True
) )
manufacturer = models.ForeignKey( manufacturer = models.ForeignKey(
to='dcim.Manufacturer', to='dcim.Manufacturer',
@ -451,7 +448,7 @@ class Platform(ChangeLoggedModel):
) )
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
""" """
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
@ -585,14 +582,15 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )
secrets = GenericRelation(
to='secrets.Secret',
content_type_field='assigned_object_type',
object_id_field='assigned_object_id',
related_query_name='device'
)
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = ConfigContextModelQuerySet.as_manager() objects = ConfigContextModelQuerySet.as_manager()
@ -605,16 +603,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster', 'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster',
] ]
STATUS_CLASS_MAP = {
DeviceStatusChoices.STATUS_OFFLINE: 'warning',
DeviceStatusChoices.STATUS_ACTIVE: 'success',
DeviceStatusChoices.STATUS_PLANNED: 'info',
DeviceStatusChoices.STATUS_STAGED: 'primary',
DeviceStatusChoices.STATUS_FAILED: 'danger',
DeviceStatusChoices.STATUS_INVENTORY: 'default',
DeviceStatusChoices.STATUS_DECOMMISSIONING: 'warning',
}
class Meta: class Meta:
ordering = ('_name', 'pk') # Name may be null ordering = ('_name', 'pk') # Name may be null
unique_together = ( unique_together = (
@ -647,7 +635,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
super().validate_unique(exclude) super().validate_unique(exclude)
def clean(self): def clean(self):
super().clean() super().clean()
# Validate site/rack combination # Validate site/rack combination
@ -868,6 +855,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
""" """
Return a QuerySet or PK list matching all Cables connected to a component of this Device. Return a QuerySet or PK list matching all Cables connected to a component of this Device.
""" """
from .cables import Cable
cable_pks = [] cable_pks = []
for component_model in [ for component_model in [
ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort
@ -886,301 +874,15 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
return Device.objects.filter(parent_bay__device=self.pk) return Device.objects.filter(parent_bay__device=self.pk)
def get_status_class(self): def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status) return DeviceStatusChoices.CSS_CLASSES.get(self.status)
#
# Cables
#
@extras_features('custom_links', 'export_templates', 'webhooks')
class Cable(ChangeLoggedModel):
"""
A physical connection between two endpoints.
"""
termination_a_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_a_id = models.PositiveIntegerField()
termination_a = GenericForeignKey(
ct_field='termination_a_type',
fk_field='termination_a_id'
)
termination_b_type = models.ForeignKey(
to=ContentType,
limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
termination_b_id = models.PositiveIntegerField()
termination_b = GenericForeignKey(
ct_field='termination_b_type',
fk_field='termination_b_id'
)
type = models.CharField(
max_length=50,
choices=CableTypeChoices,
blank=True
)
status = models.CharField(
max_length=50,
choices=CableStatusChoices,
default=CableStatusChoices.STATUS_CONNECTED
)
label = models.CharField(
max_length=100,
blank=True
)
color = ColorField(
blank=True
)
length = models.PositiveSmallIntegerField(
blank=True,
null=True
)
length_unit = models.CharField(
max_length=50,
choices=CableLengthUnitChoices,
blank=True,
)
# Stores the normalized length (in meters) for database ordering
_abs_length = models.DecimalField(
max_digits=10,
decimal_places=4,
blank=True,
null=True
)
# Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
# their associated Devices.
_termination_a_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
_termination_b_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit',
]
STATUS_CLASS_MAP = {
CableStatusChoices.STATUS_CONNECTED: 'success',
CableStatusChoices.STATUS_PLANNED: 'info',
CableStatusChoices.STATUS_DECOMMISSIONING: 'warning',
}
class Meta:
ordering = ['pk']
unique_together = (
('termination_a_type', 'termination_a_id'),
('termination_b_type', 'termination_b_id'),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# A copy of the PK to be used by __str__ in case the object is deleted
self._pk = self.pk
@classmethod
def from_db(cls, db, field_names, values):
"""
Cache the original A and B terminations of existing Cable instances for later reference inside clean().
"""
instance = super().from_db(db, field_names, values)
instance._orig_termination_a_type_id = instance.termination_a_type_id
instance._orig_termination_a_id = instance.termination_a_id
instance._orig_termination_b_type_id = instance.termination_b_type_id
instance._orig_termination_b_id = instance.termination_b_id
return instance
def __str__(self):
return self.label or '#{}'.format(self._pk)
def get_absolute_url(self):
return reverse('dcim:cable', args=[self.pk])
def clean(self):
from circuits.models import CircuitTermination
# Validate that termination A exists
if not hasattr(self, 'termination_a_type'):
raise ValidationError('Termination A type has not been specified')
try:
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
})
# Validate that termination B exists
if not hasattr(self, 'termination_b_type'):
raise ValidationError('Termination B type has not been specified')
try:
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
})
# If editing an existing Cable instance, check that neither termination has been modified.
if self.pk:
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
if (
self.termination_a_type_id != self._orig_termination_a_type_id or
self.termination_a_id != self._orig_termination_a_id
):
raise ValidationError({
'termination_a': err_msg
})
if (
self.termination_b_type_id != self._orig_termination_b_type_id or
self.termination_b_id != self._orig_termination_b_id
):
raise ValidationError({
'termination_b': err_msg
})
type_a = self.termination_a_type.model
type_b = self.termination_b_type.model
# Validate interface types
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_a.get_type_display()
)
})
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_b.get_type_display()
)
})
# Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError(
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
)
# Check that a RearPort with multiple positions isn't connected to an endpoint
# or a RearPort with a different number of positions.
for term_a, term_b in [
(self.termination_a, self.termination_b),
(self.termination_b, self.termination_a)
]:
if isinstance(term_a, RearPort) and term_a.positions > 1:
if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)):
raise ValidationError(
"Rear ports with multiple positions may only be connected to other pass-through ports"
)
if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions:
raise ValidationError(
f"{term_a} of {term_a.device} has {term_a.positions} position(s) but "
f"{term_b} of {term_b.device} has {term_b.positions}. "
f"Both terminations must have the same number of positions."
)
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
# A front port cannot be connected to its corresponding rear port
if (
type_a in ['frontport', 'rearport'] and
type_b in ['frontport', 'rearport'] and
(
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
getattr(self.termination_b, 'rear_port', None) == self.termination_a
)
):
raise ValidationError("A front port cannot be connected to it corresponding rear port")
# Check for an existing Cable connected to either termination object
if self.termination_a.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_a, self.termination_a.cable_id
))
if self.termination_b.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_b, self.termination_b.cable_id
))
# Validate length and length_unit
if self.length is not None and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length")
elif self.length is None:
self.length_unit = ''
def save(self, *args, **kwargs):
# Store the given length (if any) in meters for use in database ordering
if self.length and self.length_unit:
self._abs_length = to_meters(self.length, self.length_unit)
else:
self._abs_length = None
# Store the parent Device for the A and B terminations (if applicable) to enable filtering
if hasattr(self.termination_a, 'device'):
self._termination_a_device = self.termination_a.device
if hasattr(self.termination_b, 'device'):
self._termination_b_device = self.termination_b.device
super().save(*args, **kwargs)
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
self._pk = self.pk
def to_csv(self):
return (
'{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
self.termination_a_id,
'{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
self.termination_b_id,
self.get_type_display(),
self.get_status_display(),
self.label,
self.color,
self.length,
self.length_unit,
)
def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status)
def get_compatible_types(self):
"""
Return all termination types compatible with termination A.
"""
if self.termination_a is None:
return
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
# #
# Virtual chassis # Virtual chassis
# #
@extras_features('custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class VirtualChassis(ChangeLoggedModel): class VirtualChassis(ChangeLoggedModel, CustomFieldModel):
""" """
A collection of Devices which operate with a shared control plane (e.g. a switch stack). A collection of Devices which operate with a shared control plane (e.g. a switch stack).
""" """
@ -1215,6 +917,7 @@ class VirtualChassis(ChangeLoggedModel):
return reverse('dcim:virtualchassis', kwargs={'pk': self.pk}) return reverse('dcim:virtualchassis', kwargs={'pk': self.pk})
def clean(self): def clean(self):
super().clean()
# Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
# VirtualChassis.) # VirtualChassis.)

View File

@ -1,4 +1,3 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
@ -11,7 +10,7 @@ from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.validators import ExclusionValidator from utilities.validators import ExclusionValidator
from .device_components import CableTermination from .device_components import CableTermination, PathEndpoint
__all__ = ( __all__ = (
'PowerFeed', 'PowerFeed',
@ -23,8 +22,8 @@ __all__ = (
# Power # Power
# #
@extras_features('custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class PowerPanel(ChangeLoggedModel): class PowerPanel(ChangeLoggedModel, CustomFieldModel):
""" """
A distribution point for electrical power; e.g. a data center RPP. A distribution point for electrical power; e.g. a data center RPP.
""" """
@ -39,7 +38,7 @@ class PowerPanel(ChangeLoggedModel):
null=True null=True
) )
name = models.CharField( name = models.CharField(
max_length=50 max_length=100
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
@ -65,6 +64,7 @@ class PowerPanel(ChangeLoggedModel):
) )
def clean(self): def clean(self):
super().clean()
# RackGroup must belong to assigned Site # RackGroup must belong to assigned Site
if self.rack_group and self.rack_group.site != self.site: if self.rack_group and self.rack_group.site != self.site:
@ -74,7 +74,7 @@ class PowerPanel(ChangeLoggedModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldModel):
""" """
An electrical circuit delivered from a PowerPanel. An electrical circuit delivered from a PowerPanel.
""" """
@ -89,20 +89,8 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
blank=True, blank=True,
null=True null=True
) )
connected_endpoint = models.OneToOneField(
to='dcim.PowerPort',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True,
null=True
)
name = models.CharField( name = models.CharField(
max_length=50 max_length=100
) )
status = models.CharField( status = models.CharField(
max_length=50, max_length=50,
@ -144,11 +132,6 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
@ -162,18 +145,6 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
'available_power', 'available_power',
] ]
STATUS_CLASS_MAP = {
PowerFeedStatusChoices.STATUS_OFFLINE: 'warning',
PowerFeedStatusChoices.STATUS_ACTIVE: 'success',
PowerFeedStatusChoices.STATUS_PLANNED: 'info',
PowerFeedStatusChoices.STATUS_FAILED: 'danger',
}
TYPE_CLASS_MAP = {
PowerFeedTypeChoices.TYPE_PRIMARY: 'success',
PowerFeedTypeChoices.TYPE_REDUNDANT: 'info',
}
class Meta: class Meta:
ordering = ['power_panel', 'name'] ordering = ['power_panel', 'name']
unique_together = ['power_panel', 'name'] unique_together = ['power_panel', 'name']
@ -202,6 +173,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
) )
def clean(self): def clean(self):
super().clean()
# Rack must belong to same Site as PowerPanel # Rack must belong to same Site as PowerPanel
if self.rack and self.rack.site != self.power_panel.site: if self.rack and self.rack.site != self.power_panel.site:
@ -231,7 +203,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
return self.power_panel return self.power_panel
def get_type_class(self): def get_type_class(self):
return self.TYPE_CLASS_MAP.get(self.type) return PowerFeedTypeChoices.CSS_CLASSES.get(self.type)
def get_status_class(self): def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status) return PowerFeedStatusChoices.CSS_CLASSES.get(self.status)

View File

@ -1,9 +1,9 @@
from collections import OrderedDict from collections import OrderedDict
from itertools import count, groupby
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@ -22,7 +22,8 @@ from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.mptt import TreeManager from utilities.mptt import TreeManager
from utilities.utils import serialize_object from utilities.utils import array_to_string, serialize_object
from .device_components import PowerOutlet, PowerPort
from .devices import Device from .devices import Device
from .power import PowerFeed from .power import PowerFeed
@ -46,9 +47,11 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor. campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor.
""" """
name = models.CharField( name = models.CharField(
max_length=50 max_length=100
)
slug = models.SlugField(
max_length=100
) )
slug = models.SlugField()
site = models.ForeignKey( site = models.ForeignKey(
to='dcim.Site', to='dcim.Site',
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -117,10 +120,11 @@ class RackRole(ChangeLoggedModel):
Racks can be organized by functional role, similar to Devices. Racks can be organized by functional role, similar to Devices.
""" """
name = models.CharField( name = models.CharField(
max_length=50, max_length=100,
unique=True unique=True
) )
slug = models.SlugField( slug = models.SlugField(
max_length=100,
unique=True unique=True
) )
color = ColorField( color = ColorField(
@ -160,7 +164,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
Each Rack is assigned to a Site and (optionally) a RackGroup. Each Rack is assigned to a Site and (optionally) a RackGroup.
""" """
name = models.CharField( name = models.CharField(
max_length=50 max_length=100
) )
_name = NaturalOrderingField( _name = NaturalOrderingField(
target_field='name', target_field='name',
@ -261,11 +265,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )
@ -282,14 +281,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
'outer_depth', 'outer_unit', 'outer_depth', 'outer_unit',
] ]
STATUS_CLASS_MAP = {
RackStatusChoices.STATUS_RESERVED: 'warning',
RackStatusChoices.STATUS_AVAILABLE: 'success',
RackStatusChoices.STATUS_PLANNED: 'info',
RackStatusChoices.STATUS_ACTIVE: 'primary',
RackStatusChoices.STATUS_DEPRECATED: 'danger',
}
class Meta: class Meta:
ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique
unique_together = ( unique_together = (
@ -305,6 +296,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
return reverse('dcim:rack', args=[self.pk]) return reverse('dcim:rack', args=[self.pk])
def clean(self): def clean(self):
super().clean()
# Validate outer dimensions and unit # Validate outer dimensions and unit
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit: if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
@ -385,7 +377,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
return self.name return self.name
def get_status_class(self): def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status) return RackStatusChoices.CSS_CLASSES.get(self.status)
def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True): def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True):
""" """
@ -550,24 +542,26 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
""" """
Determine the utilization rate of power in the rack and return it as a percentage. Determine the utilization rate of power in the rack and return it as a percentage.
""" """
power_stats = PowerFeed.objects.filter( powerfeeds = PowerFeed.objects.filter(rack=self)
rack=self available_power_total = sum(pf.available_power for pf in powerfeeds)
).annotate( if not available_power_total:
allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'),
).values(
'allocated_draw_total',
'available_power'
)
if power_stats:
allocated_draw_total = sum(x['allocated_draw_total'] or 0 for x in power_stats)
available_power_total = sum(x['available_power'] for x in power_stats)
return int(allocated_draw_total / available_power_total * 100) or 0
return 0 return 0
pf_powerports = PowerPort.objects.filter(
_cable_peer_type=ContentType.objects.get_for_model(PowerFeed),
_cable_peer_id__in=powerfeeds.values_list('id', flat=True)
)
poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
allocated_draw_total = PowerPort.objects.filter(
_cable_peer_type=ContentType.objects.get_for_model(PowerOutlet),
_cable_peer_id__in=poweroutlets.values_list('id', flat=True)
).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
@extras_features('custom_links', 'export_templates', 'webhooks') return int(allocated_draw_total / available_power_total * 100)
class RackReservation(ChangeLoggedModel):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class RackReservation(ChangeLoggedModel, CustomFieldModel):
""" """
One or more reserved units within a Rack. One or more reserved units within a Rack.
""" """
@ -609,6 +603,7 @@ class RackReservation(ChangeLoggedModel):
return reverse('dcim:rackreservation', args=[self.pk]) return reverse('dcim:rackreservation', args=[self.pk])
def clean(self): def clean(self):
super().clean()
if hasattr(self, 'rack') and self.units: if hasattr(self, 'rack') and self.units:
@ -647,9 +642,4 @@ class RackReservation(ChangeLoggedModel):
@property @property
def unit_list(self): def unit_list(self):
""" return array_to_string(self.units)
Express the assigned units as a string of summarized ranges. For example:
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
"""
group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)

View File

@ -39,10 +39,11 @@ class Region(MPTTModel, ChangeLoggedModel):
db_index=True db_index=True
) )
name = models.CharField( name = models.CharField(
max_length=50, max_length=100,
unique=True unique=True
) )
slug = models.SlugField( slug = models.SlugField(
max_length=100,
unique=True unique=True
) )
description = models.CharField( description = models.CharField(
@ -91,14 +92,14 @@ class Region(MPTTModel, ChangeLoggedModel):
# Sites # Sites
# #
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Site(ChangeLoggedModel, CustomFieldModel): class Site(ChangeLoggedModel, CustomFieldModel):
""" """
A Site represents a geographic location within a network; typically a building or campus. The optional facility A Site represents a geographic location within a network; typically a building or campus. The optional facility
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
""" """
name = models.CharField( name = models.CharField(
max_length=50, max_length=100,
unique=True unique=True
) )
_name = NaturalOrderingField( _name = NaturalOrderingField(
@ -107,6 +108,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
blank=True blank=True
) )
slug = models.SlugField( slug = models.SlugField(
max_length=100,
unique=True unique=True
) )
status = models.CharField( status = models.CharField(
@ -183,11 +185,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
custom_field_values = GenericRelation(
to='extras.CustomFieldValue',
content_type_field='obj_type',
object_id_field='obj_id'
)
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )
@ -204,14 +201,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
] ]
STATUS_CLASS_MAP = {
SiteStatusChoices.STATUS_PLANNED: 'info',
SiteStatusChoices.STATUS_STAGING: 'primary',
SiteStatusChoices.STATUS_ACTIVE: 'success',
SiteStatusChoices.STATUS_DECOMMISSIONING: 'warning',
SiteStatusChoices.STATUS_RETIRED: 'danger',
}
class Meta: class Meta:
ordering = ('_name',) ordering = ('_name',)
@ -243,4 +232,4 @@ class Site(ChangeLoggedModel, CustomFieldModel):
) )
def get_status_class(self): def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status) return SiteStatusChoices.CSS_CLASSES.get(self.status)

View File

@ -1,10 +1,37 @@
import logging import logging
from django.db.models.signals import post_save, pre_delete from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, post_delete, pre_delete
from django.db import transaction
from django.dispatch import receiver from django.dispatch import receiver
from .choices import CableStatusChoices from .choices import CableStatusChoices
from .models import Cable, CableTermination, Device, FrontPort, RearPort, VirtualChassis from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis
def create_cablepath(node):
"""
Create CablePaths for all paths originating from the specified node.
"""
cp = CablePath.from_origin(node)
if cp:
try:
cp.save()
except Exception as e:
print(node, node.pk)
raise e
def rebuild_paths(obj):
"""
Rebuild all CablePaths which traverse the specified node
"""
cable_paths = CablePath.objects.filter(path__contains=obj)
with transaction.atomic():
for cp in cable_paths:
cp.delete()
create_cablepath(cp.origin)
@receiver(post_save, sender=VirtualChassis) @receiver(post_save, sender=VirtualChassis)
@ -32,71 +59,73 @@ def clear_virtualchassis_members(instance, **kwargs):
@receiver(post_save, sender=Cable) @receiver(post_save, sender=Cable)
def update_connected_endpoints(instance, **kwargs): def update_connected_endpoints(instance, created, raw=False, **kwargs):
""" """
When a Cable is saved, check for and update its two connected endpoints When a Cable is saved, check for and update its two connected endpoints
""" """
logger = logging.getLogger('netbox.dcim.cable') logger = logging.getLogger('netbox.dcim.cable')
if raw:
logger.debug(f"Skipping endpoint updates for imported cable {instance}")
return
# Cache the Cable on its two termination points # Cache the Cable on its two termination points
if instance.termination_a.cable != instance: if instance.termination_a.cable != instance:
logger.debug("Updating termination A for cable {}".format(instance)) logger.debug(f"Updating termination A for cable {instance}")
instance.termination_a.cable = instance instance.termination_a.cable = instance
instance.termination_a._cable_peer = instance.termination_b
instance.termination_a.save() instance.termination_a.save()
if instance.termination_b.cable != instance: if instance.termination_b.cable != instance:
logger.debug("Updating termination B for cable {}".format(instance)) logger.debug(f"Updating termination B for cable {instance}")
instance.termination_b.cable = instance instance.termination_b.cable = instance
instance.termination_b._cable_peer = instance.termination_a
instance.termination_b.save() instance.termination_b.save()
# Update any endpoints for this Cable. # Create/update cable paths
endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() if created:
for endpoint in endpoints: for termination in (instance.termination_a, instance.termination_b):
path, split_ends, position_stack = endpoint.trace() if isinstance(termination, PathEndpoint):
# Determine overall path status (connected or planned) create_cablepath(termination)
path_status = True else:
for segment in path: rebuild_paths(termination)
if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED: elif instance.status != instance._orig_status:
path_status = False # We currently don't support modifying either termination of an existing Cable. (This
break # may change in the future.) However, we do need to capture status changes and update
# any CablePaths accordingly.
endpoint_a = path[0][0] if instance.status != CableStatusChoices.STATUS_CONNECTED:
endpoint_b = path[-1][2] if not split_ends and not position_stack else None CablePath.objects.filter(path__contains=instance).update(is_active=False)
else:
# Patch panel ports are not connected endpoints, all other cable terminations are rebuild_paths(instance)
if isinstance(endpoint_a, CableTermination) and not isinstance(endpoint_a, (FrontPort, RearPort)) and \
isinstance(endpoint_b, CableTermination) and not isinstance(endpoint_b, (FrontPort, RearPort)):
logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
endpoint_a.connected_endpoint = endpoint_b
endpoint_a.connection_status = path_status
endpoint_a.save()
endpoint_b.connected_endpoint = endpoint_a
endpoint_b.connection_status = path_status
endpoint_b.save()
@receiver(pre_delete, sender=Cable) @receiver(post_delete, sender=Cable)
def nullify_connected_endpoints(instance, **kwargs): def nullify_connected_endpoints(instance, **kwargs):
""" """
When a Cable is deleted, check for and update its two connected endpoints When a Cable is deleted, check for and update its two connected endpoints
""" """
logger = logging.getLogger('netbox.dcim.cable') logger = logging.getLogger('netbox.dcim.cable')
endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints()
# Disassociate the Cable from its termination points # Disassociate the Cable from its termination points
if instance.termination_a is not None: if instance.termination_a is not None:
logger.debug("Nullifying termination A for cable {}".format(instance)) logger.debug(f"Nullifying termination A for cable {instance}")
instance.termination_a.cable = None instance.termination_a.cable = None
instance.termination_a._cable_peer = None
instance.termination_a.save() instance.termination_a.save()
if instance.termination_b is not None: if instance.termination_b is not None:
logger.debug("Nullifying termination B for cable {}".format(instance)) logger.debug(f"Nullifying termination B for cable {instance}")
instance.termination_b.cable = None instance.termination_b.cable = None
instance.termination_b._cable_peer = None
instance.termination_b.save() instance.termination_b.save()
# If this Cable was part of any complete end-to-end paths, tear them down. # Delete and retrace any dependent cable paths
for endpoint in endpoints: for cablepath in CablePath.objects.filter(path__contains=instance):
logger.debug(f"Removing path information for {endpoint}") cp = CablePath.from_origin(cablepath.origin)
if hasattr(endpoint, 'connected_endpoint'): if cp:
endpoint.connected_endpoint = None CablePath.objects.filter(pk=cablepath.pk).update(
endpoint.connection_status = None path=cp.path,
endpoint.save() destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None,
destination_id=cp.destination.pk if cp.destination else None,
is_active=cp.is_active,
is_split=cp.is_split
)
else:
cablepath.delete()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,108 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from utilities.tables import BaseTable, BooleanColumn
from dcim.models import ConsolePort, Interface, PowerPort
from .cables import *
from .devices import *
from .devicetypes import *
from .power import *
from .racks import *
from .sites import *
#
# Device connections
#
class ConsoleConnectionTable(BaseTable):
console_server = tables.Column(
accessor=Accessor('_path__destination__device'),
orderable=False,
linkify=True,
verbose_name='Console Server'
)
console_server_port = tables.Column(
accessor=Accessor('_path__destination'),
orderable=False,
linkify=True,
verbose_name='Port'
)
device = tables.Column(
linkify=True
)
name = tables.Column(
linkify=True,
verbose_name='Console Port'
)
reachable = BooleanColumn(
accessor=Accessor('_path__is_active'),
verbose_name='Reachable'
)
class Meta(BaseTable.Meta):
model = ConsolePort
fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable')
class PowerConnectionTable(BaseTable):
pdu = tables.Column(
accessor=Accessor('_path__destination__device'),
orderable=False,
linkify=True,
verbose_name='PDU'
)
outlet = tables.Column(
accessor=Accessor('_path__destination'),
orderable=False,
linkify=True,
verbose_name='Outlet'
)
device = tables.Column(
linkify=True
)
name = tables.Column(
linkify=True,
verbose_name='Power Port'
)
reachable = BooleanColumn(
accessor=Accessor('_path__is_active'),
verbose_name='Reachable'
)
class Meta(BaseTable.Meta):
model = PowerPort
fields = ('device', 'name', 'pdu', 'outlet', 'reachable')
class InterfaceConnectionTable(BaseTable):
device_a = tables.Column(
accessor=Accessor('device'),
linkify=True,
verbose_name='Device A'
)
interface_a = tables.Column(
accessor=Accessor('name'),
linkify=True,
verbose_name='Interface A'
)
device_b = tables.Column(
accessor=Accessor('_path__destination__device'),
orderable=False,
linkify=True,
verbose_name='Device B'
)
interface_b = tables.Column(
accessor=Accessor('_path__destination'),
orderable=False,
linkify=True,
verbose_name='Interface B'
)
reachable = BooleanColumn(
accessor=Accessor('_path__is_active'),
verbose_name='Reachable'
)
class Meta(BaseTable.Meta):
model = Interface
fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable')

View File

@ -0,0 +1,64 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Cable
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, ToggleColumn
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
__all__ = (
'CableTable',
)
#
# Cables
#
class CableTable(BaseTable):
pk = ToggleColumn()
id = tables.Column(
linkify=True,
verbose_name='ID'
)
termination_a_parent = tables.TemplateColumn(
template_code=CABLE_TERMINATION_PARENT,
accessor=Accessor('termination_a'),
orderable=False,
verbose_name='Side A'
)
termination_a = tables.LinkColumn(
accessor=Accessor('termination_a'),
orderable=False,
verbose_name='Termination A'
)
termination_b_parent = tables.TemplateColumn(
template_code=CABLE_TERMINATION_PARENT,
accessor=Accessor('termination_b'),
orderable=False,
verbose_name='Side B'
)
termination_b = tables.LinkColumn(
accessor=Accessor('termination_b'),
orderable=False,
verbose_name='Termination B'
)
status = ChoiceFieldColumn()
length = tables.TemplateColumn(
template_code=CABLE_LENGTH,
order_by='_abs_length'
)
color = ColorColumn()
tags = TagColumn(
url_name='dcim:cable_list'
)
class Meta(BaseTable.Meta):
model = Cable
fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
'status', 'type', 'color', 'length', 'tags',
)
default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
'status', 'type',
)

View File

@ -0,0 +1,642 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import (
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
PowerOutlet, PowerPort, RearPort, VirtualChassis,
)
from tenancy.tables import COL_TENANT
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
TagColumn, ToggleColumn,
)
from .template_code import (
CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
FRONTPORT_BUTTONS, INTERFACE_BUTTONS, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS,
POWERPORT_BUTTONS, REARPORT_BUTTONS,
)
__all__ = (
'ConsolePortTable',
'ConsoleServerPortTable',
'DeviceBayTable',
'DeviceConsolePortTable',
'DeviceConsoleServerPortTable',
'DeviceDeviceBayTable',
'DeviceFrontPortTable',
'DeviceImportTable',
'DeviceInterfaceTable',
'DeviceInventoryItemTable',
'DevicePowerPortTable',
'DevicePowerOutletTable',
'DeviceRearPortTable',
'DeviceRoleTable',
'DeviceTable',
'FrontPortTable',
'InterfaceTable',
'InventoryItemTable',
'PlatformTable',
'PowerOutletTable',
'PowerPortTable',
'RearPortTable',
'VirtualChassisTable',
)
#
# Device roles
#
class DeviceRoleTable(BaseTable):
pk = ToggleColumn()
device_count = LinkedCountColumn(
viewname='dcim:device_list',
url_params={'role': 'slug'},
verbose_name='Devices'
)
vm_count = LinkedCountColumn(
viewname='virtualization:virtualmachine_list',
url_params={'role': 'slug'},
verbose_name='VMs'
)
color = ColorColumn()
vm_role = BooleanColumn()
actions = ButtonsColumn(DeviceRole, pk_field='slug')
class Meta(BaseTable.Meta):
model = DeviceRole
fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
#
# Platforms
#
class PlatformTable(BaseTable):
pk = ToggleColumn()
device_count = LinkedCountColumn(
viewname='dcim:device_list',
url_params={'platform': 'slug'},
verbose_name='Devices'
)
vm_count = LinkedCountColumn(
viewname='virtualization:virtualmachine_list',
url_params={'platform': 'slug'},
verbose_name='VMs'
)
actions = ButtonsColumn(Platform, pk_field='slug')
class Meta(BaseTable.Meta):
model = Platform
fields = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'description', 'actions',
)
default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',
)
#
# Devices
#
class DeviceTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
order_by=('_name',),
template_code=DEVICE_LINK
)
status = ChoiceFieldColumn()
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
site = tables.Column(
linkify=True
)
rack = tables.Column(
linkify=True
)
device_role = ColoredLabelColumn(
verbose_name='Role'
)
device_type = tables.LinkColumn(
viewname='dcim:devicetype',
args=[Accessor('device_type__pk')],
verbose_name='Type',
text=lambda record: record.device_type.display_name
)
primary_ip = tables.Column(
linkify=True,
verbose_name='IP Address'
)
primary_ip4 = tables.Column(
linkify=True,
verbose_name='IPv4 Address'
)
primary_ip6 = tables.Column(
linkify=True,
verbose_name='IPv6 Address'
)
cluster = tables.LinkColumn(
viewname='virtualization:cluster',
args=[Accessor('cluster__pk')]
)
virtual_chassis = tables.LinkColumn(
viewname='dcim:virtualchassis',
args=[Accessor('virtual_chassis__pk')]
)
vc_position = tables.Column(
verbose_name='VC Position'
)
vc_priority = tables.Column(
verbose_name='VC Priority'
)
tags = TagColumn(
url_name='dcim:device_list'
)
class Meta(BaseTable.Meta):
model = Device
fields = (
'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site',
'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis',
'vc_position', 'vc_priority', 'tags',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip',
)
class DeviceImportTable(BaseTable):
name = tables.TemplateColumn(
template_code=DEVICE_LINK
)
status = ChoiceFieldColumn()
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
site = tables.Column(
linkify=True
)
rack = tables.Column(
linkify=True
)
device_role = tables.Column(
verbose_name='Role'
)
device_type = tables.Column(
verbose_name='Type'
)
class Meta(BaseTable.Meta):
model = Device
fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
empty_text = False
#
# Device components
#
class DeviceComponentTable(BaseTable):
pk = ToggleColumn()
device = tables.Column(
linkify=True
)
name = tables.Column(
linkify=True,
order_by=('_name',)
)
cable = tables.Column(
linkify=True
)
class Meta(BaseTable.Meta):
order_by = ('device', 'name')
class CableTerminationTable(BaseTable):
cable = tables.Column(
linkify=True
)
cable_peer = tables.TemplateColumn(
accessor='_cable_peer',
template_code=CABLETERMINATION,
orderable=False,
verbose_name='Cable Peer'
)
class PathEndpointTable(CableTerminationTable):
connection = tables.TemplateColumn(
accessor='_path.destination',
template_code=CABLETERMINATION,
verbose_name='Connection',
orderable=False
)
class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
tags = TagColumn(
url_name='dcim:consoleport_list'
)
class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = (
'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
class DeviceConsolePortTable(ConsolePortTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-console"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=ConsolePort,
buttons=('edit', 'delete'),
prepend_template=CONSOLEPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = (
'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions'
)
default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
row_attrs = {
'class': lambda record: record.cable.get_status_class() if record.cable else ''
}
class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
tags = TagColumn(
url_name='dcim:consoleserverport_list'
)
class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
class DeviceConsoleServerPortTable(ConsoleServerPortTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-console-network-outline"></i> '
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=ConsoleServerPort,
buttons=('edit', 'delete'),
prepend_template=CONSOLESERVERPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = (
'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions'
)
default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
row_attrs = {
'class': lambda record: record.cable.get_status_class() if record.cable else ''
}
class PowerPortTable(DeviceComponentTable, PathEndpointTable):
tags = TagColumn(
url_name='dcim:powerport_list'
)
class Meta(DeviceComponentTable.Meta):
model = PowerPort
fields = (
'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable',
'cable_peer', 'connection', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
class DevicePowerPortTable(PowerPortTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-power-plug-outline"></i> <a href="{{ record.get_absolute_url }}">'
'{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=PowerPort,
buttons=('edit', 'delete'),
prepend_template=POWERPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
model = PowerPort
fields = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_peer',
'connection', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
'actions',
)
row_attrs = {
'class': lambda record: record.cable.get_status_class() if record.cable else ''
}
class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
power_port = tables.Column(
linkify=True
)
tags = TagColumn(
url_name='dcim:poweroutlet_list'
)
class Meta(DeviceComponentTable.Meta):
model = PowerOutlet
fields = (
'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'cable_peer',
'connection', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
class DevicePowerOutletTable(PowerOutletTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-power-socket"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=PowerOutlet,
buttons=('edit', 'delete'),
prepend_template=POWEROUTLET_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
model = PowerOutlet
fields = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'cable_peer', 'connection',
'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
)
row_attrs = {
'class': lambda record: record.cable.get_status_class() if record.cable else ''
}
class BaseInterfaceTable(BaseTable):
enabled = BooleanColumn()
ip_addresses = tables.TemplateColumn(
template_code=INTERFACE_IPADDRESSES,
orderable=False,
verbose_name='IP Addresses'
)
untagged_vlan = tables.Column(linkify=True)
tagged_vlans = tables.TemplateColumn(
template_code=INTERFACE_TAGGED_VLANS,
orderable=False,
verbose_name='Tagged VLANs'
)
class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
tags = TagColumn(
url_name='dcim:interface_list'
)
class Meta(DeviceComponentTable.Meta):
model = Interface
fields = (
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'description', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
)
default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
class DeviceInterfaceTable(InterfaceTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}drag-horizontal-variant'
'{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}ethernet'
'{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}}
)
lag = tables.Column(
linkify=True,
verbose_name='LAG'
)
actions = ButtonsColumn(
model=Interface,
buttons=('edit', 'delete'),
prepend_template=INTERFACE_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
model = Interface
fields = (
'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description',
'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable',
'connection', 'actions',
)
row_attrs = {
'class': lambda record: record.cable.get_status_class() if record.cable else ''
}
class FrontPortTable(DeviceComponentTable, CableTerminationTable):
rear_port_position = tables.Column(
verbose_name='Position'
)
rear_port = tables.Column(
linkify=True
)
tags = TagColumn(
url_name='dcim:frontport_list'
)
class Meta(DeviceComponentTable.Meta):
model = FrontPort
fields = (
'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable',
'cable_peer', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
class DeviceFrontPortTable(FrontPortTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-square-rounded{% if not record.cable %}-outline{% endif %}"></i> '
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=FrontPort,
buttons=('edit', 'delete'),
prepend_template=FRONTPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
model = FrontPort
fields = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer',
'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer',
'actions',
)
row_attrs = {
'class': lambda record: record.cable.get_status_class() if record.cable else ''
}
class RearPortTable(DeviceComponentTable, CableTerminationTable):
tags = TagColumn(
url_name='dcim:rearport_list'
)
class Meta(DeviceComponentTable.Meta):
model = RearPort
fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
class DeviceRearPortTable(RearPortTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-square-rounded{% if not record.cable %}-outline{% endif %}"></i> '
'<a href="{{ record.get_absolute_url }}">{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=RearPort,
buttons=('edit', 'delete'),
prepend_template=REARPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
model = RearPort
fields = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions',
)
row_attrs = {
'class': lambda record: record.cable.get_status_class() if record.cable else ''
}
class DeviceBayTable(DeviceComponentTable):
status = tables.TemplateColumn(
template_code=DEVICEBAY_STATUS
)
installed_device = tables.Column(
linkify=True
)
tags = TagColumn(
url_name='dcim:devicebay_list'
)
class Meta(DeviceComponentTable.Meta):
model = DeviceBay
fields = ('pk', 'device', 'name', 'label', 'status', 'installed_device', 'description', 'tags')
default_columns = ('pk', 'device', 'name', 'label', 'status', 'installed_device', 'description')
class DeviceDeviceBayTable(DeviceBayTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-circle{% if record.installed_device %}slice-8{% else %}outline{% endif %}'
'"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=DeviceBay,
buttons=('edit', 'delete'),
prepend_template=DEVICEBAY_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
model = DeviceBay
fields = (
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions',
)
class InventoryItemTable(DeviceComponentTable):
manufacturer = tables.Column(
linkify=True
)
discovered = BooleanColumn()
tags = TagColumn(
url_name='dcim:inventoryitem_list'
)
cable = None # Override DeviceComponentTable
class Meta(DeviceComponentTable.Meta):
model = InventoryItem
fields = (
'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
'discovered', 'tags',
)
default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
class DeviceInventoryItemTable(InventoryItemTable):
name = tables.TemplateColumn(
template_code='<a href="{{ record.get_absolute_url }}" style="padding-left: {{ record.level }}0px">'
'{{ value }}</a>',
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=InventoryItem,
buttons=('edit', 'delete')
)
class Meta(DeviceComponentTable.Meta):
model = InventoryItem
fields = (
'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
'actions',
)
#
# Virtual chassis
#
class VirtualChassisTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
master = tables.Column(
linkify=True
)
member_count = LinkedCountColumn(
viewname='dcim:device_list',
url_params={'virtual_chassis_id': 'pk'},
verbose_name='Members'
)
tags = TagColumn(
url_name='dcim:virtualchassis_list'
)
class Meta(BaseTable.Meta):
model = VirtualChassis
fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags')
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')

View File

@ -0,0 +1,200 @@
import django_tables2 as tables
from dcim.models import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
)
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, LinkedCountColumn, TagColumn, ToggleColumn
__all__ = (
'ConsolePortTemplateTable',
'ConsoleServerPortTemplateTable',
'DeviceBayTemplateTable',
'DeviceTypeTable',
'FrontPortTemplateTable',
'InterfaceTemplateTable',
'ManufacturerTable',
'PowerOutletTemplateTable',
'PowerPortTemplateTable',
'RearPortTemplateTable',
)
#
# Manufacturers
#
class ManufacturerTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
devicetype_count = tables.Column(
verbose_name='Device Types'
)
inventoryitem_count = tables.Column(
verbose_name='Inventory Items'
)
platform_count = tables.Column(
verbose_name='Platforms'
)
slug = tables.Column()
actions = ButtonsColumn(Manufacturer, pk_field='slug')
class Meta(BaseTable.Meta):
model = Manufacturer
fields = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
)
#
# Device types
#
class DeviceTypeTable(BaseTable):
pk = ToggleColumn()
model = tables.Column(
linkify=True,
verbose_name='Device Type'
)
is_full_depth = BooleanColumn(
verbose_name='Full Depth'
)
instance_count = LinkedCountColumn(
viewname='dcim:device_list',
url_params={'device_type_id': 'pk'},
verbose_name='Instances'
)
tags = TagColumn(
url_name='dcim:devicetype_list'
)
class Meta(BaseTable.Meta):
model = DeviceType
fields = (
'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'instance_count', 'tags',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
)
#
# Device type components
#
class ComponentTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
order_by=('_name',)
)
class ConsolePortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=ConsolePortTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_consoleports'
)
class Meta(BaseTable.Meta):
model = ConsolePortTemplate
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
empty_text = "None"
class ConsoleServerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=ConsoleServerPortTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_consoleserverports'
)
class Meta(BaseTable.Meta):
model = ConsoleServerPortTemplate
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
empty_text = "None"
class PowerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=PowerPortTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_powerports'
)
class Meta(BaseTable.Meta):
model = PowerPortTemplate
fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions')
empty_text = "None"
class PowerOutletTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=PowerOutletTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_poweroutlets'
)
class Meta(BaseTable.Meta):
model = PowerOutletTemplate
fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions')
empty_text = "None"
class InterfaceTemplateTable(ComponentTemplateTable):
mgmt_only = BooleanColumn(
verbose_name='Management Only'
)
actions = ButtonsColumn(
model=InterfaceTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_interfaces'
)
class Meta(BaseTable.Meta):
model = InterfaceTemplate
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
empty_text = "None"
class FrontPortTemplateTable(ComponentTemplateTable):
rear_port_position = tables.Column(
verbose_name='Position'
)
actions = ButtonsColumn(
model=FrontPortTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_frontports'
)
class Meta(BaseTable.Meta):
model = FrontPortTemplate
fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'actions')
empty_text = "None"
class RearPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=RearPortTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_rearports'
)
class Meta(BaseTable.Meta):
model = RearPortTemplate
fields = ('pk', 'name', 'label', 'type', 'positions', 'description', 'actions')
empty_text = "None"
class DeviceBayTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=DeviceBayTemplate,
buttons=('edit', 'delete'),
return_url_extra='%23tab_devicebays'
)
class Meta(BaseTable.Meta):
model = DeviceBayTemplate
fields = ('pk', 'name', 'label', 'description', 'actions')
empty_text = "None"

View File

@ -0,0 +1,77 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import PowerFeed, PowerPanel
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn
from .devices import CableTerminationTable
from .template_code import POWERFEED_CABLE, POWERFEED_CABLETERMINATION
__all__ = (
'PowerFeedTable',
'PowerPanelTable',
)
#
# Power panels
#
class PowerPanelTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
site = tables.LinkColumn(
viewname='dcim:site',
args=[Accessor('site__slug')]
)
powerfeed_count = LinkedCountColumn(
viewname='dcim:powerfeed_list',
url_params={'power_panel_id': 'pk'},
verbose_name='Feeds'
)
tags = TagColumn(
url_name='dcim:powerpanel_list'
)
class Meta(BaseTable.Meta):
model = PowerPanel
fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count', 'tags')
default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')
#
# Power feeds
#
# We're not using PathEndpointTable for PowerFeed because power connections
# cannot traverse pass-through ports.
class PowerFeedTable(CableTerminationTable):
pk = ToggleColumn()
name = tables.LinkColumn()
power_panel = tables.Column(
linkify=True
)
rack = tables.Column(
linkify=True
)
status = ChoiceFieldColumn()
type = ChoiceFieldColumn()
max_utilization = tables.TemplateColumn(
template_code="{{ value }}%"
)
available_power = tables.Column(
verbose_name='Available power (VA)'
)
tags = TagColumn(
url_name='dcim:powerfeed_list'
)
class Meta(BaseTable.Meta):
model = PowerFeed
fields = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'cable', 'cable_peer', 'connection', 'available_power', 'tags',
)
default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
'cable_peer',
)

170
netbox/dcim/tables/racks.py Normal file
View File

@ -0,0 +1,170 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Rack, RackGroup, RackReservation, RackRole
from tenancy.tables import COL_TENANT
from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn,
ToggleColumn,
)
from .template_code import MPTT_LINK, RACKGROUP_ELEVATIONS, UTILIZATION_GRAPH
__all__ = (
'RackTable',
'RackDetailTable',
'RackGroupTable',
'RackReservationTable',
'RackRoleTable',
)
#
# Rack groups
#
class RackGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
template_code=MPTT_LINK,
orderable=False
)
site = tables.LinkColumn(
viewname='dcim:site',
args=[Accessor('site__slug')],
verbose_name='Site'
)
rack_count = tables.Column(
verbose_name='Racks'
)
actions = ButtonsColumn(
model=RackGroup,
prepend_template=RACKGROUP_ELEVATIONS
)
class Meta(BaseTable.Meta):
model = RackGroup
fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions')
#
# Rack roles
#
class RackRoleTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(linkify=True)
rack_count = tables.Column(verbose_name='Racks')
color = ColorColumn()
actions = ButtonsColumn(RackRole)
class Meta(BaseTable.Meta):
model = RackRole
fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions')
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
#
# Racks
#
class RackTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
order_by=('_name',),
linkify=True
)
group = tables.Column(
linkify=True
)
site = tables.Column(
linkify=True
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
status = ChoiceFieldColumn()
role = ColoredLabelColumn()
u_height = tables.TemplateColumn(
template_code="{{ record.u_height }}U",
verbose_name='Height'
)
class Meta(BaseTable.Meta):
model = Rack
fields = (
'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height',
)
default_columns = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height')
class RackDetailTable(RackTable):
device_count = LinkedCountColumn(
viewname='dcim:device_list',
url_params={'rack_id': 'pk'},
verbose_name='Devices'
)
get_utilization = tables.TemplateColumn(
template_code=UTILIZATION_GRAPH,
orderable=False,
verbose_name='Space'
)
get_power_utilization = tables.TemplateColumn(
template_code=UTILIZATION_GRAPH,
orderable=False,
verbose_name='Power'
)
tags = TagColumn(
url_name='dcim:rack_list'
)
class Meta(RackTable.Meta):
fields = (
'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
)
default_columns = (
'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
'get_utilization', 'get_power_utilization',
)
#
# Rack reservations
#
class RackReservationTable(BaseTable):
pk = ToggleColumn()
reservation = tables.Column(
accessor='pk',
linkify=True
)
site = tables.Column(
accessor=Accessor('rack__site'),
linkify=True
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
rack = tables.Column(
linkify=True
)
unit_list = tables.Column(
orderable=False,
verbose_name='Units'
)
tags = TagColumn(
url_name='dcim:rackreservation_list'
)
actions = ButtonsColumn(RackReservation)
class Meta(BaseTable.Meta):
model = RackReservation
fields = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
'actions',
)
default_columns = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',
)

View File

@ -0,0 +1,62 @@
import django_tables2 as tables
from dcim.models import Region, Site
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
from .template_code import MPTT_LINK
__all__ = (
'RegionTable',
'SiteTable',
)
#
# Regions
#
class RegionTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
template_code=MPTT_LINK,
orderable=False
)
site_count = tables.Column(
verbose_name='Sites'
)
actions = ButtonsColumn(Region)
class Meta(BaseTable.Meta):
model = Region
fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
#
# Sites
#
class SiteTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(
order_by=('_name',)
)
status = ChoiceFieldColumn()
region = tables.Column(
linkify=True
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
tags = TagColumn(
url_name='dcim:site_list'
)
class Meta(BaseTable.Meta):
model = Site
fields = (
'pk', 'name', 'slug', 'status', 'facility', 'region', 'tenant', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'tags',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')

View File

@ -0,0 +1,251 @@
CABLETERMINATION = """
{% if value %}
<a href="{{ value.parent.get_absolute_url }}">{{ value.parent }}</a>
<i class="mdi mdi-chevron-right"></i>
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% else %}
&mdash;
{% endif %}
"""
CABLE_LENGTH = """
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}&mdash;{% endif %}
"""
CABLE_TERMINATION_PARENT = """
{% if value.device %}
<a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
{% elif value.circuit %}
<a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
{% elif value.power_panel %}
<a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
{% endif %}
"""
DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}">
{{ record.name|default:'<span class="label label-info">Unnamed device</span>' }}
</a>
"""
DEVICEBAY_STATUS = """
{% if record.installed_device_id %}
<span class="label label-{{ record.installed_device.get_status_class }}">
{{ record.installed_device.get_status_display }}
</span>
{% else %}
<span class="label label-default">Vacant</span>
{% endif %}
"""
INTERFACE_IPADDRESSES = """
{% for ip in record.ip_addresses.all %}
<a href="{{ ip.get_absolute_url }}">{{ ip }}</a><br />
{% endfor %}
"""
INTERFACE_TAGGED_VLANS = """
{% if record.mode == 'tagged' %}
{% for vlan in record.tagged_vlans.all %}
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
{% endfor %}
{% elif record.mode == 'tagged-all' %}
All
{% else %}
&mdash;
{% endif %}
"""
MPTT_LINK = """
{% if record.get_children %}
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="mdi mdi-chevron-right"></i>
{% else %}
<span style="padding-left: {{ record.get_ancestors|length }}9px">
{% endif %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
</span>
"""
POWERFEED_CABLE = """
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
<a href="{% url 'dcim:powerfeed_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
"""
POWERFEED_CABLETERMINATION = """
<a href="{{ value.parent.get_absolute_url }}">{{ value.parent }}</a>
<i class="mdi mdi-chevron-right"></i>
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
"""
RACKGROUP_ELEVATIONS = """
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
<i class="mdi mdi-server"></i>
</a>
"""
UTILIZATION_GRAPH = """
{% load helpers %}
{% utilization_graph value %}
"""
#
# Device component buttons
#
CONSOLEPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:consoleport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Server Port</a></li>
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Front Port</a></li>
<li><a href="{% url 'dcim:consoleport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Rear Port</a></li>
</ul>
</span>
{% endif %}
"""
CONSOLESERVERPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:consoleserverport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Port</a></li>
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Front Port</a></li>
<li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Rear Port</a></li>
</ul>
</span>
{% endif %}
"""
POWERPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:powerport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-outlet' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Outlet</a></li>
<li><a href="{% url 'dcim:powerport_connect' termination_a_id=record.pk termination_b_type='power-feed' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Feed</a></li>
</ul>
</span>
{% endif %}
"""
POWEROUTLET_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:poweroutlet_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<a href="{% url 'dcim:poweroutlet_connect' termination_a_id=record.pk termination_b_type='power-port' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-xs">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
</a>
{% endif %}
"""
INTERFACE_BUTTONS = """
{% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}" class="btn btn-xs btn-success" title="Add IP address">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
{% if record.cable %}
<a href="{% url 'dcim:interface_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% elif record.is_connectable and perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interface</a></li>
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Front Port</a></li>
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Rear Port</a></li>
<li><a href="{% url 'dcim:interface_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% endif %}
"""
FRONTPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:frontport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Interface</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Server Port</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Console Port</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Port</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Rear Port</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% endif %}
"""
REARPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:rearport_trace' pk=record.pk %}" class="btn btn-primary btn-xs" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
<a href="#" class="btn btn-default btn-xs disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
<span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
<li><a href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
</ul>
</span>
{% endif %}
"""
DEVICEBAY_BUTTONS = """
{% if perms.dcim.change_devicebay %}
{% if record.installed_device %}
<a href="{% url 'dcim:devicebay_depopulate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-xs">
<i class="mdi mdi-minus-thick" aria-hidden="true" title="Remove device"></i>
</a>
{% else %}
<a href="{% url 'dcim:devicebay_populate' pk=record.pk %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-success btn-xs">
<i class="mdi mdi-plus-thick" aria-hidden="true" title="Install device"></i>
</a>
{% endif %}
{% endif %}
"""

View File

@ -1,10 +1,7 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import ( from dcim.models import (
@ -14,7 +11,6 @@ from dcim.models import (
Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
) )
from ipam.models import VLAN from ipam.models import VLAN
from extras.models import Graph
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
from virtualization.models import Cluster, ClusterType from virtualization.models import Cluster, ClusterType
@ -83,6 +79,9 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
'slug': 'region-6', 'slug': 'region-6',
}, },
] ]
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -95,6 +94,9 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
class SiteTest(APIViewTestCases.APIViewTestCase): class SiteTest(APIViewTestCases.APIViewTestCase):
model = Site model = Site
brief_fields = ['id', 'name', 'slug', 'url'] brief_fields = ['id', 'name', 'slug', 'url']
bulk_update_data = {
'status': 'planned',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -132,30 +134,13 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
}, },
] ]
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_site_graphs(self):
"""
Test retrieval of Graphs assigned to Sites.
"""
ct = ContentType.objects.get_for_model(Site)
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
self.add_permissions('dcim.view_site')
url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?site=site-1&foo=1')
class RackGroupTest(APIViewTestCases.APIViewTestCase): class RackGroupTest(APIViewTestCases.APIViewTestCase):
model = RackGroup model = RackGroup
brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url'] brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -217,6 +202,9 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
'color': 'ffff00', 'color': 'ffff00',
}, },
] ]
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -232,6 +220,9 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
class RackTest(APIViewTestCases.APIViewTestCase): class RackTest(APIViewTestCases.APIViewTestCase):
model = Rack model = Rack
brief_fields = ['device_count', 'display_name', 'id', 'name', 'url'] brief_fields = ['device_count', 'display_name', 'id', 'name', 'url']
bulk_update_data = {
'status': 'planned',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -317,6 +308,9 @@ class RackTest(APIViewTestCases.APIViewTestCase):
class RackReservationTest(APIViewTestCases.APIViewTestCase): class RackReservationTest(APIViewTestCases.APIViewTestCase):
model = RackReservation model = RackReservation
brief_fields = ['id', 'units', 'url', 'user'] brief_fields = ['id', 'units', 'url', 'user']
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -379,6 +373,9 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
'slug': 'manufacturer-6', 'slug': 'manufacturer-6',
}, },
] ]
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -394,6 +391,9 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
class DeviceTypeTest(APIViewTestCases.APIViewTestCase): class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
model = DeviceType model = DeviceType
brief_fields = ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url'] brief_fields = ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
bulk_update_data = {
'part_number': 'ABC123',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -433,6 +433,9 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConsolePortTemplate model = ConsolePortTemplate
brief_fields = ['id', 'name', 'url'] brief_fields = ['id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -467,6 +470,9 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConsoleServerPortTemplate model = ConsoleServerPortTemplate
brief_fields = ['id', 'name', 'url'] brief_fields = ['id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -501,6 +507,9 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = PowerPortTemplate model = PowerPortTemplate
brief_fields = ['id', 'name', 'url'] brief_fields = ['id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -535,6 +544,9 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
model = PowerOutletTemplate model = PowerOutletTemplate
brief_fields = ['id', 'name', 'url'] brief_fields = ['id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -569,6 +581,9 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
model = InterfaceTemplate model = InterfaceTemplate
brief_fields = ['id', 'name', 'url'] brief_fields = ['id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -606,6 +621,9 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = FrontPortTemplate model = FrontPortTemplate
brief_fields = ['id', 'name', 'url'] brief_fields = ['id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -652,21 +670,21 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
'name': 'Front Port Template 4', 'name': 'Front Port Template 4',
'type': PortTypeChoices.TYPE_8P8C, 'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[3].pk, 'rear_port': rear_port_templates[3].pk,
'position': 1, 'rear_port_position': 1,
}, },
{ {
'device_type': devicetype.pk, 'device_type': devicetype.pk,
'name': 'Front Port Template 5', 'name': 'Front Port Template 5',
'type': PortTypeChoices.TYPE_8P8C, 'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[4].pk, 'rear_port': rear_port_templates[4].pk,
'position': 1, 'rear_port_position': 1,
}, },
{ {
'device_type': devicetype.pk, 'device_type': devicetype.pk,
'name': 'Front Port Template 6', 'name': 'Front Port Template 6',
'type': PortTypeChoices.TYPE_8P8C, 'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port_templates[5].pk, 'rear_port': rear_port_templates[5].pk,
'position': 1, 'rear_port_position': 1,
}, },
] ]
@ -674,6 +692,9 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
model = RearPortTemplate model = RearPortTemplate
brief_fields = ['id', 'name', 'url'] brief_fields = ['id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -711,6 +732,9 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
model = DeviceBayTemplate model = DeviceBayTemplate
brief_fields = ['id', 'name', 'url'] brief_fields = ['id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -762,6 +786,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
'color': 'ffff00', 'color': 'ffff00',
}, },
] ]
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -791,6 +818,9 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
'slug': 'platform-6', 'slug': 'platform-6',
}, },
] ]
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -806,6 +836,9 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
class DeviceTest(APIViewTestCases.APIViewTestCase): class DeviceTest(APIViewTestCases.APIViewTestCase):
model = Device model = Device
brief_fields = ['display_name', 'id', 'name', 'url'] brief_fields = ['display_name', 'id', 'name', 'url']
bulk_update_data = {
'status': 'failed',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -902,26 +935,6 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
}, },
] ]
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_device_graphs(self):
"""
Test retrieval of Graphs assigned to Devices.
"""
ct = ContentType.objects.get_for_model(Device)
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?device={{ obj.name }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?device={{ obj.name }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?device={{ obj.name }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
self.add_permissions('dcim.view_device')
url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?device=Device 1&foo=1')
def test_config_context_included_by_default_in_list_view(self): def test_config_context_included_by_default_in_list_view(self):
""" """
Check that config context data is included by default in the devices list. Check that config context data is included by default in the devices list.
@ -963,7 +976,10 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsolePort model = ConsolePort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = ConsoleServerPort peer_termination_type = ConsoleServerPort
@classmethod @classmethod
@ -999,7 +1015,10 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsoleServerPort model = ConsoleServerPort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = ConsolePort peer_termination_type = ConsolePort
@classmethod @classmethod
@ -1035,7 +1054,10 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerPort model = PowerPort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = PowerOutlet peer_termination_type = PowerOutlet
@classmethod @classmethod
@ -1071,7 +1093,10 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerOutlet model = PowerOutlet
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = PowerPort peer_termination_type = PowerPort
@classmethod @classmethod
@ -1107,7 +1132,10 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = Interface model = Interface
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = Interface peer_termination_type = Interface
@classmethod @classmethod
@ -1159,30 +1187,13 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
}, },
] ]
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_interface_graphs(self):
"""
Test retrieval of Graphs assigned to Devices.
"""
ct = ContentType.objects.get_for_model(Interface)
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
self.add_permissions('dcim.view_interface') class FrontPortTest(APIViewTestCases.APIViewTestCase):
url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1')
class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = FrontPort model = FrontPort
brief_fields = ['cable', 'device', 'id', 'name', 'url'] brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = Interface peer_termination_type = Interface
@classmethod @classmethod
@ -1235,9 +1246,12 @@ class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
] ]
class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): class RearPortTest(APIViewTestCases.APIViewTestCase):
model = RearPort model = RearPort
brief_fields = ['cable', 'device', 'id', 'name', 'url'] brief_fields = ['cable', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
peer_termination_type = Interface peer_termination_type = Interface
@classmethod @classmethod
@ -1277,6 +1291,9 @@ class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase)
class DeviceBayTest(APIViewTestCases.APIViewTestCase): class DeviceBayTest(APIViewTestCases.APIViewTestCase):
model = DeviceBay model = DeviceBay
brief_fields = ['device', 'id', 'name', 'url'] brief_fields = ['device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -1336,7 +1353,10 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
class InventoryItemTest(APIViewTestCases.APIViewTestCase): class InventoryItemTest(APIViewTestCases.APIViewTestCase):
model = InventoryItem model = InventoryItem
brief_fields = ['device', 'id', 'name', 'url'] brief_fields = ['_depth', 'device', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -1346,12 +1366,9 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000') devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site) device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
inventory_items = ( InventoryItem.objects.create(device=device, name='Inventory Item 1', manufacturer=manufacturer)
InventoryItem(device=device, name='Inventory Item 1', manufacturer=manufacturer), InventoryItem.objects.create(device=device, name='Inventory Item 2', manufacturer=manufacturer)
InventoryItem(device=device, name='Inventory Item 2', manufacturer=manufacturer), InventoryItem.objects.create(device=device, name='Inventory Item 3', manufacturer=manufacturer)
InventoryItem(device=device, name='Inventory Item 3', manufacturer=manufacturer),
)
InventoryItem.objects.bulk_create(inventory_items)
cls.create_data = [ cls.create_data = [
{ {
@ -1375,6 +1392,10 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
class CableTest(APIViewTestCases.APIViewTestCase): class CableTest(APIViewTestCases.APIViewTestCase):
model = Cable model = Cable
brief_fields = ['id', 'label', 'url'] brief_fields = ['id', 'label', 'url']
bulk_update_data = {
'length': 100,
'length_unit': 'm',
}
# TODO: Allow updating cable terminations # TODO: Allow updating cable terminations
test_update_object = None test_update_object = None
@ -1431,377 +1452,6 @@ class CableTest(APIViewTestCases.APIViewTestCase):
] ]
class ConnectionTest(APITestCase):
def setUp(self):
super().setUp()
self.site = Site.objects.create(
name='Test Site 1', slug='test-site-1'
)
manufacturer = Manufacturer.objects.create(
name='Test Manufacturer 1', slug='test-manufacturer-1'
)
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=self.site
)
self.device2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 2', site=self.site
)
self.panel1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Panel 1', site=self.site
)
self.panel2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Panel 2', site=self.site
)
def test_create_direct_console_connection(self):
consoleport1 = ConsolePort.objects.create(
device=self.device1, name='Test Console Port 1'
)
consoleserverport1 = ConsoleServerPort.objects.create(
device=self.device2, name='Test Console Server Port 1'
)
data = {
'termination_a_type': 'dcim.consoleport',
'termination_a_id': consoleport1.pk,
'termination_b_type': 'dcim.consoleserverport',
'termination_b_id': consoleserverport1.pk,
}
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Cable.objects.count(), 1)
cable = Cable.objects.get(pk=response.data['id'])
consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk)
consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk)
self.assertEqual(cable.termination_a, consoleport1)
self.assertEqual(cable.termination_b, consoleserverport1)
self.assertEqual(consoleport1.cable, cable)
self.assertEqual(consoleserverport1.cable, cable)
self.assertEqual(consoleport1.connected_endpoint, consoleserverport1)
self.assertEqual(consoleserverport1.connected_endpoint, consoleport1)
def test_create_patched_console_connection(self):
consoleport1 = ConsolePort.objects.create(
device=self.device1, name='Test Console Port 1'
)
consoleserverport1 = ConsoleServerPort.objects.create(
device=self.device2, name='Test Console Server Port 1'
)
rearport1 = RearPort.objects.create(
device=self.panel1, name='Test Rear Port 1', type=PortTypeChoices.TYPE_8P8C
)
frontport1 = FrontPort.objects.create(
device=self.panel1, name='Test Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport1
)
rearport2 = RearPort.objects.create(
device=self.panel2, name='Test Rear Port 2', type=PortTypeChoices.TYPE_8P8C
)
frontport2 = FrontPort.objects.create(
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
)
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
cables = [
# Console port to panel1 front
{
'termination_a_type': 'dcim.consoleport',
'termination_a_id': consoleport1.pk,
'termination_b_type': 'dcim.frontport',
'termination_b_id': frontport1.pk,
},
# Panel1 rear to panel2 rear
{
'termination_a_type': 'dcim.rearport',
'termination_a_id': rearport1.pk,
'termination_b_type': 'dcim.rearport',
'termination_b_id': rearport2.pk,
},
# Panel2 front to console server port
{
'termination_a_type': 'dcim.frontport',
'termination_a_id': frontport2.pk,
'termination_b_type': 'dcim.consoleserverport',
'termination_b_id': consoleserverport1.pk,
},
]
for data in cables:
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
cable = Cable.objects.get(pk=response.data['id'])
self.assertEqual(cable.termination_a.cable, cable)
self.assertEqual(cable.termination_b.cable, cable)
consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk)
consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk)
self.assertEqual(consoleport1.connected_endpoint, consoleserverport1)
self.assertEqual(consoleserverport1.connected_endpoint, consoleport1)
def test_create_direct_power_connection(self):
powerport1 = PowerPort.objects.create(
device=self.device1, name='Test Power Port 1'
)
poweroutlet1 = PowerOutlet.objects.create(
device=self.device2, name='Test Power Outlet 1'
)
data = {
'termination_a_type': 'dcim.powerport',
'termination_a_id': powerport1.pk,
'termination_b_type': 'dcim.poweroutlet',
'termination_b_id': poweroutlet1.pk,
}
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Cable.objects.count(), 1)
cable = Cable.objects.get(pk=response.data['id'])
powerport1 = PowerPort.objects.get(pk=powerport1.pk)
poweroutlet1 = PowerOutlet.objects.get(pk=poweroutlet1.pk)
self.assertEqual(cable.termination_a, powerport1)
self.assertEqual(cable.termination_b, poweroutlet1)
self.assertEqual(powerport1.cable, cable)
self.assertEqual(poweroutlet1.cable, cable)
self.assertEqual(powerport1.connected_endpoint, poweroutlet1)
self.assertEqual(poweroutlet1.connected_endpoint, powerport1)
# Note: Power connections via patch ports are not supported.
def test_create_direct_interface_connection(self):
interface1 = Interface.objects.create(
device=self.device1, name='Test Interface 1'
)
interface2 = Interface.objects.create(
device=self.device2, name='Test Interface 2'
)
data = {
'termination_a_type': 'dcim.interface',
'termination_a_id': interface1.pk,
'termination_b_type': 'dcim.interface',
'termination_b_id': interface2.pk,
}
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Cable.objects.count(), 1)
cable = Cable.objects.get(pk=response.data['id'])
interface1 = Interface.objects.get(pk=interface1.pk)
interface2 = Interface.objects.get(pk=interface2.pk)
self.assertEqual(cable.termination_a, interface1)
self.assertEqual(cable.termination_b, interface2)
self.assertEqual(interface1.cable, cable)
self.assertEqual(interface2.cable, cable)
self.assertEqual(interface1.connected_endpoint, interface2)
self.assertEqual(interface2.connected_endpoint, interface1)
def test_create_patched_interface_connection(self):
interface1 = Interface.objects.create(
device=self.device1, name='Test Interface 1'
)
interface2 = Interface.objects.create(
device=self.device2, name='Test Interface 2'
)
rearport1 = RearPort.objects.create(
device=self.panel1, name='Test Rear Port 1', type=PortTypeChoices.TYPE_8P8C
)
frontport1 = FrontPort.objects.create(
device=self.panel1, name='Test Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport1
)
rearport2 = RearPort.objects.create(
device=self.panel2, name='Test Rear Port 2', type=PortTypeChoices.TYPE_8P8C
)
frontport2 = FrontPort.objects.create(
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
)
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
cables = [
# Interface1 to panel1 front
{
'termination_a_type': 'dcim.interface',
'termination_a_id': interface1.pk,
'termination_b_type': 'dcim.frontport',
'termination_b_id': frontport1.pk,
},
# Panel1 rear to panel2 rear
{
'termination_a_type': 'dcim.rearport',
'termination_a_id': rearport1.pk,
'termination_b_type': 'dcim.rearport',
'termination_b_id': rearport2.pk,
},
# Panel2 front to interface2
{
'termination_a_type': 'dcim.frontport',
'termination_a_id': frontport2.pk,
'termination_b_type': 'dcim.interface',
'termination_b_id': interface2.pk,
},
]
for data in cables:
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
cable = Cable.objects.get(pk=response.data['id'])
self.assertEqual(cable.termination_a.cable, cable)
self.assertEqual(cable.termination_b.cable, cable)
interface1 = Interface.objects.get(pk=interface1.pk)
interface2 = Interface.objects.get(pk=interface2.pk)
self.assertEqual(interface1.connected_endpoint, interface2)
self.assertEqual(interface2.connected_endpoint, interface1)
def test_create_direct_circuittermination_connection(self):
provider = Provider.objects.create(
name='Test Provider 1', slug='test-provider-1'
)
circuittype = CircuitType.objects.create(
name='Test Circuit Type 1', slug='test-circuit-type-1'
)
circuit = Circuit.objects.create(
provider=provider, type=circuittype, cid='Test Circuit 1'
)
interface1 = Interface.objects.create(
device=self.device1, name='Test Interface 1'
)
circuittermination1 = CircuitTermination.objects.create(
circuit=circuit, term_side='A', site=self.site, port_speed=10000
)
data = {
'termination_a_type': 'dcim.interface',
'termination_a_id': interface1.pk,
'termination_b_type': 'circuits.circuittermination',
'termination_b_id': circuittermination1.pk,
}
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Cable.objects.count(), 1)
cable = Cable.objects.get(pk=response.data['id'])
interface1 = Interface.objects.get(pk=interface1.pk)
circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk)
self.assertEqual(cable.termination_a, interface1)
self.assertEqual(cable.termination_b, circuittermination1)
self.assertEqual(interface1.cable, cable)
self.assertEqual(circuittermination1.cable, cable)
self.assertEqual(interface1.connected_endpoint, circuittermination1)
self.assertEqual(circuittermination1.connected_endpoint, interface1)
def test_create_patched_circuittermination_connection(self):
provider = Provider.objects.create(
name='Test Provider 1', slug='test-provider-1'
)
circuittype = CircuitType.objects.create(
name='Test Circuit Type 1', slug='test-circuit-type-1'
)
circuit = Circuit.objects.create(
provider=provider, type=circuittype, cid='Test Circuit 1'
)
interface1 = Interface.objects.create(
device=self.device1, name='Test Interface 1'
)
circuittermination1 = CircuitTermination.objects.create(
circuit=circuit, term_side='A', site=self.site, port_speed=10000
)
rearport1 = RearPort.objects.create(
device=self.panel1, name='Test Rear Port 1', type=PortTypeChoices.TYPE_8P8C
)
frontport1 = FrontPort.objects.create(
device=self.panel1, name='Test Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport1
)
rearport2 = RearPort.objects.create(
device=self.panel2, name='Test Rear Port 2', type=PortTypeChoices.TYPE_8P8C
)
frontport2 = FrontPort.objects.create(
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
)
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
cables = [
# Interface to panel1 front
{
'termination_a_type': 'dcim.interface',
'termination_a_id': interface1.pk,
'termination_b_type': 'dcim.frontport',
'termination_b_id': frontport1.pk,
},
# Panel1 rear to panel2 rear
{
'termination_a_type': 'dcim.rearport',
'termination_a_id': rearport1.pk,
'termination_b_type': 'dcim.rearport',
'termination_b_id': rearport2.pk,
},
# Panel2 front to circuit termination
{
'termination_a_type': 'dcim.frontport',
'termination_a_id': frontport2.pk,
'termination_b_type': 'circuits.circuittermination',
'termination_b_id': circuittermination1.pk,
},
]
for data in cables:
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
cable = Cable.objects.get(pk=response.data['id'])
self.assertEqual(cable.termination_a.cable, cable)
self.assertEqual(cable.termination_b.cable, cable)
interface1 = Interface.objects.get(pk=interface1.pk)
circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk)
self.assertEqual(interface1.connected_endpoint, circuittermination1)
self.assertEqual(circuittermination1.connected_endpoint, interface1)
class ConnectedDeviceTest(APITestCase): class ConnectedDeviceTest(APITestCase):
def setUp(self): def setUp(self):
@ -1888,10 +1538,13 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
VirtualChassis(name='Virtual Chassis 3', master=devices[6], domain='domain-3'), VirtualChassis(name='Virtual Chassis 3', master=devices[6], domain='domain-3'),
) )
VirtualChassis.objects.bulk_create(virtual_chassis) VirtualChassis.objects.bulk_create(virtual_chassis)
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis[0], vc_position=1)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2) Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2)
Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3) Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3)
Device.objects.filter(pk=devices[3].pk).update(virtual_chassis=virtual_chassis[1], vc_position=1)
Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2) Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2)
Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3) Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3)
Device.objects.filter(pk=devices[6].pk).update(virtual_chassis=virtual_chassis[2], vc_position=1)
Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2) Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2)
Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3) Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3)
@ -1916,6 +1569,10 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
}, },
] ]
cls.bulk_update_data = {
'domain': 'newdomain',
}
class PowerPanelTest(APIViewTestCases.APIViewTestCase): class PowerPanelTest(APIViewTestCases.APIViewTestCase):
model = PowerPanel model = PowerPanel
@ -1923,43 +1580,55 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1') sites = (
Site.objects.create(name='Site 1', slug='site-1'),
Site.objects.create(name='Site 2', slug='site-2'),
)
rack_groups = ( rack_groups = (
RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=site), RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2', site=site), RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2', site=sites[0]),
RackGroup.objects.create(name='Rack Group 3', slug='rack-group-3', site=site), RackGroup.objects.create(name='Rack Group 3', slug='rack-group-3', site=sites[0]),
RackGroup.objects.create(name='Rack Group 4', slug='rack-group-3', site=sites[1]),
) )
power_panels = ( power_panels = (
PowerPanel(site=site, rack_group=rack_groups[0], name='Power Panel 1'), PowerPanel(site=sites[0], rack_group=rack_groups[0], name='Power Panel 1'),
PowerPanel(site=site, rack_group=rack_groups[1], name='Power Panel 2'), PowerPanel(site=sites[0], rack_group=rack_groups[1], name='Power Panel 2'),
PowerPanel(site=site, rack_group=rack_groups[2], name='Power Panel 3'), PowerPanel(site=sites[0], rack_group=rack_groups[2], name='Power Panel 3'),
) )
PowerPanel.objects.bulk_create(power_panels) PowerPanel.objects.bulk_create(power_panels)
cls.create_data = [ cls.create_data = [
{ {
'name': 'Power Panel 4', 'name': 'Power Panel 4',
'site': site.pk, 'site': sites[0].pk,
'rack_group': rack_groups[0].pk, 'rack_group': rack_groups[0].pk,
}, },
{ {
'name': 'Power Panel 5', 'name': 'Power Panel 5',
'site': site.pk, 'site': sites[0].pk,
'rack_group': rack_groups[1].pk, 'rack_group': rack_groups[1].pk,
}, },
{ {
'name': 'Power Panel 6', 'name': 'Power Panel 6',
'site': site.pk, 'site': sites[0].pk,
'rack_group': rack_groups[2].pk, 'rack_group': rack_groups[2].pk,
}, },
] ]
cls.bulk_update_data = {
'site': sites[1].pk,
'rack_group': rack_groups[3].pk
}
class PowerFeedTest(APIViewTestCases.APIViewTestCase): class PowerFeedTest(APIViewTestCases.APIViewTestCase):
model = PowerFeed model = PowerFeed
brief_fields = ['cable', 'id', 'name', 'url'] brief_fields = ['cable', 'id', 'name', 'url']
bulk_update_data = {
'status': 'planned',
}
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -0,0 +1,923 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from circuits.models import *
from dcim.choices import CableStatusChoices
from dcim.models import *
from dcim.utils import object_to_path_node
class CablePathTestCase(TestCase):
"""
Test NetBox's ability to trace and retrace CablePaths in response to data model changes. Tests are numbered
as follows:
1XX: Test direct connections between different endpoint types
2XX: Test different cable topologies
3XX: Test responses to changes in existing objects
"""
@classmethod
def setUpTestData(cls):
# Create a single device that will hold all components
cls.site = Site.objects.create(name='Site', slug='site')
manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device')
device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
cls.device = Device.objects.create(site=cls.site, device_type=device_type, device_role=device_role, name='Test Device')
cls.powerpanel = PowerPanel.objects.create(site=cls.site, name='Power Panel')
provider = Provider.objects.create(name='Provider', slug='provider')
circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
def assertPathExists(self, origin, destination, path=None, is_active=None, msg=None):
"""
Assert that a CablePath from origin to destination with a specific intermediate path exists.
:param origin: Originating endpoint
:param destination: Terminating endpoint, or None
:param path: Sequence of objects comprising the intermediate path (optional)
:param is_active: Boolean indicating whether the end-to-end path is complete and active (optional)
:param msg: Custom failure message (optional)
:return: The matching CablePath (if any)
"""
kwargs = {
'origin_type': ContentType.objects.get_for_model(origin),
'origin_id': origin.pk,
}
if destination is not None:
kwargs['destination_type'] = ContentType.objects.get_for_model(destination)
kwargs['destination_id'] = destination.pk
else:
kwargs['destination_type__isnull'] = True
kwargs['destination_id__isnull'] = True
if path is not None:
kwargs['path'] = [object_to_path_node(obj) for obj in path]
if is_active is not None:
kwargs['is_active'] = is_active
if msg is None:
if destination is not None:
msg = f"Missing path from {origin} to {destination}"
else:
msg = f"Missing partial path originating from {origin}"
cablepath = CablePath.objects.filter(**kwargs).first()
self.assertIsNotNone(cablepath, msg=msg)
return cablepath
def assertPathIsSet(self, origin, cablepath, msg=None):
"""
Assert that a specific CablePath instance is set as the path on the origin.
:param origin: The originating path endpoint
:param cablepath: The CablePath instance originating from this endpoint
:param msg: Custom failure message (optional)
"""
if msg is None:
msg = f"Path #{cablepath.pk} not set on originating endpoint {origin}"
self.assertEqual(origin._path_id, cablepath.pk, msg=msg)
def assertPathIsNotSet(self, origin, msg=None):
"""
Assert that a specific CablePath instance is set as the path on the origin.
:param origin: The originating path endpoint
:param msg: Custom failure message (optional)
"""
if msg is None:
msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!"
self.assertIsNone(origin._path_id, msg=msg)
def test_101_interface_to_interface(self):
"""
[IF1] --C1-- [IF2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=interface2)
cable1.save()
path1 = self.assertPathExists(
origin=interface1,
destination=interface2,
path=(cable1,),
is_active=True
)
path2 = self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable1,),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
interface1.refresh_from_db()
interface2.refresh_from_db()
self.assertPathIsSet(interface1, path1)
self.assertPathIsSet(interface2, path2)
# Delete cable 1
cable1.delete()
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_102_consoleport_to_consoleserverport(self):
"""
[CP1] --C1-- [CSP1]
"""
consoleport1 = ConsolePort.objects.create(device=self.device, name='Console Port 1')
consoleserverport1 = ConsoleServerPort.objects.create(device=self.device, name='Console Server Port 1')
# Create cable 1
cable1 = Cable(termination_a=consoleport1, termination_b=consoleserverport1)
cable1.save()
path1 = self.assertPathExists(
origin=consoleport1,
destination=consoleserverport1,
path=(cable1,),
is_active=True
)
path2 = self.assertPathExists(
origin=consoleserverport1,
destination=consoleport1,
path=(cable1,),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
consoleport1.refresh_from_db()
consoleserverport1.refresh_from_db()
self.assertPathIsSet(consoleport1, path1)
self.assertPathIsSet(consoleserverport1, path2)
# Delete cable 1
cable1.delete()
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_103_powerport_to_poweroutlet(self):
"""
[PP1] --C1-- [PO1]
"""
powerport1 = PowerPort.objects.create(device=self.device, name='Power Port 1')
poweroutlet1 = PowerOutlet.objects.create(device=self.device, name='Power Outlet 1')
# Create cable 1
cable1 = Cable(termination_a=powerport1, termination_b=poweroutlet1)
cable1.save()
path1 = self.assertPathExists(
origin=powerport1,
destination=poweroutlet1,
path=(cable1,),
is_active=True
)
path2 = self.assertPathExists(
origin=poweroutlet1,
destination=powerport1,
path=(cable1,),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
powerport1.refresh_from_db()
poweroutlet1.refresh_from_db()
self.assertPathIsSet(powerport1, path1)
self.assertPathIsSet(poweroutlet1, path2)
# Delete cable 1
cable1.delete()
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_104_powerport_to_powerfeed(self):
"""
[PP1] --C1-- [PF1]
"""
powerport1 = PowerPort.objects.create(device=self.device, name='Power Port 1')
powerfeed1 = PowerFeed.objects.create(power_panel=self.powerpanel, name='Power Feed 1')
# Create cable 1
cable1 = Cable(termination_a=powerport1, termination_b=powerfeed1)
cable1.save()
path1 = self.assertPathExists(
origin=powerport1,
destination=powerfeed1,
path=(cable1,),
is_active=True
)
path2 = self.assertPathExists(
origin=powerfeed1,
destination=powerport1,
path=(cable1,),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
powerport1.refresh_from_db()
powerfeed1.refresh_from_db()
self.assertPathIsSet(powerport1, path1)
self.assertPathIsSet(powerfeed1, path2)
# Delete cable 1
cable1.delete()
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_105_interface_to_circuittermination(self):
"""
[IF1] --C1-- [CT1A]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=circuittermination1)
cable1.save()
path1 = self.assertPathExists(
origin=interface1,
destination=circuittermination1,
path=(cable1,),
is_active=True
)
path2 = self.assertPathExists(
origin=circuittermination1,
destination=interface1,
path=(cable1,),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
interface1.refresh_from_db()
circuittermination1.refresh_from_db()
self.assertPathIsSet(interface1, path1)
self.assertPathIsSet(circuittermination1, path2)
# Delete cable 1
cable1.delete()
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_201_single_path_via_pass_through(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [IF2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=frontport1)
cable1.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1, rearport1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
# Create cable 2
cable2 = Cable(termination_a=rearport1, termination_b=interface2)
cable2.save()
self.assertPathExists(
origin=interface1,
destination=interface2,
path=(cable1, frontport1, rearport1, cable2),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable2, rearport1, frontport1, cable1),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
# Delete cable 2
cable2.delete()
path1 = self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1, rearport1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
interface1.refresh_from_db()
interface2.refresh_from_db()
self.assertPathIsSet(interface1, path1)
self.assertPathIsNotSet(interface2)
def test_202_multiple_paths_via_pass_through(self):
"""
[IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF3]
[IF2] --C2-- [FP1:2] [FP2:2] --C5-- [IF4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
interface4 = Interface.objects.create(device=self.device, name='Interface 4')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
frontport1_1 = FrontPort.objects.create(
device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
frontport2_1 = FrontPort.objects.create(
device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
)
frontport2_2 = FrontPort.objects.create(
device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
)
# Create cables 1-2
cable1 = Cable(termination_a=interface1, termination_b=frontport1_1)
cable1.save()
cable2 = Cable(termination_a=interface2, termination_b=frontport1_2)
cable2.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1_1, rearport1),
is_active=False
)
self.assertPathExists(
origin=interface2,
destination=None,
path=(cable2, frontport1_2, rearport1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 2)
# Create cable 3
cable3 = Cable(termination_a=rearport1, termination_b=rearport2)
cable3.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1),
is_active=False
)
self.assertPathExists(
origin=interface2,
destination=None,
path=(cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 2)
# Create cables 4-5
cable4 = Cable(termination_a=frontport2_1, termination_b=interface3)
cable4.save()
cable5 = Cable(termination_a=frontport2_2, termination_b=interface4)
cable5.save()
path1 = self.assertPathExists(
origin=interface1,
destination=interface3,
path=(
cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1,
cable4,
),
is_active=True
)
path2 = self.assertPathExists(
origin=interface2,
destination=interface4,
path=(
cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2,
cable5,
),
is_active=True
)
path3 = self.assertPathExists(
origin=interface3,
destination=interface1,
path=(
cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1,
cable1
),
is_active=True
)
path4 = self.assertPathExists(
origin=interface4,
destination=interface2,
path=(
cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2,
cable2
),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)
# Delete cable 3
cable3.delete()
# Check for four partial paths; one from each interface
self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4)
self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0)
interface1.refresh_from_db()
interface2.refresh_from_db()
interface3.refresh_from_db()
interface4.refresh_from_db()
self.assertPathIsSet(interface1, path1)
self.assertPathIsSet(interface2, path2)
self.assertPathIsSet(interface3, path3)
self.assertPathIsSet(interface4, path4)
def test_203_multiple_paths_via_nested_pass_throughs(self):
"""
[IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2] [RP2] --C4-- [RP3] [FP3] --C5-- [RP4] [FP4:1] --C6-- [IF3]
[IF2] --C2-- [FP1:2] [FP4:2] --C7-- [IF4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
interface4 = Interface.objects.create(device=self.device, name='Interface 4')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=4)
frontport1_1 = FrontPort.objects.create(
device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
frontport3 = FrontPort.objects.create(
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
)
frontport4_1 = FrontPort.objects.create(
device=self.device, name='Front Port 4:1', rear_port=rearport4, rear_port_position=1
)
frontport4_2 = FrontPort.objects.create(
device=self.device, name='Front Port 4:2', rear_port=rearport4, rear_port_position=2
)
# Create cables 1-2, 6-7
cable1 = Cable(termination_a=interface1, termination_b=frontport1_1)
cable1.save()
cable2 = Cable(termination_a=interface2, termination_b=frontport1_2)
cable2.save()
cable6 = Cable(termination_a=interface3, termination_b=frontport4_1)
cable6.save()
cable7 = Cable(termination_a=interface4, termination_b=frontport4_2)
cable7.save()
self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface
# Create cables 3 and 5
cable3 = Cable(termination_a=rearport1, termination_b=frontport2)
cable3.save()
cable5 = Cable(termination_a=rearport4, termination_b=frontport3)
cable5.save()
self.assertEqual(CablePath.objects.count(), 4) # Four (longer) partial paths; one from each interface
# Create cable 4
cable4 = Cable(termination_a=rearport2, termination_b=rearport3)
cable4.save()
self.assertPathExists(
origin=interface1,
destination=interface3,
path=(
cable1, frontport1_1, rearport1, cable3, frontport2, rearport2,
cable4, rearport3, frontport3, cable5, rearport4, frontport4_1,
cable6
),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface4,
path=(
cable2, frontport1_2, rearport1, cable3, frontport2, rearport2,
cable4, rearport3, frontport3, cable5, rearport4, frontport4_2,
cable7
),
is_active=True
)
self.assertPathExists(
origin=interface3,
destination=interface1,
path=(
cable6, frontport4_1, rearport4, cable5, frontport3, rearport3,
cable4, rearport2, frontport2, cable3, rearport1, frontport1_1,
cable1
),
is_active=True
)
self.assertPathExists(
origin=interface4,
destination=interface2,
path=(
cable7, frontport4_2, rearport4, cable5, frontport3, rearport3,
cable4, rearport2, frontport2, cable3, rearport1, frontport1_2,
cable2
),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)
# Delete cable 3
cable3.delete()
# Check for four partial paths; one from each interface
self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4)
self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0)
def test_204_multiple_paths_via_multiple_pass_throughs(self):
"""
[IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [FP3:1] [RP3] --C6-- [RP4] [FP4:1] --C7-- [IF3]
[IF2] --C2-- [FP1:2] [FP2:1] --C5-- [FP3:1] [FP4:2] --C8-- [IF4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
interface4 = Interface.objects.create(device=self.device, name='Interface 4')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=4)
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=4)
frontport1_1 = FrontPort.objects.create(
device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
frontport2_1 = FrontPort.objects.create(
device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
)
frontport2_2 = FrontPort.objects.create(
device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
)
frontport3_1 = FrontPort.objects.create(
device=self.device, name='Front Port 3:1', rear_port=rearport3, rear_port_position=1
)
frontport3_2 = FrontPort.objects.create(
device=self.device, name='Front Port 3:2', rear_port=rearport3, rear_port_position=2
)
frontport4_1 = FrontPort.objects.create(
device=self.device, name='Front Port 4:1', rear_port=rearport4, rear_port_position=1
)
frontport4_2 = FrontPort.objects.create(
device=self.device, name='Front Port 4:2', rear_port=rearport4, rear_port_position=2
)
# Create cables 1-3, 6-8
cable1 = Cable(termination_a=interface1, termination_b=frontport1_1)
cable1.save()
cable2 = Cable(termination_a=interface2, termination_b=frontport1_2)
cable2.save()
cable3 = Cable(termination_a=rearport1, termination_b=rearport2)
cable3.save()
cable6 = Cable(termination_a=rearport3, termination_b=rearport4)
cable6.save()
cable7 = Cable(termination_a=interface3, termination_b=frontport4_1)
cable7.save()
cable8 = Cable(termination_a=interface4, termination_b=frontport4_2)
cable8.save()
self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface
# Create cables 4 and 5
cable4 = Cable(termination_a=frontport2_1, termination_b=frontport3_1)
cable4.save()
cable5 = Cable(termination_a=frontport2_2, termination_b=frontport3_2)
cable5.save()
self.assertPathExists(
origin=interface1,
destination=interface3,
path=(
cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1,
cable4, frontport3_1, rearport3, cable6, rearport4, frontport4_1,
cable7
),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface4,
path=(
cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2,
cable5, frontport3_2, rearport3, cable6, rearport4, frontport4_2,
cable8
),
is_active=True
)
self.assertPathExists(
origin=interface3,
destination=interface1,
path=(
cable7, frontport4_1, rearport4, cable6, rearport3, frontport3_1,
cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1,
cable1
),
is_active=True
)
self.assertPathExists(
origin=interface4,
destination=interface2,
path=(
cable8, frontport4_2, rearport4, cable6, rearport3, frontport3_2,
cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2,
cable2
),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)
# Delete cable 5
cable5.delete()
# Check for two complete paths (IF1 <--> IF2) and two partial (IF3 <--> IF4)
self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 2)
self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 2)
def test_205_multiple_paths_via_patched_pass_throughs(self):
"""
[IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2] [RP2] --C4-- [RP3] [FP3:1] --C5-- [IF3]
[IF2] --C2-- [FP1:2] [FP3:2] --C6-- [IF4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
interface4 = Interface.objects.create(device=self.device, name='Interface 4')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1)
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
frontport1_1 = FrontPort.objects.create(
device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 5', rear_port=rearport2, rear_port_position=1
)
frontport3_1 = FrontPort.objects.create(
device=self.device, name='Front Port 2:1', rear_port=rearport3, rear_port_position=1
)
frontport3_2 = FrontPort.objects.create(
device=self.device, name='Front Port 2:2', rear_port=rearport3, rear_port_position=2
)
# Create cables 1-2, 5-6
cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) # IF1 -> FP1:1
cable1.save()
cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) # IF2 -> FP1:2
cable2.save()
cable5 = Cable(termination_a=interface3, termination_b=frontport3_1) # IF3 -> FP3:1
cable5.save()
cable6 = Cable(termination_a=interface4, termination_b=frontport3_2) # IF4 -> FP3:2
cable6.save()
self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface
# Create cables 3-4
cable3 = Cable(termination_a=rearport1, termination_b=frontport2) # RP1 -> FP2
cable3.save()
cable4 = Cable(termination_a=rearport2, termination_b=rearport3) # RP2 -> RP3
cable4.save()
self.assertPathExists(
origin=interface1,
destination=interface3,
path=(
cable1, frontport1_1, rearport1, cable3, frontport2, rearport2,
cable4, rearport3, frontport3_1, cable5
),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface4,
path=(
cable2, frontport1_2, rearport1, cable3, frontport2, rearport2,
cable4, rearport3, frontport3_2, cable6
),
is_active=True
)
self.assertPathExists(
origin=interface3,
destination=interface1,
path=(
cable5, frontport3_1, rearport3, cable4, rearport2, frontport2,
cable3, rearport1, frontport1_1, cable1
),
is_active=True
)
self.assertPathExists(
origin=interface4,
destination=interface2,
path=(
cable6, frontport3_2, rearport3, cable4, rearport2, frontport2,
cable3, rearport1, frontport1_2, cable2
),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)
# Delete cable 3
cable3.delete()
# Check for four partial paths; one from each interface
self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4)
self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0)
def test_206_unidirectional_split_paths(self):
"""
[IF1] --C1-- [RP1] [FP1:1] --C2-- [IF2]
[FP1:2] --C3-- [IF3]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
frontport1_1 = FrontPort.objects.create(
device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
# Create cables 1
cable1 = Cable(termination_a=interface1, termination_b=rearport1)
cable1.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, rearport1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
# Create cables 2-3
cable2 = Cable(termination_a=interface2, termination_b=frontport1_1)
cable2.save()
cable3 = Cable(termination_a=interface3, termination_b=frontport1_2)
cable3.save()
self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable2, frontport1_1, rearport1, cable1),
is_active=True
)
self.assertPathExists(
origin=interface3,
destination=interface1,
path=(cable3, frontport1_2, rearport1, cable1),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 3)
# Delete cable 1
cable1.delete()
# Check that the partial path was deleted and the two complete paths are now partial
self.assertPathExists(
origin=interface2,
destination=None,
path=(cable2, frontport1_1, rearport1),
is_active=False
)
self.assertPathExists(
origin=interface3,
destination=None,
path=(cable3, frontport1_2, rearport1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 2)
def test_207_rearport_without_frontport(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
# Create cables
cable1 = Cable(termination_a=interface1, termination_b=frontport1)
cable1.save()
cable2 = Cable(termination_a=rearport1, termination_b=rearport2)
cable2.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1, rearport1, cable2, rearport2),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
def test_301_create_path_via_existing_cable(self):
"""
[IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
frontport2 = FrontPort.objects.create(
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
)
# Create cable 2
cable2 = Cable(termination_a=rearport1, termination_b=rearport2)
cable2.save()
self.assertEqual(CablePath.objects.count(), 0)
# Create cable1
cable1 = Cable(termination_a=interface1, termination_b=frontport1)
cable1.save()
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, frontport1, rearport1, cable2, rearport2, frontport2),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
# Create cable 3
cable3 = Cable(termination_a=frontport2, termination_b=interface2)
cable3.save()
self.assertPathExists(
origin=interface1,
destination=interface2,
path=(cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable3, frontport2, rearport2, cable2, rearport1, frontport1, cable1),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
def test_302_update_path_on_cable_status_change(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [IF2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
frontport1 = FrontPort.objects.create(
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
)
# Create cables 1 and 2
cable1 = Cable(termination_a=interface1, termination_b=frontport1)
cable1.save()
cable2 = Cable(termination_a=rearport1, termination_b=interface2)
cable2.save()
self.assertEqual(CablePath.objects.filter(is_active=True).count(), 2)
self.assertEqual(CablePath.objects.count(), 2)
# Change cable 2's status to "planned"
cable2.status = CableStatusChoices.STATUS_PLANNED
cable2.save()
self.assertPathExists(
origin=interface1,
destination=interface2,
path=(cable1, frontport1, rearport1, cable2),
is_active=False
)
self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable2, rearport1, frontport1, cable1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 2)
# Change cable 2's status to "connected"
cable2 = Cable.objects.get(pk=cable2.pk)
cable2.status = CableStatusChoices.STATUS_CONNECTED
cable2.save()
self.assertPathExists(
origin=interface1,
destination=interface2,
path=(cable1, frontport1, rearport1, cable2),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable2, rearport1, frontport1, cable1),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)

View File

@ -1514,10 +1514,11 @@ class ConsolePortTestCase(TestCase):
params = {'description': ['First', 'Second']} params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Fix boolean value def test_connected(self):
def test_connection_status(self): params = {'connected': True}
params = {'connection_status': 'True'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_region(self): def test_region(self):
regions = Region.objects.all()[:2] regions = Region.objects.all()[:2]
@ -1609,10 +1610,11 @@ class ConsoleServerPortTestCase(TestCase):
params = {'description': ['First', 'Second']} params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Fix boolean value def test_connected(self):
def test_connection_status(self): params = {'connected': True}
params = {'connection_status': 'True'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_region(self): def test_region(self):
regions = Region.objects.all()[:2] regions = Region.objects.all()[:2]
@ -1712,10 +1714,11 @@ class PowerPortTestCase(TestCase):
params = {'allocated_draw': [50, 100]} params = {'allocated_draw': [50, 100]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Fix boolean value def test_connected(self):
def test_connection_status(self): params = {'connected': True}
params = {'connection_status': 'True'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_region(self): def test_region(self):
regions = Region.objects.all()[:2] regions = Region.objects.all()[:2]
@ -1812,10 +1815,11 @@ class PowerOutletTestCase(TestCase):
params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A} params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
# TODO: Fix boolean value def test_connected(self):
def test_connection_status(self): params = {'connected': True}
params = {'connection_status': 'True'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_region(self): def test_region(self):
regions = Region.objects.all()[:2] regions = Region.objects.all()[:2]
@ -1900,10 +1904,11 @@ class InterfaceTestCase(TestCase):
params = {'name': ['Interface 1', 'Interface 2']} params = {'name': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Fix boolean value def test_connected(self):
def test_connection_status(self): params = {'connected': True}
params = {'connection_status': 'True'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self): def test_enabled(self):
params = {'enabled': 'true'} params = {'enabled': 'true'}
@ -2285,14 +2290,16 @@ class InventoryItemTestCase(TestCase):
InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'), InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'),
InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'), InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'),
) )
InventoryItem.objects.bulk_create(inventory_items) for i in inventory_items:
i.save()
child_inventory_items = ( child_inventory_items = (
InventoryItem(device=devices[0], name='Inventory Item 1A', parent=inventory_items[0]), InventoryItem(device=devices[0], name='Inventory Item 1A', parent=inventory_items[0]),
InventoryItem(device=devices[1], name='Inventory Item 2A', parent=inventory_items[1]), InventoryItem(device=devices[1], name='Inventory Item 2A', parent=inventory_items[1]),
InventoryItem(device=devices[2], name='Inventory Item 3A', parent=inventory_items[2]), InventoryItem(device=devices[2], name='Inventory Item 3A', parent=inventory_items[2]),
) )
InventoryItem.objects.bulk_create(child_inventory_items) for i in child_inventory_items:
i.save()
def test_id(self): def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:2]} params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
@ -2660,6 +2667,18 @@ class PowerFeedTestCase(TestCase):
) )
PowerFeed.objects.bulk_create(power_feeds) PowerFeed.objects.bulk_create(power_feeds)
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model', slug='model')
device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
device = Device.objects.create(name='Device', device_type=device_type, device_role=device_role, site=sites[0])
power_ports = [
PowerPort(device=device, name='Power Port 1'),
PowerPort(device=device, name='Power Port 2'),
]
PowerPort.objects.bulk_create(power_ports)
Cable(termination_a=power_feeds[0], termination_b=power_ports[0]).save()
Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).save()
def test_id(self): def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:2]} params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -2721,5 +2740,17 @@ class PowerFeedTestCase(TestCase):
params = {'rack_id': [racks[0].pk, racks[1].pk]} params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cabled': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
# TODO: Connection filters # TODO: Connection filters

View File

@ -390,17 +390,19 @@ class CableTestCase(TestCase):
self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') self.provider = Provider.objects.create(name='Provider 1', slug='provider-1')
self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1')
self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A', port_speed=1000) self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A')
self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z', port_speed=1000) self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z')
def test_cable_creation(self): def test_cable_creation(self):
""" """
When a new Cable is created, it must be cached on either termination point. When a new Cable is created, it must be cached on either termination point.
""" """
interface1 = Interface.objects.get(pk=self.interface1.pk) interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertEqual(self.cable.termination_a, interface1)
interface2 = Interface.objects.get(pk=self.interface2.pk) interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertEqual(self.cable.termination_a, interface1)
self.assertEqual(interface1._cable_peer, interface2)
self.assertEqual(self.cable.termination_b, interface2) self.assertEqual(self.cable.termination_b, interface2)
self.assertEqual(interface2._cable_peer, interface1)
def test_cable_deletion(self): def test_cable_deletion(self):
""" """
@ -412,8 +414,10 @@ class CableTestCase(TestCase):
self.assertNotEqual(str(self.cable), '#None') self.assertNotEqual(str(self.cable), '#None')
interface1 = Interface.objects.get(pk=self.interface1.pk) interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.cable) self.assertIsNone(interface1.cable)
self.assertIsNone(interface1._cable_peer)
interface2 = Interface.objects.get(pk=self.interface2.pk) interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertIsNone(interface2.cable) self.assertIsNone(interface2.cable)
self.assertIsNone(interface2._cable_peer)
def test_cabletermination_deletion(self): def test_cabletermination_deletion(self):
""" """
@ -457,21 +461,9 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
cable.clean() cable.clean()
def test_connection_via_single_position_rearport(self): def test_rearport_connections(self):
""" """
A RearPort with one position can be connected to anything. Test various combinations of RearPort connections.
[CableTermination X]---[RP(pos=1) FP]---[CableTermination Y]
is allowed anywhere
[CableTermination X]---[CableTermination Y]
is allowed.
A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort
with a different number of positions. RearPorts with a single position on the other hand may be connected
to such CableTerminations. Check that this is indeed allowed.
""" """
# Connecting a single-position RearPort to a multi-position RearPort is ok # Connecting a single-position RearPort to a multi-position RearPort is ok
Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean() Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean()
@ -482,68 +474,22 @@ class CableTestCase(TestCase):
# Connecting a single-position RearPort to a CircuitTermination is ok # Connecting a single-position RearPort to a CircuitTermination is ok
Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean() Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean()
def test_connection_via_multi_position_rearport(self):
"""
A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort
with a different number of positions.
The following scenario's are allowed (with x>1):
~----------+ +---------~
| |
RP2(pos=x)|---|RP(pos=x)
| |
~----------+ +---------~
~----------+ +---------~
| |
RP2(pos=x)|---|RP(pos=1)
| |
~----------+ +---------~
~----------+ +------------------~
| |
RP2(pos=x)|---|CircuitTermination
| |
~----------+ +------------------~
These scenarios are NOT allowed (with x>1):
~----------+ +----------~
| |
RP2(pos=x)|---|RP(pos!=x)
| |
~----------+ +----------~
~----------+ +----------~
| |
RP2(pos=x)|---|Interface
| |
~----------+ +----------~
These scenarios are tested in this order below.
"""
# Connecting a multi-position RearPort to another RearPort with the same number of positions is ok # Connecting a multi-position RearPort to another RearPort with the same number of positions is ok
Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean() Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean()
# Connecting a multi-position RearPort to a single-position RearPort is ok # Connecting a multi-position RearPort to an Interface is ok
Cable(termination_a=self.rear_port2, termination_b=self.rear_port1).full_clean() Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean()
# Connecting a multi-position RearPort to a CircuitTermination is ok # Connecting a multi-position RearPort to a CircuitTermination is ok
Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean() Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean()
# Connecting a two-position RearPort to a three-position RearPort is NOT ok
with self.assertRaises( with self.assertRaises(
ValidationError, ValidationError,
msg='Connecting a 2-position RearPort to a 3-position RearPort should fail' msg='Connecting a 2-position RearPort to a 3-position RearPort should fail'
): ):
Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean() Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean()
with self.assertRaises(
ValidationError,
msg='Connecting a multi-position RearPort to an Interface should fail'
):
Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean()
def test_cable_cannot_terminate_to_a_virtual_interface(self): def test_cable_cannot_terminate_to_a_virtual_interface(self):
""" """
A cable cannot terminate to a virtual interface A cable cannot terminate to a virtual interface
@ -561,628 +507,3 @@ class CableTestCase(TestCase):
cable = Cable(termination_a=self.interface2, termination_b=wireless_interface) cable = Cable(termination_a=self.interface2, termination_b=wireless_interface)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
cable.clean() cable.clean()
class CablePathTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
devicerole = DeviceRole.objects.create(
name='Device Role 1', slug='device-role-1', color='ff0000'
)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuit = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
CircuitTermination.objects.bulk_create((
CircuitTermination(circuit=circuit, site=site, term_side='A', port_speed=1000),
CircuitTermination(circuit=circuit, site=site, term_side='Z', port_speed=1000),
))
# Create four network devices with four interfaces each
devices = (
Device(device_type=devicetype, device_role=devicerole, name='Device 1', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Device 2', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Device 3', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Device 4', site=site),
)
Device.objects.bulk_create(devices)
for device in devices:
Interface.objects.bulk_create((
Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
))
# Create four patch panels, each with one rear port and four front ports
patch_panels = (
Device(device_type=devicetype, device_role=devicerole, name='Panel 1', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 2', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 3', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 4', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 5', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 6', site=site),
)
Device.objects.bulk_create(patch_panels)
# Create patch panels with 4 positions
for patch_panel in patch_panels[:4]:
rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=4, type=PortTypeChoices.TYPE_8P8C)
FrontPort.objects.bulk_create((
FrontPort(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C),
FrontPort(device=patch_panel, name='Front Port 2', rear_port=rearport, rear_port_position=2, type=PortTypeChoices.TYPE_8P8C),
FrontPort(device=patch_panel, name='Front Port 3', rear_port=rearport, rear_port_position=3, type=PortTypeChoices.TYPE_8P8C),
FrontPort(device=patch_panel, name='Front Port 4', rear_port=rearport, rear_port_position=4, type=PortTypeChoices.TYPE_8P8C),
))
# Create 1-on-1 patch panels
for patch_panel in patch_panels[4:]:
rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=1, type=PortTypeChoices.TYPE_8P8C)
FrontPort.objects.create(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C)
def test_direct_connection(self):
"""
Test a direct connection between two interfaces.
[Device 1] ----- [Device 2]
Iface1 Iface1
"""
# Create cable
cable = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable.full_clean()
cable.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete cable
cable.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
def test_connection_via_single_rear_port(self):
"""
Test a connection which passes through a rear port with exactly one front port.
1 2
[Device 1] ----- [Panel 5] ----- [Device 2]
Iface1 FP1 RP1 Iface1
"""
# Create cables (FP first, RP second)
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
self.assertEqual(cable2.termination_a.positions, 1) # Sanity check
cable2.full_clean()
cable2.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete cable 1
cable1.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
def test_connections_via_nested_single_position_rearport(self):
"""
Test a connection which passes through a single front/rear port pair between two multi-position rear ports.
Test two connections via patched rear ports:
Device 1 <---> Device 2
Device 3 <---> Device 4
1 2
[Device 1] -----------+ +----------- [Device 2]
Iface1 | | Iface1
FP1 | 3 4 | FP1
[Panel 1] ----- [Panel 5] ----- [Panel 2]
FP2 | RP1 RP1 FP1 RP1 | FP2
Iface1 | | Iface1
[Device 3] -----------+ +----------- [Device 4]
5 6
"""
# Create cables (Panel 5 RP first, FP second)
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1'),
termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2'),
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1')
)
cable5.full_clean()
cable5.save()
cable6 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'),
termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1')
)
cable6.full_clean()
cable6.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
self.assertTrue(endpoint_c.connection_status)
self.assertTrue(endpoint_d.connection_status)
# Delete cable 3
cable3.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
endpoint_c.refresh_from_db()
endpoint_d.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_c.connected_endpoint)
self.assertIsNone(endpoint_d.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
self.assertIsNone(endpoint_c.connection_status)
self.assertIsNone(endpoint_d.connection_status)
def test_connections_via_patch(self):
"""
Test two connections via patched rear ports:
Device 1 <---> Device 2
Device 3 <---> Device 4
1 2
[Device 1] -----------+ +----------- [Device 2]
Iface1 | | Iface1
FP1 | 3 | FP1
[Panel 1] ----- [Panel 2]
FP2 | RP1 RP1 | FP2
Iface1 | | Iface1
[Device 3] -----------+ +----------- [Device 4]
4 5
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2')
)
cable5.full_clean()
cable5.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
self.assertTrue(endpoint_c.connection_status)
self.assertTrue(endpoint_d.connection_status)
# Delete cable 3
cable3.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
endpoint_c.refresh_from_db()
endpoint_d.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_c.connected_endpoint)
self.assertIsNone(endpoint_d.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
self.assertIsNone(endpoint_c.connection_status)
self.assertIsNone(endpoint_d.connection_status)
def test_connections_via_multiple_patches(self):
"""
Test two connections via patched rear ports:
Device 1 <---> Device 2
Device 3 <---> Device 4
1 2 3
[Device 1] -----------+ +---------------+ +----------- [Device 2]
Iface1 | | | | Iface1
FP1 | 4 | FP1 FP1 | 5 | FP1
[Panel 1] ----- [Panel 2] [Panel 3] ----- [Panel 4]
FP2 | RP1 RP1 | FP2 FP2 | RP1 RP1 | FP2
Iface1 | | | | Iface1
[Device 3] -----------+ +---------------+ +----------- [Device 4]
6 7 8
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
)
cable5.full_clean()
cable5.save()
cable6 = Cable(
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
)
cable6.full_clean()
cable6.save()
cable7 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'),
termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 2')
)
cable7.full_clean()
cable7.save()
cable8 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
)
cable8.full_clean()
cable8.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
self.assertTrue(endpoint_c.connection_status)
self.assertTrue(endpoint_d.connection_status)
# Delete cables 4 and 5
cable4.delete()
cable5.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
endpoint_c.refresh_from_db()
endpoint_d.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_c.connected_endpoint)
self.assertIsNone(endpoint_d.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
self.assertIsNone(endpoint_c.connection_status)
self.assertIsNone(endpoint_d.connection_status)
def test_connections_via_nested_rear_ports(self):
"""
Test two connections via nested rear ports:
Device 1 <---> Device 2
Device 3 <---> Device 4
1 2
[Device 1] -----------+ +----------- [Device 2]
Iface1 | | Iface1
FP1 | 3 4 5 | FP1
[Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4]
FP2 | RP1 FP1 RP1 RP1 FP1 RP1 | FP2
Iface1 | | Iface1
[Device 3] -----------+ +----------- [Device 4]
6 7
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
)
cable5.full_clean()
cable5.save()
cable6 = Cable(
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
)
cable6.full_clean()
cable6.save()
cable7 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
)
cable7.full_clean()
cable7.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
self.assertTrue(endpoint_c.connection_status)
self.assertTrue(endpoint_d.connection_status)
# Delete cable 4
cable4.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
endpoint_c.refresh_from_db()
endpoint_d.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_c.connected_endpoint)
self.assertIsNone(endpoint_d.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
self.assertIsNone(endpoint_c.connection_status)
self.assertIsNone(endpoint_d.connection_status)
def test_connection_via_circuit(self):
"""
1 2
[Device 1] ----- [Circuit] ----- [Device 2]
Iface1 A Z Iface1
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=CircuitTermination.objects.get(term_side='A')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=CircuitTermination.objects.get(term_side='Z'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable2.full_clean()
cable2.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete circuit
circuit = Circuit.objects.first().delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
def test_connection_via_patched_circuit(self):
"""
1 2 3 4
[Device 1] ----- [Panel 5] ----- [Circuit] ----- [Panel 6] ----- [Device 2]
Iface1 FP1 RP1 A Z RP1 FP1 Iface1
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'),
termination_b=CircuitTermination.objects.get(term_side='A')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_a=CircuitTermination.objects.get(term_side='Z'),
termination_b=RearPort.objects.get(device__name='Panel 6', name='Rear Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 6', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable4.full_clean()
cable4.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
# Delete circuit
circuit = Circuit.objects.first().delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)

View File

@ -101,10 +101,10 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
"name,slug", "name,slug,status",
"Site 4,site-4", "Site 4,site-4,planned",
"Site 5,site-5", "Site 5,site-5,active",
"Site 6,site-6", "Site 6,site-6,staging",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -983,9 +983,9 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.csv_data = ( cls.csv_data = (
"device_role,manufacturer,device_type,status,name,site,rack_group,rack,position,face", "device_role,manufacturer,device_type,status,name,site,rack_group,rack,position,face",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Device 4,Site 1,Rack Group 1,Rack 1,10,Front", "Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Rack Group 1,Rack 1,10,front",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Device 5,Site 1,Rack Group 1,Rack 1,20,Front", "Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Rack Group 1,Rack 1,20,front",
"Device Role 1,Manufacturer 1,Device Type 1,Active,Device 6,Site 1,Rack Group 1,Rack 1,30,Front", "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Rack Group 1,Rack 1,30,front",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -997,6 +997,130 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'status': DeviceStatusChoices.STATUS_DECOMMISSIONING, 'status': DeviceStatusChoices.STATUS_DECOMMISSIONING,
} }
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_consoleports(self):
device = Device.objects.first()
console_ports = (
ConsolePort(device=device, name='Console Port 1'),
ConsolePort(device=device, name='Console Port 2'),
ConsolePort(device=device, name='Console Port 3'),
)
ConsolePort.objects.bulk_create(console_ports)
url = reverse('dcim:device_consoleports', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_consoleserverports(self):
device = Device.objects.first()
console_server_ports = (
ConsoleServerPort(device=device, name='Console Server Port 1'),
ConsoleServerPort(device=device, name='Console Server Port 2'),
ConsoleServerPort(device=device, name='Console Server Port 3'),
)
ConsoleServerPort.objects.bulk_create(console_server_ports)
url = reverse('dcim:device_consoleserverports', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_powerports(self):
device = Device.objects.first()
power_ports = (
PowerPort(device=device, name='Power Port 1'),
PowerPort(device=device, name='Power Port 2'),
PowerPort(device=device, name='Power Port 3'),
)
PowerPort.objects.bulk_create(power_ports)
url = reverse('dcim:device_powerports', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_poweroutlets(self):
device = Device.objects.first()
power_outlets = (
PowerOutlet(device=device, name='Power Outlet 1'),
PowerOutlet(device=device, name='Power Outlet 2'),
PowerOutlet(device=device, name='Power Outlet 3'),
)
PowerOutlet.objects.bulk_create(power_outlets)
url = reverse('dcim:device_poweroutlets', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_interfaces(self):
device = Device.objects.first()
interfaces = (
Interface(device=device, name='Interface 1'),
Interface(device=device, name='Interface 2'),
Interface(device=device, name='Interface 3'),
)
Interface.objects.bulk_create(interfaces)
url = reverse('dcim:device_interfaces', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_rearports(self):
device = Device.objects.first()
rear_ports = (
RearPort(device=device, name='Rear Port 1'),
RearPort(device=device, name='Rear Port 2'),
RearPort(device=device, name='Rear Port 3'),
)
RearPort.objects.bulk_create(rear_ports)
url = reverse('dcim:device_rearports', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_frontports(self):
device = Device.objects.first()
rear_ports = (
RearPort(device=device, name='Rear Port 1'),
RearPort(device=device, name='Rear Port 2'),
RearPort(device=device, name='Rear Port 3'),
)
RearPort.objects.bulk_create(rear_ports)
front_ports = (
FrontPort(device=device, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1),
FrontPort(device=device, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1),
FrontPort(device=device, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1),
)
FrontPort.objects.bulk_create(front_ports)
url = reverse('dcim:device_frontports', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_devicebays(self):
device = Device.objects.first()
device_bays = (
DeviceBay(device=device, name='Device Bay 1'),
DeviceBay(device=device, name='Device Bay 2'),
DeviceBay(device=device, name='Device Bay 3'),
)
DeviceBay.objects.bulk_create(device_bays)
url = reverse('dcim:device_devicebays', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_inventory(self):
device = Device.objects.first()
inventory_items = (
InventoryItem(device=device, name='Inventory Item 1'),
InventoryItem(device=device, name='Inventory Item 2'),
InventoryItem(device=device, name='Inventory Item 3'),
)
for item in inventory_items:
item.save()
url = reverse('dcim:device_inventory', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200)
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort model = ConsolePort
@ -1267,9 +1391,9 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.csv_data = ( cls.csv_data = (
"device,name,type", "device,name,type",
"Device 1,Interface 4,1000BASE-T (1GE)", "Device 1,Interface 4,1000base-t",
"Device 1,Interface 5,1000BASE-T (1GE)", "Device 1,Interface 5,1000base-t",
"Device 1,Interface 6,1000BASE-T (1GE)", "Device 1,Interface 6,1000base-t",
) )
@ -1326,9 +1450,9 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.csv_data = ( cls.csv_data = (
"device,name,type,rear_port,rear_port_position", "device,name,type,rear_port,rear_port_position",
"Device 1,Front Port 4,8P8C,Rear Port 4,1", "Device 1,Front Port 4,8p8c,Rear Port 4,1",
"Device 1,Front Port 5,8P8C,Rear Port 5,1", "Device 1,Front Port 5,8p8c,Rear Port 5,1",
"Device 1,Front Port 6,8P8C,Rear Port 6,1", "Device 1,Front Port 6,8p8c,Rear Port 6,1",
) )
@ -1372,9 +1496,9 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.csv_data = ( cls.csv_data = (
"device,name,type,positions", "device,name,type,positions",
"Device 1,Rear Port 4,8P8C,1", "Device 1,Rear Port 4,8p8c,1",
"Device 1,Rear Port 5,8P8C,1", "Device 1,Rear Port 5,8p8c,1",
"Device 1,Rear Port 6,8P8C,1", "Device 1,Rear Port 6,8p8c,1",
) )
@ -1430,11 +1554,9 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
device = create_test_device('Device 1') device = create_test_device('Device 1')
manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
InventoryItem.objects.bulk_create([ InventoryItem.objects.create(device=device, name='Inventory Item 1')
InventoryItem(device=device, name='Inventory Item 1'), InventoryItem.objects.create(device=device, name='Inventory Item 2')
InventoryItem(device=device, name='Inventory Item 2'), InventoryItem.objects.create(device=device, name='Inventory Item 3')
InventoryItem(device=device, name='Inventory Item 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
@ -1546,9 +1668,9 @@ class CableTestCase(
cls.csv_data = ( cls.csv_data = (
"side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name", "side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name",
"Device 3,interface,Interface 1,Device 4,interface,Interface 1", "Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
"Device 3,interface,Interface 2,Device 4,interface,Interface 2", "Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
"Device 3,interface,Interface 3,Device 4,interface,Interface 3", "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -1716,11 +1838,6 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'max_utilization': 50, 'max_utilization': 50,
'comments': 'New comments', 'comments': 'New comments',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
# Connection
'cable': None,
'connected_endpoint': None,
'connection_status': None,
} }
cls.csv_data = ( cls.csv_data = (

View File

@ -187,9 +187,17 @@ urlpatterns = [
path('devices/<int:pk>/', views.DeviceView.as_view(), name='device'), path('devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
path('devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'), path('devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
path('devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'), path('devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), path('devices/<int:pk>/console-ports/', views.DeviceConsolePortsView.as_view(), name='device_consoleports'),
path('devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), path('devices/<int:pk>/console-server-ports/', views.DeviceConsoleServerPortsView.as_view(), name='device_consoleserverports'),
path('devices/<int:pk>/power-ports/', views.DevicePowerPortsView.as_view(), name='device_powerports'),
path('devices/<int:pk>/power-outlets/', views.DevicePowerOutletsView.as_view(), name='device_poweroutlets'),
path('devices/<int:pk>/interfaces/', views.DeviceInterfacesView.as_view(), name='device_interfaces'),
path('devices/<int:pk>/front-ports/', views.DeviceFrontPortsView.as_view(), name='device_frontports'),
path('devices/<int:pk>/rear-ports/', views.DeviceRearPortsView.as_view(), name='device_rearports'),
path('devices/<int:pk>/device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'),
path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
path('devices/<int:pk>/changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'), path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
@ -208,7 +216,7 @@ urlpatterns = [
path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path('console-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}), path('console-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
path('console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), path('console-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
@ -224,7 +232,7 @@ urlpatterns = [
path('console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), path('console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path('console-server-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}), path('console-server-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), path('console-server-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
@ -240,7 +248,7 @@ urlpatterns = [
path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path('power-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}), path('power-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
path('power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), path('power-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
@ -256,7 +264,7 @@ urlpatterns = [
path('power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), path('power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path('power-outlets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}), path('power-outlets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), path('power-outlets/<int:pk>/trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
@ -272,7 +280,7 @@ urlpatterns = [
path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'), path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path('interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), path('interfaces/<int:pk>/trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
@ -288,7 +296,7 @@ urlpatterns = [
path('front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), path('front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path('front-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}), path('front-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
path('front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), path('front-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
@ -304,7 +312,7 @@ urlpatterns = [
path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'), path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path('rear-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}), path('rear-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
path('rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), path('rear-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
@ -384,6 +392,8 @@ urlpatterns = [
path('power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'), path('power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'),
path('power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), path('power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
path('power-feeds/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}),
] ]

28
netbox/dcim/utils.py Normal file
View File

@ -0,0 +1,28 @@
from django.contrib.contenttypes.models import ContentType
def compile_path_node(ct_id, object_id):
return f'{ct_id}:{object_id}'
def decompile_path_node(repr):
ct_id, object_id = repr.split(':')
return int(ct_id), int(object_id)
def object_to_path_node(obj):
"""
Return a representation of an object suitable for inclusion in a CablePath path. Node representation is in the
form <ContentType ID>:<Object ID>.
"""
ct = ContentType.objects.get_for_model(obj)
return compile_path_node(ct.pk, obj.pk)
def path_node_to_object(repr):
"""
Given the string representation of a path node, return the corresponding instance.
"""
ct_id, object_id = decompile_path_node(repr)
ct = ContentType.objects.get_for_id(ct_id)
return ct.model_class().objects.get(pk=object_id)

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ from django import forms
from django.contrib import admin from django.contrib import admin
from utilities.forms import LaxURLField from utilities.forms import LaxURLField
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, JobResult, Webhook from .models import CustomField, CustomLink, ExportTemplate, JobResult, Webhook
def order_content_types(field): def order_content_types(field):
@ -29,8 +29,8 @@ class WebhookForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'obj_type' in self.fields: if 'content_types' in self.fields:
order_content_types(self.fields['obj_type']) order_content_types(self.fields['content_types'])
@admin.register(Webhook) @admin.register(Webhook)
@ -40,12 +40,12 @@ class WebhookAdmin(admin.ModelAdmin):
'ssl_verification', 'ssl_verification',
] ]
list_filter = [ list_filter = [
'enabled', 'type_create', 'type_update', 'type_delete', 'obj_type', 'enabled', 'type_create', 'type_update', 'type_delete', 'content_types',
] ]
form = WebhookForm form = WebhookForm
fieldsets = ( fieldsets = (
(None, { (None, {
'fields': ('name', 'obj_type', 'enabled') 'fields': ('name', 'content_types', 'enabled')
}), }),
('Events', { ('Events', {
'fields': ('type_create', 'type_update', 'type_delete') 'fields': ('type_create', 'type_update', 'type_delete')
@ -62,7 +62,7 @@ class WebhookAdmin(admin.ModelAdmin):
) )
def models(self, obj): def models(self, obj):
return ', '.join([ct.name for ct in obj.obj_type.all()]) return ', '.join([ct.name for ct in obj.content_types.all()])
# #
@ -74,31 +74,52 @@ class CustomFieldForm(forms.ModelForm):
class Meta: class Meta:
model = CustomField model = CustomField
exclude = [] exclude = []
widgets = {
'default': forms.TextInput(),
'validation_regex': forms.Textarea(
attrs={
'cols': 80,
'rows': 3,
}
)
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
order_content_types(self.fields['obj_type']) order_content_types(self.fields['content_types'])
class CustomFieldChoiceAdmin(admin.TabularInline):
model = CustomFieldChoice
extra = 5
@admin.register(CustomField) @admin.register(CustomField)
class CustomFieldAdmin(admin.ModelAdmin): class CustomFieldAdmin(admin.ModelAdmin):
inlines = [CustomFieldChoiceAdmin] actions = None
form = CustomFieldForm
list_display = [ list_display = [
'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description', 'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description',
] ]
list_filter = [ list_filter = [
'type', 'required', 'obj_type', 'type', 'required', 'content_types',
] ]
form = CustomFieldForm fieldsets = (
('Custom Field', {
'fields': ('type', 'name', 'weight', 'label', 'description', 'required', 'default', 'filter_logic')
}),
('Assignment', {
'description': 'A custom field must be assigned to one or more object types.',
'fields': ('content_types',)
}),
('Validation Rules', {
'fields': ('validation_minimum', 'validation_maximum', 'validation_regex'),
'classes': ('monospace',)
}),
('Choices', {
'description': 'A selection field must have two or more choices assigned to it.',
'fields': ('choices',)
})
)
def models(self, obj): def models(self, obj):
return ', '.join([ct.name for ct in obj.obj_type.all()]) return ', '.join([ct.name for ct in obj.content_types.all()])
# #
@ -150,45 +171,6 @@ class CustomLinkAdmin(admin.ModelAdmin):
form = CustomLinkForm form = CustomLinkForm
#
# Graphs
#
class GraphForm(forms.ModelForm):
class Meta:
model = Graph
exclude = ()
help_texts = {
'template_language': "<a href=\"https://jinja.palletsprojects.com\">Jinja2</a> is strongly recommended for "
"new graphs."
}
widgets = {
'source': forms.Textarea,
'link': forms.Textarea,
}
@admin.register(Graph)
class GraphAdmin(admin.ModelAdmin):
fieldsets = (
('Graph', {
'fields': ('type', 'name', 'weight')
}),
('Templates', {
'fields': ('template_language', 'source', 'link'),
'classes': ('monospace',)
})
)
form = GraphForm
list_display = [
'name', 'type', 'weight', 'template_language', 'source',
]
list_filter = [
'type', 'template_language',
]
# #
# Export templates # Export templates
# #
@ -198,11 +180,6 @@ class ExportTemplateForm(forms.ModelForm):
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
exclude = [] exclude = []
help_texts = {
'template_language': "<strong>Warning:</strong> Support for Django templating will be dropped in NetBox "
"v2.10. <a href=\"https://jinja.palletsprojects.com\">Jinja2</a> is strongly "
"recommended."
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -219,7 +196,7 @@ class ExportTemplateAdmin(admin.ModelAdmin):
'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension') 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension')
}), }),
('Content', { ('Content', {
'fields': ('template_language', 'template_code'), 'fields': ('template_code',),
'classes': ('monospace',) 'classes': ('monospace',)
}) })
) )

View File

@ -1,15 +1,9 @@
from datetime import datetime
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from rest_framework.fields import CreateOnlyDefault, Field
from django.db import transaction
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CreateOnlyDefault
from extras.choices import * from extras.choices import *
from extras.models import CustomField, CustomFieldChoice, CustomFieldValue from extras.models import CustomField
from utilities.api import ValidatedModelSerializer from netbox.api import ValidatedModelSerializer
# #
@ -27,104 +21,39 @@ class CustomFieldDefaultValues:
# Retrieve the CustomFields for the parent model # Retrieve the CustomFields for the parent model
content_type = ContentType.objects.get_for_model(self.model) content_type = ContentType.objects.get_for_model(self.model)
fields = CustomField.objects.filter(obj_type=content_type) fields = CustomField.objects.filter(content_types=content_type)
# Populate the default value for each CustomField # Populate the default value for each CustomField
value = {} value = {}
for field in fields: for field in fields:
if field.default: if field.default is not None:
if field.type == CustomFieldTypeChoices.TYPE_INTEGER: value[field.name] = field.default
field_value = int(field.default)
elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
# TODO: Fix default value assignment for boolean custom fields
field_value = False if field.default.lower() == 'false' else bool(field.default)
elif field.type == CustomFieldTypeChoices.TYPE_SELECT:
try:
field_value = field.choices.get(value=field.default).pk
except ObjectDoesNotExist:
# Invalid default value
field_value = None
else:
field_value = field.default
value[field.name] = field_value
else: else:
value[field.name] = None value[field.name] = None
return value return value
class CustomFieldsSerializer(serializers.BaseSerializer): class CustomFieldsDataField(Field):
def _get_custom_fields(self):
"""
Cache CustomFields assigned to this model to avoid redundant database queries
"""
if not hasattr(self, '_custom_fields'):
content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
self._custom_fields = CustomField.objects.filter(content_types=content_type)
return self._custom_fields
def to_representation(self, obj): def to_representation(self, obj):
return obj return {
cf.name: obj.get(cf.name) for cf in self._get_custom_fields()
def to_internal_value(self, data):
content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
custom_fields = {
field.name: field for field in CustomField.objects.filter(obj_type=content_type)
} }
for field_name, value in data.items(): def to_internal_value(self, data):
# If updating an existing instance, start with existing custom_field_data
try: if self.parent.instance:
cf = custom_fields[field_name] data = {**self.parent.instance.custom_field_data, **data}
except KeyError:
raise ValidationError(
"Invalid custom field for {} objects: {}".format(content_type, field_name)
)
# Data validation
if value not in [None, '']:
# Validate integer
if cf.type == CustomFieldTypeChoices.TYPE_INTEGER:
try:
int(value)
except ValueError:
raise ValidationError(
"Invalid value for integer field {}: {}".format(field_name, value)
)
# Validate boolean
if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
raise ValidationError(
"Invalid value for boolean field {}: {}".format(field_name, value)
)
# Validate date
if cf.type == CustomFieldTypeChoices.TYPE_DATE:
try:
datetime.strptime(value, '%Y-%m-%d')
except ValueError:
raise ValidationError(
"Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(field_name, value)
)
# Validate selected choice
if cf.type == CustomFieldTypeChoices.TYPE_SELECT:
try:
value = int(value)
except ValueError:
raise ValidationError(
"{}: Choice selections must be passed as integers.".format(field_name)
)
valid_choices = [c.pk for c in cf.choices.all()]
if value not in valid_choices:
raise ValidationError(
"Invalid choice for field {}: {}".format(field_name, value)
)
elif cf.required:
raise ValidationError("Required field {} cannot be empty.".format(field_name))
# Check for missing required fields
missing_fields = []
for field_name, field in custom_fields.items():
if field.required and field_name not in data:
missing_fields.append(field_name)
if missing_fields:
raise ValidationError("Missing required fields: {}".format(u", ".join(missing_fields)))
return data return data
@ -133,8 +62,8 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
""" """
Extends ModelSerializer to render any CustomFields and their values associated with an object. Extends ModelSerializer to render any CustomFields and their values associated with an object.
""" """
custom_fields = CustomFieldsSerializer( custom_fields = CustomFieldsDataField(
required=False, source='custom_field_data',
default=CreateOnlyDefault(CustomFieldDefaultValues()) default=CreateOnlyDefault(CustomFieldDefaultValues())
) )
@ -145,73 +74,16 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
# Retrieve the set of CustomFields which apply to this type of object # Retrieve the set of CustomFields which apply to this type of object
content_type = ContentType.objects.get_for_model(self.Meta.model) content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(obj_type=content_type) fields = CustomField.objects.filter(content_types=content_type)
# Populate CustomFieldValues for each instance from database # Populate CustomFieldValues for each instance from database
try: if type(self.instance) in (list, tuple):
for obj in self.instance: for obj in self.instance:
self._populate_custom_fields(obj, fields) self._populate_custom_fields(obj, fields)
except TypeError: else:
self._populate_custom_fields(self.instance, fields) self._populate_custom_fields(self.instance, fields)
def _populate_custom_fields(self, instance, custom_fields): def _populate_custom_fields(self, instance, custom_fields):
instance.custom_fields = {} instance.custom_fields = {}
for field in custom_fields: for field in custom_fields:
value = instance.cf.get(field.name) instance.custom_fields[field.name] = instance.cf.get(field.name)
if field.type == CustomFieldTypeChoices.TYPE_SELECT and type(value) is CustomFieldChoice:
instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
else:
instance.custom_fields[field.name] = value
def _save_custom_fields(self, instance, custom_fields):
content_type = ContentType.objects.get_for_model(self.Meta.model)
for field_name, value in custom_fields.items():
custom_field = CustomField.objects.get(name=field_name)
CustomFieldValue.objects.update_or_create(
field=custom_field,
obj_type=content_type,
obj_id=instance.pk,
defaults={'serialized_value': custom_field.serialize_value(value)},
)
def create(self, validated_data):
with transaction.atomic():
instance = super().create(validated_data)
# Save custom fields
custom_fields = validated_data.get('custom_fields')
if custom_fields is not None:
self._save_custom_fields(instance, custom_fields)
instance.custom_fields = custom_fields
return instance
def update(self, instance, validated_data):
with transaction.atomic():
custom_fields = validated_data.get('custom_fields')
instance._cf = custom_fields
instance = super().update(instance, validated_data)
# Save custom fields
if custom_fields is not None:
self._save_custom_fields(instance, custom_fields)
instance.custom_fields = custom_fields
return instance
class CustomFieldChoiceSerializer(serializers.ModelSerializer):
"""
Imitate utilities.api.ChoiceFieldSerializer
"""
value = serializers.IntegerField(source='pk')
label = serializers.CharField(source='value')
class Meta:
model = CustomFieldChoice
fields = ['value', 'label']

View File

@ -1,19 +1,27 @@
from rest_framework import serializers from rest_framework import serializers
from extras import choices, models from extras import choices, models
from netbox.api import ChoiceField, WritableNestedSerializer
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
from utilities.api import ChoiceField, WritableNestedSerializer
__all__ = [ __all__ = [
'NestedConfigContextSerializer', 'NestedConfigContextSerializer',
'NestedCustomFieldSerializer',
'NestedExportTemplateSerializer', 'NestedExportTemplateSerializer',
'NestedGraphSerializer',
'NestedImageAttachmentSerializer', 'NestedImageAttachmentSerializer',
'NestedJobResultSerializer', 'NestedJobResultSerializer',
'NestedTagSerializer', 'NestedTagSerializer',
] ]
class NestedCustomFieldSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
class Meta:
model = models.CustomField
fields = ['id', 'url', 'name']
class NestedConfigContextSerializer(WritableNestedSerializer): class NestedConfigContextSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
@ -30,14 +38,6 @@ class NestedExportTemplateSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'name'] fields = ['id', 'url', 'name']
class NestedGraphSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail')
class Meta:
model = models.Graph
fields = ['id', 'url', 'name']
class NestedImageAttachmentSerializer(WritableNestedSerializer): class NestedImageAttachmentSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')

View File

@ -10,56 +10,39 @@ from dcim.api.nested_serializers import (
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
from extras.choices import * from extras.choices import *
from extras.models import ( from extras.models import (
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag, ConfigContext, CustomField, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag,
) )
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
from netbox.api.exceptions import SerializerNotFound
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
from utilities.api import ( from utilities.api import get_serializer_for_model
ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField,
ValidatedModelSerializer,
)
from virtualization.api.nested_serializers import NestedClusterGroupSerializer, NestedClusterSerializer from virtualization.api.nested_serializers import NestedClusterGroupSerializer, NestedClusterSerializer
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .nested_serializers import * from .nested_serializers import *
# #
# Graphs # Custom fields
# #
class GraphSerializer(ValidatedModelSerializer): class CustomFieldSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail') url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
type = ContentTypeField( content_types = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('graphs').get_query()), queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
many=True
) )
type = ChoiceField(choices=CustomFieldTypeChoices)
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
class Meta: class Meta:
model = Graph model = CustomField
fields = ['id', 'url', 'type', 'weight', 'name', 'template_language', 'source', 'link'] fields = [
'id', 'url', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
class RenderedGraphSerializer(serializers.ModelSerializer): ]
embed_url = serializers.SerializerMethodField(
read_only=True
)
embed_link = serializers.SerializerMethodField(
read_only=True
)
type = ContentTypeField(
read_only=True
)
class Meta:
model = Graph
fields = ['id', 'type', 'weight', 'name', 'embed_url', 'embed_link']
def get_embed_url(self, obj):
return obj.embed_url(self.context['graphed_object'])
def get_embed_link(self, obj):
return obj.embed_link(self.context['graphed_object'])
# #
@ -71,17 +54,10 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
content_type = ContentTypeField( content_type = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
) )
template_language = ChoiceField(
choices=TemplateLanguageChoices,
default=TemplateLanguageChoices.LANGUAGE_JINJA2
)
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = [ fields = ['id', 'url', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension']
'id', 'url', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type',
'file_extension',
]
# #
@ -383,3 +359,20 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
data = serializer(obj.changed_object, context=context).data data = serializer(obj.changed_object, context=context).data
return data return data
#
# ContentTypes
#
class ContentTypeSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail')
display_name = serializers.SerializerMethodField()
class Meta:
model = ContentType
fields = ['id', 'url', 'app_label', 'model', 'display_name']
@swagger_serializer_method(serializer_or_field=serializers.CharField)
def get_display_name(self, obj):
return obj.app_labeled_name

View File

@ -1,15 +1,12 @@
from utilities.api import OrderedDefaultRouter from netbox.api import OrderedDefaultRouter
from . import views from . import views
router = OrderedDefaultRouter() router = OrderedDefaultRouter()
router.APIRootView = views.ExtrasRootView router.APIRootView = views.ExtrasRootView
# Custom field choices # Custom fields
router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice') router.register('custom-fields', views.CustomFieldViewSet)
# Graphs
router.register('graphs', views.GraphViewSet)
# Export templates # Export templates
router.register('export-templates', views.ExportTemplateViewSet) router.register('export-templates', views.ExportTemplateViewSet)
@ -35,5 +32,8 @@ router.register('object-changes', views.ObjectChangeViewSet)
# Job Results # Job Results
router.register('job-results', views.JobResultViewSet) router.register('job-results', views.JobResultViewSet)
# ContentTypes
router.register('content-types', views.ContentTypeViewSet)
app_name = 'extras-api' app_name = 'extras-api'
urlpatterns = router.urls urlpatterns = router.urls

View File

@ -1,5 +1,3 @@
from collections import OrderedDict
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.http import Http404 from django.http import Http404
from django_rq.queues import get_connection from django_rq.queues import get_connection
@ -14,17 +12,27 @@ from rq import Worker
from extras import filters from extras import filters
from extras.choices import JobResultStatusChoices from extras.choices import JobResultStatusChoices
from extras.models import ( from extras.models import (
ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem, ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem,
) )
from extras.models import CustomField
from extras.reports import get_report, get_reports, run_report from extras.reports import get_report, get_reports, run_report
from extras.scripts import get_script, get_scripts, run_script from extras.scripts import get_script, get_scripts, run_script
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet
from utilities.exceptions import RQWorkerNotRunningException from utilities.exceptions import RQWorkerNotRunningException
from utilities.metadata import ContentTypeMetadata
from utilities.utils import copy_safe_request, get_subquery from utilities.utils import copy_safe_request, get_subquery
from . import serializers from . import serializers
class ExtrasRootView(APIRootView):
"""
Extras API root view
"""
def get_view_name(self):
return 'Extras'
class ConfigContextQuerySetMixin: class ConfigContextQuerySetMixin:
""" """
Used by views that work with config context models (device and virtual machine). Used by views that work with config context models (device and virtual machine).
@ -48,48 +56,17 @@ class ConfigContextQuerySetMixin:
return self.queryset.annotate_config_context_data() return self.queryset.annotate_config_context_data()
class ExtrasRootView(APIRootView):
"""
Extras API root view
"""
def get_view_name(self):
return 'Extras'
#
# Custom field choices
#
class CustomFieldChoicesViewSet(ViewSet):
"""
"""
permission_classes = [IsAuthenticatedOrLoginNotRequired]
def __init__(self, *args, **kwargs):
super(CustomFieldChoicesViewSet, self).__init__(*args, **kwargs)
self._fields = OrderedDict()
for cfc in CustomFieldChoice.objects.all():
self._fields.setdefault(cfc.field.name, {})
self._fields[cfc.field.name][cfc.value] = cfc.pk
def list(self, request):
return Response(self._fields)
def retrieve(self, request, pk):
if pk not in self._fields:
raise Http404
return Response(self._fields[pk])
def get_view_name(self):
return "Custom Field choices"
# #
# Custom fields # Custom fields
# #
class CustomFieldViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = CustomField.objects.all()
serializer_class = serializers.CustomFieldSerializer
filterset_class = filters.CustomFieldFilterSet
class CustomFieldModelViewSet(ModelViewSet): class CustomFieldModelViewSet(ModelViewSet):
""" """
Include the applicable set of CustomFields in the ModelViewSet context. Include the applicable set of CustomFields in the ModelViewSet context.
@ -99,37 +76,14 @@ class CustomFieldModelViewSet(ModelViewSet):
# Gather all custom fields for the model # Gather all custom fields for the model
content_type = ContentType.objects.get_for_model(self.queryset.model) content_type = ContentType.objects.get_for_model(self.queryset.model)
custom_fields = content_type.custom_fields.prefetch_related('choices') custom_fields = content_type.custom_fields.all()
# Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object.
custom_field_choices = {}
for field in custom_fields:
for cfc in field.choices.all():
custom_field_choices[cfc.id] = cfc.value
custom_field_choices = custom_field_choices
context = super().get_serializer_context() context = super().get_serializer_context()
context.update({ context.update({
'custom_fields': custom_fields, 'custom_fields': custom_fields,
'custom_field_choices': custom_field_choices,
}) })
return context return context
def get_queryset(self):
# Prefetch custom field values
return super().get_queryset().prefetch_related('custom_field_values__field')
#
# Graphs
#
class GraphViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Graph.objects.all()
serializer_class = serializers.GraphSerializer
filterset_class = filters.GraphFilterSet
# #
# Export templates # Export templates
@ -388,3 +342,16 @@ class JobResultViewSet(ReadOnlyModelViewSet):
queryset = JobResult.objects.prefetch_related('user') queryset = JobResult.objects.prefetch_related('user')
serializer_class = serializers.JobResultSerializer serializer_class = serializers.JobResultSerializer
filterset_class = filters.JobResultFilterSet filterset_class = filters.JobResultFilterSet
#
# ContentTypes
#
class ContentTypeViewSet(ReadOnlyModelViewSet):
"""
Read-only list of ContentTypes. Limit results to ContentTypes pertinent to NetBox objects.
"""
queryset = ContentType.objects.order_by('app_label', 'model')
serializer_class = serializers.ContentTypeSerializer
filterset_class = filters.ContentTypeFilterSet

View File

@ -78,20 +78,11 @@ class ObjectChangeActionChoices(ChoiceSet):
(ACTION_DELETE, 'Deleted'), (ACTION_DELETE, 'Deleted'),
) )
CSS_CLASSES = {
# ACTION_CREATE: 'success',
# ExportTemplates ACTION_UPDATE: 'primary',
# ACTION_DELETE: 'danger',
}
class TemplateLanguageChoices(ChoiceSet):
LANGUAGE_JINJA2 = 'jinja2'
LANGUAGE_DJANGO = 'django'
CHOICES = (
(LANGUAGE_JINJA2, 'Jinja2'),
(LANGUAGE_DJANGO, 'Django (Legacy)'),
)
# #
@ -114,13 +105,13 @@ class LogLevelChoices(ChoiceSet):
(LOG_FAILURE, 'Failure'), (LOG_FAILURE, 'Failure'),
) )
CLASS_MAP = ( CSS_CLASSES = {
(LOG_DEFAULT, 'default'), LOG_DEFAULT: 'default',
(LOG_SUCCESS, 'success'), LOG_SUCCESS: 'success',
(LOG_INFO, 'info'), LOG_INFO: 'info',
(LOG_WARNING, 'warning'), LOG_WARNING: 'warning',
(LOG_FAILURE, 'danger'), LOG_FAILURE: 'danger',
) }
# #

View File

@ -6,7 +6,6 @@ EXTRAS_FEATURES = [
'custom_fields', 'custom_fields',
'custom_links', 'custom_links',
'export_templates', 'export_templates',
'graphs',
'job_results', 'job_results',
'webhooks' 'webhooks'
] ]

View File

@ -5,34 +5,39 @@ from django.db.models import Q
from dcim.models import DeviceRole, Platform, Region, Site from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.filters import BaseFilterSet from utilities.filters import BaseFilterSet, ContentTypeFilter
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
from .models import ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, JobResult, ObjectChange, Tag from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag
__all__ = ( __all__ = (
'ConfigContextFilterSet', 'ConfigContextFilterSet',
'ContentTypeFilterSet',
'CreatedUpdatedFilterSet', 'CreatedUpdatedFilterSet',
'CustomFieldFilter', 'CustomFieldFilter',
'CustomFieldFilterSet', 'CustomFieldModelFilterSet',
'ExportTemplateFilterSet', 'ExportTemplateFilterSet',
'GraphFilterSet',
'ImageAttachmentFilterSet', 'ImageAttachmentFilterSet',
'LocalConfigContextFilterSet', 'LocalConfigContextFilterSet',
'ObjectChangeFilterSet', 'ObjectChangeFilterSet',
'TagFilterSet', 'TagFilterSet',
) )
EXACT_FILTER_TYPES = (
CustomFieldTypeChoices.TYPE_BOOLEAN,
CustomFieldTypeChoices.TYPE_DATE,
CustomFieldTypeChoices.TYPE_INTEGER,
CustomFieldTypeChoices.TYPE_SELECT,
)
class CustomFieldFilter(django_filters.Filter): class CustomFieldFilter(django_filters.Filter):
""" """
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name. Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
""" """
def __init__(self, custom_field, *args, **kwargs): def __init__(self, custom_field, *args, **kwargs):
self.cf_type = custom_field.type self.custom_field = custom_field
self.filter_logic = custom_field.filter_logic
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def filter(self, queryset, value): def filter(self, queryset, value):
@ -41,50 +46,27 @@ class CustomFieldFilter(django_filters.Filter):
if value is None or not value.strip(): if value is None or not value.strip():
return queryset return queryset
# Selection fields get special treatment (values must be integers)
if self.cf_type == CustomFieldTypeChoices.TYPE_SELECT:
try:
# Treat 0 as None
if int(value) == 0:
return queryset.exclude(
custom_field_values__field__name=self.field_name,
)
# Match on exact CustomFieldChoice PK
else:
return queryset.filter(
custom_field_values__field__name=self.field_name,
custom_field_values__serialized_value=value,
)
except ValueError:
return queryset.none()
# Apply the assigned filter logic (exact or loose) # Apply the assigned filter logic (exact or loose)
if (self.cf_type == CustomFieldTypeChoices.TYPE_BOOLEAN or if (
self.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT): self.custom_field.type in EXACT_FILTER_TYPES or
queryset = queryset.filter( self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT
custom_field_values__field__name=self.field_name, ):
custom_field_values__serialized_value=value kwargs = {f'custom_field_data__{self.field_name}': value}
)
else: else:
queryset = queryset.filter( kwargs = {f'custom_field_data__{self.field_name}__icontains': value}
custom_field_values__field__name=self.field_name,
custom_field_values__serialized_value__icontains=value
)
return queryset return queryset.filter(**kwargs)
class CustomFieldFilterSet(django_filters.FilterSet): class CustomFieldModelFilterSet(django_filters.FilterSet):
""" """
Dynamically add a Filter for each CustomField applicable to the parent model. Dynamically add a Filter for each CustomField applicable to the parent model.
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
obj_type = ContentType.objects.get_for_model(self._meta.model)
custom_fields = CustomField.objects.filter( custom_fields = CustomField.objects.filter(
obj_type=obj_type content_types=ContentType.objects.get_for_model(self._meta.model)
).exclude( ).exclude(
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
) )
@ -92,25 +74,26 @@ class CustomFieldFilterSet(django_filters.FilterSet):
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf) self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf)
class GraphFilterSet(BaseFilterSet): class CustomFieldFilterSet(django_filters.FilterSet):
class Meta: class Meta:
model = Graph model = CustomField
fields = ['id', 'type', 'name', 'template_language'] fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
class ExportTemplateFilterSet(BaseFilterSet): class ExportTemplateFilterSet(BaseFilterSet):
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ['id', 'content_type', 'name', 'template_language'] fields = ['id', 'content_type', 'name']
class ImageAttachmentFilterSet(BaseFilterSet): class ImageAttachmentFilterSet(BaseFilterSet):
content_type = ContentTypeFilter()
class Meta: class Meta:
model = ImageAttachment model = ImageAttachment
fields = ['id', 'content_type', 'object_id', 'name'] fields = ['id', 'content_type_id', 'object_id', 'name']
class TagFilterSet(BaseFilterSet): class TagFilterSet(BaseFilterSet):
@ -260,6 +243,7 @@ class ObjectChangeFilterSet(BaseFilterSet):
label='Search', label='Search',
) )
time = django_filters.DateTimeFromToRangeFilter() time = django_filters.DateTimeFromToRangeFilter()
changed_object_type = ContentTypeFilter()
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=User.objects.all(),
label='User (ID)', label='User (ID)',
@ -274,7 +258,8 @@ class ObjectChangeFilterSet(BaseFilterSet):
class Meta: class Meta:
model = ObjectChange model = ObjectChange
fields = [ fields = [
'id', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'object_repr', 'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
'object_repr',
] ]
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -335,3 +320,14 @@ class JobResultFilterSet(BaseFilterSet):
return queryset.filter( return queryset.filter(
Q(user__username__icontains=value) Q(user__username__icontains=value)
) )
#
# ContentTypes
#
class ContentTypeFilterSet(django_filters.FilterSet):
class Meta:
model = ContentType
fields = ['id', 'app_label', 'model']

View File

@ -12,7 +12,7 @@ from utilities.forms import (
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag
# #
@ -25,78 +25,34 @@ class CustomFieldModelForm(forms.ModelForm):
self.obj_type = ContentType.objects.get_for_model(self._meta.model) self.obj_type = ContentType.objects.get_for_model(self._meta.model)
self.custom_fields = [] self.custom_fields = []
self.custom_field_values = {}
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.instance._cf is None:
self.instance._cf = {}
self._append_customfield_fields() self._append_customfield_fields()
def _append_customfield_fields(self): def _append_customfield_fields(self):
""" """
Append form fields for all CustomFields assigned to this model. Append form fields for all CustomFields assigned to this model.
""" """
# Retrieve initial CustomField values for the instance
if self.instance.pk:
for cfv in CustomFieldValue.objects.filter(
obj_type=self.obj_type,
obj_id=self.instance.pk
).prefetch_related('field'):
self.custom_field_values[cfv.field.name] = cfv.serialized_value
# Append form fields; assign initial values if modifying and existing object # Append form fields; assign initial values if modifying and existing object
for cf in CustomField.objects.filter(obj_type=self.obj_type): for cf in CustomField.objects.filter(content_types=self.obj_type):
field_name = 'cf_{}'.format(cf.name) field_name = 'cf_{}'.format(cf.name)
if self.instance.pk: if self.instance.pk:
self.fields[field_name] = cf.to_form_field(set_initial=False) self.fields[field_name] = cf.to_form_field(set_initial=False)
value = self.custom_field_values.get(cf.name) self.fields[field_name].initial = self.instance.custom_field_data.get(cf.name)
self.fields[field_name].initial = value
self.instance._cf[cf.name] = value
else: else:
self.fields[field_name] = cf.to_form_field() self.fields[field_name] = cf.to_form_field()
self.instance._cf[cf.name] = self.fields[field_name].initial
# Annotate the field in the list of CustomField form fields # Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name) self.custom_fields.append(field_name)
def _save_custom_fields(self):
for field_name in self.custom_fields:
try:
cfv = CustomFieldValue.objects.prefetch_related('field').get(
field=self.fields[field_name].model,
obj_type=self.obj_type,
obj_id=self.instance.pk
)
except CustomFieldValue.DoesNotExist:
# Skip this field if none exists already and its value is empty
if self.cleaned_data[field_name] in [None, '']:
continue
cfv = CustomFieldValue(
field=self.fields[field_name].model,
obj_type=self.obj_type,
obj_id=self.instance.pk
)
cfv.value = self.cleaned_data[field_name]
cfv.save()
def save(self, commit=True): def save(self, commit=True):
# Cache custom field values on object prior to save to ensure change logging # Save custom field data on instance
for cf_name in self.custom_fields: for cf_name in self.custom_fields:
self.instance._cf[cf_name[3:]] = self.cleaned_data.get(cf_name) self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name)
obj = super().save(commit) return super().save(commit)
# Handle custom fields the same way we do M2M fields
if commit:
self._save_custom_fields()
else:
obj.save_custom_fields = self._save_custom_fields
return obj
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
@ -104,7 +60,7 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
def _append_customfield_fields(self): def _append_customfield_fields(self):
# Append form fields # Append form fields
for cf in CustomField.objects.filter(obj_type=self.obj_type): for cf in CustomField.objects.filter(content_types=self.obj_type):
field_name = 'cf_{}'.format(cf.name) field_name = 'cf_{}'.format(cf.name)
self.fields[field_name] = cf.to_form_field(for_csv_import=True) self.fields[field_name] = cf.to_form_field(for_csv_import=True)
@ -121,7 +77,7 @@ class CustomFieldBulkEditForm(BulkEditForm):
self.obj_type = ContentType.objects.get_for_model(self.model) self.obj_type = ContentType.objects.get_for_model(self.model)
# Add all applicable CustomFields to the form # Add all applicable CustomFields to the form
custom_fields = CustomField.objects.filter(obj_type=self.obj_type) custom_fields = CustomField.objects.filter(content_types=self.obj_type)
for cf in custom_fields: for cf in custom_fields:
# Annotate non-required custom fields as nullable # Annotate non-required custom fields as nullable
if not cf.required: if not cf.required:
@ -140,7 +96,7 @@ class CustomFieldFilterForm(forms.Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Add all applicable CustomFields to the form # Add all applicable CustomFields to the form
custom_fields = CustomField.objects.filter(obj_type=self.obj_type).exclude( custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
) )
for cf in custom_fields: for cf in custom_fields:
@ -406,11 +362,14 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
api_url='/api/users/users/', api_url='/api/users/users/',
) )
) )
changed_object_type = forms.ModelChoiceField( changed_object_type_id = DynamicModelMultipleChoiceField(
queryset=ContentType.objects.order_by('model'), queryset=ContentType.objects.all(),
required=False, required=False,
widget=ContentTypeSelect(), display_field='display_name',
label='Object Type' label='Object Type',
widget=APISelectMultiple(
api_url='/api/extras/content-types/',
)
) )

View File

@ -1,32 +0,0 @@
import uuid
from .context_managers import change_logging
class ObjectChangeMiddleware(object):
"""
This middleware performs three functions in response to an object being created, updated, or deleted:
1. Create an ObjectChange to reflect the modification to the object in the changelog.
2. Enqueue any relevant webhooks.
3. Increment the metric counter for the event type.
The post_save and post_delete signals are employed to catch object modifications, however changes are recorded a bit
differently for each. Objects being saved are cached into thread-local storage for action *after* the response has
completed. This ensures that serialization of the object is performed only after any related objects (e.g. tags)
have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the
object is recorded before it (and any related objects) are actually deleted from the database.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Assign a random unique ID to the request. This will be used to associate multiple object changes made during
# the same request.
request.id = uuid.uuid4()
# Process the request with change logging enabled
with change_logging(request):
response = self.get_response(request)
return response

View File

@ -0,0 +1,17 @@
# Generated by Django 3.1 on 2020-08-21 15:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0047_tag_ordering'),
]
operations = [
migrations.RemoveField(
model_name='exporttemplate',
name='template_language',
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 3.1 on 2020-08-21 15:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0048_exporttemplate_remove_template_language'),
]
operations = [
migrations.DeleteModel(
name='Graph',
),
]

View File

@ -0,0 +1,65 @@
import django.contrib.postgres.fields
import django.core.validators
from django.db import migrations, models
import utilities.validators
class Migration(migrations.Migration):
dependencies = [
('extras', '0049_remove_graph'),
]
operations = [
# Rename reverse relation on CustomFieldChoice
migrations.AlterField(
model_name='customfieldchoice',
name='field',
field=models.ForeignKey(
limit_choices_to={'type': 'select'},
on_delete=django.db.models.deletion.CASCADE,
related_name='_choices',
to='extras.customfield'
),
),
# Add choices field to CustomField
migrations.AddField(
model_name='customfield',
name='choices',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=100),
blank=True,
null=True,
size=None
),
),
# Introduce new default field (to be renamed later)
migrations.AddField(
model_name='customfield',
name='default2',
field=models.JSONField(blank=True, null=True),
),
# Rename obj_type to content_types
migrations.RenameField(
model_name='customfield',
old_name='obj_type',
new_name='content_types',
),
# Add validation fields
migrations.AddField(
model_name='customfield',
name='validation_maximum',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='customfield',
name='validation_minimum',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='customfield',
name='validation_regex',
field=models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex]),
),
]

View File

@ -0,0 +1,110 @@
from django.db import migrations
from extras.choices import CustomFieldTypeChoices
def deserialize_value(field, value):
"""
Convert serialized values to JSON equivalents.
"""
if field.type in (CustomFieldTypeChoices.TYPE_INTEGER):
return int(value)
if field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
return bool(int(value))
if field.type == CustomFieldTypeChoices.TYPE_SELECT:
return field._choices.get(pk=int(value)).value
return value
def migrate_customfield_defaults(apps, schema_editor):
"""
Copy old serialized defaults to native JSON types.
"""
CustomField = apps.get_model('extras', 'CustomField')
for customfield in CustomField.objects.exclude(default=''):
try:
if customfield.type == CustomFieldTypeChoices.TYPE_INTEGER:
value = int(customfield.default)
elif customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
value = customfield.default in ['true', 'yes', '1']
else:
value = customfield.default
except ValueError:
raise ValueError(
f'Invalid default value "{customfield.default}" found for {customfield.type} '
f'custom field {customfield.name}'
)
CustomField.objects.filter(pk=customfield.pk).update(default2=value)
def migrate_customfieldchoices(apps, schema_editor):
"""
Collect all CustomFieldChoices for each applicable CustomField, and save them locally as an array on
the CustomField instance.
"""
CustomField = apps.get_model('extras', 'CustomField')
CustomFieldChoice = apps.get_model('extras', 'CustomFieldChoice')
for cf in CustomField.objects.filter(type='select'):
cf.choices = [
cfc.value for cfc in CustomFieldChoice.objects.filter(field=cf).order_by('weight', 'value')
]
cf.save()
def migrate_customfieldvalues(apps, schema_editor):
"""
Copy data from CustomFieldValues into the custom_field_data JSON field on each model instance.
"""
CustomFieldValue = apps.get_model('extras', 'CustomFieldValue')
for cfv in CustomFieldValue.objects.prefetch_related('field').exclude(serialized_value=''):
model = apps.get_model(cfv.obj_type.app_label, cfv.obj_type.model)
# Read and update custom field value for each instance
# TODO: This can be done more efficiently once .update() is supported for JSON fields
cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first()
try:
cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field, cfv.serialized_value)
except ValueError as e:
print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})')
raise e
model.objects.filter(pk=cfv.obj_id).update(**cf_data)
def fix_filter_logic_values(apps, schema_editor):
"""
Fix invalid values for CustomField.filter_logic (see #5376)
"""
CustomField = apps.get_model('extras', 'CustomField')
CustomField.objects.filter(filter_logic="integer").update(filter_logic="loose")
class Migration(migrations.Migration):
dependencies = [
('circuits', '0020_custom_field_data'),
('dcim', '0117_custom_field_data'),
('extras', '0050_customfield_changes'),
('ipam', '0038_custom_field_data'),
('secrets', '0010_custom_field_data'),
('tenancy', '0010_custom_field_data'),
('virtualization', '0018_custom_field_data'),
]
operations = [
migrations.RunPython(
code=migrate_customfield_defaults
),
migrations.RunPython(
code=migrate_customfieldchoices
),
migrations.RunPython(
code=migrate_customfieldvalues
),
migrations.RunPython(
code=fix_filter_logic_values
),
]

View File

@ -0,0 +1,26 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0051_migrate_customfields'),
]
operations = [
migrations.RemoveField(
model_name='CustomField',
name='default',
),
migrations.RenameField(
model_name='CustomField',
old_name='default2',
new_name='default'
),
migrations.DeleteModel(
name='CustomFieldChoice',
),
migrations.DeleteModel(
name='CustomFieldValue',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1 on 2020-12-02 19:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0052_customfield_cleanup'),
]
operations = [
migrations.RenameField(
model_name='webhook',
old_name='obj_type',
new_name='content_types',
),
]

Some files were not shown because too many files have changed in this diff Show More