mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-09 09:59:37 -06:00
Merge pull request #5454 from netbox-community/develop-2.10
Stage v2.10 release
This commit is contained in:
commit
bbf820f1e8
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -5,7 +5,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.6, 3.7]
|
||||
python-version: [3.6, 3.7, 3.8]
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
|
||||
@ -15,8 +15,6 @@ categories for discussions:
|
||||
* **Ideas** - Ideas for new functionality that isn't yet ready for a formal
|
||||
feature request
|
||||
* **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
|
||||
|
||||
|
||||
@ -1,26 +1,69 @@
|
||||
# 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)
|
||||
* Integer
|
||||
* Boolean (true/false)
|
||||
* Date
|
||||
* URL
|
||||
* Selection
|
||||
## Creating Custom Fields
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@ -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
|
||||
```
|
||||
@ -185,7 +185,7 @@ To delete an object, simply call `delete()` on its instance. This will return a
|
||||
>>> vlan
|
||||
<VLAN: 123 (BetterName)>
|
||||
>>> 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.
|
||||
@ -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()
|
||||
27
|
||||
>>> Device.objects.filter(name__icontains='test').delete()
|
||||
(35, {'extras.CustomFieldValue': 0, 'dcim.DeviceBay': 0, 'secrets.Secret': 0,
|
||||
'dcim.InterfaceConnection': 4, 'extras.ImageAttachment': 0, 'dcim.Device': 27,
|
||||
'dcim.Interface': 4, 'dcim.ConsolePort': 0, 'dcim.PowerPort': 0})
|
||||
(35, {'dcim.DeviceBay': 0, 'secrets.Secret': 0, 'dcim.InterfaceConnection': 4,
|
||||
'extras.ImageAttachment': 0, 'dcim.Device': 27, 'dcim.Interface': 4,
|
||||
'dcim.ConsolePort': 0, 'dcim.PowerPort': 0})
|
||||
```
|
||||
|
||||
!!! warning
|
||||
|
||||
@ -15,3 +15,4 @@
|
||||
---
|
||||
|
||||
{!docs/models/ipam/vrf.md!}
|
||||
{!docs/models/ipam/routetarget.md!}
|
||||
|
||||
@ -55,7 +55,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@ -5,30 +5,27 @@ This section entails the installation and configuration of a local PostgreSQL da
|
||||
!!! warning
|
||||
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
|
||||
|
||||
#### 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
|
||||
# apt-get update
|
||||
# apt-get install -y postgresql libpq-dev
|
||||
sudo apt update
|
||||
sudo apt install -y postgresql libpq-dev
|
||||
```
|
||||
|
||||
#### 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
|
||||
# yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm
|
||||
# yum install -y postgresql96 postgresql96-server postgresql96-devel
|
||||
# /usr/pgsql-9.6/bin/postgresql96-setup initdb
|
||||
sudo yum install -y postgresql-server libpq-devel
|
||||
sudo postgresql-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
|
||||
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:
|
||||
|
||||
```no-highlight
|
||||
# systemctl start postgresql-9.6
|
||||
# systemctl enable postgresql-9.6
|
||||
sudo systemctl start postgresql
|
||||
sudo systemctl enable postgresql
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
```no-highlight
|
||||
# sudo -u postgres psql
|
||||
psql (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1))
|
||||
$ sudo -u postgres psql
|
||||
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
|
||||
Type "help" for help.
|
||||
|
||||
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.)
|
||||
|
||||
```no-highlight
|
||||
# psql --username netbox --password --host localhost netbox
|
||||
$ psql --username netbox --password --host localhost 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)
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@ -10,16 +10,15 @@
|
||||
### Ubuntu
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y redis-server
|
||||
sudo apt install -y redis-server
|
||||
```
|
||||
|
||||
### CentOS
|
||||
|
||||
```no-highlight
|
||||
# yum install -y epel-release
|
||||
# yum install -y redis
|
||||
# systemctl start redis
|
||||
# systemctl enable redis
|
||||
sudo yum install -y redis
|
||||
sudo systemctl start redis
|
||||
sudo 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.
|
||||
|
||||
@ -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.
|
||||
|
||||
!!! 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
|
||||
|
||||
```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
|
||||
|
||||
```no-highlight
|
||||
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config
|
||||
# easy_install-3.6 pip
|
||||
sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config
|
||||
```
|
||||
|
||||
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
|
||||
|
||||
```no-highlight
|
||||
# pip3 install --upgrade pip
|
||||
sudo pip3 install --upgrade pip
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
# ln -s /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
# ls -l /opt | grep netbox
|
||||
$ sudo wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
$ sudo tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
$ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
$ ls -l /opt | grep netbox
|
||||
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
|
||||
```
|
||||
@ -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`.
|
||||
|
||||
```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:
|
||||
@ -61,19 +60,19 @@ If `git` is not already installed, install it:
|
||||
#### Ubuntu
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y git
|
||||
sudo apt install -y git
|
||||
```
|
||||
|
||||
#### CentOS
|
||||
|
||||
```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.)
|
||||
|
||||
```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 '.'...
|
||||
remote: Counting objects: 1994, 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
|
||||
|
||||
```
|
||||
# adduser --system --group netbox
|
||||
# chown --recursive netbox /opt/netbox/netbox/media/
|
||||
sudo adduser --system --group netbox
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/media/
|
||||
```
|
||||
|
||||
#### CentOS
|
||||
|
||||
```
|
||||
# groupadd --system netbox
|
||||
# adduser --system -g netbox netbox
|
||||
# chown --recursive netbox /opt/netbox/netbox/media/
|
||||
sudo groupadd --system netbox
|
||||
sudo adduser --system -g netbox netbox
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/media/
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
```no-highlight
|
||||
# cd /opt/netbox/netbox/netbox/
|
||||
# cp configuration.example.py configuration.py
|
||||
cd /opt/netbox/netbox/netbox/
|
||||
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:
|
||||
@ -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.
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
```no-highlight
|
||||
# python3 ../generate_secret_key.py
|
||||
python3 ../generate_secret_key.py
|
||||
```
|
||||
|
||||
!!! 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.
|
||||
|
||||
```no-highlight
|
||||
# echo napalm >> /opt/netbox/local_requirements.txt
|
||||
sudo echo napalm >> /opt/netbox/local_requirements.txt
|
||||
```
|
||||
|
||||
### 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`.
|
||||
|
||||
```no-highlight
|
||||
# echo django-storages >> /opt/netbox/local_requirements.txt
|
||||
sudo echo django-storages >> /opt/netbox/local_requirements.txt
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
```no-highlight
|
||||
# /opt/netbox/upgrade.sh
|
||||
sudo /opt/netbox/upgrade.sh
|
||||
```
|
||||
|
||||
!!! 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:
|
||||
|
||||
```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.
|
||||
@ -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.
|
||||
|
||||
```no-highlight
|
||||
(venv) # cd /opt/netbox/netbox
|
||||
(venv) # python3 manage.py createsuperuser
|
||||
(venv) $ cd /opt/netbox/netbox
|
||||
(venv) $ python3 manage.py createsuperuser
|
||||
Username: admin
|
||||
Email address: admin@example.com
|
||||
Password:
|
||||
@ -250,28 +249,33 @@ Superuser created successfully.
|
||||
|
||||
## 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
|
||||
(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...
|
||||
|
||||
System check identified no issues (0 silenced).
|
||||
November 28, 2018 - 09:33:45
|
||||
Django version 2.0.9, using settings 'netbox.settings'
|
||||
November 17, 2020 - 16:08:13
|
||||
Django version 3.1.3, using settings 'netbox.settings'
|
||||
Starting development server at http://0.0.0.0:8000/
|
||||
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
|
||||
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.
|
||||
|
||||

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

|
||||
|
||||
Type `Ctrl+c` to stop the development server.
|
||||
|
||||
@ -1,32 +1,31 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
# cd /opt/netbox
|
||||
# cp contrib/gunicorn.py /opt/netbox/gunicorn.py
|
||||
sudo cp /opt/netbox/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
|
||||
|
||||
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
|
||||
# cp contrib/*.service /etc/systemd/system/
|
||||
# systemctl daemon-reload
|
||||
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
|
||||
|
||||
```no-highlight
|
||||
# systemctl start netbox netbox-rq
|
||||
# systemctl enable netbox netbox-rq
|
||||
sudo systemctl start 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:
|
||||
@ -35,15 +34,19 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic
|
||||
# systemctl status netbox.service
|
||||
● netbox.service - NetBox WSGI Service
|
||||
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/
|
||||
Main PID: 11993 (gunicorn)
|
||||
Tasks: 6 (limit: 2362)
|
||||
Main PID: 22836 (gunicorn)
|
||||
Tasks: 6 (limit: 2345)
|
||||
Memory: 339.3M
|
||||
CGroup: /system.slice/netbox.service
|
||||
├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12016 /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>
|
||||
├─22854 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid>
|
||||
├─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.
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
# 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
|
||||
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
|
||||
|
||||
@ -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.
|
||||
|
||||
```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 \
|
||||
-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:
|
||||
|
||||
```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`.)
|
||||
|
||||
```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.
|
||||
|
||||
```no-highlight
|
||||
# cd /etc/nginx/sites-enabled/
|
||||
# rm default
|
||||
# ln -s /etc/nginx/sites-available/netbox
|
||||
sudo rm /etc/nginx/sites-enabled/default
|
||||
sudo ln -s /etc/nginx/sites-available/netbox /etc/nginx/sites-enabled/netbox
|
||||
```
|
||||
|
||||
Finally, restart the `nginx` service to use the new configuration.
|
||||
|
||||
```no-highlight
|
||||
# service nginx restart
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
### Option B: Apache
|
||||
@ -54,26 +53,26 @@ Finally, restart the `nginx` service to use the new configuration.
|
||||
Begin by installing Apache:
|
||||
|
||||
```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.
|
||||
|
||||
```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:
|
||||
|
||||
```no-highlight
|
||||
# a2enmod ssl proxy proxy_http headers
|
||||
# a2ensite netbox
|
||||
# service apache2 restart
|
||||
sudo a2enmod ssl proxy proxy_http headers
|
||||
sudo a2ensite netbox
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
## 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
|
||||
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:
|
||||
|
||||
* 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`
|
||||
|
||||
@ -9,13 +9,13 @@ This guide explains how to implement LDAP authentication using an external serve
|
||||
On Ubuntu:
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y libldap2-dev libsasl2-dev libssl-dev
|
||||
sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
|
||||
```
|
||||
|
||||
On CentOS:
|
||||
|
||||
```no-highlight
|
||||
# yum install -y openldap-devel
|
||||
sudo yum install -y openldap-devel
|
||||
```
|
||||
|
||||
### Install django-auth-ldap
|
||||
@ -23,15 +23,14 @@ On CentOS:
|
||||
Activate the Python virtual environment and install the `django-auth-ldap` package using pip:
|
||||
|
||||
```no-highlight
|
||||
# cd /opt/netbox/
|
||||
# source venv/bin/activate
|
||||
(venv) # pip3 install django-auth-ldap
|
||||
source /opt/netbox/venv/bin/activate
|
||||
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:
|
||||
|
||||
```no-highlight
|
||||
(venv) # echo django-auth-ldap >> local_requirements.txt
|
||||
sudo echo django-auth-ldap >> /opt/netbox/local_requirements.txt
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@ -42,7 +41,7 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to
|
||||
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
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# 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:
|
||||
|
||||
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)
|
||||
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:
|
||||
|
||||

|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
|
||||
@ -14,7 +14,7 @@ NetBox v2.9.0 and later requires the following:
|
||||
| PostgreSQL | 9.6 |
|
||||
| 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.
|
||||
|
||||
@ -25,47 +25,36 @@ Download the [latest stable release](https://github.com/netbox-community/netbox/
|
||||
Download and extract the latest version:
|
||||
|
||||
```no-highlight
|
||||
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
# cd /opt/
|
||||
# ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
sudo tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
sudo 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
|
||||
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
|
||||
```
|
||||
|
||||
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
|
||||
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/
|
||||
```
|
||||
|
||||
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
|
||||
# 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
|
||||
# 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/scripts /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:
|
||||
|
||||
```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
|
||||
@ -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:
|
||||
|
||||
```no-highlight
|
||||
# cd /opt/netbox
|
||||
# git checkout master
|
||||
# git pull origin master
|
||||
# git status
|
||||
cd /opt/netbox
|
||||
sudo git checkout master
|
||||
sudo git pull origin master
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
```no-highlight
|
||||
# ./upgrade.sh
|
||||
sudo ./upgrade.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
This script performs the following actions:
|
||||
|
||||
* Destroys and rebuilds the Python virtual environment
|
||||
* 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
|
||||
|
||||
!!! note
|
||||
It's possible that the upgrade script will display a notice warning of unreflected database migrations:
|
||||
|
||||
Your models have changes that are not yet reflected in a migration, and so won't be applied.
|
||||
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.
|
||||
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
|
||||
intentionally modifying the database schema.
|
||||
|
||||
## Restart the NetBox Services
|
||||
|
||||
!!! 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:
|
||||
|
||||
```no-highlight
|
||||
# sudo systemctl restart netbox netbox-rq
|
||||
sudo systemctl restart netbox netbox-rq
|
||||
```
|
||||
|
||||
!!! 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.
|
||||
|
||||
5
docs/models/ipam/routetarget.md
Normal file
5
docs/models/ipam/routetarget.md
Normal 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.
|
||||
@ -10,3 +10,5 @@ By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This b
|
||||
|
||||
!!! note
|
||||
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.
|
||||
|
||||
@ -1 +1 @@
|
||||
version-2.9.md
|
||||
version-2.10.md
|
||||
176
docs/release-notes/version-2.10.md
Normal file
176
docs/release-notes/version-2.10.md
Normal 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`.
|
||||
@ -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.
|
||||
|
||||
```no-highlight
|
||||
curl -s -X PATCH \
|
||||
> -H "Authorization: Token $TOKEN" \
|
||||
> -H "Content-Type: application/json" \
|
||||
> http://netbox/api/ipam/prefixes/18691/ \
|
||||
> --data '{"status": "reserved"}' | jq '.'
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://netbox/api/ipam/prefixes/18691/ \
|
||||
--data '{"status": "reserved"}' | jq '.'
|
||||
```
|
||||
|
||||
```json
|
||||
@ -515,6 +515,23 @@ curl -s -X 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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
@ -49,7 +49,6 @@ nav:
|
||||
- Custom Links: 'additional-features/custom-links.md'
|
||||
- Custom Scripts: 'additional-features/custom-scripts.md'
|
||||
- Export Templates: 'additional-features/export-templates.md'
|
||||
- Graphs: 'additional-features/graphs.md'
|
||||
- NAPALM: 'additional-features/napalm.md'
|
||||
- Prometheus Metrics: 'additional-features/prometheus-metrics.md'
|
||||
- Reports: 'additional-features/reports.md'
|
||||
@ -76,6 +75,7 @@ nav:
|
||||
- User Preferences: 'development/user-preferences.md'
|
||||
- Release Checklist: 'development/release-checklist.md'
|
||||
- Release Notes:
|
||||
- Version 2.10: 'release-notes/version-2.10.md'
|
||||
- Version 2.9: 'release-notes/version-2.9.md'
|
||||
- Version 2.8: 'release-notes/version-2.8.md'
|
||||
- Version 2.7: 'release-notes/version-2.7.md'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
from utilities.api import WritableNestedSerializer
|
||||
from netbox.api import WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedCircuitSerializer',
|
||||
|
||||
@ -3,11 +3,11 @@ from rest_framework import serializers
|
||||
from circuits.choices import CircuitStatusChoices
|
||||
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
||||
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.serializers import TaggedObjectSerializer
|
||||
from netbox.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
|
||||
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')
|
||||
circuit = NestedCircuitSerializer()
|
||||
site = NestedSiteSerializer()
|
||||
@ -77,5 +77,6 @@ class CircuitTerminationSerializer(ConnectedEndpointSerializer):
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'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'
|
||||
]
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from utilities.api import OrderedDefaultRouter
|
||||
from netbox.api import OrderedDefaultRouter
|
||||
from . import views
|
||||
|
||||
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
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 circuits import filters
|
||||
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.models import Graph
|
||||
from utilities.api import ModelViewSet
|
||||
from netbox.api.views import ModelViewSet
|
||||
from utilities.utils import get_subquery
|
||||
from . import serializers
|
||||
|
||||
@ -33,16 +29,6 @@ class ProviderViewSet(CustomFieldModelViewSet):
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
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
|
||||
@ -62,9 +48,7 @@ class CircuitTypeViewSet(ModelViewSet):
|
||||
|
||||
class CircuitViewSet(CustomFieldModelViewSet):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related(
|
||||
'site', 'connected_endpoint__device'
|
||||
)),
|
||||
Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')),
|
||||
'type', 'tenant', 'provider',
|
||||
).prefetch_related('tags')
|
||||
serializer_class = serializers.CircuitSerializer
|
||||
@ -75,9 +59,9 @@ class CircuitViewSet(CustomFieldModelViewSet):
|
||||
# Circuit Terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationViewSet(ModelViewSet):
|
||||
class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = CircuitTermination.objects.prefetch_related(
|
||||
'circuit', 'site', 'connected_endpoint__device', 'cable'
|
||||
'circuit', 'site', '_path__destination', 'cable'
|
||||
)
|
||||
serializer_class = serializers.CircuitTerminationSerializer
|
||||
filterset_class = filters.CircuitTerminationFilterSet
|
||||
|
||||
@ -23,6 +23,15 @@ class CircuitStatusChoices(ChoiceSet):
|
||||
(STATUS_DECOMMISSIONED, 'Decommissioned'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
STATUS_DEPROVISIONING: 'warning',
|
||||
STATUS_ACTIVE: 'success',
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_PROVISIONING: 'primary',
|
||||
STATUS_OFFLINE: 'danger',
|
||||
STATUS_DECOMMISSIONED: 'default',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# CircuitTerminations
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet
|
||||
from dcim.models import Region, Site
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet
|
||||
from tenancy.filters import TenancyFilterSet
|
||||
from utilities.filters import (
|
||||
BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
|
||||
@ -18,7 +19,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@ -72,7 +73,7 @@ class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
|
||||
class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@ -144,7 +145,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr
|
||||
).distinct()
|
||||
|
||||
|
||||
class CircuitTerminationFilterSet(BaseFilterSet):
|
||||
class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
|
||||
22
netbox/circuits/migrations/0020_custom_field_data.py
Normal file
22
netbox/circuits/migrations/0020_custom_field_data.py
Normal 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),
|
||||
),
|
||||
]
|
||||
49
netbox/circuits/migrations/0021_cache_cable_peer.py
Normal file
49
netbox/circuits/migrations/0021_cache_cable_peer.py
Normal 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
|
||||
),
|
||||
]
|
||||
26
netbox/circuits/migrations/0022_cablepath.py
Normal file
26
netbox/circuits/migrations/0022_cablepath.py
Normal 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',
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
38
netbox/circuits/migrations/0024_standardize_name_length.py
Normal file
38
netbox/circuits/migrations/0024_standardize_name_length.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -1,11 +1,9 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.constants import CONNECTION_STATUS_CHOICES
|
||||
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.utils import extras_features
|
||||
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):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
asn = ASNField(
|
||||
@ -61,11 +60,6 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
@ -105,10 +99,11 @@ class CircuitType(ChangeLoggedModel):
|
||||
"Long Haul," "Metro," or "Out-of-Band".
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
description = models.CharField(
|
||||
@ -145,7 +140,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
in Kbps.
|
||||
"""
|
||||
cid = models.CharField(
|
||||
max_length=50,
|
||||
max_length=100,
|
||||
verbose_name='Circuit ID'
|
||||
)
|
||||
provider = models.ForeignKey(
|
||||
@ -186,11 +181,6 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
objects = CircuitQuerySet.as_manager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
@ -202,15 +192,6 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
'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:
|
||||
ordering = ['provider', 'cid']
|
||||
unique_together = ['provider', 'cid']
|
||||
@ -235,7 +216,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
)
|
||||
|
||||
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):
|
||||
for ct in self.terminations.all():
|
||||
@ -252,7 +233,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
return self._get_termination('Z')
|
||||
|
||||
|
||||
class CircuitTermination(CableTermination):
|
||||
class CircuitTermination(PathEndpoint, CableTermination):
|
||||
circuit = models.ForeignKey(
|
||||
to='circuits.Circuit',
|
||||
on_delete=models.CASCADE,
|
||||
@ -268,20 +249,10 @@ class CircuitTermination(CableTermination):
|
||||
on_delete=models.PROTECT,
|
||||
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(
|
||||
verbose_name='Port speed (Kbps)'
|
||||
verbose_name='Port speed (Kbps)',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
upstream_speed = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
|
||||
@ -2,13 +2,9 @@ import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
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
|
||||
|
||||
STATUS_LABEL = """
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Providers
|
||||
@ -64,9 +60,7 @@ class CircuitTable(BaseTable):
|
||||
viewname='circuits:provider',
|
||||
args=[Accessor('provider__slug')]
|
||||
)
|
||||
status = tables.TemplateColumn(
|
||||
template_code=STATUS_LABEL
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from circuits.choices import *
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
from dcim.models import Site
|
||||
from extras.models import Graph
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
|
||||
|
||||
@ -35,6 +32,9 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
|
||||
'slug': 'provider-6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'asn': 1234,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -46,27 +46,6 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
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):
|
||||
model = CircuitType
|
||||
@ -85,6 +64,9 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
'slug': 'circuit-type-6',
|
||||
},
|
||||
)
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -100,6 +82,9 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
class CircuitTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Circuit
|
||||
brief_fields = ['cid', 'id', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -168,10 +153,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
circuit_terminations = (
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], port_speed=100000, term_side=SIDE_A),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[1], port_speed=100000, term_side=SIDE_Z),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[0], port_speed=100000, term_side=SIDE_A),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], port_speed=100000, term_side=SIDE_Z),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side=SIDE_A),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[1], term_side=SIDE_Z),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[0], term_side=SIDE_A),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], term_side=SIDE_Z),
|
||||
)
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
@ -189,3 +174,7 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
|
||||
'port_speed': 200000,
|
||||
},
|
||||
]
|
||||
|
||||
cls.bulk_update_data = {
|
||||
'port_speed': 123456
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ from django.test import TestCase
|
||||
from circuits.choices import *
|
||||
from circuits.filters import *
|
||||
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
|
||||
|
||||
|
||||
@ -50,8 +50,8 @@ class ProviderTestCase(TestCase):
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
CircuitTermination.objects.bulk_create((
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000),
|
||||
CircuitTermination(circuit=circuits[1], 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'),
|
||||
))
|
||||
|
||||
def test_id(self):
|
||||
@ -176,9 +176,9 @@ class CircuitTestCase(TestCase):
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
circuit_terminations = ((
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=1000),
|
||||
CircuitTermination(circuit=circuits[2], site=sites[2], 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'),
|
||||
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'),
|
||||
))
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
@ -286,6 +286,8 @@ class CircuitTerminationTestCase(TestCase):
|
||||
))
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
Cable(termination_a=circuit_terminations[0], termination_b=circuit_terminations[1]).save()
|
||||
|
||||
def test_term_side(self):
|
||||
params = {'term_side': 'A'}
|
||||
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)
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
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)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from dcim.views import CableCreateView, CableTraceView
|
||||
from dcim.views import CableCreateView, PathTraceView
|
||||
from extras.views import ObjectChangeLogView
|
||||
from . import views
|
||||
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>/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: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}),
|
||||
|
||||
]
|
||||
|
||||
@ -3,13 +3,10 @@ from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from extras.models import Graph
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
from . import filters, forms, tables
|
||||
from .choices import CircuitTerminationSideChoices
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
@ -19,7 +16,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderListView(ObjectListView):
|
||||
class ProviderListView(generic.ObjectListView):
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=get_subquery(Circuit, 'provider')
|
||||
)
|
||||
@ -28,18 +25,15 @@ class ProviderListView(ObjectListView):
|
||||
table = tables.ProviderTable
|
||||
|
||||
|
||||
class ProviderView(ObjectView):
|
||||
class ProviderView(generic.ObjectView):
|
||||
queryset = Provider.objects.all()
|
||||
|
||||
def get(self, request, slug):
|
||||
|
||||
provider = get_object_or_404(self.queryset, slug=slug)
|
||||
def get_extra_context(self, request, instance):
|
||||
circuits = Circuit.objects.restrict(request.user, 'view').filter(
|
||||
provider=provider
|
||||
provider=instance
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'terminations__site'
|
||||
).annotate_sites()
|
||||
show_graphs = Graph.objects.filter(type__model='provider').exists()
|
||||
|
||||
circuits_table = tables.CircuitTable(circuits)
|
||||
circuits_table.columns.hide('provider')
|
||||
@ -50,30 +44,28 @@ class ProviderView(ObjectView):
|
||||
}
|
||||
RequestConfig(request, paginate).configure(circuits_table)
|
||||
|
||||
return render(request, 'circuits/provider.html', {
|
||||
'provider': provider,
|
||||
return {
|
||||
'circuits_table': circuits_table,
|
||||
'show_graphs': show_graphs,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class ProviderEditView(ObjectEditView):
|
||||
class ProviderEditView(generic.ObjectEditView):
|
||||
queryset = Provider.objects.all()
|
||||
model_form = forms.ProviderForm
|
||||
template_name = 'circuits/provider_edit.html'
|
||||
|
||||
|
||||
class ProviderDeleteView(ObjectDeleteView):
|
||||
class ProviderDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Provider.objects.all()
|
||||
|
||||
|
||||
class ProviderBulkImportView(BulkImportView):
|
||||
class ProviderBulkImportView(generic.BulkImportView):
|
||||
queryset = Provider.objects.all()
|
||||
model_form = forms.ProviderCSVForm
|
||||
table = tables.ProviderTable
|
||||
|
||||
|
||||
class ProviderBulkEditView(BulkEditView):
|
||||
class ProviderBulkEditView(generic.BulkEditView):
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=get_subquery(Circuit, 'provider')
|
||||
)
|
||||
@ -82,7 +74,7 @@ class ProviderBulkEditView(BulkEditView):
|
||||
form = forms.ProviderBulkEditForm
|
||||
|
||||
|
||||
class ProviderBulkDeleteView(BulkDeleteView):
|
||||
class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=get_subquery(Circuit, 'provider')
|
||||
)
|
||||
@ -94,29 +86,29 @@ class ProviderBulkDeleteView(BulkDeleteView):
|
||||
# Circuit Types
|
||||
#
|
||||
|
||||
class CircuitTypeListView(ObjectListView):
|
||||
class CircuitTypeListView(generic.ObjectListView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=get_subquery(Circuit, 'type')
|
||||
)
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
||||
class CircuitTypeEditView(ObjectEditView):
|
||||
class CircuitTypeEditView(generic.ObjectEditView):
|
||||
queryset = CircuitType.objects.all()
|
||||
model_form = forms.CircuitTypeForm
|
||||
|
||||
|
||||
class CircuitTypeDeleteView(ObjectDeleteView):
|
||||
class CircuitTypeDeleteView(generic.ObjectDeleteView):
|
||||
queryset = CircuitType.objects.all()
|
||||
|
||||
|
||||
class CircuitTypeBulkImportView(BulkImportView):
|
||||
class CircuitTypeBulkImportView(generic.BulkImportView):
|
||||
queryset = CircuitType.objects.all()
|
||||
model_form = forms.CircuitTypeCSVForm
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
||||
class CircuitTypeBulkDeleteView(BulkDeleteView):
|
||||
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=get_subquery(Circuit, 'type')
|
||||
)
|
||||
@ -127,7 +119,7 @@ class CircuitTypeBulkDeleteView(BulkDeleteView):
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitListView(ObjectListView):
|
||||
class CircuitListView(generic.ObjectListView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
).annotate_sites()
|
||||
@ -136,52 +128,52 @@ class CircuitListView(ObjectListView):
|
||||
table = tables.CircuitTable
|
||||
|
||||
|
||||
class CircuitView(ObjectView):
|
||||
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group')
|
||||
class CircuitView(generic.ObjectView):
|
||||
queryset = Circuit.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
circuit = get_object_or_404(self.queryset, pk=pk)
|
||||
def get_extra_context(self, request, instance):
|
||||
|
||||
# A-side termination
|
||||
termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
|
||||
'site__region', 'connected_endpoint__device'
|
||||
'site__region'
|
||||
).filter(
|
||||
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
|
||||
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
|
||||
).first()
|
||||
if termination_a and termination_a.connected_endpoint:
|
||||
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(
|
||||
'site__region', 'connected_endpoint__device'
|
||||
'site__region'
|
||||
).filter(
|
||||
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
|
||||
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
|
||||
).first()
|
||||
if termination_z and termination_z.connected_endpoint:
|
||||
termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
|
||||
|
||||
return render(request, 'circuits/circuit.html', {
|
||||
'circuit': circuit,
|
||||
return {
|
||||
'termination_a': termination_a,
|
||||
'termination_z': termination_z,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
class CircuitEditView(ObjectEditView):
|
||||
class CircuitEditView(generic.ObjectEditView):
|
||||
queryset = Circuit.objects.all()
|
||||
model_form = forms.CircuitForm
|
||||
template_name = 'circuits/circuit_edit.html'
|
||||
|
||||
|
||||
class CircuitDeleteView(ObjectDeleteView):
|
||||
class CircuitDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Circuit.objects.all()
|
||||
|
||||
|
||||
class CircuitBulkImportView(BulkImportView):
|
||||
class CircuitBulkImportView(generic.BulkImportView):
|
||||
queryset = Circuit.objects.all()
|
||||
model_form = forms.CircuitCSVForm
|
||||
table = tables.CircuitTable
|
||||
|
||||
|
||||
class CircuitBulkEditView(BulkEditView):
|
||||
class CircuitBulkEditView(generic.BulkEditView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
)
|
||||
@ -190,7 +182,7 @@ class CircuitBulkEditView(BulkEditView):
|
||||
form = forms.CircuitBulkEditForm
|
||||
|
||||
|
||||
class CircuitBulkDeleteView(BulkDeleteView):
|
||||
class CircuitBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
)
|
||||
@ -198,7 +190,7 @@ class CircuitBulkDeleteView(BulkDeleteView):
|
||||
table = tables.CircuitTable
|
||||
|
||||
|
||||
class CircuitSwapTerminations(ObjectEditView):
|
||||
class CircuitSwapTerminations(generic.ObjectEditView):
|
||||
"""
|
||||
Swap the A and Z terminations of a circuit.
|
||||
"""
|
||||
@ -271,7 +263,7 @@ class CircuitSwapTerminations(ObjectEditView):
|
||||
# Circuit terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationEditView(ObjectEditView):
|
||||
class CircuitTerminationEditView(generic.ObjectEditView):
|
||||
queryset = CircuitTermination.objects.all()
|
||||
model_form = forms.CircuitTerminationForm
|
||||
template_name = 'circuits/circuittermination_edit.html'
|
||||
@ -285,5 +277,5 @@ class CircuitTerminationEditView(ObjectEditView):
|
||||
return obj.circuit.get_absolute_url()
|
||||
|
||||
|
||||
class CircuitTerminationDeleteView(ObjectDeleteView):
|
||||
class CircuitTerminationDeleteView(generic.ObjectDeleteView):
|
||||
queryset = CircuitTermination.objects.all()
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.constants import CONNECTION_STATUS_CHOICES
|
||||
from dcim import models
|
||||
from utilities.api import ChoiceField, WritableNestedSerializer
|
||||
from netbox.api import WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedCableSerializer',
|
||||
@ -228,51 +227,46 @@ class NestedDeviceSerializer(WritableNestedSerializer):
|
||||
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ConsoleServerPort
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
fields = ['id', 'url', 'device', 'name', 'cable']
|
||||
|
||||
|
||||
class NestedConsolePortSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ConsolePort
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
fields = ['id', 'url', 'device', 'name', 'cable']
|
||||
|
||||
|
||||
class NestedPowerOutletSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutlet
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
fields = ['id', 'url', 'device', 'name', 'cable']
|
||||
|
||||
|
||||
class NestedPowerPortSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPort
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
fields = ['id', 'url', 'device', 'name', 'cable']
|
||||
|
||||
|
||||
class NestedInterfaceSerializer(WritableNestedSerializer):
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
|
||||
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Interface
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
fields = ['id', 'url', 'device', 'name', 'cable']
|
||||
|
||||
|
||||
class NestedRearPortSerializer(WritableNestedSerializer):
|
||||
@ -305,10 +299,11 @@ class NestedDeviceBaySerializer(WritableNestedSerializer):
|
||||
class NestedInventoryItemSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.InventoryItem
|
||||
fields = ['id', 'url', 'device', 'name']
|
||||
fields = ['id', 'url', 'device', 'name', '_depth']
|
||||
|
||||
|
||||
#
|
||||
|
||||
@ -7,37 +7,57 @@ from rest_framework.validators import UniqueTogetherValidator
|
||||
from dcim.choices import *
|
||||
from dcim.constants 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,
|
||||
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
from dcim.utils import decompile_path_node
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from extras.api.serializers import TaggedObjectSerializer
|
||||
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
|
||||
from ipam.models import VLAN
|
||||
from netbox.api import (
|
||||
ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer,
|
||||
WritableNestedSerializer,
|
||||
)
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
from utilities.api import (
|
||||
ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer,
|
||||
WritableNestedSerializer, get_serializer_for_model,
|
||||
)
|
||||
from utilities.api import get_serializer_for_model
|
||||
from virtualization.api.nested_serializers import NestedClusterSerializer
|
||||
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):
|
||||
connected_endpoint_type = 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):
|
||||
if hasattr(obj, 'connected_endpoint') and obj.connected_endpoint is not None:
|
||||
return '{}.{}'.format(
|
||||
obj.connected_endpoint._meta.app_label,
|
||||
obj.connected_endpoint._meta.model_name
|
||||
)
|
||||
if obj._path is not None and obj._path.destination is not None:
|
||||
return f'{obj._path.destination._meta.app_label}.{obj._path.destination._meta.model_name}'
|
||||
return None
|
||||
|
||||
@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.
|
||||
"""
|
||||
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
|
||||
|
||||
serializer = get_serializer_for_model(obj.connected_endpoint, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
data = serializer(obj.connected_endpoint, context=context).data
|
||||
|
||||
return 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
|
||||
|
||||
|
||||
#
|
||||
@ -168,7 +191,7 @@ class RackUnitSerializer(serializers.Serializer):
|
||||
occupied = serializers.BooleanField(read_only=True)
|
||||
|
||||
|
||||
class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
|
||||
rack = NestedRackSerializer()
|
||||
user = NestedUserSerializer()
|
||||
@ -176,7 +199,7 @@ class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
@ -452,7 +475,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
|
||||
method = serializers.DictField()
|
||||
|
||||
|
||||
class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(
|
||||
@ -465,12 +488,12 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSeria
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type',
|
||||
'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type',
|
||||
'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')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(
|
||||
@ -483,12 +506,12 @@ class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer)
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type',
|
||||
'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type',
|
||||
'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')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(
|
||||
@ -511,12 +534,13 @@ class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer)
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
|
||||
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable',
|
||||
'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')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(
|
||||
@ -529,12 +553,13 @@ class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
|
||||
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable',
|
||||
'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')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
@ -554,32 +579,26 @@ class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
|
||||
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode',
|
||||
'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
|
||||
'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'cable', 'cable_peer', 'cable_peer_type',
|
||||
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
|
||||
'count_ipaddresses',
|
||||
]
|
||||
|
||||
# TODO: This validation should be handled by Interface.clean()
|
||||
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')
|
||||
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', []):
|
||||
if vlan.site not in [device.site, None]:
|
||||
raise serializers.ValidationError({
|
||||
'tagged_vlans': "VLAN {} must belong to the same site as the interface's parent device, or it must "
|
||||
"be global.".format(vlan)
|
||||
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent device, or "
|
||||
f"it must be global."
|
||||
})
|
||||
|
||||
return super().validate(data)
|
||||
|
||||
|
||||
class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
@ -587,7 +606,10 @@ class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
@ -601,7 +623,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
|
||||
fields = ['id', 'url', 'name', 'label']
|
||||
|
||||
|
||||
class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
@ -612,7 +634,7 @@ class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
model = FrontPort
|
||||
fields = [
|
||||
'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
|
||||
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
|
||||
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = [
|
||||
'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
|
||||
#
|
||||
|
||||
class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
|
||||
termination_a_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
|
||||
@ -667,6 +690,7 @@ class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
fields = [
|
||||
'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',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
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
|
||||
#
|
||||
@ -713,37 +780,43 @@ class TracedCableSerializer(serializers.ModelSerializer):
|
||||
class InterfaceConnectionSerializer(ValidatedModelSerializer):
|
||||
interface_a = serializers.SerializerMethodField()
|
||||
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:
|
||||
model = Interface
|
||||
fields = ['interface_a', 'interface_b', 'connection_status']
|
||||
fields = ['interface_a', 'interface_b', 'connected_endpoint_reachable']
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer)
|
||||
def get_interface_a(self, obj):
|
||||
context = {'request': self.context['request']}
|
||||
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
|
||||
#
|
||||
|
||||
class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||
master = NestedDeviceSerializer(required=False)
|
||||
member_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'member_count']
|
||||
fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
|
||||
|
||||
|
||||
#
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
|
||||
site = NestedSiteSerializer()
|
||||
rack_group = NestedRackGroupSerializer(
|
||||
@ -755,10 +828,15 @@ class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
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')
|
||||
power_panel = NestedPowerPanelSerializer()
|
||||
rack = NestedRackSerializer(
|
||||
@ -788,5 +866,7 @@ class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
model = PowerFeed
|
||||
fields = [
|
||||
'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',
|
||||
]
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from utilities.api import OrderedDefaultRouter
|
||||
from netbox.api import OrderedDefaultRouter
|
||||
from . import views
|
||||
|
||||
|
||||
|
||||
@ -17,21 +17,20 @@ from rest_framework.viewsets import GenericViewSet, ViewSet
|
||||
from circuits.models import Circuit
|
||||
from dcim import filters
|
||||
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,
|
||||
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
|
||||
from extras.models import Graph
|
||||
from ipam.models import Prefix, VLAN
|
||||
from utilities.api import (
|
||||
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable,
|
||||
)
|
||||
from netbox.api.views import ModelViewSet
|
||||
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.metadata import ContentTypeMetadata
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
@ -47,7 +46,7 @@ class DCIMRootView(APIRootView):
|
||||
|
||||
# Mixins
|
||||
|
||||
class CableTraceMixin(object):
|
||||
class PathEndpointMixin(object):
|
||||
|
||||
@action(detail=True, url_path='trace')
|
||||
def trace(self, request, pk):
|
||||
@ -59,7 +58,10 @@ class CableTraceMixin(object):
|
||||
# Initialize the path array
|
||||
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
|
||||
serializer_a = get_serializer_for_model(near_end, prefix='Nested')
|
||||
@ -79,6 +81,20 @@ class CableTraceMixin(object):
|
||||
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
|
||||
#
|
||||
@ -113,16 +129,6 @@ class SiteViewSet(CustomFieldModelViewSet):
|
||||
serializer_class = serializers.SiteSerializer
|
||||
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
|
||||
@ -363,17 +369,6 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
|
||||
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(
|
||||
manual_parameters=[
|
||||
Parameter(
|
||||
@ -494,59 +489,47 @@ class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsolePortViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = ConsolePort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
|
||||
class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
filterset_class = filters.ConsolePortFilterSet
|
||||
|
||||
|
||||
class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = ConsoleServerPort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
|
||||
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = ConsoleServerPort.objects.prefetch_related(
|
||||
'device', '_path__destination', 'cable', '_cable_peer', 'tags'
|
||||
)
|
||||
serializer_class = serializers.ConsoleServerPortSerializer
|
||||
filterset_class = filters.ConsoleServerPortFilterSet
|
||||
|
||||
|
||||
class PowerPortViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = PowerPort.objects.prefetch_related(
|
||||
'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable', 'tags'
|
||||
)
|
||||
class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
filterset_class = filters.PowerPortFilterSet
|
||||
|
||||
|
||||
class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = PowerOutlet.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
|
||||
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
|
||||
serializer_class = serializers.PowerOutletSerializer
|
||||
filterset_class = filters.PowerOutletFilterSet
|
||||
|
||||
|
||||
class InterfaceViewSet(CableTraceMixin, ModelViewSet):
|
||||
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
|
||||
queryset = Interface.objects.prefetch_related(
|
||||
'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags'
|
||||
).filter(
|
||||
device__isnull=False
|
||||
'device', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
|
||||
)
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
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(CableTraceMixin, ModelViewSet):
|
||||
class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
|
||||
serializer_class = serializers.FrontPortSerializer
|
||||
filterset_class = filters.FrontPortFilterSet
|
||||
|
||||
|
||||
class RearPortViewSet(CableTraceMixin, ModelViewSet):
|
||||
class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
|
||||
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
|
||||
serializer_class = serializers.RearPortSerializer
|
||||
filterset_class = filters.RearPortFilterSet
|
||||
@ -569,32 +552,26 @@ class InventoryItemViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
queryset = ConsolePort.objects.prefetch_related(
|
||||
'device', 'connected_endpoint__device'
|
||||
).filter(
|
||||
connected_endpoint__isnull=False
|
||||
queryset = ConsolePort.objects.prefetch_related('device', '_path').filter(
|
||||
_path__destination_id__isnull=False
|
||||
)
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
filterset_class = filters.ConsoleConnectionFilterSet
|
||||
|
||||
|
||||
class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
queryset = PowerPort.objects.prefetch_related(
|
||||
'device', 'connected_endpoint__device'
|
||||
).filter(
|
||||
_connected_poweroutlet__isnull=False
|
||||
queryset = PowerPort.objects.prefetch_related('device', '_path').filter(
|
||||
_path__destination_id__isnull=False
|
||||
)
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
filterset_class = filters.PowerConnectionFilterSet
|
||||
|
||||
|
||||
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
queryset = Interface.objects.prefetch_related(
|
||||
'device', '_connected_interface__device'
|
||||
).filter(
|
||||
queryset = Interface.objects.prefetch_related('device', '_path').filter(
|
||||
# Avoid duplicate connections by only selecting the lower PK in a connected pair
|
||||
_connected_interface__isnull=False,
|
||||
pk__lt=F('_connected_interface')
|
||||
_path__destination_id__isnull=False,
|
||||
pk__lt=F('_path__destination_id')
|
||||
)
|
||||
serializer_class = serializers.InterfaceConnectionSerializer
|
||||
filterset_class = filters.InterfaceConnectionFilterSet
|
||||
@ -643,8 +620,10 @@ class PowerPanelViewSet(ModelViewSet):
|
||||
# Power feeds
|
||||
#
|
||||
|
||||
class PowerFeedViewSet(CustomFieldModelViewSet):
|
||||
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack', 'tags')
|
||||
class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
|
||||
queryset = PowerFeed.objects.prefetch_related(
|
||||
'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags'
|
||||
)
|
||||
serializer_class = serializers.PowerFeedSerializer
|
||||
filterset_class = filters.PowerFeedFilterSet
|
||||
|
||||
@ -699,7 +678,7 @@ class ConnectedDeviceViewSet(ViewSet):
|
||||
device__name=peer_device_name,
|
||||
name=peer_interface_name
|
||||
)
|
||||
local_interface = peer_interface._connected_interface
|
||||
local_interface = peer_interface.connected_endpoint
|
||||
|
||||
if local_interface is None:
|
||||
return Response()
|
||||
|
||||
@ -21,6 +21,14 @@ class SiteStatusChoices(ChoiceSet):
|
||||
(STATUS_RETIRED, 'Retired'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_STAGING: 'primary',
|
||||
STATUS_ACTIVE: 'success',
|
||||
STATUS_DECOMMISSIONING: 'warning',
|
||||
STATUS_RETIRED: 'danger',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
@ -74,6 +82,14 @@ class RackStatusChoices(ChoiceSet):
|
||||
(STATUS_DEPRECATED, 'Deprecated'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
STATUS_RESERVED: 'warning',
|
||||
STATUS_AVAILABLE: 'success',
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_ACTIVE: 'primary',
|
||||
STATUS_DEPRECATED: 'danger',
|
||||
}
|
||||
|
||||
|
||||
class RackDimensionUnitChoices(ChoiceSet):
|
||||
|
||||
@ -147,6 +163,16 @@ class DeviceStatusChoices(ChoiceSet):
|
||||
(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
|
||||
@ -969,6 +995,12 @@ class CableStatusChoices(ChoiceSet):
|
||||
(STATUS_DECOMMISSIONING, 'Decommissioning'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
STATUS_CONNECTED: 'success',
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_DECOMMISSIONING: 'warning',
|
||||
}
|
||||
|
||||
|
||||
class CableLengthUnitChoices(ChoiceSet):
|
||||
|
||||
@ -1003,6 +1035,13 @@ class PowerFeedStatusChoices(ChoiceSet):
|
||||
(STATUS_FAILED, 'Failed'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
STATUS_OFFLINE: 'warning',
|
||||
STATUS_ACTIVE: 'success',
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_FAILED: 'danger',
|
||||
}
|
||||
|
||||
|
||||
class PowerFeedTypeChoices(ChoiceSet):
|
||||
|
||||
@ -1014,6 +1053,11 @@ class PowerFeedTypeChoices(ChoiceSet):
|
||||
(TYPE_REDUNDANT, 'Redundant'),
|
||||
)
|
||||
|
||||
CSS_CLASSES = {
|
||||
TYPE_PRIMARY: 'success',
|
||||
TYPE_REDUNDANT: 'info',
|
||||
}
|
||||
|
||||
|
||||
class PowerFeedSupplyChoices(ChoiceSet):
|
||||
|
||||
|
||||
@ -59,12 +59,6 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
|
||||
# Cabling and connections
|
||||
#
|
||||
|
||||
# Console/power/interface connection statuses
|
||||
CONNECTION_STATUS_CHOICES = [
|
||||
[False, 'Not Connected'],
|
||||
[True, 'Connected'],
|
||||
]
|
||||
|
||||
# Cable endpoint types
|
||||
CABLE_TERMINATION_MODELS = Q(
|
||||
Q(app_label='circuits', model__in=(
|
||||
@ -83,12 +77,13 @@ CABLE_TERMINATION_MODELS = Q(
|
||||
)
|
||||
|
||||
COMPATIBLE_TERMINATION_TYPES = {
|
||||
'circuittermination': ['interface', 'frontport', 'rearport', 'circuittermination'],
|
||||
'consoleport': ['consoleserverport', 'frontport', 'rearport'],
|
||||
'consoleserverport': ['consoleport', 'frontport', 'rearport'],
|
||||
'powerport': ['poweroutlet', 'powerfeed'],
|
||||
'poweroutlet': ['powerport'],
|
||||
'interface': ['interface', 'circuittermination', 'frontport', 'rearport'],
|
||||
'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
|
||||
'powerfeed': ['powerport'],
|
||||
'poweroutlet': ['powerport'],
|
||||
'powerport': ['poweroutlet', 'powerfeed'],
|
||||
'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
|
||||
'circuittermination': ['interface', 'frontport', 'rearport', 'circuittermination'],
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -1,9 +1,11 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.db import models
|
||||
from netaddr import AddrFormatError, EUI, mac_unix_expanded
|
||||
|
||||
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
|
||||
from .lookups import PathContains
|
||||
|
||||
|
||||
class ASNField(models.BigIntegerField):
|
||||
@ -50,3 +52,15 @@ class MACAddressField(models.Field):
|
||||
if not value:
|
||||
return None
|
||||
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)
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import django_filters
|
||||
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.models import Tenant
|
||||
from utilities.choices import ColorChoices
|
||||
@ -23,6 +24,7 @@ from .models import (
|
||||
|
||||
__all__ = (
|
||||
'CableFilterSet',
|
||||
'CableTerminationFilterSet',
|
||||
'ConsoleConnectionFilterSet',
|
||||
'ConsolePortFilterSet',
|
||||
'ConsolePortTemplateFilterSet',
|
||||
@ -40,6 +42,7 @@ __all__ = (
|
||||
'InterfaceTemplateFilterSet',
|
||||
'InventoryItemFilterSet',
|
||||
'ManufacturerFilterSet',
|
||||
'PathEndpointFilterSet',
|
||||
'PlatformFilterSet',
|
||||
'PowerConnectionFilterSet',
|
||||
'PowerFeedFilterSet',
|
||||
@ -77,7 +80,7 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@ -176,7 +179,7 @@ class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
|
||||
|
||||
class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@ -322,7 +325,7 @@ class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class DeviceTypeFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
@ -501,7 +504,7 @@ class DeviceFilterSet(
|
||||
BaseFilterSet,
|
||||
TenancyFilterSet,
|
||||
LocalConfigContextFilterSet,
|
||||
CustomFieldFilterSet,
|
||||
CustomFieldModelFilterSet,
|
||||
CreatedUpdatedFilterSet
|
||||
):
|
||||
q = django_filters.CharFilter(
|
||||
@ -746,71 +749,76 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=ConsolePortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
class CableTerminationFilterSet(django_filters.FilterSet):
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
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:
|
||||
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(
|
||||
choices=ConsolePortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
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(
|
||||
choices=PowerPortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
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(
|
||||
choices=PowerOutletTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
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(
|
||||
method='search',
|
||||
label='Search',
|
||||
@ -827,11 +835,6 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
field_name='pk',
|
||||
label='Device (ID)',
|
||||
)
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
kind = django_filters.CharFilter(
|
||||
method='filter_kind',
|
||||
label='Kind of interface',
|
||||
@ -858,7 +861,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
try:
|
||||
@ -908,24 +911,14 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
}.get(value, queryset.none())
|
||||
|
||||
|
||||
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = ['id', 'name', 'type', 'description']
|
||||
|
||||
|
||||
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet):
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
@ -1133,7 +1126,20 @@ class CableFilterSet(BaseFilterSet):
|
||||
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(
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
@ -1148,23 +1154,10 @@ class ConsoleConnectionFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['name', 'connection_status']
|
||||
|
||||
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})
|
||||
)
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class PowerConnectionFilterSet(BaseFilterSet):
|
||||
class PowerConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
|
||||
site = django_filters.CharFilter(
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
@ -1179,23 +1172,10 @@ class PowerConnectionFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['name', 'connection_status']
|
||||
|
||||
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})
|
||||
)
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class InterfaceConnectionFilterSet(BaseFilterSet):
|
||||
class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
|
||||
site = django_filters.CharFilter(
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
@ -1210,23 +1190,7 @@ class InterfaceConnectionFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['connection_status']
|
||||
|
||||
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})
|
||||
)
|
||||
fields = []
|
||||
|
||||
|
||||
class PowerPanelFilterSet(BaseFilterSet):
|
||||
@ -1278,7 +1242,13 @@ class PowerPanelFilterSet(BaseFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class PowerFeedFilterSet(
|
||||
BaseFilterSet,
|
||||
CableTerminationFilterSet,
|
||||
PathEndpointFilterSet,
|
||||
CustomFieldModelFilterSet,
|
||||
CreatedUpdatedFilterSet
|
||||
):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
|
||||
@ -22,9 +22,10 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ColorSelect, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK,
|
||||
SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm,
|
||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
|
||||
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
||||
BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from .choices import *
|
||||
@ -88,13 +89,12 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class InterfaceCommonForm:
|
||||
class InterfaceCommonForm(forms.Form):
|
||||
|
||||
def clean(self):
|
||||
|
||||
super().clean()
|
||||
|
||||
# Validate VLAN assignments
|
||||
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
|
||||
tagged_vlans = self.cleaned_data['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
|
||||
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]
|
||||
|
||||
if invalid_vlans:
|
||||
raise forms.ValidationError({
|
||||
'tagged_vlans': "The tagged VLANs ({}) must belong to the same site as the interface's parent "
|
||||
"device/VM, or they must be global".format(', '.join(invalid_vlans))
|
||||
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
|
||||
f"the interface's parent device/VM, or they must be global"
|
||||
})
|
||||
|
||||
|
||||
@ -721,7 +721,7 @@ class RackElevationFilterForm(RackFilterForm):
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
||||
class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@ -2688,7 +2688,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
|
||||
class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm):
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
@ -3361,6 +3361,13 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
display_field='display_name'
|
||||
)
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=InventoryItem.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
}
|
||||
)
|
||||
manufacturer = DynamicModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False
|
||||
@ -3373,7 +3380,8 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
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(),
|
||||
required=False
|
||||
)
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=InventoryItem.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
}
|
||||
)
|
||||
part_id = forms.CharField(
|
||||
max_length=50,
|
||||
required=False,
|
||||
@ -3396,8 +3411,8 @@ class InventoryItemCreateForm(ComponentCreateForm):
|
||||
required=False,
|
||||
)
|
||||
field_order = (
|
||||
'device', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
|
||||
'tags',
|
||||
'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag',
|
||||
'description', 'tags',
|
||||
)
|
||||
|
||||
|
||||
@ -3472,7 +3487,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
# Cables
|
||||
#
|
||||
|
||||
class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
|
||||
class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm):
|
||||
"""
|
||||
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(
|
||||
queryset=Provider.objects.all(),
|
||||
label='Provider',
|
||||
@ -3663,7 +3678,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
|
||||
|
||||
|
||||
class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
|
||||
class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
|
||||
termination_b_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region',
|
||||
@ -3720,7 +3735,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
|
||||
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
|
||||
|
||||
|
||||
class CableForm(BootstrapMixin, forms.ModelForm):
|
||||
class CableForm(BootstrapMixin, CustomFieldModelForm):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
@ -3750,10 +3765,9 @@ class CableCSVForm(CSVModelForm):
|
||||
to_field_name='name',
|
||||
help_text='Side A device'
|
||||
)
|
||||
side_a_type = CSVModelChoiceField(
|
||||
side_a_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
to_field_name='model',
|
||||
help_text='Side A type'
|
||||
)
|
||||
side_a_name = forms.CharField(
|
||||
@ -3766,10 +3780,9 @@ class CableCSVForm(CSVModelForm):
|
||||
to_field_name='name',
|
||||
help_text='Side B device'
|
||||
)
|
||||
side_b_type = CSVModelChoiceField(
|
||||
side_b_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
to_field_name='model',
|
||||
help_text='Side B type'
|
||||
)
|
||||
side_b_name = forms.CharField(
|
||||
@ -3803,58 +3816,36 @@ class CableCSVForm(CSVModelForm):
|
||||
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
|
||||
}
|
||||
|
||||
# TODO: Merge the clean() methods for either end
|
||||
def clean_side_a_name(self):
|
||||
def _clean_side(self, side):
|
||||
"""
|
||||
Derive a Cable's A/B termination objects.
|
||||
|
||||
device = self.cleaned_data.get('side_a_device')
|
||||
content_type = self.cleaned_data.get('side_a_type')
|
||||
name = self.cleaned_data.get('side_a_name')
|
||||
:param side: 'a' or 'b'
|
||||
"""
|
||||
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:
|
||||
return None
|
||||
|
||||
model = content_type.model_class()
|
||||
try:
|
||||
termination_object = model.objects.get(
|
||||
device=device,
|
||||
name=name
|
||||
)
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None:
|
||||
raise forms.ValidationError(
|
||||
"Side A: {} {} is already connected".format(device, termination_object)
|
||||
)
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(
|
||||
"A side termination not found: {} {}".format(device, name)
|
||||
)
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
||||
self.instance.termination_a = termination_object
|
||||
setattr(self.instance, f'termination_{side}', termination_object)
|
||||
return termination_object
|
||||
|
||||
def clean_side_a_name(self):
|
||||
return self._clean_side('a')
|
||||
|
||||
def clean_side_b_name(self):
|
||||
|
||||
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
|
||||
return self._clean_side('b')
|
||||
|
||||
def clean_length_unit(self):
|
||||
# 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(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@ -4126,7 +4117,7 @@ class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
|
||||
return instance
|
||||
|
||||
|
||||
class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
|
||||
class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm):
|
||||
master = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
@ -4316,7 +4307,7 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelForm(BootstrapMixin, forms.ModelForm):
|
||||
class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
|
||||
10
netbox/dcim/lookups.py
Normal file
10
netbox/dcim/lookups.py
Normal 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()
|
||||
0
netbox/dcim/management/__init__.py
Normal file
0
netbox/dcim/management/__init__.py
Normal file
0
netbox/dcim/management/commands/__init__.py
Normal file
0
netbox/dcim/management/commands/__init__.py
Normal file
88
netbox/dcim/management/commands/trace_paths.py
Normal file
88
netbox/dcim/management/commands/trace_paths.py
Normal 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.'))
|
||||
60
netbox/dcim/migrations/0117_custom_field_data.py
Normal file
60
netbox/dcim/migrations/0117_custom_field_data.py
Normal 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),
|
||||
),
|
||||
]
|
||||
44
netbox/dcim/migrations/0118_inventoryitem_mptt.py
Normal file
44
netbox/dcim/migrations/0118_inventoryitem_mptt.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
26
netbox/dcim/migrations/0119_inventoryitem_mptt_rebuild.py
Normal file
26
netbox/dcim/migrations/0119_inventoryitem_mptt_rebuild.py
Normal 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
|
||||
),
|
||||
]
|
||||
141
netbox/dcim/migrations/0120_cache_cable_peer.py
Normal file
141
netbox/dcim/migrations/0120_cache_cable_peer.py
Normal 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
|
||||
),
|
||||
]
|
||||
108
netbox/dcim/migrations/0121_cablepath.py
Normal file
108
netbox/dcim/migrations/0121_cablepath.py
Normal 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',
|
||||
),
|
||||
]
|
||||
98
netbox/dcim/migrations/0122_standardize_name_length.py
Normal file
98
netbox/dcim/migrations/0122_standardize_name_length.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -1,3 +1,4 @@
|
||||
from .cables import *
|
||||
from .device_component_templates import *
|
||||
from .device_components import *
|
||||
from .devices import *
|
||||
@ -8,6 +9,7 @@ from .sites import *
|
||||
__all__ = (
|
||||
'BaseInterface',
|
||||
'Cable',
|
||||
'CablePath',
|
||||
'CableTermination',
|
||||
'ConsolePort',
|
||||
'ConsolePortTemplate',
|
||||
|
||||
495
netbox/dcim/models/cables.py
Normal file
495
netbox/dcim/models/cables.py
Normal 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)
|
||||
@ -1,20 +1,20 @@
|
||||
import logging
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
from django.urls import reverse
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.exceptions import CableTraceSplit
|
||||
from dcim.fields import MACAddressField
|
||||
from extras.models import ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.fields import NaturalOrderingField
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.ordering import naturalize_interface
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.query_functions import CollateAsChar
|
||||
@ -30,6 +30,7 @@ __all__ = (
|
||||
'FrontPort',
|
||||
'Interface',
|
||||
'InventoryItem',
|
||||
'PathEndpoint',
|
||||
'PowerOutlet',
|
||||
'PowerPort',
|
||||
'RearPort',
|
||||
@ -37,6 +38,9 @@ __all__ = (
|
||||
|
||||
|
||||
class ComponentModel(models.Model):
|
||||
"""
|
||||
An abstract model inherited by any model which has a parent Device.
|
||||
"""
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
@ -91,6 +95,14 @@ class ComponentModel(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(
|
||||
to='dcim.Cable',
|
||||
on_delete=models.SET_NULL,
|
||||
@ -98,6 +110,21 @@ class CableTermination(models.Model):
|
||||
blank=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.
|
||||
_cabled_as_a = GenericRelation(
|
||||
@ -114,138 +141,57 @@ class CableTermination(models.Model):
|
||||
class Meta:
|
||||
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):
|
||||
if self.cable is None:
|
||||
return None
|
||||
if self._cabled_as_a.exists():
|
||||
return self.cable.termination_b
|
||||
if self._cabled_as_b.exists():
|
||||
return self.cable.termination_a
|
||||
return self._cable_peer
|
||||
|
||||
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
|
||||
path, split_ends, position_stack = self.trace()
|
||||
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)
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
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')
|
||||
class ConsolePort(CableTermination, ComponentModel):
|
||||
class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
|
||||
"""
|
||||
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
|
||||
"""
|
||||
@ -263,18 +209,6 @@ class ConsolePort(CableTermination, ComponentModel):
|
||||
blank=True,
|
||||
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)
|
||||
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'description']
|
||||
@ -301,7 +235,7 @@ class ConsolePort(CableTermination, ComponentModel):
|
||||
#
|
||||
|
||||
@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.
|
||||
"""
|
||||
@ -311,11 +245,6 @@ class ConsoleServerPort(CableTermination, ComponentModel):
|
||||
blank=True,
|
||||
help_text='Physical port type'
|
||||
)
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'description']
|
||||
@ -342,7 +271,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
|
||||
#
|
||||
|
||||
@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.
|
||||
"""
|
||||
@ -364,25 +293,6 @@ class PowerPort(CableTermination, ComponentModel):
|
||||
validators=[MinValueValidator(1)],
|
||||
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)
|
||||
|
||||
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)."
|
||||
})
|
||||
|
||||
@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):
|
||||
"""
|
||||
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
|
||||
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)
|
||||
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'),
|
||||
allocated_draw_total=Sum('allocated_draw'),
|
||||
)
|
||||
@ -469,10 +346,13 @@ class PowerPort(CableTermination, ComponentModel):
|
||||
}
|
||||
|
||||
# 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:
|
||||
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'),
|
||||
allocated_draw_total=Sum('allocated_draw'),
|
||||
)
|
||||
@ -499,7 +379,7 @@ class PowerPort(CableTermination, ComponentModel):
|
||||
#
|
||||
|
||||
@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.
|
||||
"""
|
||||
@ -522,11 +402,6 @@ class PowerOutlet(CableTermination, ComponentModel):
|
||||
blank=True,
|
||||
help_text="Phase (for three-phase feeds)"
|
||||
)
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description']
|
||||
@ -602,8 +477,8 @@ class BaseInterface(models.Model):
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@extras_features('graphs', 'export_templates', 'webhooks')
|
||||
class Interface(CableTermination, ComponentModel, BaseInterface):
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
"""
|
||||
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,
|
||||
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(
|
||||
to='self',
|
||||
on_delete=models.SET_NULL,
|
||||
@ -736,42 +592,6 @@ class Interface(CableTermination, ComponentModel, BaseInterface):
|
||||
"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
|
||||
def parent(self):
|
||||
return self.device
|
||||
@ -975,17 +795,18 @@ class DeviceBay(ComponentModel):
|
||||
#
|
||||
|
||||
@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.
|
||||
InventoryItems are used only for inventory purposes.
|
||||
"""
|
||||
parent = models.ForeignKey(
|
||||
parent = TreeForeignKey(
|
||||
to='self',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='child_items',
|
||||
blank=True,
|
||||
null=True
|
||||
null=True,
|
||||
db_index=True
|
||||
)
|
||||
manufacturer = models.ForeignKey(
|
||||
to='dcim.Manufacturer',
|
||||
@ -1020,6 +841,8 @@ class InventoryItem(ComponentModel):
|
||||
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = TreeManager()
|
||||
|
||||
csv_headers = [
|
||||
'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
|
||||
]
|
||||
|
||||
@ -2,9 +2,8 @@ from collections import OrderedDict
|
||||
|
||||
import yaml
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, ProtectedError
|
||||
@ -20,12 +19,10 @@ from extras.utils import extras_features
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import to_meters
|
||||
from .device_components import *
|
||||
|
||||
|
||||
__all__ = (
|
||||
'Cable',
|
||||
'Device',
|
||||
'DeviceRole',
|
||||
'DeviceType',
|
||||
@ -45,10 +42,11 @@ class Manufacturer(ChangeLoggedModel):
|
||||
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
description = models.CharField(
|
||||
@ -99,9 +97,11 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
related_name='device_types'
|
||||
)
|
||||
model = models.CharField(
|
||||
max_length=50
|
||||
max_length=100
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100
|
||||
)
|
||||
slug = models.SlugField()
|
||||
part_number = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
@ -135,11 +135,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
@ -259,6 +254,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
return yaml.dump(dict(data), sort_keys=False)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# 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
|
||||
@ -349,10 +345,11 @@ class DeviceRole(ChangeLoggedModel):
|
||||
virtual machines as well.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
color = ColorField(
|
||||
@ -399,8 +396,8 @@ class Platform(ChangeLoggedModel):
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
unique=True,
|
||||
max_length=100
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
manufacturer = models.ForeignKey(
|
||||
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):
|
||||
"""
|
||||
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(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
images = GenericRelation(
|
||||
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)
|
||||
|
||||
objects = ConfigContextModelQuerySet.as_manager()
|
||||
@ -605,16 +603,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
'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:
|
||||
ordering = ('_name', 'pk') # Name may be null
|
||||
unique_together = (
|
||||
@ -647,7 +635,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
super().validate_unique(exclude)
|
||||
|
||||
def clean(self):
|
||||
|
||||
super().clean()
|
||||
|
||||
# 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.
|
||||
"""
|
||||
from .cables import Cable
|
||||
cable_pks = []
|
||||
for component_model in [
|
||||
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)
|
||||
|
||||
def get_status_class(self):
|
||||
return self.STATUS_CLASS_MAP.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]
|
||||
return DeviceStatusChoices.CSS_CLASSES.get(self.status)
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
@extras_features('custom_links', 'export_templates', 'webhooks')
|
||||
class VirtualChassis(ChangeLoggedModel):
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class VirtualChassis(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
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})
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
|
||||
# VirtualChassis.)
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
@ -11,7 +10,7 @@ from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import ExclusionValidator
|
||||
from .device_components import CableTermination
|
||||
from .device_components import CableTermination, PathEndpoint
|
||||
|
||||
__all__ = (
|
||||
'PowerFeed',
|
||||
@ -23,8 +22,8 @@ __all__ = (
|
||||
# Power
|
||||
#
|
||||
|
||||
@extras_features('custom_links', 'export_templates', 'webhooks')
|
||||
class PowerPanel(ChangeLoggedModel):
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class PowerPanel(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A distribution point for electrical power; e.g. a data center RPP.
|
||||
"""
|
||||
@ -39,7 +38,7 @@ class PowerPanel(ChangeLoggedModel):
|
||||
null=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
max_length=100
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
@ -65,6 +64,7 @@ class PowerPanel(ChangeLoggedModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# RackGroup must belong to assigned 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')
|
||||
class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldModel):
|
||||
"""
|
||||
An electrical circuit delivered from a PowerPanel.
|
||||
"""
|
||||
@ -89,20 +89,8 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
blank=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(
|
||||
max_length=50
|
||||
max_length=100
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
@ -144,11 +132,6 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
@ -162,18 +145,6 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
'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:
|
||||
ordering = ['power_panel', 'name']
|
||||
unique_together = ['power_panel', 'name']
|
||||
@ -202,6 +173,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Rack must belong to same Site as PowerPanel
|
||||
if self.rack and self.rack.site != self.power_panel.site:
|
||||
@ -231,7 +203,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
return self.power_panel
|
||||
|
||||
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):
|
||||
return self.STATUS_CLASS_MAP.get(self.status)
|
||||
return PowerFeedStatusChoices.CSS_CLASSES.get(self.status)
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
from collections import OrderedDict
|
||||
from itertools import count, groupby
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@ -22,7 +22,8 @@ from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
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 .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.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
max_length=100
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100
|
||||
)
|
||||
slug = models.SlugField()
|
||||
site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
on_delete=models.CASCADE,
|
||||
@ -117,10 +120,11 @@ class RackRole(ChangeLoggedModel):
|
||||
Racks can be organized by functional role, similar to Devices.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
color = ColorField(
|
||||
@ -160,7 +164,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
Each Rack is assigned to a Site and (optionally) a RackGroup.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
max_length=100
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
@ -261,11 +265,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
@ -282,14 +281,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
'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:
|
||||
ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique
|
||||
unique_together = (
|
||||
@ -305,6 +296,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
return reverse('dcim:rack', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate outer dimensions and 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
|
||||
|
||||
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):
|
||||
"""
|
||||
@ -550,24 +542,26 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
Determine the utilization rate of power in the rack and return it as a percentage.
|
||||
"""
|
||||
power_stats = PowerFeed.objects.filter(
|
||||
rack=self
|
||||
).annotate(
|
||||
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
|
||||
powerfeeds = PowerFeed.objects.filter(rack=self)
|
||||
available_power_total = sum(pf.available_power for pf in powerfeeds)
|
||||
if not available_power_total:
|
||||
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')
|
||||
class RackReservation(ChangeLoggedModel):
|
||||
return int(allocated_draw_total / available_power_total * 100)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class RackReservation(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
One or more reserved units within a Rack.
|
||||
"""
|
||||
@ -609,6 +603,7 @@ class RackReservation(ChangeLoggedModel):
|
||||
return reverse('dcim:rackreservation', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if hasattr(self, 'rack') and self.units:
|
||||
|
||||
@ -647,9 +642,4 @@ class RackReservation(ChangeLoggedModel):
|
||||
|
||||
@property
|
||||
def unit_list(self):
|
||||
"""
|
||||
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)
|
||||
return array_to_string(self.units)
|
||||
|
||||
@ -39,10 +39,11 @@ class Region(MPTTModel, ChangeLoggedModel):
|
||||
db_index=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
description = models.CharField(
|
||||
@ -91,14 +92,14 @@ class Region(MPTTModel, ChangeLoggedModel):
|
||||
# Sites
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
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).
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
@ -107,6 +108,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
blank=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
status = models.CharField(
|
||||
@ -183,11 +185,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
@ -204,14 +201,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
'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:
|
||||
ordering = ('_name',)
|
||||
|
||||
@ -243,4 +232,4 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return self.STATUS_CLASS_MAP.get(self.status)
|
||||
return SiteStatusChoices.CSS_CLASSES.get(self.status)
|
||||
|
||||
@ -1,10 +1,37 @@
|
||||
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 .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)
|
||||
@ -32,71 +59,73 @@ def clear_virtualchassis_members(instance, **kwargs):
|
||||
|
||||
|
||||
@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
|
||||
"""
|
||||
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
|
||||
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_peer = instance.termination_b
|
||||
instance.termination_a.save()
|
||||
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_peer = instance.termination_a
|
||||
instance.termination_b.save()
|
||||
|
||||
# Update any endpoints for this Cable.
|
||||
endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints()
|
||||
for endpoint in endpoints:
|
||||
path, split_ends, position_stack = endpoint.trace()
|
||||
# Determine overall path status (connected or planned)
|
||||
path_status = True
|
||||
for segment in path:
|
||||
if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED:
|
||||
path_status = False
|
||||
break
|
||||
|
||||
endpoint_a = path[0][0]
|
||||
endpoint_b = path[-1][2] if not split_ends and not position_stack else None
|
||||
|
||||
# Patch panel ports are not connected endpoints, all other cable terminations are
|
||||
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()
|
||||
# Create/update cable paths
|
||||
if created:
|
||||
for termination in (instance.termination_a, instance.termination_b):
|
||||
if isinstance(termination, PathEndpoint):
|
||||
create_cablepath(termination)
|
||||
else:
|
||||
rebuild_paths(termination)
|
||||
elif instance.status != instance._orig_status:
|
||||
# We currently don't support modifying either termination of an existing Cable. (This
|
||||
# may change in the future.) However, we do need to capture status changes and update
|
||||
# any CablePaths accordingly.
|
||||
if instance.status != CableStatusChoices.STATUS_CONNECTED:
|
||||
CablePath.objects.filter(path__contains=instance).update(is_active=False)
|
||||
else:
|
||||
rebuild_paths(instance)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Cable)
|
||||
@receiver(post_delete, sender=Cable)
|
||||
def nullify_connected_endpoints(instance, **kwargs):
|
||||
"""
|
||||
When a Cable is deleted, check for and update its two connected endpoints
|
||||
"""
|
||||
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
|
||||
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_peer = None
|
||||
instance.termination_a.save()
|
||||
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_peer = None
|
||||
instance.termination_b.save()
|
||||
|
||||
# If this Cable was part of any complete end-to-end paths, tear them down.
|
||||
for endpoint in endpoints:
|
||||
logger.debug(f"Removing path information for {endpoint}")
|
||||
if hasattr(endpoint, 'connected_endpoint'):
|
||||
endpoint.connected_endpoint = None
|
||||
endpoint.connection_status = None
|
||||
endpoint.save()
|
||||
# Delete and retrace any dependent cable paths
|
||||
for cablepath in CablePath.objects.filter(path__contains=instance):
|
||||
cp = CablePath.from_origin(cablepath.origin)
|
||||
if cp:
|
||||
CablePath.objects.filter(pk=cablepath.pk).update(
|
||||
path=cp.path,
|
||||
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
108
netbox/dcim/tables/__init__.py
Normal file
108
netbox/dcim/tables/__init__.py
Normal 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')
|
||||
64
netbox/dcim/tables/cables.py
Normal file
64
netbox/dcim/tables/cables.py
Normal 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',
|
||||
)
|
||||
642
netbox/dcim/tables/devices.py
Normal file
642
netbox/dcim/tables/devices.py
Normal 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')
|
||||
200
netbox/dcim/tables/devicetypes.py
Normal file
200
netbox/dcim/tables/devicetypes.py
Normal 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"
|
||||
77
netbox/dcim/tables/power.py
Normal file
77
netbox/dcim/tables/power.py
Normal 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
170
netbox/dcim/tables/racks.py
Normal 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',
|
||||
)
|
||||
62
netbox/dcim/tables/sites.py
Normal file
62
netbox/dcim/tables/sites.py
Normal 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')
|
||||
251
netbox/dcim/tables/template_code.py
Normal file
251
netbox/dcim/tables/template_code.py
Normal 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 %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
CABLE_LENGTH = """
|
||||
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% 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 %}
|
||||
—
|
||||
{% 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 %}
|
||||
"""
|
||||
@ -1,10 +1,7 @@
|
||||
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 rest_framework import status
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import (
|
||||
@ -14,7 +11,6 @@ from dcim.models import (
|
||||
Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
|
||||
)
|
||||
from ipam.models import VLAN
|
||||
from extras.models import Graph
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
from virtualization.models import Cluster, ClusterType
|
||||
|
||||
@ -83,6 +79,9 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
|
||||
'slug': 'region-6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -95,6 +94,9 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
|
||||
class SiteTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Site
|
||||
brief_fields = ['id', 'name', 'slug', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
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):
|
||||
model = RackGroup
|
||||
brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -217,6 +202,9 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
'color': 'ffff00',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -232,6 +220,9 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
class RackTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Rack
|
||||
brief_fields = ['device_count', 'display_name', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -317,6 +308,9 @@ class RackTest(APIViewTestCases.APIViewTestCase):
|
||||
class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RackReservation
|
||||
brief_fields = ['id', 'units', 'url', 'user']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -379,6 +373,9 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
|
||||
'slug': 'manufacturer-6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -394,6 +391,9 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
|
||||
class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceType
|
||||
brief_fields = ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
|
||||
bulk_update_data = {
|
||||
'part_number': 'ABC123',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -433,6 +433,9 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
|
||||
class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConsolePortTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -467,6 +470,9 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConsoleServerPortTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -501,6 +507,9 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = PowerPortTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -535,6 +544,9 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = PowerOutletTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -569,6 +581,9 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = InterfaceTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -606,6 +621,9 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = FrontPortTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -652,21 +670,21 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
'name': 'Front Port Template 4',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': rear_port_templates[3].pk,
|
||||
'position': 1,
|
||||
'rear_port_position': 1,
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Front Port Template 5',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': rear_port_templates[4].pk,
|
||||
'position': 1,
|
||||
'rear_port_position': 1,
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Front Port Template 6',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'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):
|
||||
model = RearPortTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -711,6 +732,9 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceBayTemplate
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -762,6 +786,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
'color': 'ffff00',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -791,6 +818,9 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
|
||||
'slug': 'platform-6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -806,6 +836,9 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
|
||||
class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Device
|
||||
brief_fields = ['display_name', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'failed',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
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
|
||||
|
||||
@classmethod
|
||||
@ -999,7 +1015,10 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
|
||||
|
||||
class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
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
|
||||
|
||||
@classmethod
|
||||
@ -1035,7 +1054,10 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
|
||||
|
||||
class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
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
|
||||
|
||||
@classmethod
|
||||
@ -1071,7 +1093,10 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
|
||||
class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
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
|
||||
|
||||
@classmethod
|
||||
@ -1107,7 +1132,10 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
|
||||
|
||||
class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
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
|
||||
|
||||
@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')
|
||||
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):
|
||||
class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
||||
model = FrontPort
|
||||
brief_fields = ['cable', 'device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
peer_termination_type = Interface
|
||||
|
||||
@classmethod
|
||||
@ -1235,9 +1246,12 @@ class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
]
|
||||
|
||||
|
||||
class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RearPort
|
||||
brief_fields = ['cable', 'device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
peer_termination_type = Interface
|
||||
|
||||
@classmethod
|
||||
@ -1277,6 +1291,9 @@ class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase)
|
||||
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceBay
|
||||
brief_fields = ['device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -1336,7 +1353,10 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class InventoryItemTest(APIViewTestCases.APIViewTestCase):
|
||||
model = InventoryItem
|
||||
brief_fields = ['device', 'id', 'name', 'url']
|
||||
brief_fields = ['_depth', 'device', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
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')
|
||||
device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
|
||||
|
||||
inventory_items = (
|
||||
InventoryItem(device=device, name='Inventory Item 1', manufacturer=manufacturer),
|
||||
InventoryItem(device=device, name='Inventory Item 2', manufacturer=manufacturer),
|
||||
InventoryItem(device=device, name='Inventory Item 3', manufacturer=manufacturer),
|
||||
)
|
||||
InventoryItem.objects.bulk_create(inventory_items)
|
||||
InventoryItem.objects.create(device=device, name='Inventory Item 1', manufacturer=manufacturer)
|
||||
InventoryItem.objects.create(device=device, name='Inventory Item 2', manufacturer=manufacturer)
|
||||
InventoryItem.objects.create(device=device, name='Inventory Item 3', manufacturer=manufacturer)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
@ -1375,6 +1392,10 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
|
||||
class CableTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Cable
|
||||
brief_fields = ['id', 'label', 'url']
|
||||
bulk_update_data = {
|
||||
'length': 100,
|
||||
'length_unit': 'm',
|
||||
}
|
||||
|
||||
# TODO: Allow updating cable terminations
|
||||
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):
|
||||
|
||||
def setUp(self):
|
||||
@ -1888,10 +1538,13 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
||||
VirtualChassis(name='Virtual Chassis 3', master=devices[6], domain='domain-3'),
|
||||
)
|
||||
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[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[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[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):
|
||||
model = PowerPanel
|
||||
@ -1923,43 +1580,55 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
@classmethod
|
||||
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 = (
|
||||
RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=site),
|
||||
RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2', site=site),
|
||||
RackGroup.objects.create(name='Rack Group 3', slug='rack-group-3', 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=sites[0]),
|
||||
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 = (
|
||||
PowerPanel(site=site, rack_group=rack_groups[0], name='Power Panel 1'),
|
||||
PowerPanel(site=site, 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[0], name='Power Panel 1'),
|
||||
PowerPanel(site=sites[0], rack_group=rack_groups[1], name='Power Panel 2'),
|
||||
PowerPanel(site=sites[0], rack_group=rack_groups[2], name='Power Panel 3'),
|
||||
)
|
||||
PowerPanel.objects.bulk_create(power_panels)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Power Panel 4',
|
||||
'site': site.pk,
|
||||
'site': sites[0].pk,
|
||||
'rack_group': rack_groups[0].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Power Panel 5',
|
||||
'site': site.pk,
|
||||
'site': sites[0].pk,
|
||||
'rack_group': rack_groups[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Power Panel 6',
|
||||
'site': site.pk,
|
||||
'site': sites[0].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):
|
||||
model = PowerFeed
|
||||
brief_fields = ['cable', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
923
netbox/dcim/tests/test_cablepaths.py
Normal file
923
netbox/dcim/tests/test_cablepaths.py
Normal 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)
|
||||
@ -1514,10 +1514,11 @@ class ConsolePortTestCase(TestCase):
|
||||
params = {'description': ['First', 'Second']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# TODO: Fix boolean value
|
||||
def test_connection_status(self):
|
||||
params = {'connection_status': 'True'}
|
||||
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)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
@ -1609,10 +1610,11 @@ class ConsoleServerPortTestCase(TestCase):
|
||||
params = {'description': ['First', 'Second']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# TODO: Fix boolean value
|
||||
def test_connection_status(self):
|
||||
params = {'connection_status': 'True'}
|
||||
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)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
@ -1712,10 +1714,11 @@ class PowerPortTestCase(TestCase):
|
||||
params = {'allocated_draw': [50, 100]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# TODO: Fix boolean value
|
||||
def test_connection_status(self):
|
||||
params = {'connection_status': 'True'}
|
||||
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)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
@ -1812,10 +1815,11 @@ class PowerOutletTestCase(TestCase):
|
||||
params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
# TODO: Fix boolean value
|
||||
def test_connection_status(self):
|
||||
params = {'connection_status': 'True'}
|
||||
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)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
@ -1900,10 +1904,11 @@ class InterfaceTestCase(TestCase):
|
||||
params = {'name': ['Interface 1', 'Interface 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# TODO: Fix boolean value
|
||||
def test_connection_status(self):
|
||||
params = {'connection_status': 'True'}
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
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):
|
||||
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[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 = (
|
||||
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[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):
|
||||
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
|
||||
@ -2660,6 +2667,18 @@ class PowerFeedTestCase(TestCase):
|
||||
)
|
||||
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):
|
||||
params = {'id': self.queryset.values_list('pk', flat=True)[: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]}
|
||||
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
|
||||
|
||||
@ -390,17 +390,19 @@ class CableTestCase(TestCase):
|
||||
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.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.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z', 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')
|
||||
|
||||
def test_cable_creation(self):
|
||||
"""
|
||||
When a new Cable is created, it must be cached on either termination point.
|
||||
"""
|
||||
interface1 = Interface.objects.get(pk=self.interface1.pk)
|
||||
self.assertEqual(self.cable.termination_a, interface1)
|
||||
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(interface2._cable_peer, interface1)
|
||||
|
||||
def test_cable_deletion(self):
|
||||
"""
|
||||
@ -412,8 +414,10 @@ class CableTestCase(TestCase):
|
||||
self.assertNotEqual(str(self.cable), '#None')
|
||||
interface1 = Interface.objects.get(pk=self.interface1.pk)
|
||||
self.assertIsNone(interface1.cable)
|
||||
self.assertIsNone(interface1._cable_peer)
|
||||
interface2 = Interface.objects.get(pk=self.interface2.pk)
|
||||
self.assertIsNone(interface2.cable)
|
||||
self.assertIsNone(interface2._cable_peer)
|
||||
|
||||
def test_cabletermination_deletion(self):
|
||||
"""
|
||||
@ -457,21 +461,9 @@ class CableTestCase(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
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.
|
||||
|
||||
[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.
|
||||
Test various combinations of RearPort connections.
|
||||
"""
|
||||
# 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()
|
||||
@ -482,68 +474,22 @@ class CableTestCase(TestCase):
|
||||
# Connecting a single-position RearPort to a CircuitTermination is ok
|
||||
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
|
||||
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
|
||||
Cable(termination_a=self.rear_port2, termination_b=self.rear_port1).full_clean()
|
||||
# Connecting a multi-position RearPort to an Interface is ok
|
||||
Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean()
|
||||
|
||||
# Connecting a multi-position RearPort to a CircuitTermination is ok
|
||||
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(
|
||||
ValidationError,
|
||||
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()
|
||||
|
||||
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):
|
||||
"""
|
||||
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)
|
||||
with self.assertRaises(ValidationError):
|
||||
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)
|
||||
|
||||
@ -101,10 +101,10 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,slug",
|
||||
"Site 4,site-4",
|
||||
"Site 5,site-5",
|
||||
"Site 6,site-6",
|
||||
"name,slug,status",
|
||||
"Site 4,site-4,planned",
|
||||
"Site 5,site-5,active",
|
||||
"Site 6,site-6,staging",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@ -983,9 +983,9 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
||||
cls.csv_data = (
|
||||
"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 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 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 6,Site 1,Rack Group 1,Rack 1,30,front",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@ -997,6 +997,130 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'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):
|
||||
model = ConsolePort
|
||||
@ -1267,9 +1391,9 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.csv_data = (
|
||||
"device,name,type",
|
||||
"Device 1,Interface 4,1000BASE-T (1GE)",
|
||||
"Device 1,Interface 5,1000BASE-T (1GE)",
|
||||
"Device 1,Interface 6,1000BASE-T (1GE)",
|
||||
"Device 1,Interface 4,1000base-t",
|
||||
"Device 1,Interface 5,1000base-t",
|
||||
"Device 1,Interface 6,1000base-t",
|
||||
)
|
||||
|
||||
|
||||
@ -1326,9 +1450,9 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.csv_data = (
|
||||
"device,name,type,rear_port,rear_port_position",
|
||||
"Device 1,Front Port 4,8P8C,Rear Port 4,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 4,8p8c,Rear Port 4,1",
|
||||
"Device 1,Front Port 5,8p8c,Rear Port 5,1",
|
||||
"Device 1,Front Port 6,8p8c,Rear Port 6,1",
|
||||
)
|
||||
|
||||
|
||||
@ -1372,9 +1496,9 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.csv_data = (
|
||||
"device,name,type,positions",
|
||||
"Device 1,Rear Port 4,8P8C,1",
|
||||
"Device 1,Rear Port 5,8P8C,1",
|
||||
"Device 1,Rear Port 6,8P8C,1",
|
||||
"Device 1,Rear Port 4,8p8c,1",
|
||||
"Device 1,Rear Port 5,8p8c,1",
|
||||
"Device 1,Rear Port 6,8p8c,1",
|
||||
)
|
||||
|
||||
|
||||
@ -1430,11 +1554,9 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
device = create_test_device('Device 1')
|
||||
manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
|
||||
InventoryItem.objects.bulk_create([
|
||||
InventoryItem(device=device, name='Inventory Item 1'),
|
||||
InventoryItem(device=device, name='Inventory Item 2'),
|
||||
InventoryItem(device=device, name='Inventory Item 3'),
|
||||
])
|
||||
InventoryItem.objects.create(device=device, name='Inventory Item 1')
|
||||
InventoryItem.objects.create(device=device, name='Inventory Item 2')
|
||||
InventoryItem.objects.create(device=device, name='Inventory Item 3')
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
@ -1546,9 +1668,9 @@ class CableTestCase(
|
||||
|
||||
cls.csv_data = (
|
||||
"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,interface,Interface 2,Device 4,interface,Interface 2",
|
||||
"Device 3,interface,Interface 3,Device 4,interface,Interface 3",
|
||||
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
|
||||
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
|
||||
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@ -1716,11 +1838,6 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'max_utilization': 50,
|
||||
'comments': 'New comments',
|
||||
'tags': [t.pk for t in tags],
|
||||
|
||||
# Connection
|
||||
'cable': None,
|
||||
'connected_endpoint': None,
|
||||
'connection_status': None,
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
|
||||
@ -187,9 +187,17 @@ urlpatterns = [
|
||||
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>/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>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
|
||||
path('devices/<int:pk>/console-ports/', views.DeviceConsolePortsView.as_view(), name='device_consoleports'),
|
||||
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>/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>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
||||
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>/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>/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('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>/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>/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('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>/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>/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('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>/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>/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('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>/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>/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('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>/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>/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('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>/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>/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('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>/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>/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: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
28
netbox/dcim/utils.py
Normal 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)
|
||||
1179
netbox/dcim/views.py
1179
netbox/dcim/views.py
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,7 @@ from django import forms
|
||||
from django.contrib import admin
|
||||
|
||||
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):
|
||||
@ -29,8 +29,8 @@ class WebhookForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if 'obj_type' in self.fields:
|
||||
order_content_types(self.fields['obj_type'])
|
||||
if 'content_types' in self.fields:
|
||||
order_content_types(self.fields['content_types'])
|
||||
|
||||
|
||||
@admin.register(Webhook)
|
||||
@ -40,12 +40,12 @@ class WebhookAdmin(admin.ModelAdmin):
|
||||
'ssl_verification',
|
||||
]
|
||||
list_filter = [
|
||||
'enabled', 'type_create', 'type_update', 'type_delete', 'obj_type',
|
||||
'enabled', 'type_create', 'type_update', 'type_delete', 'content_types',
|
||||
]
|
||||
form = WebhookForm
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('name', 'obj_type', 'enabled')
|
||||
'fields': ('name', 'content_types', 'enabled')
|
||||
}),
|
||||
('Events', {
|
||||
'fields': ('type_create', 'type_update', 'type_delete')
|
||||
@ -62,7 +62,7 @@ class WebhookAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
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:
|
||||
model = CustomField
|
||||
exclude = []
|
||||
widgets = {
|
||||
'default': forms.TextInput(),
|
||||
'validation_regex': forms.Textarea(
|
||||
attrs={
|
||||
'cols': 80,
|
||||
'rows': 3,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
order_content_types(self.fields['obj_type'])
|
||||
|
||||
|
||||
class CustomFieldChoiceAdmin(admin.TabularInline):
|
||||
model = CustomFieldChoice
|
||||
extra = 5
|
||||
order_content_types(self.fields['content_types'])
|
||||
|
||||
|
||||
@admin.register(CustomField)
|
||||
class CustomFieldAdmin(admin.ModelAdmin):
|
||||
inlines = [CustomFieldChoiceAdmin]
|
||||
actions = None
|
||||
form = CustomFieldForm
|
||||
list_display = [
|
||||
'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description',
|
||||
]
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
#
|
||||
# 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
|
||||
#
|
||||
@ -198,11 +180,6 @@ class ExportTemplateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ExportTemplate
|
||||
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):
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -219,7 +196,7 @@ class ExportTemplateAdmin(admin.ModelAdmin):
|
||||
'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension')
|
||||
}),
|
||||
('Content', {
|
||||
'fields': ('template_language', 'template_code'),
|
||||
'fields': ('template_code',),
|
||||
'classes': ('monospace',)
|
||||
})
|
||||
)
|
||||
|
||||
@ -1,15 +1,9 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CreateOnlyDefault
|
||||
from rest_framework.fields import CreateOnlyDefault, Field
|
||||
|
||||
from extras.choices import *
|
||||
from extras.models import CustomField, CustomFieldChoice, CustomFieldValue
|
||||
from utilities.api import ValidatedModelSerializer
|
||||
from extras.models import CustomField
|
||||
from netbox.api import ValidatedModelSerializer
|
||||
|
||||
|
||||
#
|
||||
@ -27,104 +21,39 @@ class CustomFieldDefaultValues:
|
||||
|
||||
# Retrieve the CustomFields for the parent 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
|
||||
value = {}
|
||||
for field in fields:
|
||||
if field.default:
|
||||
if field.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||
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
|
||||
if field.default is not None:
|
||||
value[field.name] = field.default
|
||||
else:
|
||||
value[field.name] = None
|
||||
|
||||
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):
|
||||
return obj
|
||||
|
||||
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)
|
||||
return {
|
||||
cf.name: obj.get(cf.name) for cf in self._get_custom_fields()
|
||||
}
|
||||
|
||||
for field_name, value in data.items():
|
||||
|
||||
try:
|
||||
cf = custom_fields[field_name]
|
||||
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)))
|
||||
def to_internal_value(self, data):
|
||||
# If updating an existing instance, start with existing custom_field_data
|
||||
if self.parent.instance:
|
||||
data = {**self.parent.instance.custom_field_data, **data}
|
||||
|
||||
return data
|
||||
|
||||
@ -133,8 +62,8 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
||||
"""
|
||||
Extends ModelSerializer to render any CustomFields and their values associated with an object.
|
||||
"""
|
||||
custom_fields = CustomFieldsSerializer(
|
||||
required=False,
|
||||
custom_fields = CustomFieldsDataField(
|
||||
source='custom_field_data',
|
||||
default=CreateOnlyDefault(CustomFieldDefaultValues())
|
||||
)
|
||||
|
||||
@ -145,73 +74,16 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
|
||||
|
||||
# Retrieve the set of CustomFields which apply to this type of object
|
||||
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
|
||||
try:
|
||||
if type(self.instance) in (list, tuple):
|
||||
for obj in self.instance:
|
||||
self._populate_custom_fields(obj, fields)
|
||||
except TypeError:
|
||||
else:
|
||||
self._populate_custom_fields(self.instance, fields)
|
||||
|
||||
def _populate_custom_fields(self, instance, custom_fields):
|
||||
instance.custom_fields = {}
|
||||
for field in custom_fields:
|
||||
value = 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']
|
||||
instance.custom_fields[field.name] = instance.cf.get(field.name)
|
||||
|
||||
@ -1,19 +1,27 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from extras import choices, models
|
||||
from netbox.api import ChoiceField, WritableNestedSerializer
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
from utilities.api import ChoiceField, WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedConfigContextSerializer',
|
||||
'NestedCustomFieldSerializer',
|
||||
'NestedExportTemplateSerializer',
|
||||
'NestedGraphSerializer',
|
||||
'NestedImageAttachmentSerializer',
|
||||
'NestedJobResultSerializer',
|
||||
'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):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
|
||||
|
||||
@ -30,14 +38,6 @@ class NestedExportTemplateSerializer(WritableNestedSerializer):
|
||||
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):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
|
||||
|
||||
|
||||
@ -10,56 +10,39 @@ from dcim.api.nested_serializers import (
|
||||
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
|
||||
from extras.choices 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 netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
|
||||
from netbox.api.exceptions import SerializerNotFound
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
from utilities.api import (
|
||||
ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField,
|
||||
ValidatedModelSerializer,
|
||||
)
|
||||
from utilities.api import get_serializer_for_model
|
||||
from virtualization.api.nested_serializers import NestedClusterGroupSerializer, NestedClusterSerializer
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from .nested_serializers import *
|
||||
|
||||
|
||||
#
|
||||
# Graphs
|
||||
# Custom fields
|
||||
#
|
||||
|
||||
class GraphSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail')
|
||||
type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('graphs').get_query()),
|
||||
class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail')
|
||||
content_types = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||
many=True
|
||||
)
|
||||
type = ChoiceField(choices=CustomFieldTypeChoices)
|
||||
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Graph
|
||||
fields = ['id', 'url', 'type', 'weight', 'name', 'template_language', 'source', 'link']
|
||||
|
||||
|
||||
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'])
|
||||
model = CustomField
|
||||
fields = [
|
||||
'id', 'url', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
|
||||
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
@ -71,17 +54,10 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
|
||||
content_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||
)
|
||||
template_language = ChoiceField(
|
||||
choices=TemplateLanguageChoices,
|
||||
default=TemplateLanguageChoices.LANGUAGE_JINJA2
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ExportTemplate
|
||||
fields = [
|
||||
'id', 'url', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type',
|
||||
'file_extension',
|
||||
]
|
||||
fields = ['id', 'url', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension']
|
||||
|
||||
|
||||
#
|
||||
@ -383,3 +359,20 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
|
||||
data = serializer(obj.changed_object, context=context).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
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
from utilities.api import OrderedDefaultRouter
|
||||
from netbox.api import OrderedDefaultRouter
|
||||
from . import views
|
||||
|
||||
|
||||
router = OrderedDefaultRouter()
|
||||
router.APIRootView = views.ExtrasRootView
|
||||
|
||||
# Custom field choices
|
||||
router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
|
||||
|
||||
# Graphs
|
||||
router.register('graphs', views.GraphViewSet)
|
||||
# Custom fields
|
||||
router.register('custom-fields', views.CustomFieldViewSet)
|
||||
|
||||
# Export templates
|
||||
router.register('export-templates', views.ExportTemplateViewSet)
|
||||
@ -35,5 +32,8 @@ router.register('object-changes', views.ObjectChangeViewSet)
|
||||
# Job Results
|
||||
router.register('job-results', views.JobResultViewSet)
|
||||
|
||||
# ContentTypes
|
||||
router.register('content-types', views.ContentTypeViewSet)
|
||||
|
||||
app_name = 'extras-api'
|
||||
urlpatterns = router.urls
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import Http404
|
||||
from django_rq.queues import get_connection
|
||||
@ -14,17 +12,27 @@ from rq import Worker
|
||||
from extras import filters
|
||||
from extras.choices import JobResultStatusChoices
|
||||
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.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.metadata import ContentTypeMetadata
|
||||
from utilities.utils import copy_safe_request, get_subquery
|
||||
from . import serializers
|
||||
|
||||
|
||||
class ExtrasRootView(APIRootView):
|
||||
"""
|
||||
Extras API root view
|
||||
"""
|
||||
def get_view_name(self):
|
||||
return 'Extras'
|
||||
|
||||
|
||||
class ConfigContextQuerySetMixin:
|
||||
"""
|
||||
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()
|
||||
|
||||
|
||||
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
|
||||
#
|
||||
|
||||
class CustomFieldViewSet(ModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = CustomField.objects.all()
|
||||
serializer_class = serializers.CustomFieldSerializer
|
||||
filterset_class = filters.CustomFieldFilterSet
|
||||
|
||||
|
||||
class CustomFieldModelViewSet(ModelViewSet):
|
||||
"""
|
||||
Include the applicable set of CustomFields in the ModelViewSet context.
|
||||
@ -99,37 +76,14 @@ class CustomFieldModelViewSet(ModelViewSet):
|
||||
|
||||
# Gather all custom fields for the model
|
||||
content_type = ContentType.objects.get_for_model(self.queryset.model)
|
||||
custom_fields = content_type.custom_fields.prefetch_related('choices')
|
||||
|
||||
# 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
|
||||
custom_fields = content_type.custom_fields.all()
|
||||
|
||||
context = super().get_serializer_context()
|
||||
context.update({
|
||||
'custom_fields': custom_fields,
|
||||
'custom_field_choices': custom_field_choices,
|
||||
})
|
||||
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
|
||||
@ -388,3 +342,16 @@ class JobResultViewSet(ReadOnlyModelViewSet):
|
||||
queryset = JobResult.objects.prefetch_related('user')
|
||||
serializer_class = serializers.JobResultSerializer
|
||||
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
|
||||
|
||||
@ -78,20 +78,11 @@ class ObjectChangeActionChoices(ChoiceSet):
|
||||
(ACTION_DELETE, 'Deleted'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# ExportTemplates
|
||||
#
|
||||
|
||||
class TemplateLanguageChoices(ChoiceSet):
|
||||
|
||||
LANGUAGE_JINJA2 = 'jinja2'
|
||||
LANGUAGE_DJANGO = 'django'
|
||||
|
||||
CHOICES = (
|
||||
(LANGUAGE_JINJA2, 'Jinja2'),
|
||||
(LANGUAGE_DJANGO, 'Django (Legacy)'),
|
||||
)
|
||||
CSS_CLASSES = {
|
||||
ACTION_CREATE: 'success',
|
||||
ACTION_UPDATE: 'primary',
|
||||
ACTION_DELETE: 'danger',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
@ -114,13 +105,13 @@ class LogLevelChoices(ChoiceSet):
|
||||
(LOG_FAILURE, 'Failure'),
|
||||
)
|
||||
|
||||
CLASS_MAP = (
|
||||
(LOG_DEFAULT, 'default'),
|
||||
(LOG_SUCCESS, 'success'),
|
||||
(LOG_INFO, 'info'),
|
||||
(LOG_WARNING, 'warning'),
|
||||
(LOG_FAILURE, 'danger'),
|
||||
)
|
||||
CSS_CLASSES = {
|
||||
LOG_DEFAULT: 'default',
|
||||
LOG_SUCCESS: 'success',
|
||||
LOG_INFO: 'info',
|
||||
LOG_WARNING: 'warning',
|
||||
LOG_FAILURE: 'danger',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
|
||||
@ -6,7 +6,6 @@ EXTRAS_FEATURES = [
|
||||
'custom_fields',
|
||||
'custom_links',
|
||||
'export_templates',
|
||||
'graphs',
|
||||
'job_results',
|
||||
'webhooks'
|
||||
]
|
||||
|
||||
@ -5,34 +5,39 @@ from django.db.models import Q
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.filters import BaseFilterSet
|
||||
from utilities.filters import BaseFilterSet, ContentTypeFilter
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
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__ = (
|
||||
'ConfigContextFilterSet',
|
||||
'ContentTypeFilterSet',
|
||||
'CreatedUpdatedFilterSet',
|
||||
'CustomFieldFilter',
|
||||
'CustomFieldFilterSet',
|
||||
'CustomFieldModelFilterSet',
|
||||
'ExportTemplateFilterSet',
|
||||
'GraphFilterSet',
|
||||
'ImageAttachmentFilterSet',
|
||||
'LocalConfigContextFilterSet',
|
||||
'ObjectChangeFilterSet',
|
||||
'TagFilterSet',
|
||||
)
|
||||
|
||||
EXACT_FILTER_TYPES = (
|
||||
CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
CustomFieldTypeChoices.TYPE_DATE,
|
||||
CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
CustomFieldTypeChoices.TYPE_SELECT,
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldFilter(django_filters.Filter):
|
||||
"""
|
||||
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):
|
||||
self.cf_type = custom_field.type
|
||||
self.filter_logic = custom_field.filter_logic
|
||||
self.custom_field = custom_field
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def filter(self, queryset, value):
|
||||
@ -41,50 +46,27 @@ class CustomFieldFilter(django_filters.Filter):
|
||||
if value is None or not value.strip():
|
||||
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)
|
||||
if (self.cf_type == CustomFieldTypeChoices.TYPE_BOOLEAN or
|
||||
self.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT):
|
||||
queryset = queryset.filter(
|
||||
custom_field_values__field__name=self.field_name,
|
||||
custom_field_values__serialized_value=value
|
||||
)
|
||||
if (
|
||||
self.custom_field.type in EXACT_FILTER_TYPES or
|
||||
self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT
|
||||
):
|
||||
kwargs = {f'custom_field_data__{self.field_name}': value}
|
||||
else:
|
||||
queryset = queryset.filter(
|
||||
custom_field_values__field__name=self.field_name,
|
||||
custom_field_values__serialized_value__icontains=value
|
||||
)
|
||||
kwargs = {f'custom_field_data__{self.field_name}__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.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
obj_type = ContentType.objects.get_for_model(self._meta.model)
|
||||
custom_fields = CustomField.objects.filter(
|
||||
obj_type=obj_type
|
||||
content_types=ContentType.objects.get_for_model(self._meta.model)
|
||||
).exclude(
|
||||
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)
|
||||
|
||||
|
||||
class GraphFilterSet(BaseFilterSet):
|
||||
class CustomFieldFilterSet(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Graph
|
||||
fields = ['id', 'type', 'name', 'template_language']
|
||||
model = CustomField
|
||||
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
|
||||
|
||||
|
||||
class ExportTemplateFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ExportTemplate
|
||||
fields = ['id', 'content_type', 'name', 'template_language']
|
||||
fields = ['id', 'content_type', 'name']
|
||||
|
||||
|
||||
class ImageAttachmentFilterSet(BaseFilterSet):
|
||||
content_type = ContentTypeFilter()
|
||||
|
||||
class Meta:
|
||||
model = ImageAttachment
|
||||
fields = ['id', 'content_type', 'object_id', 'name']
|
||||
fields = ['id', 'content_type_id', 'object_id', 'name']
|
||||
|
||||
|
||||
class TagFilterSet(BaseFilterSet):
|
||||
@ -260,6 +243,7 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
label='Search',
|
||||
)
|
||||
time = django_filters.DateTimeFromToRangeFilter()
|
||||
changed_object_type = ContentTypeFilter()
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label='User (ID)',
|
||||
@ -274,7 +258,8 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
class Meta:
|
||||
model = ObjectChange
|
||||
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):
|
||||
@ -335,3 +320,14 @@ class JobResultFilterSet(BaseFilterSet):
|
||||
return queryset.filter(
|
||||
Q(user__username__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# ContentTypes
|
||||
#
|
||||
|
||||
class ContentTypeFilterSet(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = ['id', 'app_label', 'model']
|
||||
|
||||
@ -12,7 +12,7 @@ from utilities.forms import (
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
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.custom_fields = []
|
||||
self.custom_field_values = {}
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.instance._cf is None:
|
||||
self.instance._cf = {}
|
||||
|
||||
self._append_customfield_fields()
|
||||
|
||||
def _append_customfield_fields(self):
|
||||
"""
|
||||
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
|
||||
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)
|
||||
if self.instance.pk:
|
||||
self.fields[field_name] = cf.to_form_field(set_initial=False)
|
||||
value = self.custom_field_values.get(cf.name)
|
||||
self.fields[field_name].initial = value
|
||||
self.instance._cf[cf.name] = value
|
||||
self.fields[field_name].initial = self.instance.custom_field_data.get(cf.name)
|
||||
else:
|
||||
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
|
||||
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):
|
||||
|
||||
# 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:
|
||||
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)
|
||||
|
||||
# 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
|
||||
return super().save(commit)
|
||||
|
||||
|
||||
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
|
||||
@ -104,7 +60,7 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
|
||||
def _append_customfield_fields(self):
|
||||
|
||||
# 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)
|
||||
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)
|
||||
|
||||
# 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:
|
||||
# Annotate non-required custom fields as nullable
|
||||
if not cf.required:
|
||||
@ -140,7 +96,7 @@ class CustomFieldFilterForm(forms.Form):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# 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
|
||||
)
|
||||
for cf in custom_fields:
|
||||
@ -406,11 +362,14 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
)
|
||||
changed_object_type = forms.ModelChoiceField(
|
||||
queryset=ContentType.objects.order_by('model'),
|
||||
changed_object_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
required=False,
|
||||
widget=ContentTypeSelect(),
|
||||
label='Object Type'
|
||||
display_field='display_name',
|
||||
label='Object Type',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/extras/content-types/',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
16
netbox/extras/migrations/0049_remove_graph.py
Normal file
16
netbox/extras/migrations/0049_remove_graph.py
Normal 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',
|
||||
),
|
||||
]
|
||||
65
netbox/extras/migrations/0050_customfield_changes.py
Normal file
65
netbox/extras/migrations/0050_customfield_changes.py
Normal 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]),
|
||||
),
|
||||
]
|
||||
110
netbox/extras/migrations/0051_migrate_customfields.py
Normal file
110
netbox/extras/migrations/0051_migrate_customfields.py
Normal 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
|
||||
),
|
||||
]
|
||||
26
netbox/extras/migrations/0052_customfield_cleanup.py
Normal file
26
netbox/extras/migrations/0052_customfield_cleanup.py
Normal 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',
|
||||
),
|
||||
]
|
||||
18
netbox/extras/migrations/0053_rename_webhook_obj_type.py
Normal file
18
netbox/extras/migrations/0053_rename_webhook_obj_type.py
Normal 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
Loading…
Reference in New Issue
Block a user