mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-05 08:29:35 +01:00
Compare commits
218 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88dace75a1 | ||
|
|
f8bced34eb | ||
|
|
cf64ef342f | ||
|
|
c7acc9ad69 | ||
|
|
31e8986e35 | ||
|
|
050b6449d4 | ||
|
|
49dd5761f8 | ||
|
|
5215779061 | ||
|
|
48e9cd6a00 | ||
|
|
e06bfffd60 | ||
|
|
e7b08f8f2f | ||
|
|
8edaff860c | ||
|
|
d9d7068c5f | ||
|
|
e647065e63 | ||
|
|
5716207ba6 | ||
|
|
bdff71db9e | ||
|
|
9e670d318c | ||
|
|
1882d832c3 | ||
|
|
04fd197c9b | ||
|
|
edb8904474 | ||
|
|
a5fe4468d0 | ||
|
|
65d8bb8c26 | ||
|
|
cf796fb40f | ||
|
|
0ac3e91e3b | ||
|
|
e8684240a7 | ||
|
|
c1b6da771f | ||
|
|
3de51876d0 | ||
|
|
0e4d02bd10 | ||
|
|
7b06f5e9fc | ||
|
|
37b2ff02e7 | ||
|
|
1ed5389703 | ||
|
|
b6da5ce6bd | ||
|
|
ae8f40ed8d | ||
|
|
96de61ddfb | ||
|
|
9fd9719d0b | ||
|
|
f0d8e02d63 | ||
|
|
44d5ff26a4 | ||
|
|
550efcb640 | ||
|
|
15bec75167 | ||
|
|
c94d111401 | ||
|
|
6f1532adac | ||
|
|
b7fe220860 | ||
|
|
b451ece057 | ||
|
|
b56e37ad84 | ||
|
|
712567cabc | ||
|
|
017263f640 | ||
|
|
f02c222d4f | ||
|
|
66fa877198 | ||
|
|
6a9f26a68d | ||
|
|
bf817eb69e | ||
|
|
298ac1ba7a | ||
|
|
bd40f72ad5 | ||
|
|
a0eff04185 | ||
|
|
8eb140fd65 | ||
|
|
a68e82575f | ||
|
|
5035a9567b | ||
|
|
d5095362d7 | ||
|
|
3a6d7a1f7f | ||
|
|
cc6ae8ebe4 | ||
|
|
b4940a64be | ||
|
|
fca812928e | ||
|
|
4a9b4c5387 | ||
|
|
1f09f3d096 | ||
|
|
efb95937fc | ||
|
|
ce7ee1771a | ||
|
|
da216e2c22 | ||
|
|
e58ee4e0e3 | ||
|
|
66be85a41f | ||
|
|
2171dcee7f | ||
|
|
3262262a8a | ||
|
|
28b586aca7 | ||
|
|
f007b0dbde | ||
|
|
6e5950be77 | ||
|
|
eb4cd0e723 | ||
|
|
300ee820fa | ||
|
|
7d6d7942d9 | ||
|
|
05debf7e40 | ||
|
|
dc88cb5ac7 | ||
|
|
b275009544 | ||
|
|
d960481adb | ||
|
|
2986840755 | ||
|
|
9b8bae501b | ||
|
|
9ea3383fde | ||
|
|
77ac79f32c | ||
|
|
e31fae5ec5 | ||
|
|
8bff8bcbe2 | ||
|
|
cc79b1136b | ||
|
|
1af9ea9e2d | ||
|
|
814c11167e | ||
|
|
1d509a8ff8 | ||
|
|
f2232a15d9 | ||
|
|
955abcef21 | ||
|
|
9eaf153673 | ||
|
|
8e71c0f2a8 | ||
|
|
18a516ee53 | ||
|
|
f5b2420b4b | ||
|
|
f569561997 | ||
|
|
99c2911a66 | ||
|
|
a0ee6b0d58 | ||
|
|
d891c8c981 | ||
|
|
07e34fbe84 | ||
|
|
7dfd32a5c4 | ||
|
|
9c7f55d8d0 | ||
|
|
e496dc710f | ||
|
|
13cdc44caf | ||
|
|
1f3f9781d9 | ||
|
|
57ddd5086f | ||
|
|
6c1fb1bd02 | ||
|
|
ea92e92c5a | ||
|
|
96eaea7db9 | ||
|
|
bbac6e2ba6 | ||
|
|
76c6fbbfba | ||
|
|
ad1c3d4910 | ||
|
|
f2137683f9 | ||
|
|
084b86cab1 | ||
|
|
41af9c8900 | ||
|
|
2d58cfaa05 | ||
|
|
4af3072b53 | ||
|
|
a37d2ff4f8 | ||
|
|
c525939b13 | ||
|
|
dc186a57cd | ||
|
|
d97dd266b7 | ||
|
|
df9a6a0c53 | ||
|
|
fd38daf0c5 | ||
|
|
28b4f6b8fd | ||
|
|
2db50dd4a7 | ||
|
|
5cd9c11169 | ||
|
|
f8f5d6876b | ||
|
|
198674f368 | ||
|
|
e22eafc4a7 | ||
|
|
f44a322df5 | ||
|
|
fc2ac8a02b | ||
|
|
13243785f1 | ||
|
|
35c207e936 | ||
|
|
998608111f | ||
|
|
6018700421 | ||
|
|
c171547037 | ||
|
|
493b7d594d | ||
|
|
4d40c015e4 | ||
|
|
4405bc4182 | ||
|
|
54a0639a6e | ||
|
|
334b286ebf | ||
|
|
c09cb5df3d | ||
|
|
0da3661ff0 | ||
|
|
5a4ccbc066 | ||
|
|
49cbdc22da | ||
|
|
579ed0a985 | ||
|
|
464797858f | ||
|
|
0ff46bf5d0 | ||
|
|
330abe5a2d | ||
|
|
73945899fe | ||
|
|
8227a9ff9c | ||
|
|
f1c70cd896 | ||
|
|
7055292803 | ||
|
|
3503c77699 | ||
|
|
b68c64041e | ||
|
|
36066068d4 | ||
|
|
8ed174e7af | ||
|
|
7336fdf162 | ||
|
|
b5a7dd7d6d | ||
|
|
35918ae966 | ||
|
|
ce01bb59a3 | ||
|
|
18a5a966e3 | ||
|
|
833499ffe8 | ||
|
|
5b7f350ded | ||
|
|
d5fc0e9ce7 | ||
|
|
c6592faeb2 | ||
|
|
dec00cdb55 | ||
|
|
30c7c2d359 | ||
|
|
118bb5ea73 | ||
|
|
35b3d8e33a | ||
|
|
187a6dee17 | ||
|
|
0900a6bf49 | ||
|
|
6cba2e92f2 | ||
|
|
796b131f73 | ||
|
|
bdb8d62cef | ||
|
|
d049c1c244 | ||
|
|
45432a6f29 | ||
|
|
a803bd8033 | ||
|
|
0001bbc966 | ||
|
|
1ebba3ee26 | ||
|
|
fde24258e3 | ||
|
|
59c6d5b1ec | ||
|
|
33694030b7 | ||
|
|
f8f973dac2 | ||
|
|
bffabef556 | ||
|
|
325d96dabb | ||
|
|
b7b1682f42 | ||
|
|
aa2612aeba | ||
|
|
b99704082b | ||
|
|
75d8852bf7 | ||
|
|
0444ac7db9 | ||
|
|
b2684aeefc | ||
|
|
6ccc6244dd | ||
|
|
e618bf40ec | ||
|
|
e3f0a12313 | ||
|
|
687e68db69 | ||
|
|
b10e29aaac | ||
|
|
d0c92b4f8a | ||
|
|
513408f16a | ||
|
|
64326e7c9d | ||
|
|
ce9d853883 | ||
|
|
814a0e7344 | ||
|
|
2c7c0ce29d | ||
|
|
2015d08407 | ||
|
|
9dea5656ad | ||
|
|
daadf7a49b | ||
|
|
2567412121 | ||
|
|
5e4fce248c | ||
|
|
824d2d8205 | ||
|
|
9718895ff9 | ||
|
|
9eec975800 | ||
|
|
5601be87f7 | ||
|
|
440610836a | ||
|
|
4fa536b940 | ||
|
|
af519b93b7 | ||
|
|
2213e3e0cf | ||
|
|
0708942ab8 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
*.pyc
|
||||
configuration.py
|
||||
/netbox/netbox/configuration.py
|
||||
/netbox/static
|
||||
.idea
|
||||
/*.sh
|
||||
!upgrade.sh
|
||||
|
||||
@@ -43,8 +43,9 @@ take some time for someone to address your issue.
|
||||
* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you're
|
||||
requesting is already listed. (Be sure to search closed issues as well, since some feature requests are rejected.) If
|
||||
the feature you'd like to see has already been requested, click "add a reaction" in the top right corner of the issue
|
||||
and add a thumbs up (+1). This ensures that the issue has a better chance of making it onto the roadmap. Also feel free
|
||||
to add a comment with any additional justification for the feature.
|
||||
and add a thumbs up. This ensures that the issue has a better chance of making it onto the roadmap. Also feel free
|
||||
to add a comment with any additional justification for the feature. (However, note that comments with no substance
|
||||
other than a "+1" will be deleted as spam. Please use GitHub's reactions feature to indicate your support.)
|
||||
|
||||
* While suggestions for new features are welcome, it's important to limit the scope of NetBox's feature set to avoid
|
||||
feature creep. For example, the following features would be firmly out of scope for NetBox:
|
||||
|
||||
@@ -5,7 +5,10 @@ WORKDIR /opt/netbox
|
||||
ARG BRANCH=master
|
||||
ARG URL=https://github.com/digitalocean/netbox.git
|
||||
RUN git clone --depth 1 $URL -b $BRANCH . && \
|
||||
pip install gunicorn==17.5 && pip install -r requirements.txt
|
||||
apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev graphviz && \
|
||||
pip install gunicorn==17.5 && \
|
||||
pip install django-auth-ldap && \
|
||||
pip install -r requirements.txt
|
||||
|
||||
ADD docker/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py
|
||||
|
||||
@@ -6,7 +6,7 @@ NetBox runs as a web application atop the [Django](https://www.djangoproject.com
|
||||
|
||||
The complete documentation for Netbox can be found at [Read the Docs](http://netbox.readthedocs.io/en/latest/).
|
||||
|
||||
Questions? Comments? Please join us on IRC in **#netbox** on **irc.freenode.net**!
|
||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), or join us on IRC in **#netbox** on **irc.freenode.net**!
|
||||
|
||||
### Build Status
|
||||
|
||||
|
||||
@@ -26,6 +26,18 @@ BANNER_BOTTOM = BANNER_TOP
|
||||
|
||||
---
|
||||
|
||||
## BASE_PATH
|
||||
|
||||
Default: None
|
||||
|
||||
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set:
|
||||
|
||||
```
|
||||
BASE_PATH = 'netbox/'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DEBUG
|
||||
|
||||
Default: False
|
||||
|
||||
@@ -35,6 +35,8 @@ Each export template is associated with a certain type of object. For instance,
|
||||
|
||||
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable. Typically, you'll want to iterate through this list using a for loop.
|
||||
|
||||
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
|
||||
|
||||
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
|
||||
|
||||
## Example
|
||||
@@ -96,4 +98,4 @@ dist-switch\d
|
||||
access-switch\d+,oob-switch\d+
|
||||
```
|
||||
|
||||
Note that you can combine multiple regexes onto one line using commas. (Commas can only be used for separating regexes; they will not be processed as part of a regex.) The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those.
|
||||
Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those.
|
||||
|
||||
@@ -86,3 +86,9 @@ One IP address can be designated as the network address translation (NAT) IP add
|
||||
A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094). Note that while it is good practice, neither VLAN names nor IDs must be unique within a site. This is to accommodate the fact that many real-world network use less-than-optimal VLAN allocations and may have overlapping VLAN ID assignments in practice.
|
||||
|
||||
Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role.
|
||||
|
||||
---
|
||||
|
||||
# Services
|
||||
|
||||
A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, SSH (TCP/22). A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any IP address.)
|
||||
|
||||
@@ -4,10 +4,10 @@ This guide demonstrates how to build and run NetBox as a Docker container. It as
|
||||
|
||||
To get NetBox up and running:
|
||||
|
||||
```
|
||||
git clone -b master https://github.com/digitalocean/netbox.git
|
||||
cd netbox
|
||||
docker-compose up -d
|
||||
```no-highlight
|
||||
# git clone -b master https://github.com/digitalocean/netbox.git
|
||||
# cd netbox
|
||||
# docker-compose up -d
|
||||
```
|
||||
|
||||
The application will be available on http://localhost/ after a few minutes.
|
||||
|
||||
@@ -7,19 +7,19 @@ built-in Django users in the event of a failure.
|
||||
|
||||
On Ubuntu:
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
sudo apt-get install -y python-dev libldap2-dev libsasl2-dev libssl-dev
|
||||
```
|
||||
|
||||
On CentOS:
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
sudo yum install -y python-devel openldap-devel
|
||||
```
|
||||
|
||||
## Install django-auth-ldap
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
sudo pip install django-auth-ldap
|
||||
```
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
# Installation
|
||||
|
||||
NetBox requires following system dependencies:
|
||||
|
||||
* python2.7
|
||||
* python-dev
|
||||
* python-pip
|
||||
* libxml2-dev
|
||||
* libxslt1-dev
|
||||
* libffi-dev
|
||||
* graphviz
|
||||
* libpq-dev
|
||||
* libssl-dev
|
||||
**Debian/Ubuntu**
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
|
||||
```
|
||||
# sudo apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
|
||||
|
||||
**CentOS/RHEL**
|
||||
|
||||
```no-highlight
|
||||
# yum install -y epel-release
|
||||
# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
|
||||
```
|
||||
|
||||
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
|
||||
@@ -22,7 +19,7 @@ You may opt to install NetBox either from a numbered release or by cloning the m
|
||||
|
||||
Download the [latest stable release](https://github.com/digitalocean/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`.
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
|
||||
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
# cd /opt/
|
||||
@@ -34,20 +31,27 @@ Download the [latest stable release](https://github.com/digitalocean/netbox/rele
|
||||
|
||||
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
|
||||
|
||||
```
|
||||
# mkdir -p /opt/netbox/
|
||||
# cd /opt/netbox/
|
||||
```no-highlight
|
||||
# mkdir -p /opt/netbox/ && cd /opt/netbox/
|
||||
```
|
||||
|
||||
If `git` is not already installed, install it:
|
||||
|
||||
**Debian/Ubuntu**
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y git
|
||||
```
|
||||
# sudo apt-get install -y git
|
||||
|
||||
**CentOS/RHEL**
|
||||
|
||||
```no-highlight
|
||||
# yum install -y git
|
||||
```
|
||||
|
||||
Next, clone the **master** branch of the NetBox GitHub repository into the current directory:
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# git clone -b master https://github.com/digitalocean/netbox.git .
|
||||
Cloning into '.'...
|
||||
remote: Counting objects: 1994, done.
|
||||
@@ -62,21 +66,21 @@ Checking connectivity... done.
|
||||
|
||||
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
|
||||
|
||||
```
|
||||
# sudo pip install -r requirements.txt
|
||||
```no-highlight
|
||||
# pip install -r requirements.txt
|
||||
```
|
||||
|
||||
# Configuration
|
||||
|
||||
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# cd netbox/netbox/
|
||||
# cp configuration.example.py configuration.py
|
||||
```
|
||||
|
||||
Open `configuration.py` with your preferred editor and set the following variables:
|
||||
|
||||
|
||||
* ALLOWED_HOSTS
|
||||
* DATABASE
|
||||
* SECRET_KEY
|
||||
@@ -87,7 +91,7 @@ This is a list of the valid hostnames by which this server can be reached. You m
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
```python
|
||||
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
|
||||
```
|
||||
|
||||
@@ -97,7 +101,7 @@ This parameter holds the database configuration details. You must define the use
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
```python
|
||||
DATABASE = {
|
||||
'NAME': 'netbox', # Database name
|
||||
'USER': 'netbox', # PostgreSQL username
|
||||
@@ -120,7 +124,7 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
|
||||
|
||||
Before NetBox can run, we need to install the database schema. This is done by running `./manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# cd /opt/netbox/netbox/
|
||||
# ./manage.py migrate
|
||||
Operations to perform:
|
||||
@@ -139,18 +143,18 @@ If this step results in a PostgreSQL authentication error, ensure that the usern
|
||||
|
||||
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# ./manage.py createsuperuser
|
||||
Username: admin
|
||||
Email address: admin@example.com
|
||||
Password:
|
||||
Password (again):
|
||||
Password:
|
||||
Password (again):
|
||||
Superuser created successfully.
|
||||
```
|
||||
|
||||
# Collect Static Files
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# ./manage.py collectstatic
|
||||
|
||||
You have requested to collect static files at the destination
|
||||
@@ -171,7 +175,7 @@ NetBox ships with some initial data to help you get started: RIR definitions, co
|
||||
!!! note
|
||||
This step is optional. It's perfectly fine to start using NetBox without using this initial data if you'd rather create everything from scratch.
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# ./manage.py loaddata initial_data
|
||||
Installed 43 object(s) from 4 fixture(s)
|
||||
```
|
||||
@@ -180,7 +184,7 @@ Installed 43 object(s) from 4 fixture(s)
|
||||
|
||||
At this point, NetBox should be able to run. We can verify this by starting a development instance:
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# ./manage.py runserver 0.0.0.0:8000 --insecure
|
||||
Performing system checks...
|
||||
|
||||
@@ -191,7 +195,7 @@ Starting development server at http://0.0.0.0:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
|
||||
Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we 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.
|
||||
Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we 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.**
|
||||
|
||||
!!! 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.
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
NetBox requires a PostgreSQL database to store data. MySQL is not supported, as NetBox leverage's PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).
|
||||
NetBox requires a PostgreSQL database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).)
|
||||
|
||||
# Installation
|
||||
|
||||
The following packages are needed to install PostgreSQL with Python support:
|
||||
**Debian/Ubuntu**
|
||||
|
||||
* postgresql
|
||||
* libpq-dev
|
||||
* python-psycopg2
|
||||
|
||||
```
|
||||
# sudo apt-get install -y postgresql libpq-dev python-psycopg2
|
||||
```no-highlight
|
||||
# apt-get install -y postgresql libpq-dev python-psycopg2
|
||||
```
|
||||
|
||||
# Configuration
|
||||
**CentOS/RHEL**
|
||||
|
||||
```no-highlight
|
||||
# yum install -y postgresql postgresql-server postgresql-devel python-psycopg2
|
||||
# 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/data/pg_hba.conf`. For example:
|
||||
|
||||
```no-highlight
|
||||
host all all 127.0.0.1/32 md5
|
||||
host all all ::1/128 md5
|
||||
```
|
||||
|
||||
Then, start the service:
|
||||
|
||||
```no-highlight
|
||||
# systemctl start postgresql
|
||||
```
|
||||
|
||||
# Database Creation
|
||||
|
||||
At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands.
|
||||
|
||||
!!! danger
|
||||
DO NOT USE THE PASSWORD FROM THE EXAMPLE.
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# sudo -u postgres psql
|
||||
psql (9.3.13)
|
||||
Type "help" for help.
|
||||
@@ -35,7 +51,7 @@ postgres=# \q
|
||||
|
||||
You can verify that authentication works issuing the following command and providing the configured password:
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# psql -U netbox -h localhost -W
|
||||
```
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Download the [latest stable release](https://github.com/digitalocean/netbox/rele
|
||||
|
||||
Download and extract the latest version:
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
|
||||
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
# cd /opt/
|
||||
@@ -17,13 +17,13 @@ Download and extract the latest version:
|
||||
|
||||
Copy the 'configuration.py' you created when first installing to the new version:
|
||||
|
||||
```
|
||||
# cp /opt/netbox-X.Y.Z/configuration.py /opt/netbox/configuration.py
|
||||
```no-highlight
|
||||
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
|
||||
```
|
||||
|
||||
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_config.py /opt/netbox/gunicorn_config.py
|
||||
```
|
||||
|
||||
@@ -31,7 +31,7 @@ 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
|
||||
@@ -42,7 +42,7 @@ This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most
|
||||
|
||||
Once the new code is in place, run the upgrade script (which may need to be run as root depending on how your environment is configured).
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# ./upgrade.sh
|
||||
```
|
||||
|
||||
@@ -56,6 +56,6 @@ This script:
|
||||
|
||||
Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `supervisorctl`:
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# sudo supervisorctl restart netbox
|
||||
```
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
# Web Server Installation
|
||||
|
||||
We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence.
|
||||
We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence.
|
||||
|
||||
```
|
||||
# sudo apt-get install -y gunicorn supervisor
|
||||
!!! info
|
||||
Only Debian/Ubuntu instructions are provided here, but the installation process for CentOS/RHEL does not differ much. Please consult the documentation for those distributions for details.
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y gunicorn supervisor
|
||||
```
|
||||
|
||||
## Option A: nginx
|
||||
|
||||
The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
|
||||
|
||||
```
|
||||
# sudo apt-get install -y nginx
|
||||
```no-highlight
|
||||
# apt-get install -y nginx
|
||||
```
|
||||
|
||||
Once nginx is installed, proceed with the following configuration:
|
||||
Once nginx is installed, save the following configuration 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`.)
|
||||
|
||||
```
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
@@ -38,32 +41,31 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
Save this configuration to `/etc/nginx/sites-available/netbox`. Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
|
||||
Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# cd /etc/nginx/sites-enabled/
|
||||
# rm default
|
||||
# ln -s /etc/nginx/sites-available/netbox
|
||||
# ln -s /etc/nginx/sites-available/netbox
|
||||
```
|
||||
|
||||
Restart the nginx service to use the new configuration.
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# service nginx restart
|
||||
* Restarting nginx nginx
|
||||
```
|
||||
|
||||
To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-14-04).
|
||||
|
||||
## Option B: Apache
|
||||
|
||||
```
|
||||
# sudo apt-get install -y apache2
|
||||
```no-highlight
|
||||
# apt-get install -y apache2
|
||||
```
|
||||
|
||||
Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately):
|
||||
|
||||
```
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ProxyPreserveHost On
|
||||
|
||||
@@ -88,7 +90,7 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
|
||||
|
||||
Save the contents of the above example in `/etc/apache2/sites-available/netbox.conf`, enable the `proxy` and `proxy_http` modules, and reload Apache:
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# a2enmod proxy
|
||||
# a2enmod proxy_http
|
||||
# a2ensite netbox
|
||||
@@ -99,9 +101,9 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https
|
||||
|
||||
# gunicorn Installation
|
||||
|
||||
Save the following configuration file in the root netbox installation path (in this example, `/opt/netbox/`) as `gunicorn_config.py`. Be sure to verify the location of the gunicorn executable (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed.
|
||||
Save the following configuration in the root netbox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
command = '/usr/bin/gunicorn'
|
||||
pythonpath = '/opt/netbox/netbox'
|
||||
bind = '127.0.0.1:8001'
|
||||
@@ -111,18 +113,18 @@ user = 'www-data'
|
||||
|
||||
# supervisord Installation
|
||||
|
||||
Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed.
|
||||
Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
[program:netbox]
|
||||
command = gunicorn -c /opt/netbox/gunicorn_config.py netbox.wsgi
|
||||
directory = /opt/netbox/netbox/
|
||||
user = www-data
|
||||
```
|
||||
|
||||
Finally, restart the supervisor service to detect and run the gunicorn service:
|
||||
Then, restart the supervisor service to detect and run the gunicorn service:
|
||||
|
||||
```
|
||||
```no-highlight
|
||||
# service supervisor restart
|
||||
```
|
||||
|
||||
|
||||
@@ -21,11 +21,9 @@ class CircuitTypeAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Circuit)
|
||||
class CircuitAdmin(admin.ModelAdmin):
|
||||
list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed_human',
|
||||
'upstream_speed_human', 'commit_rate_human', 'xconnect_id']
|
||||
list_display = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate_human']
|
||||
list_filter = ['provider', 'type', 'tenant']
|
||||
exclude = ['interface']
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super(CircuitAdmin, self).get_queryset(request)
|
||||
return qs.select_related('provider', 'type', 'tenant', 'site')
|
||||
return qs.select_related('provider', 'type', 'tenant')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from circuits.models import Provider, CircuitType, Circuit
|
||||
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
||||
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
|
||||
from extras.api.serializers import CustomFieldSerializer
|
||||
from tenancy.api.serializers import TenantNestedSerializer
|
||||
@@ -45,17 +45,24 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer):
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
provider = ProviderNestedSerializer()
|
||||
type = CircuitTypeNestedSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
class CircuitTerminationSerializer(serializers.ModelSerializer):
|
||||
site = SiteNestedSerializer()
|
||||
interface = InterfaceNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = ['id', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info']
|
||||
|
||||
|
||||
class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
provider = ProviderNestedSerializer()
|
||||
type = CircuitTypeNestedSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
terminations = CircuitTerminationSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed',
|
||||
'upstream_speed', 'commit_rate', 'xconnect_id', 'comments', 'custom_fields']
|
||||
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'comments', 'terminations', 'custom_fields']
|
||||
|
||||
|
||||
class CircuitNestedSerializer(CircuitSerializer):
|
||||
|
||||
@@ -43,7 +43,7 @@ class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List circuits (filterable)
|
||||
"""
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.CircuitSerializer
|
||||
filter_class = CircuitFilter
|
||||
@@ -53,6 +53,6 @@ class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single circuit
|
||||
"""
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.CircuitSerializer
|
||||
|
||||
@@ -5,6 +5,8 @@ from django.db.models import Q
|
||||
from dcim.models import Site
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NullableModelMultipleChoiceFilter
|
||||
|
||||
from .models import Provider, Circuit, CircuitType
|
||||
|
||||
|
||||
@@ -14,12 +16,12 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
label='Search',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='circuits__site',
|
||||
name='circuits__terminations__site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='circuits__site',
|
||||
name='circuits__terminations__site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
@@ -27,7 +29,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ['q', 'name', 'account', 'asn']
|
||||
fields = ['name', 'account', 'asn']
|
||||
|
||||
def search(self, queryset, value):
|
||||
return queryset.filter(
|
||||
@@ -48,7 +50,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
label='Provider (ID)',
|
||||
)
|
||||
provider = django_filters.ModelMultipleChoiceFilter(
|
||||
name='provider',
|
||||
name='provider__slug',
|
||||
queryset=Provider.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Provider (slug)',
|
||||
@@ -59,29 +61,29 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
label='Circuit type (ID)',
|
||||
)
|
||||
type = django_filters.ModelMultipleChoiceFilter(
|
||||
name='type',
|
||||
name='type__slug',
|
||||
queryset=CircuitType.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Circuit type (slug)',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
name='terminations__site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
name='terminations__site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
@@ -89,12 +91,11 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['q', 'provider_id', 'provider', 'type_id', 'type', 'site_id', 'site', 'interface', 'install_date']
|
||||
fields = ['install_date']
|
||||
|
||||
def search(self, queryset, value):
|
||||
return queryset.filter(
|
||||
Q(cid__icontains=value) |
|
||||
Q(xconnect_id__icontains=value) |
|
||||
Q(pp_info__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
@@ -3,13 +3,13 @@ from django.db.models import Count
|
||||
|
||||
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
|
||||
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||
from tenancy.forms import bulkedit_tenant_choices
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField,
|
||||
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea,
|
||||
SlugField,
|
||||
)
|
||||
|
||||
from .models import Circuit, CircuitType, Provider
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
|
||||
|
||||
#
|
||||
@@ -43,7 +43,7 @@ class ProviderFromCSVForm(forms.ModelForm):
|
||||
fields = ['name', 'slug', 'asn', 'account', 'portal_url']
|
||||
|
||||
|
||||
class ProviderImportForm(BulkImportForm, BootstrapMixin):
|
||||
class ProviderImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=ProviderFromCSVForm)
|
||||
|
||||
|
||||
@@ -54,25 +54,22 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
portal_url = forms.URLField(required=False, label='Portal')
|
||||
noc_contact = forms.CharField(required=False, widget=SmallTextarea, label='NOC contact')
|
||||
admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact')
|
||||
comments = CommentField()
|
||||
comments = CommentField(widget=SmallTextarea)
|
||||
|
||||
|
||||
def provider_site_choices():
|
||||
site_choices = Site.objects.all()
|
||||
return [(s.slug, s.name) for s in site_choices]
|
||||
class Meta:
|
||||
nullable_fields = ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
|
||||
|
||||
|
||||
class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Provider
|
||||
site = forms.MultipleChoiceField(required=False, choices=provider_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
|
||||
|
||||
|
||||
#
|
||||
# Circuit types
|
||||
#
|
||||
|
||||
class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
|
||||
class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
@@ -85,41 +82,96 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
|
||||
#
|
||||
|
||||
class CircuitForm(BootstrapMixin, CustomFieldForm):
|
||||
comments = CommentField()
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'comments']
|
||||
help_texts = {
|
||||
'cid': "Unique circuit ID",
|
||||
'install_date': "Format: YYYY-MM-DD",
|
||||
'commit_rate': "Committed rate",
|
||||
}
|
||||
|
||||
|
||||
class CircuitFromCSVForm(forms.ModelForm):
|
||||
provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Provider not found.'})
|
||||
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid circuit type.'})
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate']
|
||||
|
||||
|
||||
class CircuitImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=CircuitFromCSVForm)
|
||||
|
||||
|
||||
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
|
||||
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
|
||||
comments = CommentField(widget=SmallTextarea)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['tenant', 'commit_rate', 'comments']
|
||||
|
||||
|
||||
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Circuit
|
||||
type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
|
||||
to_field_name='slug')
|
||||
provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
|
||||
to_field_name='slug')
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
|
||||
to_field_name='slug')
|
||||
|
||||
|
||||
#
|
||||
# Circuit terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
|
||||
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
|
||||
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
attrs={'filter-for': 'device'}))
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
|
||||
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
|
||||
attrs={'filter-for': 'interface'}))
|
||||
display_field='display_name', attrs={'filter-for': 'interface'}))
|
||||
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
|
||||
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
|
||||
)
|
||||
interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface',
|
||||
widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
|
||||
disabled_indicator='is_connected'))
|
||||
comments = CommentField()
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
|
||||
'port_speed', 'upstream_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
|
||||
]
|
||||
model = CircuitTermination
|
||||
fields = ['term_side', 'site', 'rack', 'device', 'livesearch', 'interface', 'port_speed', 'upstream_speed',
|
||||
'xconnect_id', 'pp_info']
|
||||
help_texts = {
|
||||
'cid': "Unique circuit ID",
|
||||
'install_date': "Format: YYYY-MM-DD",
|
||||
'port_speed': "Physical circuit speed",
|
||||
'commit_rate': "Commited rate",
|
||||
'xconnect_id': "ID of the local cross-connect",
|
||||
'pp_info': "Patch panel ID and port number(s)"
|
||||
}
|
||||
widgets = {
|
||||
'term_side': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(CircuitForm, self).__init__(*args, **kwargs)
|
||||
super(CircuitTerminationForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# If this circuit has been assigned to an interface, initialize rack and device
|
||||
# If an interface has been assigned, initialize rack and device
|
||||
if self.instance.interface:
|
||||
self.initial['rack'] = self.instance.interface.device.rack
|
||||
self.initial['device'] = self.instance.interface.device
|
||||
@@ -143,11 +195,13 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
|
||||
# Limit interface choices
|
||||
if self.is_bound and self.data.get('device'):
|
||||
interfaces = Interface.objects.filter(device=self.data['device'])\
|
||||
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
|
||||
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
|
||||
'connected_as_b')
|
||||
self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
|
||||
elif self.initial.get('device'):
|
||||
interfaces = Interface.objects.filter(device=self.initial['device'])\
|
||||
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
|
||||
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
|
||||
'connected_as_b')
|
||||
self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
|
||||
else:
|
||||
interfaces = []
|
||||
@@ -157,64 +211,3 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
|
||||
'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
|
||||
}) for iface in interfaces
|
||||
]
|
||||
|
||||
|
||||
class CircuitFromCSVForm(forms.ModelForm):
|
||||
provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Provider not found.'})
|
||||
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid circuit type.'})
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Site not found.'})
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'upstream_speed',
|
||||
'commit_rate', 'xconnect_id', 'pp_info']
|
||||
|
||||
|
||||
class CircuitImportForm(BulkImportForm, BootstrapMixin):
|
||||
csv = CSVDataField(csv_form=CircuitFromCSVForm)
|
||||
|
||||
|
||||
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
|
||||
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
|
||||
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
|
||||
comments = CommentField()
|
||||
|
||||
|
||||
def circuit_type_choices():
|
||||
type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits'))
|
||||
return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in type_choices]
|
||||
|
||||
|
||||
def circuit_provider_choices():
|
||||
provider_choices = Provider.objects.annotate(circuit_count=Count('circuits'))
|
||||
return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
|
||||
|
||||
|
||||
def circuit_tenant_choices():
|
||||
tenant_choices = Tenant.objects.annotate(circuit_count=Count('circuits'))
|
||||
return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in tenant_choices]
|
||||
|
||||
|
||||
def circuit_site_choices():
|
||||
site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
|
||||
return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
|
||||
|
||||
|
||||
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Circuit
|
||||
type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
|
||||
provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
tenant = forms.MultipleChoiceField(required=False, choices=circuit_tenant_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
|
||||
99
netbox/circuits/migrations/0006_terminations.py
Normal file
99
netbox/circuits/migrations/0006_terminations.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-12-13 16:30
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def circuits_to_terms(apps, schema_editor):
|
||||
Circuit = apps.get_model('circuits', 'Circuit')
|
||||
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
|
||||
for c in Circuit.objects.all():
|
||||
CircuitTermination(
|
||||
circuit=c,
|
||||
term_side=b'A',
|
||||
site=c.site,
|
||||
interface=c.interface,
|
||||
port_speed=c.port_speed,
|
||||
upstream_speed=c.upstream_speed,
|
||||
xconnect_id=c.xconnect_id,
|
||||
pp_info=c.pp_info,
|
||||
).save()
|
||||
|
||||
|
||||
def terms_to_circuits(apps, schema_editor):
|
||||
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
|
||||
for ct in CircuitTermination.objects.filter(term_side='A'):
|
||||
c = ct.circuit
|
||||
c.site = ct.site
|
||||
c.interface = ct.interface
|
||||
c.port_speed = ct.port_speed
|
||||
c.upstream_speed = ct.upstream_speed
|
||||
c.xconnect_id = ct.xconnect_id
|
||||
c.pp_info = ct.pp_info
|
||||
c.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0022_color_names_to_rgb'),
|
||||
('circuits', '0005_circuit_add_upstream_speed'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CircuitTermination',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('term_side', models.CharField(choices=[(b'A', b'A'), (b'Z', b'Z')], max_length=1,
|
||||
verbose_name='Termination')),
|
||||
('port_speed', models.PositiveIntegerField(verbose_name=b'Port speed (Kbps)')),
|
||||
('upstream_speed',
|
||||
models.PositiveIntegerField(blank=True, help_text=b'Upstream speed, if different from port speed',
|
||||
null=True, verbose_name=b'Upstream speed (Kbps)')),
|
||||
('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name=b'Cross-connect ID')),
|
||||
('pp_info', models.CharField(blank=True, max_length=100, verbose_name=b'Patch panel/port(s)')),
|
||||
('circuit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations',
|
||||
to='circuits.Circuit')),
|
||||
('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='circuit_termination', to='dcim.Interface')),
|
||||
('site',
|
||||
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations',
|
||||
to='dcim.Site')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['circuit', 'term_side'],
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='circuittermination',
|
||||
unique_together=set([('circuit', 'term_side')]),
|
||||
),
|
||||
migrations.RunPython(circuits_to_terms, terms_to_circuits),
|
||||
migrations.RemoveField(
|
||||
model_name='circuit',
|
||||
name='interface',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='circuit',
|
||||
name='port_speed',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='circuit',
|
||||
name='pp_info',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='circuit',
|
||||
name='site',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='circuit',
|
||||
name='upstream_speed',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='circuit',
|
||||
name='xconnect_id',
|
||||
),
|
||||
]
|
||||
@@ -3,12 +3,35 @@ from django.core.urlresolvers import reverse
|
||||
from django.db import models
|
||||
|
||||
from dcim.fields import ASNField
|
||||
from dcim.models import Site, Interface
|
||||
from extras.models import CustomFieldModel, CustomFieldValue
|
||||
from tenancy.models import Tenant
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
|
||||
|
||||
TERM_SIDE_A = 'A'
|
||||
TERM_SIDE_Z = 'Z'
|
||||
TERM_SIDE_CHOICES = (
|
||||
(TERM_SIDE_A, 'A'),
|
||||
(TERM_SIDE_Z, 'Z'),
|
||||
)
|
||||
|
||||
|
||||
def humanize_speed(speed):
|
||||
"""
|
||||
Humanize speeds given in Kbps (e.g. 10000000 becomes '10 Gbps')
|
||||
"""
|
||||
if speed >= 1000000000 and speed % 1000000000 == 0:
|
||||
return '{} Tbps'.format(speed / 1000000000)
|
||||
elif speed >= 1000000 and speed % 1000000 == 0:
|
||||
return '{} Gbps'.format(speed / 1000000)
|
||||
elif speed >= 1000 and speed % 1000 == 0:
|
||||
return '{} Mbps'.format(speed / 1000)
|
||||
elif speed >= 1000:
|
||||
return '{} Mbps'.format(float(speed) / 1000)
|
||||
else:
|
||||
return '{} Kbps'.format(speed)
|
||||
|
||||
|
||||
class Provider(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
|
||||
@@ -71,15 +94,8 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
|
||||
type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
|
||||
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
|
||||
site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT)
|
||||
interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
|
||||
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
|
||||
port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
|
||||
upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
|
||||
help_text='Upstream speed, if different from port speed')
|
||||
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
|
||||
xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
|
||||
pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
@@ -99,42 +115,61 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.provider.name,
|
||||
self.type.name,
|
||||
self.tenant.name if self.tenant else '',
|
||||
self.site.name,
|
||||
self.install_date.isoformat() if self.install_date else '',
|
||||
str(self.port_speed),
|
||||
str(self.upstream_speed),
|
||||
str(self.commit_rate) if self.commit_rate else '',
|
||||
self.xconnect_id,
|
||||
self.pp_info,
|
||||
])
|
||||
|
||||
def _humanize_speed(self, speed):
|
||||
"""
|
||||
Humanize speeds given in Kbps (e.g. 10000000 becomes '10 Gbps')
|
||||
"""
|
||||
if speed >= 1000000000 and speed % 1000000000 == 0:
|
||||
return '{} Tbps'.format(speed / 1000000000)
|
||||
elif speed >= 1000000 and speed % 1000000 == 0:
|
||||
return '{} Gbps'.format(speed / 1000000)
|
||||
elif speed >= 1000 and speed % 1000 == 0:
|
||||
return '{} Mbps'.format(speed / 1000)
|
||||
elif speed >= 1000:
|
||||
return '{} Mbps'.format(float(speed) / 1000)
|
||||
else:
|
||||
return '{} Kbps'.format(speed)
|
||||
def _get_termination(self, side):
|
||||
for ct in self.terminations.all():
|
||||
if ct.term_side == side:
|
||||
return ct
|
||||
return None
|
||||
|
||||
@property
|
||||
def termination_a(self):
|
||||
return self._get_termination('A')
|
||||
|
||||
@property
|
||||
def termination_z(self):
|
||||
return self._get_termination('Z')
|
||||
|
||||
def commit_rate_human(self):
|
||||
return '' if not self.commit_rate else humanize_speed(self.commit_rate)
|
||||
commit_rate_human.admin_order_field = 'commit_rate'
|
||||
|
||||
|
||||
class CircuitTermination(models.Model):
|
||||
circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
|
||||
term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
|
||||
site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT)
|
||||
interface = models.OneToOneField('dcim.Interface', related_name='circuit_termination', blank=True, null=True)
|
||||
port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
|
||||
upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
|
||||
help_text='Upstream speed, if different from port speed')
|
||||
xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
|
||||
pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
|
||||
|
||||
class Meta:
|
||||
ordering = ['circuit', 'term_side']
|
||||
unique_together = ['circuit', 'term_side']
|
||||
|
||||
def __unicode__(self):
|
||||
return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
|
||||
|
||||
def get_parent_url(self):
|
||||
return self.circuit.get_absolute_url()
|
||||
|
||||
def get_peer_termination(self):
|
||||
peer_side = 'Z' if self.term_side == 'A' else 'A'
|
||||
try:
|
||||
return CircuitTermination.objects.select_related('site').get(circuit=self.circuit, term_side=peer_side)
|
||||
except CircuitTermination.DoesNotExist:
|
||||
return None
|
||||
|
||||
def port_speed_human(self):
|
||||
return self._humanize_speed(self.port_speed)
|
||||
return humanize_speed(self.port_speed)
|
||||
port_speed_human.admin_order_field = 'port_speed'
|
||||
|
||||
def upstream_speed_human(self):
|
||||
if not self.upstream_speed:
|
||||
return ''
|
||||
return self._humanize_speed(self.upstream_speed)
|
||||
return '' if not self.upstream_speed else humanize_speed(self.upstream_speed)
|
||||
upstream_speed_human.admin_order_field = 'upstream_speed'
|
||||
|
||||
def commit_rate_human(self):
|
||||
if not self.commit_rate:
|
||||
return ''
|
||||
return self._humanize_speed(self.commit_rate)
|
||||
commit_rate_human.admin_order_field = 'commit_rate'
|
||||
|
||||
@@ -56,12 +56,13 @@ class CircuitTable(BaseTable):
|
||||
type = tables.Column(verbose_name='Type')
|
||||
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
port_speed = tables.Column(accessor=Accessor('port_speed_human'), order_by=Accessor('port_speed'),
|
||||
verbose_name='Port Speed')
|
||||
a_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_a.site'), orderable=False,
|
||||
args=[Accessor('termination_a.site.slug')])
|
||||
z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
|
||||
args=[Accessor('termination_z.site.slug')])
|
||||
commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'),
|
||||
verbose_name='Commit Rate')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Circuit
|
||||
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'site', 'port_speed', 'commit_rate')
|
||||
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'commit_rate')
|
||||
|
||||
@@ -30,5 +30,11 @@ urlpatterns = [
|
||||
url(r'^circuits/(?P<pk>\d+)/$', views.circuit, name='circuit'),
|
||||
url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
|
||||
url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
|
||||
url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
|
||||
|
||||
# Circuit terminations
|
||||
url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
|
||||
url(r'^circuit-terminations/(?P<pk>\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
|
||||
url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
|
||||
|
||||
]
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
|
||||
from . import filters, forms, tables
|
||||
from .models import Circuit, CircuitType, Provider
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
|
||||
|
||||
|
||||
#
|
||||
@@ -27,7 +31,7 @@ class ProviderListView(ObjectListView):
|
||||
def provider(request, slug):
|
||||
|
||||
provider = get_object_or_404(Provider, slug=slug)
|
||||
circuits = Circuit.objects.filter(provider=provider).select_related('site', 'interface__device')
|
||||
circuits = Circuit.objects.filter(provider=provider)
|
||||
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
|
||||
|
||||
return render(request, 'circuits/provider.html', {
|
||||
@@ -42,7 +46,7 @@ class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = Provider
|
||||
form_class = forms.ProviderForm
|
||||
template_name = 'circuits/provider_edit.html'
|
||||
cancel_url = 'circuits:provider_list'
|
||||
obj_list_url = 'circuits:provider_list'
|
||||
|
||||
|
||||
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
@@ -88,8 +92,8 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'circuits.change_circuittype'
|
||||
model = CircuitType
|
||||
form_class = forms.CircuitTypeForm
|
||||
success_url = 'circuits:circuittype_list'
|
||||
cancel_url = 'circuits:circuittype_list'
|
||||
obj_list_url = 'circuits:circuittype_list'
|
||||
use_obj_view = False
|
||||
|
||||
|
||||
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@@ -103,7 +107,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class CircuitListView(ObjectListView):
|
||||
queryset = Circuit.objects.select_related('provider', 'type', 'tenant', 'site')
|
||||
queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
|
||||
filter = filters.CircuitFilter
|
||||
filter_form = forms.CircuitFilterForm
|
||||
table = tables.CircuitTable
|
||||
@@ -114,9 +118,13 @@ class CircuitListView(ObjectListView):
|
||||
def circuit(request, pk):
|
||||
|
||||
circuit = get_object_or_404(Circuit, pk=pk)
|
||||
termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first()
|
||||
termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first()
|
||||
|
||||
return render(request, 'circuits/circuit.html', {
|
||||
'circuit': circuit,
|
||||
'termination_a': termination_a,
|
||||
'termination_z': termination_z,
|
||||
})
|
||||
|
||||
|
||||
@@ -124,9 +132,9 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'circuits.change_circuit'
|
||||
model = Circuit
|
||||
form_class = forms.CircuitForm
|
||||
fields_initial = ['site']
|
||||
fields_initial = ['provider']
|
||||
template_name = 'circuits/circuit_edit.html'
|
||||
cancel_url = 'circuits:circuit_list'
|
||||
obj_list_url = 'circuits:circuit_list'
|
||||
|
||||
|
||||
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
@@ -155,3 +163,71 @@ class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'circuits.delete_circuit'
|
||||
cls = Circuit
|
||||
default_redirect_url = 'circuits:circuit_list'
|
||||
|
||||
|
||||
@permission_required('circuits.change_circuittermination')
|
||||
def circuit_terminations_swap(request, pk):
|
||||
|
||||
circuit = get_object_or_404(Circuit, pk=pk)
|
||||
termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first()
|
||||
termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first()
|
||||
if not termination_a and not termination_z:
|
||||
messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
|
||||
return redirect('circuits:circuit', pk=circuit.pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ConfirmationForm(request.POST)
|
||||
if form.is_valid():
|
||||
if termination_a and termination_z:
|
||||
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
|
||||
with transaction.atomic():
|
||||
termination_a.term_side = '_'
|
||||
termination_a.save()
|
||||
termination_z.term_side = 'A'
|
||||
termination_z.save()
|
||||
termination_a.term_side = 'Z'
|
||||
termination_a.save()
|
||||
elif termination_a:
|
||||
termination_a.term_side = 'Z'
|
||||
termination_a.save()
|
||||
else:
|
||||
termination_z.term_side = 'A'
|
||||
termination_z.save()
|
||||
messages.success(request, "Swapped terminations for circuit {}.".format(circuit))
|
||||
return redirect('circuits:circuit', pk=circuit.pk)
|
||||
|
||||
else:
|
||||
form = ConfirmationForm()
|
||||
|
||||
return render(request, 'circuits/circuit_terminations_swap.html', {
|
||||
'circuit': circuit,
|
||||
'termination_a': termination_a,
|
||||
'termination_z': termination_z,
|
||||
'form': form,
|
||||
'panel_class': 'default',
|
||||
'button_class': 'primary',
|
||||
'cancel_url': circuit.get_absolute_url(),
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Circuit terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'circuits.change_circuittermination'
|
||||
model = CircuitTermination
|
||||
form_class = forms.CircuitTerminationForm
|
||||
fields_initial = ['term_side']
|
||||
template_name = 'circuits/circuittermination_edit.html'
|
||||
|
||||
def alter_obj(self, obj, args, kwargs):
|
||||
if 'circuit' in kwargs:
|
||||
circuit = get_object_or_404(Circuit, pk=kwargs['circuit'])
|
||||
obj.circuit = circuit
|
||||
return obj
|
||||
|
||||
|
||||
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'circuits.delete_circuittermination'
|
||||
model = CircuitTermination
|
||||
|
||||
@@ -183,10 +183,14 @@ class DeviceAdmin(admin.ModelAdmin):
|
||||
DeviceBayAdmin,
|
||||
ModuleAdmin,
|
||||
]
|
||||
list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag',
|
||||
list_display = ['display_name', 'device_type_full_name', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag',
|
||||
'serial']
|
||||
list_filter = ['device_role']
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super(DeviceAdmin, self).get_queryset(request)
|
||||
return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip4', 'primary_ip6', 'rack')
|
||||
|
||||
def device_type_full_name(self, obj):
|
||||
return obj.device_type.full_name
|
||||
device_type_full_name.short_description = 'Device type'
|
||||
|
||||
@@ -5,6 +5,7 @@ from dcim.models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType,
|
||||
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
|
||||
SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
|
||||
)
|
||||
from extras.api.serializers import CustomFieldSerializer
|
||||
from tenancy.api.serializers import TenantNestedSerializer
|
||||
@@ -19,8 +20,9 @@ class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
|
||||
'custom_fields', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
|
||||
fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
||||
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
|
||||
'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
|
||||
|
||||
|
||||
class SiteNestedSerializer(SiteSerializer):
|
||||
@@ -78,7 +80,7 @@ class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
|
||||
'u_height', 'comments', 'custom_fields']
|
||||
'u_height', 'desc_units', 'comments', 'custom_fields']
|
||||
|
||||
|
||||
class RackNestedSerializer(RackSerializer):
|
||||
@@ -93,7 +95,7 @@ class RackDetailSerializer(RackSerializer):
|
||||
|
||||
class Meta(RackSerializer.Meta):
|
||||
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
|
||||
'u_height', 'comments', 'custom_fields', 'front_units', 'rear_units']
|
||||
'u_height', 'desc_units', 'comments', 'custom_fields', 'front_units', 'rear_units']
|
||||
|
||||
def get_front_units(self, obj):
|
||||
units = obj.get_rack_units(face=RACK_FACE_FRONT)
|
||||
@@ -129,13 +131,21 @@ class ManufacturerNestedSerializer(ManufacturerSerializer):
|
||||
# Device types
|
||||
#
|
||||
|
||||
class DeviceTypeSerializer(serializers.ModelSerializer):
|
||||
class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
manufacturer = ManufacturerNestedSerializer()
|
||||
subdevice_role = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||
'is_console_server', 'is_pdu', 'is_network_device']
|
||||
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields']
|
||||
|
||||
def get_subdevice_role(self, obj):
|
||||
return {
|
||||
SUBDEVICE_ROLE_PARENT: 'parent',
|
||||
SUBDEVICE_ROLE_CHILD: 'child',
|
||||
None: None,
|
||||
}[obj.subdevice_role]
|
||||
|
||||
|
||||
class DeviceTypeNestedSerializer(DeviceTypeSerializer):
|
||||
@@ -188,8 +198,9 @@ class DeviceTypeDetailSerializer(DeviceTypeSerializer):
|
||||
|
||||
class Meta(DeviceTypeSerializer.Meta):
|
||||
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||
'is_console_server', 'is_pdu', 'is_network_device', 'console_port_templates', 'cs_port_templates',
|
||||
'power_port_templates', 'power_outlet_templates', 'interface_templates']
|
||||
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields',
|
||||
'console_port_templates', 'cs_port_templates', 'power_port_templates', 'power_outlet_templates',
|
||||
'interface_templates']
|
||||
|
||||
|
||||
#
|
||||
@@ -372,7 +383,7 @@ class InterfaceNestedSerializer(InterfaceSerializer):
|
||||
|
||||
|
||||
class InterfaceDetailSerializer(InterfaceSerializer):
|
||||
connected_interface = InterfaceSerializer(source='get_connected_interface')
|
||||
connected_interface = InterfaceSerializer()
|
||||
|
||||
class Meta(InterfaceSerializer.Meta):
|
||||
fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected',
|
||||
|
||||
@@ -118,7 +118,11 @@ class RackUnitListView(APIView):
|
||||
|
||||
rack = get_object_or_404(Rack, pk=pk)
|
||||
face = request.GET.get('face', 0)
|
||||
elevation = rack.get_rack_units(face)
|
||||
try:
|
||||
exclude = int(request.GET.get('exclude', None))
|
||||
except ValueError:
|
||||
exclude = None
|
||||
elevation = rack.get_rack_units(face, exclude)
|
||||
|
||||
# Serialize Devices within the rack elevation
|
||||
for u in elevation:
|
||||
@@ -152,20 +156,20 @@ class ManufacturerDetailView(generics.RetrieveAPIView):
|
||||
# Device Types
|
||||
#
|
||||
|
||||
class DeviceTypeListView(generics.ListAPIView):
|
||||
class DeviceTypeListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List device types (filterable)
|
||||
"""
|
||||
queryset = DeviceType.objects.select_related('manufacturer')
|
||||
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.DeviceTypeSerializer
|
||||
filter_class = filters.DeviceTypeFilter
|
||||
|
||||
|
||||
class DeviceTypeDetailView(generics.RetrieveAPIView):
|
||||
class DeviceTypeDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single device type
|
||||
"""
|
||||
queryset = DeviceType.objects.select_related('manufacturer')
|
||||
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.DeviceTypeDetailSerializer
|
||||
|
||||
|
||||
@@ -451,7 +455,7 @@ class RelatedConnectionsView(APIView):
|
||||
peer_iface = Interface.objects.get(device__name=peer_device, name=peer_interface)
|
||||
except Interface.DoesNotExist:
|
||||
raise Http404()
|
||||
local_iface = peer_iface.get_connected_interface()
|
||||
local_iface = peer_iface.connected_interface
|
||||
if local_iface:
|
||||
device = local_iface.device
|
||||
else:
|
||||
@@ -484,7 +488,7 @@ class RelatedConnectionsView(APIView):
|
||||
|
||||
# Interface connections
|
||||
interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b',
|
||||
'circuit')
|
||||
'circuit_termination')
|
||||
for iface in interfaces:
|
||||
data = serializers.InterfaceDetailSerializer(instance=iface).data
|
||||
del(data['device'])
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import django_filters
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NullableModelMultipleChoiceFilter
|
||||
from .models import (
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
|
||||
Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
|
||||
@@ -15,12 +17,12 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
action='search',
|
||||
label='Search',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -48,7 +50,7 @@ class RackGroupFilter(django_filters.FilterSet):
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
name='site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
@@ -56,7 +58,6 @@ class RackGroupFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = ['site_id', 'site']
|
||||
|
||||
|
||||
class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
@@ -70,39 +71,39 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
name='site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
group_id = NullableModelMultipleChoiceFilter(
|
||||
name='group',
|
||||
queryset=RackGroup.objects.all(),
|
||||
label='Group (ID)',
|
||||
)
|
||||
group = django_filters.ModelMultipleChoiceFilter(
|
||||
group = NullableModelMultipleChoiceFilter(
|
||||
name='group',
|
||||
queryset=RackGroup.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Group',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
role_id = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=RackRole.objects.all(),
|
||||
label='Role (ID)',
|
||||
)
|
||||
role = django_filters.ModelMultipleChoiceFilter(
|
||||
role = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=RackRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -111,7 +112,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['q', 'site_id', 'site', 'u_height']
|
||||
fields = ['u_height']
|
||||
|
||||
def search(self, queryset, value):
|
||||
return queryset.filter(
|
||||
@@ -121,14 +122,18 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
)
|
||||
|
||||
|
||||
class DeviceTypeFilter(django_filters.FilterSet):
|
||||
class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
)
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='manufacturer',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
label='Manufacturer (ID)',
|
||||
)
|
||||
manufacturer = django_filters.ModelMultipleChoiceFilter(
|
||||
name='manufacturer',
|
||||
name='manufacturer__slug',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Manufacturer (slug)',
|
||||
@@ -136,8 +141,16 @@ class DeviceTypeFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = ['manufacturer_id', 'manufacturer', 'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu',
|
||||
'is_network_device']
|
||||
fields = ['model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device',
|
||||
'subdevice_role']
|
||||
|
||||
def search(self, queryset, value):
|
||||
return queryset.filter(
|
||||
Q(manufacturer__name__icontains=value) |
|
||||
Q(model__icontains=value) |
|
||||
Q(part_number__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
@@ -145,13 +158,17 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
action='search',
|
||||
label='Search',
|
||||
)
|
||||
mac_address = django_filters.MethodFilter(
|
||||
action='_mac_address',
|
||||
label='MAC address',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='rack__site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='rack__site',
|
||||
name='rack__site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site name (slug)',
|
||||
@@ -172,17 +189,17 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
label='Role (ID)',
|
||||
)
|
||||
role = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device_role',
|
||||
name='device_role__slug',
|
||||
queryset=DeviceRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -199,23 +216,23 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
label='Manufacturer (ID)',
|
||||
)
|
||||
manufacturer = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device_type__manufacturer',
|
||||
name='device_type__manufacturer__slug',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Manufacturer (slug)',
|
||||
)
|
||||
model = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device_type',
|
||||
name='device_type__slug',
|
||||
queryset=DeviceType.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Device model (slug)',
|
||||
)
|
||||
platform_id = django_filters.ModelMultipleChoiceFilter(
|
||||
platform_id = NullableModelMultipleChoiceFilter(
|
||||
name='platform',
|
||||
queryset=Platform.objects.all(),
|
||||
label='Platform (ID)',
|
||||
)
|
||||
platform = django_filters.ModelMultipleChoiceFilter(
|
||||
platform = NullableModelMultipleChoiceFilter(
|
||||
name='platform',
|
||||
queryset=Platform.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -240,9 +257,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['q', 'name', 'serial', 'asset_tag', 'site_id', 'site', 'rack_id', 'role_id', 'role', 'device_type_id',
|
||||
'manufacturer_id', 'manufacturer', 'model', 'platform_id', 'platform', 'status', 'is_console_server',
|
||||
'is_pdu', 'is_network_device']
|
||||
fields = ['name', 'serial', 'asset_tag']
|
||||
|
||||
def search(self, queryset, value):
|
||||
return queryset.filter(
|
||||
@@ -253,6 +268,15 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
Q(comments__icontains=value)
|
||||
).distinct()
|
||||
|
||||
def _mac_address(self, queryset, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
try:
|
||||
return queryset.filter(interfaces__mac_address=value).distinct()
|
||||
except AddrFormatError:
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class ConsolePortFilter(django_filters.FilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
@@ -269,7 +293,7 @@ class ConsolePortFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['device_id', 'device', 'name']
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class ConsoleServerPortFilter(django_filters.FilterSet):
|
||||
@@ -287,7 +311,7 @@ class ConsoleServerPortFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = ['device_id', 'device', 'name']
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class PowerPortFilter(django_filters.FilterSet):
|
||||
@@ -305,7 +329,7 @@ class PowerPortFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['device_id', 'device', 'name']
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class PowerOutletFilter(django_filters.FilterSet):
|
||||
@@ -323,7 +347,7 @@ class PowerOutletFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = ['device_id', 'device', 'name']
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class InterfaceFilter(django_filters.FilterSet):
|
||||
@@ -341,7 +365,7 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['device_id', 'device', 'name']
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class ConsoleConnectionFilter(django_filters.FilterSet):
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"fields": {
|
||||
"name": "Console Server",
|
||||
"slug": "console-server",
|
||||
"color": "teal"
|
||||
"color": "009688"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -14,7 +14,7 @@
|
||||
"fields": {
|
||||
"name": "Core Switch",
|
||||
"slug": "core-switch",
|
||||
"color": "blue"
|
||||
"color": "2196f3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -23,7 +23,7 @@
|
||||
"fields": {
|
||||
"name": "Distribution Switch",
|
||||
"slug": "distribution-switch",
|
||||
"color": "blue"
|
||||
"color": "2196f3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -32,7 +32,7 @@
|
||||
"fields": {
|
||||
"name": "Access Switch",
|
||||
"slug": "access-switch",
|
||||
"color": "blue"
|
||||
"color": "2196f3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -41,7 +41,7 @@
|
||||
"fields": {
|
||||
"name": "Management Switch",
|
||||
"slug": "management-switch",
|
||||
"color": "orange"
|
||||
"color": "ff9800"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -50,7 +50,7 @@
|
||||
"fields": {
|
||||
"name": "Firewall",
|
||||
"slug": "firewall",
|
||||
"color": "red"
|
||||
"color": "f44336"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@
|
||||
"fields": {
|
||||
"name": "Router",
|
||||
"slug": "router",
|
||||
"color": "purple"
|
||||
"color": "9c27b0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -68,7 +68,7 @@
|
||||
"fields": {
|
||||
"name": "Server",
|
||||
"slug": "server",
|
||||
"color": "medium_gray"
|
||||
"color": "9e9e9e"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@
|
||||
"fields": {
|
||||
"name": "PDU",
|
||||
"slug": "pdu",
|
||||
"color": "dark_gray"
|
||||
"color": "607d8b"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count, Q
|
||||
|
||||
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||
from ipam.models import IPAddress
|
||||
from tenancy.forms import bulkedit_tenant_choices
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
|
||||
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
|
||||
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField,
|
||||
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
|
||||
SlugField,
|
||||
)
|
||||
|
||||
from formfields import MACAddressFormField
|
||||
from .models import (
|
||||
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
|
||||
Interface, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole,
|
||||
Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
|
||||
Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module,
|
||||
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES,
|
||||
Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
|
||||
)
|
||||
|
||||
|
||||
@@ -42,37 +44,12 @@ def get_device_by_name_or_pk(name):
|
||||
return device
|
||||
|
||||
|
||||
def bulkedit_platform_choices():
|
||||
choices = [
|
||||
(None, '---------'),
|
||||
(0, 'None'),
|
||||
]
|
||||
choices += [(p.pk, p.name) for p in Platform.objects.all()]
|
||||
return choices
|
||||
|
||||
|
||||
def bulkedit_rackgroup_choices():
|
||||
def validate_connection_status(value):
|
||||
"""
|
||||
Include an option to remove the currently assigned group from a rack.
|
||||
Custom validator for connection statuses. value must be either "planned" or "connected" (case-insensitive).
|
||||
"""
|
||||
choices = [
|
||||
(None, '---------'),
|
||||
(0, 'None'),
|
||||
]
|
||||
choices += [(r.pk, r) for r in RackGroup.objects.all()]
|
||||
return choices
|
||||
|
||||
|
||||
def bulkedit_rackrole_choices():
|
||||
"""
|
||||
Include an option to remove the currently assigned role from a rack.
|
||||
"""
|
||||
choices = [
|
||||
(None, '---------'),
|
||||
(0, 'None'),
|
||||
]
|
||||
choices += [(r.pk, r.name) for r in RackRole.objects.all()]
|
||||
return choices
|
||||
if value.lower() not in ['planned', 'connected']:
|
||||
raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value))
|
||||
|
||||
|
||||
#
|
||||
@@ -85,7 +62,8 @@ class SiteForm(BootstrapMixin, CustomFieldForm):
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
|
||||
fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name',
|
||||
'contact_phone', 'contact_email', 'comments']
|
||||
widgets = {
|
||||
'physical_address': SmallTextarea(attrs={'rows': 3}),
|
||||
'shipping_address': SmallTextarea(attrs={'rows': 3}),
|
||||
@@ -105,34 +83,33 @@ class SiteFromCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = ['name', 'slug', 'tenant', 'facility', 'asn']
|
||||
fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email']
|
||||
|
||||
|
||||
class SiteImportForm(BulkImportForm, BootstrapMixin):
|
||||
class SiteImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=SiteFromCSVForm)
|
||||
|
||||
|
||||
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
|
||||
|
||||
|
||||
def site_tenant_choices():
|
||||
tenant_choices = Tenant.objects.annotate(site_count=Count('sites'))
|
||||
return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices]
|
||||
class Meta:
|
||||
nullable_fields = ['tenant', 'asn']
|
||||
|
||||
|
||||
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Site
|
||||
tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
|
||||
|
||||
#
|
||||
# Rack groups
|
||||
#
|
||||
|
||||
class RackGroupForm(forms.ModelForm, BootstrapMixin):
|
||||
class RackGroupForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
@@ -140,21 +117,15 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin):
|
||||
fields = ['site', 'name', 'slug']
|
||||
|
||||
|
||||
def rackgroup_site_choices():
|
||||
site_choices = Site.objects.annotate(rack_count=Count('rack_groups'))
|
||||
return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
|
||||
|
||||
|
||||
class RackGroupFilterForm(forms.Form, BootstrapMixin):
|
||||
site = forms.MultipleChoiceField(required=False, choices=rackgroup_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
class RackGroupFilterForm(BootstrapMixin, forms.Form):
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug')
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleForm(forms.ModelForm, BootstrapMixin):
|
||||
class RackRoleForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
@@ -174,7 +145,8 @@ class RackForm(BootstrapMixin, CustomFieldForm):
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'comments']
|
||||
fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
|
||||
'comments']
|
||||
help_texts = {
|
||||
'site': "The site at which the rack exists",
|
||||
'name': "Organizational rack name",
|
||||
@@ -210,7 +182,8 @@ class RackFromCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height']
|
||||
fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height',
|
||||
'desc_units']
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -238,59 +211,41 @@ class RackFromCSVForm(forms.ModelForm):
|
||||
))
|
||||
|
||||
|
||||
class RackImportForm(BulkImportForm, BootstrapMixin):
|
||||
class RackImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=RackFromCSVForm)
|
||||
|
||||
|
||||
class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site')
|
||||
group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group')
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
role = forms.TypedChoiceField(choices=bulkedit_rackrole_choices, coerce=int, required=False, label='Role')
|
||||
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group')
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
role = forms.ModelChoiceField(queryset=RackRole.objects.all(), required=False)
|
||||
type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type')
|
||||
width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width')
|
||||
u_height = forms.IntegerField(required=False, label='Height (U)')
|
||||
comments = CommentField()
|
||||
comments = CommentField(widget=SmallTextarea)
|
||||
|
||||
|
||||
def rack_site_choices():
|
||||
site_choices = Site.objects.annotate(rack_count=Count('racks'))
|
||||
return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
|
||||
|
||||
|
||||
def rack_group_choices():
|
||||
group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
|
||||
return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices]
|
||||
|
||||
|
||||
def rack_tenant_choices():
|
||||
tenant_choices = Tenant.objects.annotate(rack_count=Count('racks'))
|
||||
return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices]
|
||||
|
||||
|
||||
def rack_role_choices():
|
||||
role_choices = RackRole.objects.annotate(rack_count=Count('racks'))
|
||||
return [(r.slug, u'{} ({})'.format(r.name, r.rack_count)) for r in role_choices]
|
||||
class Meta:
|
||||
nullable_fields = ['group', 'tenant', 'role', 'comments']
|
||||
|
||||
|
||||
class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Rack
|
||||
site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
role = forms.MultipleChoiceField(required=False, choices=rack_role_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug')
|
||||
group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site')
|
||||
.annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None'))
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
role = FilterChoiceField(queryset=RackRole.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
|
||||
|
||||
#
|
||||
# Manufacturers
|
||||
#
|
||||
|
||||
class ManufacturerForm(forms.ModelForm, BootstrapMixin):
|
||||
class ManufacturerForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
@@ -302,88 +257,133 @@ class ManufacturerForm(forms.ModelForm, BootstrapMixin):
|
||||
# Device types
|
||||
#
|
||||
|
||||
class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
|
||||
class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
|
||||
slug = SlugField(slug_source='model')
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
|
||||
'is_pdu', 'is_network_device', 'subdevice_role']
|
||||
'is_pdu', 'is_network_device', 'subdevice_role', 'comments']
|
||||
|
||||
|
||||
class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
|
||||
class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
|
||||
u_height = forms.IntegerField(min_value=1, required=False)
|
||||
|
||||
|
||||
def devicetype_manufacturer_choices():
|
||||
manufacturer_choices = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
|
||||
return [(m.slug, u'{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices]
|
||||
class Meta:
|
||||
nullable_fields = []
|
||||
|
||||
|
||||
class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
|
||||
manufacturer = forms.MultipleChoiceField(required=False, choices=devicetype_manufacturer_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = DeviceType
|
||||
manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
|
||||
to_field_name='slug')
|
||||
|
||||
|
||||
#
|
||||
# Device component templates
|
||||
#
|
||||
|
||||
class ConsolePortTemplateForm(forms.ModelForm, BootstrapMixin):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePortTemplate
|
||||
fields = ['name_pattern']
|
||||
fields = ['device_type', 'name']
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateForm(forms.ModelForm, BootstrapMixin):
|
||||
class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = ['name_pattern']
|
||||
fields = ['device_type', 'name']
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class PowerPortTemplateForm(forms.ModelForm, BootstrapMixin):
|
||||
class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = PowerPortTemplate
|
||||
fields = ['name_pattern']
|
||||
fields = ['device_type', 'name']
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletTemplateForm(forms.ModelForm, BootstrapMixin):
|
||||
class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
fields = ['name_pattern']
|
||||
fields = ['device_type', 'name']
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class InterfaceTemplateForm(forms.ModelForm, BootstrapMixin):
|
||||
class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = ['name_pattern', 'form_factor', 'mgmt_only']
|
||||
fields = ['device_type', 'name', 'form_factor', 'mgmt_only']
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayTemplateForm(forms.ModelForm, BootstrapMixin):
|
||||
class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
||||
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
|
||||
|
||||
|
||||
class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = []
|
||||
|
||||
|
||||
class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = ['name_pattern']
|
||||
fields = ['device_type', 'name']
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
#
|
||||
# Device roles
|
||||
#
|
||||
|
||||
class DeviceRoleForm(forms.ModelForm, BootstrapMixin):
|
||||
class DeviceRoleForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
@@ -395,7 +395,7 @@ class DeviceRoleForm(forms.ModelForm, BootstrapMixin):
|
||||
# Platforms
|
||||
#
|
||||
|
||||
class PlatformForm(forms.ModelForm, BootstrapMixin):
|
||||
class PlatformForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
@@ -415,7 +415,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
|
||||
attrs={'filter-for': 'position'}
|
||||
))
|
||||
position = forms.TypedChoiceField(required=False, empty_value=None,
|
||||
help_text="For multi-U devices, this is the lowest occupied rack unit.",
|
||||
help_text="The lowest-numbered unit occupied by the device",
|
||||
widget=APISelect(api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}',
|
||||
disabled_indicator='device'))
|
||||
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
|
||||
@@ -459,6 +459,10 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
|
||||
ip_choices += [(ip.id, u'{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
|
||||
self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices
|
||||
|
||||
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
|
||||
# can be flipped from one face to another.
|
||||
self.fields['position'].widget.attrs['api-url'] += '&exclude={}'.format(self.instance.pk)
|
||||
|
||||
else:
|
||||
|
||||
# An object that doesn't exist yet can't have any IPs assigned to it
|
||||
@@ -608,11 +612,11 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
|
||||
self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name))
|
||||
|
||||
|
||||
class DeviceImportForm(BulkImportForm, BootstrapMixin):
|
||||
class DeviceImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=DeviceFromCSVForm)
|
||||
|
||||
|
||||
class ChildDeviceImportForm(BulkImportForm, BootstrapMixin):
|
||||
class ChildDeviceImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=ChildDeviceFromCSVForm)
|
||||
|
||||
|
||||
@@ -620,64 +624,52 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
|
||||
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
platform = forms.TypedChoiceField(choices=bulkedit_platform_choices, coerce=int, required=False,
|
||||
label='Platform')
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
|
||||
status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status')
|
||||
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
|
||||
|
||||
|
||||
def device_site_choices():
|
||||
site_choices = Site.objects.annotate(device_count=Count('racks__devices'))
|
||||
return [(s.slug, u'{} ({})'.format(s.name, s.device_count)) for s in site_choices]
|
||||
|
||||
|
||||
def device_rack_group_choices():
|
||||
group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices'))
|
||||
return [(g.pk, u'{} ({})'.format(g, g.device_count)) for g in group_choices]
|
||||
|
||||
|
||||
def device_role_choices():
|
||||
role_choices = DeviceRole.objects.annotate(device_count=Count('devices'))
|
||||
return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices]
|
||||
|
||||
|
||||
def device_tenant_choices():
|
||||
tenant_choices = Tenant.objects.annotate(device_count=Count('devices'))
|
||||
return [(t.slug, u'{} ({})'.format(t.name, t.device_count)) for t in tenant_choices]
|
||||
|
||||
|
||||
def device_type_choices():
|
||||
type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
|
||||
return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices]
|
||||
|
||||
|
||||
def device_platform_choices():
|
||||
platform_choices = Platform.objects.annotate(device_count=Count('devices'))
|
||||
return [(p.slug, u'{} ({})'.format(p.name, p.device_count)) for p in platform_choices]
|
||||
class Meta:
|
||||
nullable_fields = ['tenant', 'platform']
|
||||
|
||||
|
||||
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Device
|
||||
site = forms.MultipleChoiceField(required=False, choices=device_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group',
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
tenant = forms.MultipleChoiceField(required=False, choices=device_tenant_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices)
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
|
||||
rack_group_id = FilterChoiceField(queryset=RackGroup.objects.annotate(filter_count=Count('racks__devices')),
|
||||
label='Rack Group')
|
||||
role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug')
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
device_type_id = FilterChoiceField(queryset=DeviceType.objects.select_related('manufacturer')
|
||||
.annotate(filter_count=Count('instances')), label='Type')
|
||||
platform = FilterChoiceField(queryset=Platform.objects.annotate(filter_count=Count('devices')),
|
||||
to_field_name='slug', null_option=(0, 'None'))
|
||||
status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES))
|
||||
mac_address = forms.CharField(required=False, label='MAC address')
|
||||
|
||||
|
||||
#
|
||||
# Bulk device component creation
|
||||
#
|
||||
|
||||
class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['pk', 'name_pattern', 'form_factor', 'mgmt_only', 'description']
|
||||
|
||||
|
||||
#
|
||||
# Console ports
|
||||
#
|
||||
|
||||
class ConsolePortForm(forms.ModelForm, BootstrapMixin):
|
||||
class ConsolePortForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
@@ -687,7 +679,7 @@ class ConsolePortForm(forms.ModelForm, BootstrapMixin):
|
||||
}
|
||||
|
||||
|
||||
class ConsolePortCreateForm(forms.Form, BootstrapMixin):
|
||||
class ConsolePortCreateForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@@ -699,7 +691,7 @@ class ConsoleConnectionCSVForm(forms.Form):
|
||||
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Device not found'})
|
||||
console_port = forms.CharField()
|
||||
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
|
||||
status = forms.CharField(validators=[validate_connection_status])
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -728,7 +720,7 @@ class ConsoleConnectionCSVForm(forms.Form):
|
||||
.format(self.cleaned_data['device'], self.cleaned_data['console_port']))
|
||||
|
||||
|
||||
class ConsoleConnectionImportForm(BulkImportForm, BootstrapMixin):
|
||||
class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=ConsoleConnectionCSVForm)
|
||||
|
||||
def clean(self):
|
||||
@@ -758,11 +750,12 @@ class ConsoleConnectionImportForm(BulkImportForm, BootstrapMixin):
|
||||
self.cleaned_data['csv'] = connection_list
|
||||
|
||||
|
||||
class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin):
|
||||
class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
|
||||
widget=forms.Select(attrs={'filter-for': 'console_server'}))
|
||||
console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False,
|
||||
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_console_server=True',
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'cs_port'}))
|
||||
livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch(
|
||||
query_key='q', query_url='dcim-api:device_list', field_to_update='console_server')
|
||||
@@ -811,7 +804,7 @@ class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin):
|
||||
# Console server ports
|
||||
#
|
||||
|
||||
class ConsoleServerPortForm(forms.ModelForm, BootstrapMixin):
|
||||
class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
@@ -821,16 +814,16 @@ class ConsoleServerPortForm(forms.ModelForm, BootstrapMixin):
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortCreateForm(forms.Form, BootstrapMixin):
|
||||
class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin):
|
||||
class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
|
||||
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
|
||||
widget=forms.Select(attrs={'filter-for': 'device'}))
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
|
||||
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
|
||||
attrs={'filter-for': 'port'}))
|
||||
display_field='display_name', attrs={'filter-for': 'port'}))
|
||||
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
|
||||
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
|
||||
)
|
||||
@@ -873,7 +866,7 @@ class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin):
|
||||
# Power ports
|
||||
#
|
||||
|
||||
class PowerPortForm(forms.ModelForm, BootstrapMixin):
|
||||
class PowerPortForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
@@ -883,7 +876,7 @@ class PowerPortForm(forms.ModelForm, BootstrapMixin):
|
||||
}
|
||||
|
||||
|
||||
class PowerPortCreateForm(forms.Form, BootstrapMixin):
|
||||
class PowerPortCreateForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
@@ -894,7 +887,7 @@ class PowerConnectionCSVForm(forms.Form):
|
||||
device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Device not found'})
|
||||
power_port = forms.CharField()
|
||||
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
|
||||
status = forms.CharField(validators=[validate_connection_status])
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -924,7 +917,7 @@ class PowerConnectionCSVForm(forms.Form):
|
||||
.format(self.cleaned_data['device'], self.cleaned_data['power_port']))
|
||||
|
||||
|
||||
class PowerConnectionImportForm(BulkImportForm, BootstrapMixin):
|
||||
class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=PowerConnectionCSVForm)
|
||||
|
||||
def clean(self):
|
||||
@@ -954,12 +947,12 @@ class PowerConnectionImportForm(BulkImportForm, BootstrapMixin):
|
||||
self.cleaned_data['csv'] = connection_list
|
||||
|
||||
|
||||
class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin):
|
||||
class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
|
||||
widget=forms.Select(attrs={'filter-for': 'pdu'}))
|
||||
pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False,
|
||||
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_pdu=True',
|
||||
attrs={'filter-for': 'power_outlet'}))
|
||||
display_field='display_name', attrs={'filter-for': 'power_outlet'}))
|
||||
livesearch = forms.CharField(required=False, label='PDU', widget=Livesearch(
|
||||
query_key='q', query_url='dcim-api:device_list', field_to_update='pdu')
|
||||
)
|
||||
@@ -1007,7 +1000,7 @@ class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin):
|
||||
# Power outlets
|
||||
#
|
||||
|
||||
class PowerOutletForm(forms.ModelForm, BootstrapMixin):
|
||||
class PowerOutletForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
@@ -1017,16 +1010,16 @@ class PowerOutletForm(forms.ModelForm, BootstrapMixin):
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletCreateForm(forms.Form, BootstrapMixin):
|
||||
class PowerOutletCreateForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
class PowerOutletConnectionForm(forms.Form, BootstrapMixin):
|
||||
class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
|
||||
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
|
||||
widget=forms.Select(attrs={'filter-for': 'device'}))
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
|
||||
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
|
||||
attrs={'filter-for': 'port'}))
|
||||
display_field='display_name', attrs={'filter-for': 'port'}))
|
||||
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
|
||||
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
|
||||
)
|
||||
@@ -1069,7 +1062,7 @@ class PowerOutletConnectionForm(forms.Form, BootstrapMixin):
|
||||
# Interfaces
|
||||
#
|
||||
|
||||
class InterfaceForm(forms.ModelForm, BootstrapMixin):
|
||||
class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
@@ -1079,28 +1072,37 @@ class InterfaceForm(forms.ModelForm, BootstrapMixin):
|
||||
}
|
||||
|
||||
|
||||
class InterfaceCreateForm(forms.ModelForm, BootstrapMixin):
|
||||
class InterfaceCreateForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
||||
mac_address = MACAddressFormField(required=False, label='MAC Address')
|
||||
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['name_pattern', 'form_factor', 'mac_address', 'mgmt_only', 'description']
|
||||
|
||||
|
||||
class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
nullable_fields = ['description']
|
||||
|
||||
|
||||
#
|
||||
# Interface connections
|
||||
#
|
||||
|
||||
class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
|
||||
class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
interface_a = forms.ChoiceField(choices=[], widget=SelectWithDisabled, label='Interface')
|
||||
site_b = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
|
||||
widget=forms.Select(attrs={'filter-for': 'rack_b'}))
|
||||
rack_b = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
|
||||
widget=forms.Select(attrs={'filter-for': 'device_b'}))
|
||||
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site_b}}',
|
||||
attrs={'filter-for': 'device_b'}))
|
||||
device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
|
||||
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}',
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'interface_b'}))
|
||||
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
|
||||
query_key='q', query_url='dcim-api:device_list', field_to_update='device_b')
|
||||
@@ -1111,21 +1113,27 @@ class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = InterfaceConnection
|
||||
fields = ['interface_a', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status']
|
||||
fields = ['interface_a', 'site_b', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status']
|
||||
|
||||
def __init__(self, device_a, *args, **kwargs):
|
||||
|
||||
super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields['rack_b'].queryset = Rack.objects.filter(site=device_a.rack.site)
|
||||
|
||||
# Initialize interface A choices
|
||||
device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL) \
|
||||
.select_related('circuit', 'connected_as_a', 'connected_as_b')
|
||||
device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL)\
|
||||
.select_related('circuit_termination', 'connected_as_a', 'connected_as_b')
|
||||
self.fields['interface_a'].choices = [
|
||||
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces
|
||||
]
|
||||
|
||||
# Initialize rack_b choices if site_b is set
|
||||
if self.is_bound and self.data.get('site_b'):
|
||||
self.fields['rack_b'].queryset = Rack.objects.filter(site__pk=self.data['site_b'])
|
||||
elif self.initial.get('site_b'):
|
||||
self.fields['rack_b'].queryset = Rack.objects.filter(site=self.initial['site_b'])
|
||||
else:
|
||||
self.fields['rack_b'].choices = []
|
||||
|
||||
# Initialize device_b choices if rack_b is set
|
||||
if self.is_bound and self.data.get('rack_b'):
|
||||
self.fields['device_b'].queryset = Device.objects.filter(rack__pk=self.data['rack_b'])
|
||||
@@ -1136,11 +1144,13 @@ class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
# Initialize interface_b choices if device_b is set
|
||||
if self.is_bound:
|
||||
device_b_interfaces = Interface.objects.filter(device=self.data['device_b']) \
|
||||
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
|
||||
device_b_interfaces = Interface.objects.filter(device=self.data['device_b'])\
|
||||
.exclude(form_factor=IFACE_FF_VIRTUAL)\
|
||||
.select_related('circuit_termination', 'connected_as_a', 'connected_as_b')
|
||||
elif self.initial.get('device_b'):
|
||||
device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']) \
|
||||
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
|
||||
device_b_interfaces = Interface.objects.filter(device=self.initial['device_b'])\
|
||||
.exclude(form_factor=IFACE_FF_VIRTUAL)\
|
||||
.select_related('circuit_termination', 'connected_as_a', 'connected_as_b')
|
||||
else:
|
||||
device_b_interfaces = []
|
||||
self.fields['interface_b'].choices = [
|
||||
@@ -1155,7 +1165,7 @@ class InterfaceConnectionCSVForm(forms.Form):
|
||||
device_b = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Device B not found.'})
|
||||
interface_b = forms.CharField()
|
||||
status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')])
|
||||
status = forms.CharField(validators=[validate_connection_status])
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -1190,7 +1200,7 @@ class InterfaceConnectionCSVForm(forms.Form):
|
||||
pass
|
||||
|
||||
|
||||
class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin):
|
||||
class InterfaceConnectionImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=InterfaceConnectionCSVForm)
|
||||
|
||||
def clean(self):
|
||||
@@ -1230,7 +1240,7 @@ class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin):
|
||||
self.cleaned_data['csv'] = connection_list
|
||||
|
||||
|
||||
class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin):
|
||||
class InterfaceConnectionDeletionForm(BootstrapMixin, forms.Form):
|
||||
confirm = forms.BooleanField(required=True)
|
||||
# Used for HTTP redirect upon successful deletion
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
|
||||
@@ -1240,7 +1250,7 @@ class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin):
|
||||
# Device bays
|
||||
#
|
||||
|
||||
class DeviceBayForm(forms.ModelForm, BootstrapMixin):
|
||||
class DeviceBayForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
@@ -1250,11 +1260,11 @@ class DeviceBayForm(forms.ModelForm, BootstrapMixin):
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayCreateForm(forms.Form, BootstrapMixin):
|
||||
class DeviceBayCreateForm(BootstrapMixin, forms.Form):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
class PopulateDeviceBayForm(forms.Form, BootstrapMixin):
|
||||
class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
||||
installed_device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Child Device',
|
||||
help_text="Child devices must first be created within the rack occupied "
|
||||
"by the parent device. Then they can be assigned to a bay.")
|
||||
@@ -1275,15 +1285,15 @@ class PopulateDeviceBayForm(forms.Form, BootstrapMixin):
|
||||
# Connections
|
||||
#
|
||||
|
||||
class ConsoleConnectionFilterForm(forms.Form, BootstrapMixin):
|
||||
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
|
||||
|
||||
|
||||
class PowerConnectionFilterForm(forms.Form, BootstrapMixin):
|
||||
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
|
||||
|
||||
|
||||
class InterfaceConnectionFilterForm(forms.Form, BootstrapMixin):
|
||||
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
|
||||
|
||||
|
||||
@@ -1291,15 +1301,12 @@ class InterfaceConnectionFilterForm(forms.Form, BootstrapMixin):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressForm(forms.ModelForm, BootstrapMixin):
|
||||
class IPAddressForm(BootstrapMixin, CustomFieldForm):
|
||||
set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['address', 'vrf', 'interface', 'set_as_primary']
|
||||
help_texts = {
|
||||
'address': 'IPv4 or IPv6 address (with mask)'
|
||||
}
|
||||
fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'description']
|
||||
|
||||
def __init__(self, device, *args, **kwargs):
|
||||
|
||||
@@ -1307,19 +1314,24 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
self.fields['interface'].queryset = device.interfaces.all()
|
||||
interfaces = device.interfaces.all()
|
||||
self.fields['interface'].queryset = interfaces
|
||||
self.fields['interface'].required = True
|
||||
|
||||
# If this device does not have any IP addresses assigned, default to setting the first IP as its primary
|
||||
# If this device has only one interface, select it by default.
|
||||
if len(interfaces) == 1:
|
||||
self.fields['interface'].initial = interfaces[0]
|
||||
|
||||
# If this device does not have any IP addresses assigned, default to setting the first IP as its primary.
|
||||
if not IPAddress.objects.filter(interface__device=device).count():
|
||||
self.fields['set_as_primary'].initial = True
|
||||
|
||||
|
||||
#
|
||||
# Interfaces
|
||||
# Modules
|
||||
#
|
||||
|
||||
class ModuleForm(forms.ModelForm, BootstrapMixin):
|
||||
class ModuleForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
|
||||
20
netbox/dcim/migrations/0020_rack_desc_units.py
Normal file
20
netbox/dcim/migrations/0020_rack_desc_units.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-10-28 15:01
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0019_new_iface_form_factors'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='desc_units',
|
||||
field=models.BooleanField(default=False, help_text=b'Units are numbered top-to-bottom', verbose_name=b'Descending units'),
|
||||
),
|
||||
]
|
||||
31
netbox/dcim/migrations/0021_add_ff_flexstack.py
Normal file
31
netbox/dcim/migrations/0021_add_ff_flexstack.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-10-31 18:47
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0020_rack_desc_units'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='position',
|
||||
field=models.PositiveSmallIntegerField(blank=True, help_text=b'The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Position (U)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interfacetemplate',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
|
||||
),
|
||||
]
|
||||
57
netbox/dcim/migrations/0022_color_names_to_rgb.py
Normal file
57
netbox/dcim/migrations/0022_color_names_to_rgb.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-12-06 16:35
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
import utilities.fields
|
||||
|
||||
|
||||
COLOR_CONVERSION = {
|
||||
'teal': '009688',
|
||||
'green': '4caf50',
|
||||
'blue': '2196f3',
|
||||
'purple': '9c27b0',
|
||||
'yellow': 'ffeb3b',
|
||||
'orange': 'ff9800',
|
||||
'red': 'f44336',
|
||||
'light_gray': 'c0c0c0',
|
||||
'medium_gray': '9e9e9e',
|
||||
'dark_gray': '607d8b',
|
||||
}
|
||||
|
||||
|
||||
def color_names_to_rgb(apps, schema_editor):
|
||||
RackRole = apps.get_model('dcim', 'RackRole')
|
||||
DeviceRole = apps.get_model('dcim', 'DeviceRole')
|
||||
for color_name, color_rgb in COLOR_CONVERSION.items():
|
||||
RackRole.objects.filter(color=color_name).update(color=color_rgb)
|
||||
DeviceRole.objects.filter(color=color_name).update(color=color_rgb)
|
||||
|
||||
|
||||
def color_rgb_to_name(apps, schema_editor):
|
||||
RackRole = apps.get_model('dcim', 'RackRole')
|
||||
DeviceRole = apps.get_model('dcim', 'DeviceRole')
|
||||
for color_name, color_rgb in COLOR_CONVERSION.items():
|
||||
RackRole.objects.filter(color=color_rgb).update(color=color_name)
|
||||
DeviceRole.objects.filter(color=color_rgb).update(color=color_name)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0021_add_ff_flexstack'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(color_names_to_rgb, color_rgb_to_name),
|
||||
migrations.AlterField(
|
||||
model_name='devicerole',
|
||||
name='color',
|
||||
field=utilities.fields.ColorField(max_length=6),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rackrole',
|
||||
name='color',
|
||||
field=utilities.fields.ColorField(max_length=6),
|
||||
),
|
||||
]
|
||||
20
netbox/dcim/migrations/0023_devicetype_comments.py
Normal file
20
netbox/dcim/migrations/0023_devicetype_comments.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-12-16 16:08
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0022_color_names_to_rgb'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='devicetype',
|
||||
name='comments',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
30
netbox/dcim/migrations/0024_site_add_contact_fields.py
Normal file
30
netbox/dcim/migrations/0024_site_add_contact_fields.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2016-12-29 16:23
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0023_devicetype_comments'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='contact_email',
|
||||
field=models.EmailField(blank=True, max_length=254, verbose_name=b'Contact E-mail'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='contact_name',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='contact_phone',
|
||||
field=models.CharField(blank=True, max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -3,16 +3,17 @@ from collections import OrderedDict
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import MultipleObjectsReturned, ValidationError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Count, Q, ObjectDoesNotExist
|
||||
|
||||
from circuits.models import Circuit
|
||||
from extras.models import CustomFieldModel, CustomField, CustomFieldValue
|
||||
from extras.rpc import RPC_CLIENTS
|
||||
from tenancy.models import Tenant
|
||||
from utilities.fields import NullableCharField
|
||||
from utilities.fields import ColorField, NullableCharField
|
||||
from utilities.managers import NaturalOrderByManager
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
|
||||
@@ -54,29 +55,6 @@ SUBDEVICE_ROLE_CHOICES = (
|
||||
(SUBDEVICE_ROLE_CHILD, 'Child'),
|
||||
)
|
||||
|
||||
COLOR_TEAL = 'teal'
|
||||
COLOR_GREEN = 'green'
|
||||
COLOR_BLUE = 'blue'
|
||||
COLOR_PURPLE = 'purple'
|
||||
COLOR_YELLOW = 'yellow'
|
||||
COLOR_ORANGE = 'orange'
|
||||
COLOR_RED = 'red'
|
||||
COLOR_GRAY1 = 'light_gray'
|
||||
COLOR_GRAY2 = 'medium_gray'
|
||||
COLOR_GRAY3 = 'dark_gray'
|
||||
ROLE_COLOR_CHOICES = [
|
||||
[COLOR_TEAL, 'Teal'],
|
||||
[COLOR_GREEN, 'Green'],
|
||||
[COLOR_BLUE, 'Blue'],
|
||||
[COLOR_PURPLE, 'Purple'],
|
||||
[COLOR_YELLOW, 'Yellow'],
|
||||
[COLOR_ORANGE, 'Orange'],
|
||||
[COLOR_RED, 'Red'],
|
||||
[COLOR_GRAY1, 'Light Gray'],
|
||||
[COLOR_GRAY2, 'Medium Gray'],
|
||||
[COLOR_GRAY3, 'Dark Gray'],
|
||||
]
|
||||
|
||||
# Virtual
|
||||
IFACE_FF_VIRTUAL = 0
|
||||
# Ethernet
|
||||
@@ -107,6 +85,8 @@ IFACE_FF_E3 = 4050
|
||||
# Stacking
|
||||
IFACE_FF_STACKWISE = 5000
|
||||
IFACE_FF_STACKWISE_PLUS = 5050
|
||||
IFACE_FF_FLEXSTACK = 5100
|
||||
IFACE_FF_FLEXSTACK_PLUS = 5150
|
||||
# Other
|
||||
IFACE_FF_OTHER = 32767
|
||||
|
||||
@@ -164,6 +144,8 @@ IFACE_FF_CHOICES = [
|
||||
[
|
||||
[IFACE_FF_STACKWISE, 'Cisco StackWise'],
|
||||
[IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
|
||||
[IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
|
||||
[IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
|
||||
]
|
||||
],
|
||||
[
|
||||
@@ -263,6 +245,9 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
||||
asn = ASNField(blank=True, null=True, verbose_name='ASN')
|
||||
physical_address = models.CharField(max_length=200, blank=True)
|
||||
shipping_address = models.CharField(max_length=200, blank=True)
|
||||
contact_name = models.CharField(max_length=50, blank=True)
|
||||
contact_phone = models.CharField(max_length=20, blank=True)
|
||||
contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
@@ -283,7 +268,10 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.slug,
|
||||
self.tenant.name if self.tenant else '',
|
||||
self.facility,
|
||||
str(self.asn),
|
||||
str(self.asn) if self.asn else '',
|
||||
self.contact_name,
|
||||
self.contact_phone,
|
||||
self.contact_email,
|
||||
])
|
||||
|
||||
@property
|
||||
@@ -304,7 +292,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
||||
|
||||
@property
|
||||
def count_circuits(self):
|
||||
return self.circuits.count()
|
||||
return Circuit.objects.filter(terminations__site=self).count()
|
||||
|
||||
|
||||
#
|
||||
@@ -341,7 +329,7 @@ class RackRole(models.Model):
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES)
|
||||
color = ColorField()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -375,6 +363,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
||||
help_text='Rail-to-rail width')
|
||||
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)',
|
||||
validators=[MinValueValidator(1), MaxValueValidator(100)])
|
||||
desc_units = models.BooleanField(default=False, verbose_name='Descending units',
|
||||
help_text='Units are numbered top-to-bottom')
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
@@ -401,8 +391,11 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
||||
if top_device:
|
||||
min_height = top_device.position + top_device.device_type.u_height - 1
|
||||
if self.u_height < min_height:
|
||||
raise ValidationError("Rack must be at least {}U tall with currently installed devices."
|
||||
.format(min_height))
|
||||
raise ValidationError({
|
||||
'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
|
||||
min_height
|
||||
)
|
||||
})
|
||||
|
||||
def to_csv(self):
|
||||
return ','.join([
|
||||
@@ -415,11 +408,15 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.get_type_display() if self.type else '',
|
||||
str(self.width),
|
||||
str(self.u_height),
|
||||
'True' if self.desc_units else '',
|
||||
])
|
||||
|
||||
@property
|
||||
def units(self):
|
||||
return reversed(range(1, self.u_height + 1))
|
||||
if self.desc_units:
|
||||
return range(1, self.u_height + 1)
|
||||
else:
|
||||
return reversed(range(1, self.u_height + 1))
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
@@ -438,7 +435,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
|
||||
elevation = OrderedDict()
|
||||
for u in reversed(range(1, self.u_height + 1)):
|
||||
for u in self.units:
|
||||
elevation[u] = {'id': u, 'name': 'U{}'.format(u), 'face': face, 'device': None}
|
||||
|
||||
# Add devices to rack units list
|
||||
@@ -476,7 +473,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
|
||||
# Gather all devices which consume U space within the rack
|
||||
devices = self.devices.select_related().filter(position__gte=1).exclude(pk__in=exclude)
|
||||
devices = self.devices.select_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
|
||||
|
||||
# Initialize the rack unit skeleton
|
||||
units = range(1, self.u_height + 1)
|
||||
@@ -506,9 +503,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
Determine the utilization rate of the rack and return it as a percentage.
|
||||
"""
|
||||
if self.u_consumed is None:
|
||||
self.u_consumed = 0
|
||||
u_available = self.u_height - self.u_consumed
|
||||
u_available = len(self.get_available_units())
|
||||
return int(float(self.u_height - u_available) / self.u_height * 100)
|
||||
|
||||
|
||||
@@ -533,7 +528,7 @@ class Manufacturer(models.Model):
|
||||
return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
|
||||
|
||||
|
||||
class DeviceType(models.Model):
|
||||
class DeviceType(models.Model, CustomFieldModel):
|
||||
"""
|
||||
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
|
||||
well as high-level functional role(s).
|
||||
@@ -565,6 +560,8 @@ class DeviceType(models.Model):
|
||||
choices=SUBDEVICE_ROLE_CHOICES,
|
||||
help_text="Parent devices house child devices in device bays. Select "
|
||||
"\"None\" if this device type is neither a parent nor a child.")
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
class Meta:
|
||||
ordering = ['manufacturer', 'model']
|
||||
@@ -574,31 +571,65 @@ class DeviceType(models.Model):
|
||||
]
|
||||
|
||||
def __unicode__(self):
|
||||
return u'{} {}'.format(self.manufacturer, self.model)
|
||||
return self.model
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DeviceType, self).__init__(*args, **kwargs)
|
||||
|
||||
# Save a copy of u_height for validation in clean()
|
||||
self._original_u_height = self.u_height
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
|
||||
# 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
|
||||
# many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
|
||||
if self.pk is not None and self.u_height > self._original_u_height:
|
||||
for d in Device.objects.filter(device_type=self, position__isnull=False):
|
||||
face_required = None if self.is_full_depth else d.face
|
||||
u_available = d.rack.get_available_units(u_height=self.u_height, rack_face=face_required,
|
||||
exclude=[d.pk])
|
||||
if d.position not in u_available:
|
||||
raise ValidationError({
|
||||
'u_height': "Device {} in rack {} does not have sufficient space to accommodate a height of "
|
||||
"{}U".format(d, d.rack, self.u_height)
|
||||
})
|
||||
|
||||
if not self.is_console_server and self.cs_port_templates.count():
|
||||
raise ValidationError("Must delete all console server port templates associated with this device before "
|
||||
"declassifying it as a console server.")
|
||||
raise ValidationError({
|
||||
'is_console_server': "Must delete all console server port templates associated with this device before "
|
||||
"declassifying it as a console server."
|
||||
})
|
||||
|
||||
if not self.is_pdu and self.power_outlet_templates.count():
|
||||
raise ValidationError("Must delete all power outlet templates associated with this device before "
|
||||
"declassifying it as a PDU.")
|
||||
raise ValidationError({
|
||||
'is_pdu': "Must delete all power outlet templates associated with this device before declassifying it "
|
||||
"as a PDU."
|
||||
})
|
||||
|
||||
if not self.is_network_device and self.interface_templates.filter(mgmt_only=False).count():
|
||||
raise ValidationError("Must delete all non-management-only interface templates associated with this device "
|
||||
"before declassifying it as a network device.")
|
||||
raise ValidationError({
|
||||
'is_network_device': "Must delete all non-management-only interface templates associated with this "
|
||||
"device before declassifying it as a network device."
|
||||
})
|
||||
|
||||
if self.subdevice_role != SUBDEVICE_ROLE_PARENT and self.device_bay_templates.count():
|
||||
raise ValidationError("Must delete all device bay templates associated with this device before "
|
||||
"declassifying it as a parent device.")
|
||||
raise ValidationError({
|
||||
'subdevice_role': "Must delete all device bay templates associated with this device before "
|
||||
"declassifying it as a parent device."
|
||||
})
|
||||
|
||||
if self.u_height and self.subdevice_role == SUBDEVICE_ROLE_CHILD:
|
||||
raise ValidationError("Child device types must be 0U.")
|
||||
raise ValidationError({
|
||||
'u_height': "Child device types must be 0U."
|
||||
})
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
return u'{} {}'.format(self.manufacturer.name, self.model)
|
||||
|
||||
@property
|
||||
def is_parent_device(self):
|
||||
@@ -721,7 +752,7 @@ class DeviceRole(models.Model):
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES)
|
||||
color = ColorField()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -782,7 +813,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT)
|
||||
position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
|
||||
verbose_name='Position (U)',
|
||||
help_text='Number of the lowest U position occupied by the device')
|
||||
help_text='The lowest-numbered unit occupied by the device')
|
||||
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
|
||||
status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
|
||||
primary_ip4 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL,
|
||||
@@ -806,28 +837,40 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate device type assignment
|
||||
if not hasattr(self, 'device_type'):
|
||||
raise ValidationError("Must specify device type.")
|
||||
|
||||
# Child devices cannot be assigned to a rack face/unit
|
||||
if self.device_type.is_child_device and (self.face is not None or self.position):
|
||||
raise ValidationError("Child device types cannot be assigned a rack face or position.")
|
||||
|
||||
# Validate position/face combination
|
||||
if self.position and self.face is None:
|
||||
raise ValidationError("Must specify rack face with rack position.")
|
||||
raise ValidationError({
|
||||
'face': "Must specify rack face when defining rack position."
|
||||
})
|
||||
|
||||
# Validate rack space
|
||||
rack_face = self.face if not self.device_type.is_full_depth else None
|
||||
exclude_list = [self.pk] if self.pk else []
|
||||
try:
|
||||
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
|
||||
exclude=exclude_list)
|
||||
if self.position and self.position not in available_units:
|
||||
raise ValidationError("U{} is already occupied or does not have sufficient space to accommodate a(n) "
|
||||
"{} ({}U).".format(self.position, self.device_type, self.device_type.u_height))
|
||||
except Rack.DoesNotExist:
|
||||
# Child devices cannot be assigned to a rack face/unit
|
||||
if self.device_type.is_child_device and self.face is not None:
|
||||
raise ValidationError({
|
||||
'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
|
||||
"device."
|
||||
})
|
||||
if self.device_type.is_child_device and self.position:
|
||||
raise ValidationError({
|
||||
'position': "Child device types cannot be assigned to a rack position. This is an attribute of the "
|
||||
"parent device."
|
||||
})
|
||||
|
||||
# Validate rack space
|
||||
rack_face = self.face if not self.device_type.is_full_depth else None
|
||||
exclude_list = [self.pk] if self.pk else []
|
||||
try:
|
||||
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
|
||||
exclude=exclude_list)
|
||||
if self.position and self.position not in available_units:
|
||||
raise ValidationError({
|
||||
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} "
|
||||
"({}U).".format(self.position, self.device_type, self.device_type.u_height)
|
||||
})
|
||||
except Rack.DoesNotExist:
|
||||
pass
|
||||
|
||||
except DeviceType.DoesNotExist:
|
||||
pass
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -943,6 +986,9 @@ class ConsolePort(models.Model):
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def get_parent_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
# Used for connections export
|
||||
def to_csv(self):
|
||||
return ','.join([
|
||||
@@ -984,6 +1030,9 @@ class ConsoleServerPort(models.Model):
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def get_parent_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
|
||||
class PowerPort(models.Model):
|
||||
"""
|
||||
@@ -1002,6 +1051,9 @@ class PowerPort(models.Model):
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def get_parent_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
# Used for connections export
|
||||
def to_csv(self):
|
||||
return ','.join([
|
||||
@@ -1037,6 +1089,9 @@ class PowerOutlet(models.Model):
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def get_parent_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
|
||||
class InterfaceManager(models.Manager):
|
||||
|
||||
@@ -1073,12 +1128,16 @@ class Interface(models.Model):
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def get_parent_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
|
||||
if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected:
|
||||
raise ValidationError({'form_factor': "Virtual interfaces cannot be connected to another interface or "
|
||||
"circuit. Disconnect the interface or choose a physical form "
|
||||
"factor."})
|
||||
raise ValidationError({
|
||||
'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the "
|
||||
"interface or choose a physical form factor."
|
||||
})
|
||||
|
||||
@property
|
||||
def is_physical(self):
|
||||
@@ -1087,7 +1146,7 @@ class Interface(models.Model):
|
||||
@property
|
||||
def is_connected(self):
|
||||
try:
|
||||
return bool(self.circuit)
|
||||
return bool(self.circuit_termination)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
return bool(self.connection)
|
||||
@@ -1104,17 +1163,19 @@ class Interface(models.Model):
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_connected_interface(self):
|
||||
@property
|
||||
def connected_interface(self):
|
||||
try:
|
||||
connection = InterfaceConnection.objects.select_related().get(Q(interface_a=self) | Q(interface_b=self))
|
||||
if connection.interface_a == self:
|
||||
return connection.interface_b
|
||||
else:
|
||||
return connection.interface_a
|
||||
except InterfaceConnection.DoesNotExist:
|
||||
return None
|
||||
except InterfaceConnection.MultipleObjectsReturned:
|
||||
raise MultipleObjectsReturned("Multiple connections found for {} interface {}!".format(self.device, self))
|
||||
if self.connected_as_a:
|
||||
return self.connected_as_a.interface_b
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
try:
|
||||
if self.connected_as_b:
|
||||
return self.connected_as_b.interface_a
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class InterfaceConnection(models.Model):
|
||||
@@ -1129,7 +1190,9 @@ class InterfaceConnection(models.Model):
|
||||
|
||||
def clean(self):
|
||||
if self.interface_a == self.interface_b:
|
||||
raise ValidationError("Cannot connect an interface to itself")
|
||||
raise ValidationError({
|
||||
'interface_b': "Cannot connect an interface to itself."
|
||||
})
|
||||
|
||||
# Used for connections export
|
||||
def to_csv(self):
|
||||
@@ -1158,12 +1221,16 @@ class DeviceBay(models.Model):
|
||||
def __unicode__(self):
|
||||
return u'{} - {}'.format(self.device.name, self.name)
|
||||
|
||||
def get_parent_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate that the parent Device can have DeviceBays
|
||||
if not self.device.device_type.is_parent_device:
|
||||
raise ValidationError("This type of device ({}) does not support device bays."
|
||||
.format(self.device.device_type))
|
||||
raise ValidationError("This type of device ({}) does not support device bays.".format(
|
||||
self.device.device_type
|
||||
))
|
||||
|
||||
# Cannot install a device into itself, obviously
|
||||
if self.device == self.installed_device:
|
||||
@@ -1190,3 +1257,6 @@ class Module(models.Model):
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def get_parent_url(self):
|
||||
return reverse('dcim:device_inventory', args=[self.device.pk])
|
||||
|
||||
@@ -11,7 +11,7 @@ from .models import (
|
||||
|
||||
|
||||
COLOR_LABEL = """
|
||||
<label class="label {{ record.color }}">{{ record }}</label>
|
||||
<label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
|
||||
"""
|
||||
|
||||
DEVICE_LINK = """
|
||||
@@ -34,7 +34,7 @@ RACKROLE_ACTIONS = """
|
||||
|
||||
RACK_ROLE = """
|
||||
{% if record.role %}
|
||||
<label class="label {{ record.role.color }}">{{ value }}</label>
|
||||
<label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
@@ -59,7 +59,7 @@ PLATFORM_ACTIONS = """
|
||||
"""
|
||||
|
||||
DEVICE_ROLE = """
|
||||
<label class="label {{ record.device_role.color }}">{{ value }}</label>
|
||||
<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
|
||||
"""
|
||||
|
||||
STATUS_ICON = """
|
||||
@@ -72,7 +72,7 @@ STATUS_ICON = """
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% load helpers %}
|
||||
{% utilization_graph record.get_utilization %}
|
||||
{% utilization_graph value %}
|
||||
"""
|
||||
|
||||
|
||||
@@ -148,13 +148,12 @@ class RackTable(BaseTable):
|
||||
role = tables.TemplateColumn(RACK_ROLE, verbose_name='Role')
|
||||
u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
|
||||
devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
|
||||
u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used')
|
||||
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
|
||||
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Rack
|
||||
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'u_consumed',
|
||||
'utilization')
|
||||
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices',
|
||||
'get_utilization')
|
||||
|
||||
|
||||
class RackImportTable(BaseTable):
|
||||
@@ -196,10 +195,12 @@ class DeviceTypeTable(BaseTable):
|
||||
manufacturer = tables.Column(verbose_name='Manufacturer')
|
||||
model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
|
||||
part_number = tables.Column(verbose_name='Part Number')
|
||||
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
|
||||
instance_count = tables.Column(verbose_name='Instances')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceType
|
||||
fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height')
|
||||
fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count')
|
||||
|
||||
|
||||
#
|
||||
@@ -293,7 +294,8 @@ class PlatformTable(BaseTable):
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
device_count = tables.Column(verbose_name='Devices')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
|
||||
actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Platform
|
||||
@@ -312,7 +314,8 @@ class DeviceTable(BaseTable):
|
||||
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
|
||||
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
|
||||
device_type = tables.Column(verbose_name='Type')
|
||||
device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
|
||||
text=lambda record: record.device_type.full_name)
|
||||
primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address',
|
||||
template_code="{{ record.primary_ip.address.ip }}")
|
||||
|
||||
@@ -357,7 +360,7 @@ class PowerConnectionTable(BaseTable):
|
||||
args=[Accessor('power_outlet.device.pk')], verbose_name='PDU')
|
||||
power_outlet = tables.Column(verbose_name='Outlet')
|
||||
device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
|
||||
name = tables.Column(verbose_name='Console port')
|
||||
name = tables.Column(verbose_name='Power Port')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPort
|
||||
|
||||
@@ -2,6 +2,8 @@ import json
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SiteTest(APITestCase):
|
||||
|
||||
@@ -20,6 +22,9 @@ class SiteTest(APITestCase):
|
||||
'asn',
|
||||
'physical_address',
|
||||
'shipping_address',
|
||||
'contact_name',
|
||||
'contact_phone',
|
||||
'contact_email',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
'count_prefixes',
|
||||
@@ -47,6 +52,7 @@ class SiteTest(APITestCase):
|
||||
'type',
|
||||
'width',
|
||||
'u_height',
|
||||
'desc_units',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
@@ -57,7 +63,7 @@ class SiteTest(APITestCase):
|
||||
'embed_link',
|
||||
]
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/sites/'):
|
||||
def test_get_list(self, endpoint='/{}api/dcim/sites/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -67,7 +73,7 @@ class SiteTest(APITestCase):
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/api/dcim/sites/1/'):
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/sites/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -76,7 +82,7 @@ class SiteTest(APITestCase):
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
def test_get_site_list_rack(self, endpoint='/api/dcim/sites/1/racks/'):
|
||||
def test_get_site_list_rack(self, endpoint='/{}api/dcim/sites/1/racks/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -91,7 +97,7 @@ class SiteTest(APITestCase):
|
||||
sorted(self.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_site_list_graphs(self, endpoint='/api/dcim/sites/1/graphs/'):
|
||||
def test_get_site_list_graphs(self, endpoint='/{}api/dcim/sites/1/graphs/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -127,6 +133,7 @@ class RackTest(APITestCase):
|
||||
'type',
|
||||
'width',
|
||||
'u_height',
|
||||
'desc_units',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
@@ -143,13 +150,14 @@ class RackTest(APITestCase):
|
||||
'type',
|
||||
'width',
|
||||
'u_height',
|
||||
'desc_units',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
'front_units',
|
||||
'rear_units'
|
||||
]
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/racks/'):
|
||||
def test_get_list(self, endpoint='/{}api/dcim/racks/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -163,7 +171,7 @@ class RackTest(APITestCase):
|
||||
sorted(SiteTest.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/api/dcim/racks/1/'):
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/racks/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -192,7 +200,7 @@ class ManufacturersTest(APITestCase):
|
||||
|
||||
nested_fields = standard_fields
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/manufacturers/'):
|
||||
def test_get_list(self, endpoint='/{}api/dcim/manufacturers/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -202,7 +210,7 @@ class ManufacturersTest(APITestCase):
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/api/dcim/manufacturers/1/'):
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/manufacturers/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -227,6 +235,9 @@ class DeviceTypeTest(APITestCase):
|
||||
'is_console_server',
|
||||
'is_pdu',
|
||||
'is_network_device',
|
||||
'subdevice_role',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
nested_fields = [
|
||||
@@ -236,7 +247,7 @@ class DeviceTypeTest(APITestCase):
|
||||
'slug'
|
||||
]
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/device-types/'):
|
||||
def test_get_list(self, endpoint='/{}api/dcim/device-types/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -246,7 +257,7 @@ class DeviceTypeTest(APITestCase):
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
def test_detail_list(self, endpoint='/api/dcim/device-types/1/'):
|
||||
def test_detail_list(self, endpoint='/{}api/dcim/device-types/1/'.format(settings.BASE_PATH)):
|
||||
# TODO: details returns list view.
|
||||
# response = self.client.get(endpoint)
|
||||
# content = json.loads(response.content)
|
||||
@@ -270,7 +281,7 @@ class DeviceRolesTest(APITestCase):
|
||||
|
||||
nested_fields = ['id', 'name', 'slug']
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/device-roles/'):
|
||||
def test_get_list(self, endpoint='/{}api/dcim/device-roles/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -280,7 +291,7 @@ class DeviceRolesTest(APITestCase):
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/api/dcim/device-roles/1/'):
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/device-roles/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -298,7 +309,7 @@ class PlatformsTest(APITestCase):
|
||||
|
||||
nested_fields = ['id', 'name', 'slug']
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/platforms/'):
|
||||
def test_get_list(self, endpoint='/{}api/dcim/platforms/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -308,7 +319,7 @@ class PlatformsTest(APITestCase):
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/api/dcim/platforms/1/'):
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/platforms/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -346,7 +357,7 @@ class DeviceTest(APITestCase):
|
||||
|
||||
nested_fields = ['id', 'name', 'display_name']
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/devices/'):
|
||||
def test_get_list(self, endpoint='/{}api/dcim/devices/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -373,7 +384,7 @@ class DeviceTest(APITestCase):
|
||||
sorted(RackTest.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_list_flat(self, endpoint='/api/dcim/devices/?format=json_flat'):
|
||||
def test_get_list_flat(self, endpoint='/{}api/dcim/devices/?format=json_flat'.format(settings.BASE_PATH)):
|
||||
|
||||
flat_fields = [
|
||||
'asset_tag',
|
||||
@@ -421,7 +432,7 @@ class DeviceTest(APITestCase):
|
||||
sorted(flat_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/api/dcim/devices/1/'):
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/devices/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -439,7 +450,7 @@ class ConsoleServerPortsTest(APITestCase):
|
||||
|
||||
nested_fields = ['id', 'device', 'name']
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/devices/9/console-server-ports/'):
|
||||
def test_get_list(self, endpoint='/{}api/dcim/devices/9/console-server-ports/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -461,7 +472,7 @@ class ConsolePortsTest(APITestCase):
|
||||
|
||||
nested_fields = ['id', 'device', 'name']
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/devices/1/console-ports/'):
|
||||
def test_get_list(self, endpoint='/{}api/dcim/devices/1/console-ports/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -479,7 +490,7 @@ class ConsolePortsTest(APITestCase):
|
||||
sorted(ConsoleServerPortsTest.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/api/dcim/console-ports/1/'):
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/console-ports/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -500,7 +511,7 @@ class PowerPortsTest(APITestCase):
|
||||
|
||||
nested_fields = ['id', 'device', 'name']
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/devices/1/power-ports/'):
|
||||
def test_get_list(self, endpoint='/{}api/dcim/devices/1/power-ports/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -514,7 +525,7 @@ class PowerPortsTest(APITestCase):
|
||||
sorted(DeviceTest.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/api/dcim/power-ports/1/'):
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/power-ports/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -535,7 +546,7 @@ class PowerOutletsTest(APITestCase):
|
||||
|
||||
nested_fields = ['id', 'device', 'name']
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/devices/11/power-outlets/'):
|
||||
def test_get_list(self, endpoint='/{}api/dcim/devices/11/power-outlets/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -585,7 +596,7 @@ class InterfaceTest(APITestCase):
|
||||
'connection_status',
|
||||
]
|
||||
|
||||
def test_get_list(self, endpoint='/api/dcim/devices/1/interfaces/'):
|
||||
def test_get_list(self, endpoint='/{}api/dcim/devices/1/interfaces/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -599,7 +610,7 @@ class InterfaceTest(APITestCase):
|
||||
sorted(DeviceTest.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/api/dcim/interfaces/1/'):
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/interfaces/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -612,7 +623,7 @@ class InterfaceTest(APITestCase):
|
||||
sorted(DeviceTest.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_graph_list(self, endpoint='/api/dcim/interfaces/1/graphs/'):
|
||||
def test_get_graph_list(self, endpoint='/{}api/dcim/interfaces/1/graphs/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -622,7 +633,8 @@ class InterfaceTest(APITestCase):
|
||||
sorted(SiteTest.graph_fields),
|
||||
)
|
||||
|
||||
def test_get_interface_connections(self, endpoint='/api/dcim/interface-connections/4/'):
|
||||
def test_get_interface_connections(self, endpoint='/{}api/dcim/interface-connections/4/'
|
||||
.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -643,9 +655,8 @@ class RelatedConnectionsTest(APITestCase):
|
||||
'interfaces',
|
||||
]
|
||||
|
||||
def test_get_list(self, endpoint=(
|
||||
'/api/dcim/related-connections/'
|
||||
'?peer-device=test1-edge1&peer-interface=xe-0/0/3')):
|
||||
def test_get_list(self, endpoint=('/{}api/dcim/related-connections/?peer-device=test1-edge1&peer-interface=xe-0/0/3'
|
||||
.format(settings.BASE_PATH))):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
from django.conf.urls import url
|
||||
|
||||
from ipam.views import ServiceEditView
|
||||
from secrets.views import secret_add
|
||||
|
||||
from . import views
|
||||
from .models import (
|
||||
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, PowerPortTemplate, PowerOutletTemplate,
|
||||
InterfaceTemplate,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
@@ -75,6 +72,7 @@ urlpatterns = [
|
||||
|
||||
# Interface templates
|
||||
url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateAddView.as_view(), name='devicetype_add_interface'),
|
||||
url(r'^device-types/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
|
||||
url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
|
||||
|
||||
# Device bay templates
|
||||
@@ -107,44 +105,60 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
|
||||
url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
|
||||
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
|
||||
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
|
||||
|
||||
# Console ports
|
||||
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'),
|
||||
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortAddView.as_view(), name='consoleport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.consoleport_edit, name='consoleport_edit'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.consoleport_delete, name='consoleport_delete'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
|
||||
|
||||
# Console server ports
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.consoleserverport_add, name='consoleserverport_add'),
|
||||
url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortAddView.as_view(), name='consoleserverport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.consoleserverport_edit, name='consoleserverport_edit'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.consoleserverport_delete, name='consoleserverport_delete'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
|
||||
|
||||
# Power ports
|
||||
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.powerport_add, name='powerport_add'),
|
||||
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortAddView.as_view(), name='powerport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.powerport_edit, name='powerport_edit'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.powerport_delete, name='powerport_delete'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
|
||||
|
||||
# Power outlets
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.poweroutlet_add, name='poweroutlet_add'),
|
||||
url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletAddView.as_view(), name='poweroutlet_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.poweroutlet_edit, name='poweroutlet_edit'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
|
||||
|
||||
# Interfaces
|
||||
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceAddView.as_view(), name='interface_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
|
||||
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
|
||||
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
|
||||
|
||||
# Device bays
|
||||
url(r'^devices/(?P<pk>\d+)/bays/add/$', views.devicebay_add, name='devicebay_add'),
|
||||
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
|
||||
url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayAddView.as_view(), name='devicebay_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.devicebay_edit, name='devicebay_edit'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.devicebay_delete, name='devicebay_delete'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
|
||||
|
||||
@@ -156,18 +170,9 @@ urlpatterns = [
|
||||
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
|
||||
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
|
||||
|
||||
# Interfaces
|
||||
url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_add_multi'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
|
||||
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
|
||||
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.interface_edit, name='interface_edit'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.interface_delete, name='interface_delete'),
|
||||
|
||||
# Modules
|
||||
url(r'^devices/(?P<pk>\d+)/modules/add/$', views.module_add, name='module_add'),
|
||||
url(r'^modules/(?P<pk>\d+)/edit/$', views.module_edit, name='module_edit'),
|
||||
url(r'^modules/(?P<pk>\d+)/delete/$', views.module_delete, name='module_delete'),
|
||||
url(r'^devices/(?P<device>\d+)/modules/add/$', views.ModuleEditView.as_view(), name='module_add'),
|
||||
url(r'^modules/(?P<pk>\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'),
|
||||
url(r'^modules/(?P<pk>\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'),
|
||||
|
||||
]
|
||||
|
||||
1047
netbox/dcim/views.py
1047
netbox/dcim/views.py
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction
|
||||
|
||||
@@ -40,7 +41,7 @@ class GraphAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(ExportTemplate)
|
||||
class ExportTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ['content_type', 'name', 'mime_type', 'file_extension']
|
||||
list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension']
|
||||
|
||||
|
||||
@admin.register(TopologyMap)
|
||||
@@ -54,4 +55,7 @@ class TopologyMapAdmin(admin.ModelAdmin):
|
||||
@admin.register(UserAction)
|
||||
class UserActionAdmin(admin.ModelAdmin):
|
||||
actions = None
|
||||
list_display = ['user', 'action', 'content_type', 'object_id', 'message']
|
||||
list_display = ['user', 'action', 'content_type', 'object_id', '_message']
|
||||
|
||||
def _message(self, obj):
|
||||
return mark_safe(obj.message)
|
||||
|
||||
@@ -80,7 +80,7 @@ class TopologyMapView(APIView):
|
||||
|
||||
# Add each device to the graph
|
||||
devices = []
|
||||
for query in device_set.split(','):
|
||||
for query in device_set.split(';'): # Split regexes on semicolons
|
||||
devices += Device.objects.filter(name__regex=query)
|
||||
for d in devices:
|
||||
subgraph.node(d.name)
|
||||
@@ -94,7 +94,7 @@ class TopologyMapView(APIView):
|
||||
# Compile list of all devices
|
||||
device_superset = Q()
|
||||
for device_set in tmap.device_sets:
|
||||
for query in device_set.split(','):
|
||||
for query in device_set.split(';'): # Split regexes on semicolons
|
||||
device_superset = device_superset | Q(name__regex=query)
|
||||
|
||||
# Add all connections to the graph
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_filters
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from .models import CustomField
|
||||
from .models import CF_TYPE_SELECT, CustomField
|
||||
|
||||
|
||||
class CustomFieldFilter(django_filters.Filter):
|
||||
@@ -10,9 +10,22 @@ 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, cf_type, *args, **kwargs):
|
||||
self.cf_type = cf_type
|
||||
super(CustomFieldFilter, self).__init__(*args, **kwargs)
|
||||
|
||||
def filter(self, queryset, value):
|
||||
# Skip filter on empty value
|
||||
if not value.strip():
|
||||
return queryset
|
||||
# Treat 0 as None for Select fields
|
||||
try:
|
||||
if self.cf_type == CF_TYPE_SELECT and int(value) == 0:
|
||||
return queryset.exclude(
|
||||
custom_field_values__field__name=self.name,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(
|
||||
custom_field_values__field__name=self.name,
|
||||
custom_field_values__serialized_value=value,
|
||||
@@ -30,4 +43,4 @@ class CustomFieldFilterSet(django_filters.FilterSet):
|
||||
obj_type = ContentType.objects.get_for_model(self._meta.model)
|
||||
custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
|
||||
for cf in custom_fields:
|
||||
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name)
|
||||
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)
|
||||
|
||||
@@ -3,6 +3,7 @@ from collections import OrderedDict
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from utilities.forms import BulkEditForm, LaxURLField
|
||||
from .models import (
|
||||
CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue
|
||||
)
|
||||
@@ -43,22 +44,18 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
|
||||
|
||||
# Date
|
||||
elif cf.type == CF_TYPE_DATE:
|
||||
field = forms.DateField(required=cf.required, initial=cf.default)
|
||||
field = forms.DateField(required=cf.required, initial=cf.default, help_text="Date format: YYYY-MM-DD")
|
||||
|
||||
# Select
|
||||
elif cf.type == CF_TYPE_SELECT:
|
||||
if bulk_edit:
|
||||
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
|
||||
if not cf.required:
|
||||
choices = [(0, 'None')] + choices
|
||||
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
|
||||
if not cf.required or bulk_edit or filterable_only:
|
||||
choices = [(None, '---------')] + choices
|
||||
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
|
||||
else:
|
||||
field = forms.ModelChoiceField(queryset=cf.choices.all(), required=cf.required)
|
||||
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
|
||||
|
||||
# URL
|
||||
elif cf.type == CF_TYPE_URL:
|
||||
field = forms.URLField(required=cf.required, initial=cf.default)
|
||||
field = LaxURLField(required=cf.required, initial=cf.default)
|
||||
|
||||
# Text
|
||||
else:
|
||||
@@ -66,7 +63,8 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
|
||||
|
||||
field.model = cf
|
||||
field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
|
||||
field.help_text = cf.description
|
||||
if cf.description:
|
||||
field.help_text = cf.description
|
||||
|
||||
field_dict[field_name] = field
|
||||
|
||||
@@ -74,10 +72,10 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
|
||||
|
||||
|
||||
class CustomFieldForm(forms.ModelForm):
|
||||
custom_fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
self.custom_fields = []
|
||||
self.obj_type = ContentType.objects.get_for_model(self._meta.model)
|
||||
|
||||
super(CustomFieldForm, self).__init__(*args, **kwargs)
|
||||
@@ -94,7 +92,7 @@ class CustomFieldForm(forms.ModelForm):
|
||||
existing_values = CustomFieldValue.objects.filter(obj_type=self.obj_type, obj_id=self.instance.pk)\
|
||||
.select_related('field')
|
||||
for cfv in existing_values:
|
||||
self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.value
|
||||
self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value
|
||||
|
||||
def _save_custom_fields(self):
|
||||
|
||||
@@ -127,22 +125,24 @@ class CustomFieldForm(forms.ModelForm):
|
||||
return obj
|
||||
|
||||
|
||||
class CustomFieldBulkEditForm(forms.Form):
|
||||
custom_fields = []
|
||||
|
||||
def __init__(self, model, *args, **kwargs):
|
||||
|
||||
self.obj_type = ContentType.objects.get_for_model(model)
|
||||
class CustomFieldBulkEditForm(BulkEditForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(CustomFieldBulkEditForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.custom_fields = []
|
||||
self.obj_type = ContentType.objects.get_for_model(self.model)
|
||||
|
||||
# Add all applicable CustomFields to the form
|
||||
custom_fields = []
|
||||
for name, field in get_custom_fields_for_model(self.obj_type, bulk_edit=True).items():
|
||||
custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items()
|
||||
for name, field in custom_fields:
|
||||
# Annotate non-required custom fields as nullable
|
||||
if not field.required:
|
||||
self.nullable_fields.append(name)
|
||||
field.required = False
|
||||
self.fields[name] = field
|
||||
custom_fields.append(name)
|
||||
self.custom_fields = custom_fields
|
||||
# Annotate this as a custom field
|
||||
self.custom_fields.append(name)
|
||||
|
||||
|
||||
class CustomFieldFilterForm(forms.Form):
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-09-27 20:20
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0002_custom_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exporttemplate',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='exporttemplate',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-11-03 18:33
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from extras.models import TopologyMap
|
||||
|
||||
|
||||
def commas_to_semicolons(apps, schema_editor):
|
||||
for tm in TopologyMap.objects.filter(device_patterns__contains=','):
|
||||
tm.device_patterns = tm.device_patterns.replace(',', ';')
|
||||
tm.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0003_exporttemplate_add_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='topologymap',
|
||||
name='device_patterns',
|
||||
field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
|
||||
),
|
||||
migrations.RunPython(commas_to_semicolons),
|
||||
]
|
||||
@@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe
|
||||
|
||||
|
||||
CUSTOMFIELD_MODELS = (
|
||||
'site', 'rack', 'device', # DCIM
|
||||
'site', 'rack', 'devicetype', 'device', # DCIM
|
||||
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM
|
||||
'provider', 'circuit', # Circuits
|
||||
'tenant', # Tenants
|
||||
@@ -67,7 +67,18 @@ ACTION_CHOICES = (
|
||||
|
||||
class CustomFieldModel(object):
|
||||
|
||||
def custom_fields(self):
|
||||
def cf(self):
|
||||
"""
|
||||
Name-based CustomFieldValue accessor for use in templates
|
||||
"""
|
||||
if not hasattr(self, 'get_custom_fields'):
|
||||
return dict()
|
||||
return {field.name: value for field, value in self.get_custom_fields().items()}
|
||||
|
||||
def get_custom_fields(self):
|
||||
"""
|
||||
Return a dictionary of custom fields for a single object in the form {<field>: value}.
|
||||
"""
|
||||
|
||||
# Find all custom fields applicable to this type of object
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
@@ -119,7 +130,7 @@ class CustomField(models.Model):
|
||||
if self.type == CF_TYPE_SELECT:
|
||||
# Could be ModelChoiceField or TypedChoiceField
|
||||
return str(value.id) if hasattr(value, 'id') else str(value)
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
def deserialize_value(self, serialized_value):
|
||||
"""
|
||||
@@ -135,8 +146,10 @@ class CustomField(models.Model):
|
||||
# Read date as YYYY-MM-DD
|
||||
return date(*[int(n) for n in serialized_value.split('-')])
|
||||
if self.type == CF_TYPE_SELECT:
|
||||
# return CustomFieldChoice.objects.get(pk=int(serialized_value))
|
||||
return self.choices.get(pk=int(serialized_value))
|
||||
try:
|
||||
return self.choices.get(pk=int(serialized_value))
|
||||
except CustomFieldChoice.DoesNotExist:
|
||||
return None
|
||||
return serialized_value
|
||||
|
||||
|
||||
@@ -152,7 +165,7 @@ class CustomFieldValue(models.Model):
|
||||
unique_together = ['field', 'obj_type', 'obj_id']
|
||||
|
||||
def __unicode__(self):
|
||||
return '{} {}'.format(self.obj, self.field)
|
||||
return u'{} {}'.format(self.obj, self.field)
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
@@ -187,6 +200,12 @@ class CustomFieldChoice(models.Model):
|
||||
if self.field.type != CF_TYPE_SELECT:
|
||||
raise ValidationError("Custom field choices can only be assigned to selection fields.")
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
# When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
|
||||
pk = self.pk
|
||||
super(CustomFieldChoice, self).delete(using, keep_parents)
|
||||
CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
|
||||
|
||||
|
||||
class Graph(models.Model):
|
||||
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
|
||||
@@ -214,7 +233,8 @@ class Graph(models.Model):
|
||||
|
||||
class ExportTemplate(models.Model):
|
||||
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
|
||||
name = models.CharField(max_length=200)
|
||||
name = models.CharField(max_length=100)
|
||||
description = models.CharField(max_length=200, blank=True)
|
||||
template_code = models.TextField()
|
||||
mime_type = models.CharField(max_length=15, blank=True)
|
||||
file_extension = models.CharField(max_length=15, blank=True)
|
||||
@@ -248,10 +268,11 @@ class TopologyMap(models.Model):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True)
|
||||
device_patterns = models.TextField(help_text="Identify devices to include in the diagram using regular expressions,"
|
||||
"one per line. Each line will result in a new tier of the drawing. "
|
||||
"Separate multiple regexes on a line using commas. Devices will be "
|
||||
"rendered in the order they are defined.")
|
||||
device_patterns = models.TextField(
|
||||
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
|
||||
"result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
|
||||
"Devices will be rendered in the order they are defined."
|
||||
)
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -28,7 +28,7 @@ class RIRAdmin(admin.ModelAdmin):
|
||||
prepopulated_fields = {
|
||||
'slug': ['name'],
|
||||
}
|
||||
list_display = ['name', 'slug']
|
||||
list_display = ['name', 'slug', 'is_private']
|
||||
|
||||
|
||||
@admin.register(Aggregate)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
|
||||
from dcim.api.serializers import DeviceNestedSerializer, InterfaceNestedSerializer, SiteNestedSerializer
|
||||
from extras.api.serializers import CustomFieldSerializer
|
||||
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
from tenancy.api.serializers import TenantNestedSerializer
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ class RIRSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = RIR
|
||||
fields = ['id', 'name', 'slug']
|
||||
fields = ['id', 'name', 'slug', 'is_private']
|
||||
|
||||
|
||||
class RIRNestedSerializer(RIRSerializer):
|
||||
|
||||
class Meta(RIRSerializer.Meta):
|
||||
pass
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
@@ -138,7 +138,7 @@ class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description',
|
||||
fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||
'custom_fields']
|
||||
|
||||
|
||||
@@ -159,8 +159,8 @@ class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside',
|
||||
'custom_fields']
|
||||
fields = ['id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside',
|
||||
'nat_outside', 'custom_fields']
|
||||
|
||||
|
||||
class IPAddressNestedSerializer(IPAddressSerializer):
|
||||
@@ -170,3 +170,22 @@ class IPAddressNestedSerializer(IPAddressSerializer):
|
||||
|
||||
IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer()
|
||||
IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer()
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceSerializer(serializers.ModelSerializer):
|
||||
device = DeviceNestedSerializer()
|
||||
ipaddresses = IPAddressNestedSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
|
||||
|
||||
class ServiceNestedSerializer(ServiceSerializer):
|
||||
|
||||
class Meta(ServiceSerializer.Meta):
|
||||
fields = ['id', 'name', 'port', 'protocol']
|
||||
|
||||
@@ -37,4 +37,8 @@ urlpatterns = [
|
||||
url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
|
||||
url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),
|
||||
|
||||
# Services
|
||||
url(r'^services/$', ServiceListView.as_view(), name='service_list'),
|
||||
url(r'^services/(?P<pk>\d+)/$', ServiceDetailView.as_view(), name='service_detail'),
|
||||
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from rest_framework import generics
|
||||
|
||||
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
from ipam import filters
|
||||
|
||||
from extras.api.views import CustomFieldModelAPIView
|
||||
@@ -177,3 +177,24 @@ class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.VLANSerializer
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceListView(generics.ListAPIView):
|
||||
"""
|
||||
List services (filterable)
|
||||
"""
|
||||
queryset = Service.objects.select_related('device').prefetch_related('ipaddresses')
|
||||
serializer_class = serializers.ServiceSerializer
|
||||
filter_class = filters.ServiceFilter
|
||||
|
||||
|
||||
class ServiceDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single service
|
||||
"""
|
||||
queryset = Service.objects.select_related('device').prefetch_related('ipaddresses')
|
||||
serializer_class = serializers.ServiceSerializer
|
||||
|
||||
@@ -7,8 +7,9 @@ from django.db.models import Q
|
||||
from dcim.models import Site, Device, Interface
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NullableModelMultipleChoiceFilter
|
||||
|
||||
from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
|
||||
|
||||
class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
@@ -21,12 +22,12 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
lookup_type='icontains',
|
||||
label='Name',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -42,7 +43,14 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['name', 'rd']
|
||||
fields = ['rd']
|
||||
|
||||
|
||||
class RIRFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RIR
|
||||
fields = ['is_private']
|
||||
|
||||
|
||||
class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
@@ -56,7 +64,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
label='RIR (ID)',
|
||||
)
|
||||
rir = django_filters.ModelMultipleChoiceFilter(
|
||||
name='rir',
|
||||
name='rir__slug',
|
||||
queryset=RIR.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='RIR (slug)',
|
||||
@@ -64,7 +72,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['family', 'rir_id', 'rir', 'date_added']
|
||||
fields = ['family', 'date_added']
|
||||
|
||||
def search(self, queryset, value):
|
||||
qs_filter = Q(description__icontains=value)
|
||||
@@ -85,29 +93,34 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
action='search_by_parent',
|
||||
label='Parent prefix',
|
||||
)
|
||||
vrf = django_filters.MethodFilter(
|
||||
action='_vrf',
|
||||
vrf_id = NullableModelMultipleChoiceFilter(
|
||||
name='vrf_id',
|
||||
queryset=VRF.objects.all(),
|
||||
label='VRF',
|
||||
)
|
||||
# Duplicate of `vrf` for backward-compatibility
|
||||
vrf_id = django_filters.MethodFilter(
|
||||
action='_vrf',
|
||||
label='VRF',
|
||||
vrf = NullableModelMultipleChoiceFilter(
|
||||
name='vrf',
|
||||
queryset=VRF.objects.all(),
|
||||
to_field_name='rd',
|
||||
label='VRF (RD)',
|
||||
)
|
||||
tenant_id = django_filters.MethodFilter(
|
||||
action='_tenant_id',
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = django_filters.MethodFilter(
|
||||
action='_tenant',
|
||||
label='Tenant',
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
site_id = NullableModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
site = NullableModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -122,12 +135,12 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
name='vlan__vid',
|
||||
label='VLAN number (1-4095)',
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
role_id = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=Role.objects.all(),
|
||||
label='Role (ID)',
|
||||
)
|
||||
role = django_filters.ModelMultipleChoiceFilter(
|
||||
role = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=Role.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -136,7 +149,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['family', 'site_id', 'site', 'vrf', 'vrf_id', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
|
||||
fields = ['family', 'status']
|
||||
|
||||
def search(self, queryset, value):
|
||||
qs_filter = Q(description__icontains=value)
|
||||
@@ -157,17 +170,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
except AddrFormatError:
|
||||
return queryset.none()
|
||||
|
||||
def _vrf(self, queryset, value):
|
||||
if str(value) == '':
|
||||
return queryset
|
||||
try:
|
||||
vrf_id = int(value)
|
||||
except ValueError:
|
||||
return queryset.none()
|
||||
if vrf_id == 0:
|
||||
return queryset.filter(vrf__isnull=True)
|
||||
return queryset.filter(vrf__pk=value)
|
||||
|
||||
def _tenant(self, queryset, value):
|
||||
if str(value) == '':
|
||||
return queryset
|
||||
@@ -196,22 +198,27 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
action='search_by_parent',
|
||||
label='Parent prefix',
|
||||
)
|
||||
vrf = django_filters.MethodFilter(
|
||||
action='_vrf',
|
||||
vrf_id = NullableModelMultipleChoiceFilter(
|
||||
name='vrf_id',
|
||||
queryset=VRF.objects.all(),
|
||||
label='VRF',
|
||||
)
|
||||
# Duplicate of `vrf` for backward-compatibility
|
||||
vrf_id = django_filters.MethodFilter(
|
||||
action='_vrf',
|
||||
label='VRF',
|
||||
vrf = NullableModelMultipleChoiceFilter(
|
||||
name='vrf',
|
||||
queryset=VRF.objects.all(),
|
||||
to_field_name='rd',
|
||||
label='VRF (RD)',
|
||||
)
|
||||
tenant_id = django_filters.MethodFilter(
|
||||
action='_tenant_id',
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = django_filters.MethodFilter(
|
||||
action='_tenant',
|
||||
label='Tenant',
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='interface__device',
|
||||
@@ -219,7 +226,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
label='Device (ID)',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
name='interface__device',
|
||||
name='interface__device__name',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
@@ -232,7 +239,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['q', 'family', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id']
|
||||
fields = ['q', 'family', 'status']
|
||||
|
||||
def search(self, queryset, value):
|
||||
qs_filter = Q(description__icontains=value)
|
||||
@@ -253,35 +260,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
except AddrFormatError:
|
||||
return queryset.none()
|
||||
|
||||
def _vrf(self, queryset, value):
|
||||
if str(value) == '':
|
||||
return queryset
|
||||
try:
|
||||
vrf_id = int(value)
|
||||
except ValueError:
|
||||
return queryset.none()
|
||||
if vrf_id == 0:
|
||||
return queryset.filter(vrf__isnull=True)
|
||||
return queryset.filter(vrf__pk=value)
|
||||
|
||||
def _tenant(self, queryset, value):
|
||||
if str(value) == '':
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(tenant__slug=value) |
|
||||
Q(tenant__isnull=True, vrf__tenant__slug=value)
|
||||
)
|
||||
|
||||
def _tenant_id(self, queryset, value):
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
return queryset.none()
|
||||
return queryset.filter(
|
||||
Q(tenant__pk=value) |
|
||||
Q(tenant__isnull=True, vrf__tenant__pk=value)
|
||||
)
|
||||
|
||||
|
||||
class VLANGroupFilter(django_filters.FilterSet):
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
@@ -290,7 +268,7 @@ class VLANGroupFilter(django_filters.FilterSet):
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
name='site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
@@ -298,7 +276,6 @@ class VLANGroupFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ['site_id', 'site']
|
||||
|
||||
|
||||
class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
@@ -312,17 +289,17 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
name='site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
group_id = NullableModelMultipleChoiceFilter(
|
||||
name='group',
|
||||
queryset=VLANGroup.objects.all(),
|
||||
label='Group (ID)',
|
||||
)
|
||||
group = django_filters.ModelMultipleChoiceFilter(
|
||||
group = NullableModelMultipleChoiceFilter(
|
||||
name='group',
|
||||
queryset=VLANGroup.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -337,23 +314,23 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
name='vid',
|
||||
label='VLAN number (1-4095)',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
role_id = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=Role.objects.all(),
|
||||
label='Role (ID)',
|
||||
)
|
||||
role = django_filters.ModelMultipleChoiceFilter(
|
||||
role = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=Role.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -362,7 +339,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['site_id', 'site', 'vid', 'name', 'status', 'role_id', 'role']
|
||||
fields = ['status']
|
||||
|
||||
def search(self, queryset, value):
|
||||
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
|
||||
@@ -371,3 +348,21 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class ServiceFilter(django_filters.FilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device__name',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['name', 'protocol', 'port']
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "ARIN",
|
||||
"slug": "arin"
|
||||
"slug": "arin",
|
||||
"is_private": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -51,7 +52,8 @@
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "RIPE",
|
||||
"slug": "ripe"
|
||||
"slug": "ripe",
|
||||
"is_private": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -59,7 +61,8 @@
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "APNIC",
|
||||
"slug": "apnic"
|
||||
"slug": "apnic",
|
||||
"is_private": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -67,7 +70,8 @@
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"name": "LACNIC",
|
||||
"slug": "lacnic"
|
||||
"slug": "lacnic",
|
||||
"is_private": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -75,7 +79,8 @@
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"name": "AFRINIC",
|
||||
"slug": "afrinic"
|
||||
"slug": "afrinic",
|
||||
"is_private": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -83,7 +88,8 @@
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"name": "RFC 1918",
|
||||
"slug": "rfc-1918"
|
||||
"slug": "rfc-1918",
|
||||
"is_private": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
from django import forms
|
||||
from django.db.models import Count
|
||||
|
||||
from dcim.models import Site, Device, Interface
|
||||
from dcim.models import Site, Rack, Device, Interface
|
||||
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||
from tenancy.forms import bulkedit_tenant_choices
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField
|
||||
from utilities.forms import (
|
||||
APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch,
|
||||
SlugField, add_blank_choice,
|
||||
)
|
||||
|
||||
from .models import (
|
||||
Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF,
|
||||
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
|
||||
VLANGroup, VLAN_STATUS_CHOICES, VRF,
|
||||
)
|
||||
|
||||
|
||||
FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES
|
||||
FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES
|
||||
IP_FAMILY_CHOICES = [
|
||||
('', 'All'),
|
||||
(4, 'IPv4'),
|
||||
@@ -21,18 +22,6 @@ IP_FAMILY_CHOICES = [
|
||||
]
|
||||
|
||||
|
||||
def bulkedit_vrf_choices():
|
||||
"""
|
||||
Include an option to assign the object to the global table.
|
||||
"""
|
||||
choices = [
|
||||
(None, '---------'),
|
||||
(0, 'Global'),
|
||||
]
|
||||
choices += [(v.pk, v.name) for v in VRF.objects.all()]
|
||||
return choices
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
#
|
||||
@@ -59,37 +48,43 @@ class VRFFromCSVForm(forms.ModelForm):
|
||||
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
|
||||
|
||||
|
||||
class VRFImportForm(BulkImportForm, BootstrapMixin):
|
||||
class VRFImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=VRFFromCSVForm)
|
||||
|
||||
|
||||
class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
def vrf_tenant_choices():
|
||||
tenant_choices = Tenant.objects.annotate(vrf_count=Count('vrfs'))
|
||||
return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
|
||||
class Meta:
|
||||
nullable_fields = ['tenant', 'description']
|
||||
|
||||
|
||||
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = VRF
|
||||
tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
|
||||
null_option=(0, None))
|
||||
|
||||
|
||||
#
|
||||
# RIRs
|
||||
#
|
||||
|
||||
class RIRForm(forms.ModelForm, BootstrapMixin):
|
||||
class RIRForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
model = RIR
|
||||
fields = ['name', 'slug']
|
||||
fields = ['name', 'slug', 'is_private']
|
||||
|
||||
|
||||
class RIRFilterForm(BootstrapMixin, forms.Form):
|
||||
is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[
|
||||
('', '---------'),
|
||||
('True', 'Yes'),
|
||||
('False', 'No'),
|
||||
]))
|
||||
|
||||
|
||||
#
|
||||
@@ -117,7 +112,7 @@ class AggregateFromCSVForm(forms.ModelForm):
|
||||
fields = ['prefix', 'rir', 'date_added', 'description']
|
||||
|
||||
|
||||
class AggregateImportForm(BulkImportForm, BootstrapMixin):
|
||||
class AggregateImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=AggregateFromCSVForm)
|
||||
|
||||
|
||||
@@ -127,24 +122,22 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
date_added = forms.DateField(required=False)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
def aggregate_rir_choices():
|
||||
rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates'))
|
||||
return [(r.slug, u'{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices]
|
||||
class Meta:
|
||||
nullable_fields = ['date_added', 'description']
|
||||
|
||||
|
||||
class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Aggregate
|
||||
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
|
||||
rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR',
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug',
|
||||
label='RIR')
|
||||
|
||||
|
||||
#
|
||||
# Roles
|
||||
#
|
||||
|
||||
class RoleForm(forms.ModelForm, BootstrapMixin):
|
||||
class RoleForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
@@ -165,15 +158,7 @@ class PrefixForm(BootstrapMixin, CustomFieldForm):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'description']
|
||||
help_texts = {
|
||||
'prefix': "IPv4 or IPv6 network",
|
||||
'vrf': "VRF (if applicable)",
|
||||
'site': "The site to which this prefix is assigned (if applicable)",
|
||||
'vlan': "The VLAN to which this prefix is assigned (if applicable)",
|
||||
'status': "Operational status of this prefix",
|
||||
'role': "The primary function of this prefix",
|
||||
}
|
||||
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'is_pool', 'description']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(PrefixForm, self).__init__(*args, **kwargs)
|
||||
@@ -188,16 +173,6 @@ class PrefixForm(BootstrapMixin, CustomFieldForm):
|
||||
else:
|
||||
self.fields['vlan'].choices = []
|
||||
|
||||
def clean_prefix(self):
|
||||
prefix = self.cleaned_data['prefix']
|
||||
if prefix.version == 4 and prefix.prefixlen == 32:
|
||||
raise forms.ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 "
|
||||
"addresses instead.")
|
||||
elif prefix.version == 6 and prefix.prefixlen == 128:
|
||||
raise forms.ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 "
|
||||
"addresses instead.")
|
||||
return prefix
|
||||
|
||||
|
||||
class PrefixFromCSVForm(forms.ModelForm):
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
|
||||
@@ -214,7 +189,7 @@ class PrefixFromCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role',
|
||||
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'is_pool',
|
||||
'description']
|
||||
|
||||
def clean(self):
|
||||
@@ -246,41 +221,28 @@ class PrefixFromCSVForm(forms.ModelForm):
|
||||
self.add_error('vlan_vid', "Must specify site and/or VLAN group when assigning a VLAN.")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
m = super(PrefixFromCSVForm, self).save(commit=False)
|
||||
|
||||
# Assign Prefix status by name
|
||||
m.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
|
||||
if kwargs.get('commit'):
|
||||
m.save()
|
||||
return m
|
||||
self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
|
||||
|
||||
return super(PrefixFromCSVForm, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class PrefixImportForm(BulkImportForm, BootstrapMixin):
|
||||
class PrefixImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=PrefixFromCSVForm)
|
||||
|
||||
|
||||
class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
|
||||
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False)
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
status = forms.ChoiceField(choices=add_blank_choice(PREFIX_STATUS_CHOICES), required=False)
|
||||
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
def prefix_vrf_choices():
|
||||
vrf_choices = VRF.objects.annotate(prefix_count=Count('prefixes'))
|
||||
return [(v.pk, u'{} ({})'.format(v.name, v.prefix_count)) for v in vrf_choices]
|
||||
|
||||
|
||||
def tenant_choices():
|
||||
tenant_choices = Tenant.objects.all()
|
||||
return [(t.slug, t.name) for t in tenant_choices]
|
||||
|
||||
|
||||
def prefix_site_choices():
|
||||
site_choices = Site.objects.annotate(prefix_count=Count('prefixes'))
|
||||
return [(s.slug, u'{} ({})'.format(s.name, s.prefix_count)) for s in site_choices]
|
||||
class Meta:
|
||||
nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
|
||||
|
||||
|
||||
def prefix_status_choices():
|
||||
@@ -290,27 +252,21 @@ def prefix_status_choices():
|
||||
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
|
||||
|
||||
|
||||
def prefix_role_choices():
|
||||
role_choices = Role.objects.annotate(prefix_count=Count('prefixes'))
|
||||
return [(r.slug, u'{} ({})'.format(r.name, r.prefix_count)) for r in role_choices]
|
||||
|
||||
|
||||
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Prefix
|
||||
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
|
||||
'placeholder': 'Network',
|
||||
}))
|
||||
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
|
||||
vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF',
|
||||
widget=forms.SelectMultiple(attrs={'size': 6}))
|
||||
tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
|
||||
widget=forms.SelectMultiple(attrs={'size': 6}))
|
||||
status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 6}))
|
||||
site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 6}))
|
||||
role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 6}))
|
||||
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd',
|
||||
label='VRF', null_option=(0, 'Global'))
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
|
||||
|
||||
|
||||
@@ -323,20 +279,17 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
|
||||
widget=forms.Select(attrs={'filter-for': 'nat_device'}))
|
||||
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
|
||||
widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}',
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'nat_inside'}))
|
||||
livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
|
||||
query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address')
|
||||
)
|
||||
nat_inside = forms.ModelChoiceField(queryset=IPAddress.objects.all(), required=False, label='NAT (Inside)',
|
||||
widget=APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}',
|
||||
display_field='address'))
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['address', 'vrf', 'tenant', 'nat_device', 'nat_inside', 'description']
|
||||
help_texts = {
|
||||
'address': "IPv4 or IPv6 address and mask",
|
||||
'vrf': "VRF (if applicable)",
|
||||
fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description']
|
||||
widgets = {
|
||||
'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address')
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -379,11 +332,43 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
|
||||
self.fields['nat_inside'].choices = []
|
||||
|
||||
|
||||
class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
|
||||
address = ExpandableIPAddressField()
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
|
||||
widget=forms.Select(attrs={'filter-for': 'rack'}))
|
||||
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
|
||||
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', display_field='display_name', attrs={'filter-for': 'device'}))
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
|
||||
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', display_field='display_name', attrs={'filter-for': 'interface'}))
|
||||
livesearch = forms.CharField(required=False, label='Device', widget=Livesearch(
|
||||
query_key='q', query_url='dcim-api:device_list', field_to_update='device')
|
||||
)
|
||||
interface = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface',
|
||||
widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/'))
|
||||
set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(IPAddressAssignForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields['rack'].choices = []
|
||||
self.fields['device'].choices = []
|
||||
self.fields['interface'].choices = []
|
||||
|
||||
|
||||
class IPAddressFromCSVForm(forms.ModelForm):
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
|
||||
error_messages={'invalid_choice': 'VRF not found.'})
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in IPADDRESS_STATUS_CHOICES])
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Device not found.'})
|
||||
interface_name = forms.CharField(required=False)
|
||||
@@ -391,7 +376,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['address', 'vrf', 'tenant', 'device', 'interface_name', 'is_primary', 'description']
|
||||
fields = ['address', 'vrf', 'tenant', 'status_name', 'device', 'interface_name', 'is_primary', 'description']
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -414,7 +399,10 @@ class IPAddressFromCSVForm(forms.ModelForm):
|
||||
if is_primary and not device:
|
||||
self.add_error('is_primary', "No device specified; cannot set as primary IP")
|
||||
|
||||
def save(self, commit=True):
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Assign status by name
|
||||
self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
|
||||
|
||||
# Set interface
|
||||
if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
|
||||
@@ -427,23 +415,29 @@ class IPAddressFromCSVForm(forms.ModelForm):
|
||||
elif self.instance.address.version == 6:
|
||||
self.instance.primary_ip6_for = self.cleaned_data['device']
|
||||
|
||||
return super(IPAddressFromCSVForm, self).save(commit=commit)
|
||||
return super(IPAddressFromCSVForm, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class IPAddressImportForm(BulkImportForm, BootstrapMixin):
|
||||
class IPAddressImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=IPAddressFromCSVForm)
|
||||
|
||||
|
||||
class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
status = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_STATUS_CHOICES), required=False)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['vrf', 'tenant', 'description']
|
||||
|
||||
def ipaddress_vrf_choices():
|
||||
vrf_choices = VRF.objects.annotate(ipaddress_count=Count('ip_addresses'))
|
||||
return [(v.pk, u'{} ({})'.format(v.name, v.ipaddress_count)) for v in vrf_choices]
|
||||
|
||||
def ipaddress_status_choices():
|
||||
status_counts = {}
|
||||
for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
|
||||
|
||||
|
||||
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
@@ -452,17 +446,18 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
'placeholder': 'Prefix',
|
||||
}))
|
||||
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
|
||||
vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF',
|
||||
widget=forms.SelectMultiple(attrs={'size': 6}))
|
||||
tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant',
|
||||
widget=forms.SelectMultiple(attrs={'size': 6}))
|
||||
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd',
|
||||
label='VRF', null_option=(0, 'Global'))
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
|
||||
to_field_name='slug', null_option=(0, 'None'))
|
||||
status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
|
||||
|
||||
|
||||
#
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupForm(forms.ModelForm, BootstrapMixin):
|
||||
class VLANGroupForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
@@ -470,14 +465,8 @@ class VLANGroupForm(forms.ModelForm, BootstrapMixin):
|
||||
fields = ['site', 'name', 'slug']
|
||||
|
||||
|
||||
def vlangroup_site_choices():
|
||||
site_choices = Site.objects.annotate(vlangroup_count=Count('vlan_groups'))
|
||||
return [(s.slug, u'{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices]
|
||||
|
||||
|
||||
class VLANGroupFilterForm(forms.Form, BootstrapMixin):
|
||||
site = forms.MultipleChoiceField(required=False, choices=vlangroup_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug')
|
||||
|
||||
|
||||
#
|
||||
@@ -519,7 +508,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
|
||||
|
||||
class VLANFromCSVForm(forms.ModelForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Device not found.'})
|
||||
error_messages={'invalid_choice': 'Site not found.'})
|
||||
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'VLAN group not found.'})
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
@@ -541,7 +530,7 @@ class VLANFromCSVForm(forms.ModelForm):
|
||||
return m
|
||||
|
||||
|
||||
class VLANImportForm(BulkImportForm, BootstrapMixin):
|
||||
class VLANImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=VLANFromCSVForm)
|
||||
|
||||
|
||||
@@ -549,25 +538,13 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
|
||||
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
status = forms.ChoiceField(choices=add_blank_choice(VLAN_STATUS_CHOICES), required=False)
|
||||
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
def vlan_site_choices():
|
||||
site_choices = Site.objects.annotate(vlan_count=Count('vlans'))
|
||||
return [(s.slug, u'{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
|
||||
|
||||
|
||||
def vlan_group_choices():
|
||||
group_choices = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
|
||||
return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices]
|
||||
|
||||
|
||||
def vlan_tenant_choices():
|
||||
tenant_choices = Tenant.objects.annotate(vrf_count=Count('vlans'))
|
||||
return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices]
|
||||
class Meta:
|
||||
nullable_fields = ['group', 'tenant', 'role', 'description']
|
||||
|
||||
|
||||
def vlan_status_choices():
|
||||
@@ -577,19 +554,35 @@ def vlan_status_choices():
|
||||
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
|
||||
|
||||
|
||||
def vlan_role_choices():
|
||||
role_choices = Role.objects.annotate(vlan_count=Count('vlans'))
|
||||
return [(r.slug, u'{} ({})'.format(r.name, r.vlan_count)) for r in role_choices]
|
||||
|
||||
|
||||
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = VLAN
|
||||
site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
tenant = forms.MultipleChoiceField(required=False, choices=vlan_tenant_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
|
||||
role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug')
|
||||
group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
|
||||
null_option=(0, 'None'))
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
|
||||
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['name', 'protocol', 'port', 'ipaddresses', 'description']
|
||||
help_texts = {
|
||||
'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be "
|
||||
"reachable via all IPs assigned to the device.",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(ServiceForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit IP address choices to those assigned to interfaces of the parent device
|
||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device)
|
||||
|
||||
19
netbox/ipam/migrations/0008_prefix_change_order.py
Normal file
19
netbox/ipam/migrations/0008_prefix_change_order.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-09-15 16:08
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0007_prefix_ipaddress_add_tenant'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='prefix',
|
||||
options={'ordering': ['vrf', 'family', 'prefix'], 'verbose_name_plural': 'prefixes'},
|
||||
),
|
||||
]
|
||||
20
netbox/ipam/migrations/0009_ipaddress_add_status.py
Normal file
20
netbox/ipam/migrations/0009_ipaddress_add_status.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-10-21 15:44
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0008_prefix_change_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ipaddress',
|
||||
name='status',
|
||||
field=models.PositiveSmallIntegerField(choices=[(1, b'Active'), (2, b'Reserved'), (5, b'DHCP')], default=1, verbose_name=b'Status'),
|
||||
),
|
||||
]
|
||||
27
netbox/ipam/migrations/0010_ipaddress_help_texts.py
Normal file
27
netbox/ipam/migrations/0010_ipaddress_help_texts.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-11-01 17:46
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import ipam.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0009_ipaddress_add_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='ipaddress',
|
||||
name='address',
|
||||
field=ipam.fields.IPAddressField(help_text=b'IPv4 or IPv6 address (with mask)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ipaddress',
|
||||
name='nat_inside',
|
||||
field=models.OneToOneField(blank=True, help_text=b'The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name=b'NAT (Inside)'),
|
||||
),
|
||||
]
|
||||
20
netbox/ipam/migrations/0011_rir_add_is_private.py
Normal file
20
netbox/ipam/migrations/0011_rir_add_is_private.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-12-06 18:27
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0010_ipaddress_help_texts'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rir',
|
||||
name='is_private',
|
||||
field=models.BooleanField(default=False, help_text=b'IP space managed by this RIR is considered private', verbose_name=b'Private'),
|
||||
),
|
||||
]
|
||||
39
netbox/ipam/migrations/0012_services.py
Normal file
39
netbox/ipam/migrations/0012_services.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-12-15 20:22
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0022_color_names_to_rgb'),
|
||||
('ipam', '0011_rir_add_is_private'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Service',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateField(auto_now_add=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=30)),
|
||||
('protocol', models.PositiveSmallIntegerField(choices=[(6, b'TCP'), (17, b'UDP')])),
|
||||
('port', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], verbose_name=b'Port number')),
|
||||
('description', models.CharField(blank=True, max_length=100)),
|
||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name=b'device')),
|
||||
('ipaddresses', models.ManyToManyField(blank=True, related_name='services', to='ipam.IPAddress', verbose_name=b'IP addresses')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['device', 'protocol', 'port'],
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='service',
|
||||
unique_together=set([('device', 'protocol', 'port')]),
|
||||
),
|
||||
]
|
||||
37
netbox/ipam/migrations/0013_prefix_add_is_pool.py
Normal file
37
netbox/ipam/migrations/0013_prefix_add_is_pool.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2016-12-27 19:34
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import ipam.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0012_services'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='prefix',
|
||||
name='is_pool',
|
||||
field=models.BooleanField(default=False, help_text=b'All IP addresses within this prefix are considered usable', verbose_name=b'Is a pool'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='prefix',
|
||||
name='prefix',
|
||||
field=ipam.fields.IPNetworkField(help_text=b'IPv4 or IPv6 network with mask'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='prefix',
|
||||
name='role',
|
||||
field=models.ForeignKey(blank=True, help_text=b'The primary function of this prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='prefix',
|
||||
name='status',
|
||||
field=models.PositiveSmallIntegerField(choices=[(0, b'Container'), (1, b'Active'), (2, b'Reserved'), (3, b'Deprecated')], default=1, help_text=b'Operational status of this prefix', verbose_name=b'Status'),
|
||||
),
|
||||
]
|
||||
@@ -12,6 +12,7 @@ from dcim.models import Interface
|
||||
from extras.models import CustomFieldModel, CustomFieldValue
|
||||
from tenancy.models import Tenant
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
from utilities.sql import NullsFirstQuerySet
|
||||
|
||||
from .fields import IPNetworkField, IPAddressField
|
||||
|
||||
@@ -21,17 +22,33 @@ AF_CHOICES = (
|
||||
(6, 'IPv6'),
|
||||
)
|
||||
|
||||
PREFIX_STATUS_CONTAINER = 0
|
||||
PREFIX_STATUS_ACTIVE = 1
|
||||
PREFIX_STATUS_RESERVED = 2
|
||||
PREFIX_STATUS_DEPRECATED = 3
|
||||
PREFIX_STATUS_CHOICES = (
|
||||
(0, 'Container'),
|
||||
(1, 'Active'),
|
||||
(2, 'Reserved'),
|
||||
(3, 'Deprecated')
|
||||
(PREFIX_STATUS_CONTAINER, 'Container'),
|
||||
(PREFIX_STATUS_ACTIVE, 'Active'),
|
||||
(PREFIX_STATUS_RESERVED, 'Reserved'),
|
||||
(PREFIX_STATUS_DEPRECATED, 'Deprecated')
|
||||
)
|
||||
|
||||
IPADDRESS_STATUS_ACTIVE = 1
|
||||
IPADDRESS_STATUS_RESERVED = 2
|
||||
IPADDRESS_STATUS_DHCP = 5
|
||||
IPADDRESS_STATUS_CHOICES = (
|
||||
(IPADDRESS_STATUS_ACTIVE, 'Active'),
|
||||
(IPADDRESS_STATUS_RESERVED, 'Reserved'),
|
||||
(IPADDRESS_STATUS_DHCP, 'DHCP')
|
||||
)
|
||||
|
||||
VLAN_STATUS_ACTIVE = 1
|
||||
VLAN_STATUS_RESERVED = 2
|
||||
VLAN_STATUS_DEPRECATED = 3
|
||||
VLAN_STATUS_CHOICES = (
|
||||
(1, 'Active'),
|
||||
(2, 'Reserved'),
|
||||
(3, 'Deprecated')
|
||||
(VLAN_STATUS_ACTIVE, 'Active'),
|
||||
(VLAN_STATUS_RESERVED, 'Reserved'),
|
||||
(VLAN_STATUS_DEPRECATED, 'Deprecated')
|
||||
)
|
||||
|
||||
STATUS_CHOICE_CLASSES = {
|
||||
@@ -39,9 +56,19 @@ STATUS_CHOICE_CLASSES = {
|
||||
1: 'primary',
|
||||
2: 'info',
|
||||
3: 'danger',
|
||||
4: 'warning',
|
||||
5: 'success',
|
||||
}
|
||||
|
||||
|
||||
IP_PROTOCOL_TCP = 6
|
||||
IP_PROTOCOL_UDP = 17
|
||||
IP_PROTOCOL_CHOICES = (
|
||||
(IP_PROTOCOL_TCP, 'TCP'),
|
||||
(IP_PROTOCOL_UDP, 'UDP'),
|
||||
)
|
||||
|
||||
|
||||
class VRF(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
|
||||
@@ -84,6 +111,8 @@ class RIR(models.Model):
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
is_private = models.BooleanField(default=False, verbose_name='Private',
|
||||
help_text='IP space managed by this RIR is considered private')
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -130,16 +159,22 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
|
||||
if self.pk:
|
||||
covering_aggregates = covering_aggregates.exclude(pk=self.pk)
|
||||
if covering_aggregates:
|
||||
raise ValidationError("{} is already covered by an existing aggregate ({})"
|
||||
.format(self.prefix, covering_aggregates[0]))
|
||||
raise ValidationError({
|
||||
'prefix': "Aggregates cannot overlap. {} is already covered by an existing aggregate ({}).".format(
|
||||
self.prefix, covering_aggregates[0]
|
||||
)
|
||||
})
|
||||
|
||||
# Ensure that the aggregate being added does not cover an existing aggregate
|
||||
covered_aggregates = Aggregate.objects.filter(prefix__net_contained=str(self.prefix))
|
||||
if self.pk:
|
||||
covered_aggregates = covered_aggregates.exclude(pk=self.pk)
|
||||
if covered_aggregates:
|
||||
raise ValidationError("{} is overlaps with an existing aggregate ({})"
|
||||
.format(self.prefix, covered_aggregates[0]))
|
||||
raise ValidationError({
|
||||
'prefix': "Aggregates cannot overlap. {} covers an existing aggregate ({}).".format(
|
||||
self.prefix, covered_aggregates[0]
|
||||
)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.prefix:
|
||||
@@ -192,7 +227,7 @@ class Role(models.Model):
|
||||
return self.vlans.count()
|
||||
|
||||
|
||||
class PrefixQuerySet(models.QuerySet):
|
||||
class PrefixQuerySet(NullsFirstQuerySet):
|
||||
|
||||
def annotate_depth(self, limit=None):
|
||||
"""
|
||||
@@ -234,22 +269,26 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
||||
assigned to a VLAN where appropriate.
|
||||
"""
|
||||
family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
|
||||
prefix = IPNetworkField()
|
||||
prefix = IPNetworkField(help_text="IPv4 or IPv6 network with mask")
|
||||
site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True)
|
||||
vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
|
||||
verbose_name='VRF')
|
||||
tenant = models.ForeignKey(Tenant, related_name='prefixes', blank=True, null=True, on_delete=models.PROTECT)
|
||||
vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
|
||||
verbose_name='VLAN')
|
||||
status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=1)
|
||||
role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True)
|
||||
status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=PREFIX_STATUS_ACTIVE,
|
||||
help_text="Operational status of this prefix")
|
||||
role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True,
|
||||
help_text="The primary function of this prefix")
|
||||
is_pool = models.BooleanField(verbose_name='Is a pool', default=False,
|
||||
help_text="All IP addresses within this prefix are considered usable")
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
objects = PrefixQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['family', 'prefix']
|
||||
ordering = ['vrf', 'family', 'prefix']
|
||||
verbose_name_plural = 'prefixes'
|
||||
|
||||
def __unicode__(self):
|
||||
@@ -259,14 +298,17 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
||||
return reverse('ipam:prefix', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Disallow host masks
|
||||
if self.prefix:
|
||||
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
|
||||
raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses "
|
||||
"instead.")
|
||||
raise ValidationError({
|
||||
'prefix': "Cannot create host addresses (/32) as prefixes. Create an IPv4 address instead."
|
||||
})
|
||||
elif self.prefix.version == 6 and self.prefix.prefixlen == 128:
|
||||
raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses "
|
||||
"instead.")
|
||||
raise ValidationError({
|
||||
'prefix': "Cannot create host addresses (/128) as prefixes. Create an IPv6 address instead."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.prefix:
|
||||
@@ -282,8 +324,11 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.vrf.rd if self.vrf else '',
|
||||
self.tenant.name if self.tenant else '',
|
||||
self.site.name if self.site else '',
|
||||
self.vlan.group.name if self.vlan and self.vlan.group else '',
|
||||
str(self.vlan.vid) if self.vlan else '',
|
||||
self.get_status_display(),
|
||||
self.role.name if self.role else '',
|
||||
'True' if self.is_pool else '',
|
||||
self.description,
|
||||
])
|
||||
|
||||
@@ -328,14 +373,16 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
|
||||
which has a NAT outside IP, that Interface's Device can use either the inside or outside IP as its primary IP.
|
||||
"""
|
||||
family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False)
|
||||
address = IPAddressField()
|
||||
address = IPAddressField(help_text="IPv4 or IPv6 address (with mask)")
|
||||
vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True,
|
||||
verbose_name='VRF')
|
||||
tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT)
|
||||
status = models.PositiveSmallIntegerField('Status', choices=IPADDRESS_STATUS_CHOICES, default=1)
|
||||
interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True,
|
||||
null=True)
|
||||
nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True,
|
||||
null=True, verbose_name='NAT IP (inside)')
|
||||
null=True, verbose_name='NAT (Inside)',
|
||||
help_text="The IP for which this address is the \"outside\" IP")
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
@@ -359,13 +406,16 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
|
||||
duplicate_ips = IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip))\
|
||||
.exclude(pk=self.pk)
|
||||
if duplicate_ips:
|
||||
raise ValidationError("Duplicate IP address found in VRF {}: {}".format(self.vrf,
|
||||
duplicate_ips.first()))
|
||||
raise ValidationError({
|
||||
'address': "Duplicate IP address found in VRF {}: {}".format(self.vrf, duplicate_ips.first())
|
||||
})
|
||||
elif not self.vrf and settings.ENFORCE_GLOBAL_UNIQUE:
|
||||
duplicate_ips = IPAddress.objects.filter(vrf=None, address__net_host=str(self.address.ip))\
|
||||
.exclude(pk=self.pk)
|
||||
if duplicate_ips:
|
||||
raise ValidationError("Duplicate IP address found in global table: {}".format(duplicate_ips.first()))
|
||||
raise ValidationError({
|
||||
'address': "Duplicate IP address found in global table: {}".format(duplicate_ips.first())
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.address:
|
||||
@@ -386,6 +436,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
|
||||
str(self.address),
|
||||
self.vrf.rd if self.vrf else '',
|
||||
self.tenant.name if self.tenant else '',
|
||||
self.get_status_display(),
|
||||
self.device.identifier if self.device else '',
|
||||
self.interface.name if self.interface else '',
|
||||
'True' if is_primary else '',
|
||||
@@ -398,6 +449,9 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
|
||||
return self.interface.device
|
||||
return None
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CHOICE_CLASSES[self.status]
|
||||
|
||||
|
||||
class VLANGroup(models.Model):
|
||||
"""
|
||||
@@ -464,7 +518,9 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
||||
|
||||
# Validate VLAN group
|
||||
if self.group and self.group.site != self.site:
|
||||
raise ValidationError("VLAN group must belong to the assigned site ({}).".format(self.site))
|
||||
raise ValidationError({
|
||||
'group': "VLAN group must belong to the assigned site ({}).".format(self.site)
|
||||
})
|
||||
|
||||
def to_csv(self):
|
||||
return ','.join([
|
||||
@@ -484,3 +540,28 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CHOICE_CLASSES[self.status]
|
||||
|
||||
|
||||
class Service(CreatedUpdatedModel):
|
||||
"""
|
||||
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied
|
||||
to one or more specific IPAddresses belonging to the Device.
|
||||
"""
|
||||
device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device')
|
||||
name = models.CharField(max_length=30)
|
||||
protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES)
|
||||
port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)],
|
||||
verbose_name='Port number')
|
||||
ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True,
|
||||
verbose_name='IP addresses')
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['device', 'protocol', 'port']
|
||||
unique_together = ['device', 'protocol', 'port']
|
||||
|
||||
def __unicode__(self):
|
||||
return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
|
||||
|
||||
def get_parent_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
@@ -6,6 +6,25 @@ from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
|
||||
|
||||
|
||||
RIR_UTILIZATION = """
|
||||
<div class="progress">
|
||||
{% if record.stats.total %}
|
||||
<div class="progress-bar" role="progressbar" style="width: {{ record.stats.percentages.active }}%;">
|
||||
<span class="sr-only">{{ record.stats.percentages.active }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar progress-bar-info" role="progressbar" style="width: {{ record.stats.percentages.reserved }}%;">
|
||||
<span class="sr-only">{{ record.stats.percentages.reserved }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar progress-bar-danger" role="progressbar" style="width: {{ record.stats.percentages.deprecated }}%;">
|
||||
<span class="sr-only">{{ record.stats.percentages.deprecated }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ record.stats.percentages.available }}%;">
|
||||
<span class="sr-only">{{ record.stats.percentages.available }}%</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
RIR_ACTIONS = """
|
||||
{% if perms.ipam.change_rir %}
|
||||
<a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
@@ -14,7 +33,7 @@ RIR_ACTIONS = """
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% load helpers %}
|
||||
{% utilization_graph record.get_utilization %}
|
||||
{% utilization_graph value %}
|
||||
"""
|
||||
|
||||
ROLE_ACTIONS = """
|
||||
@@ -39,6 +58,14 @@ PREFIX_LINK_BRIEF = """
|
||||
</span>
|
||||
"""
|
||||
|
||||
PREFIX_ROLE_LINK = """
|
||||
{% if record.role %}
|
||||
<a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
IPADDRESS_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
|
||||
@@ -67,6 +94,22 @@ STATUS_LABEL = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLAN_PREFIXES = """
|
||||
{% for prefix in record.prefixes.all %}
|
||||
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
VLAN_ROLE_LINK = """
|
||||
{% if record.role %}
|
||||
<a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLANGROUP_ACTIONS = """
|
||||
{% if perms.ipam.change_vlangroup %}
|
||||
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
@@ -107,13 +150,25 @@ class VRFTable(BaseTable):
|
||||
class RIRTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
is_private = tables.BooleanColumn(verbose_name='Private')
|
||||
aggregate_count = tables.Column(verbose_name='Aggregates')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
stats_total = tables.Column(accessor='stats.total', verbose_name='Total',
|
||||
footer=lambda table: sum(r.stats['total'] for r in table.data))
|
||||
stats_active = tables.Column(accessor='stats.active', verbose_name='Active',
|
||||
footer=lambda table: sum(r.stats['active'] for r in table.data))
|
||||
stats_reserved = tables.Column(accessor='stats.reserved', verbose_name='Reserved',
|
||||
footer=lambda table: sum(r.stats['reserved'] for r in table.data))
|
||||
stats_deprecated = tables.Column(accessor='stats.deprecated', verbose_name='Deprecated',
|
||||
footer=lambda table: sum(r.stats['deprecated'] for r in table.data))
|
||||
stats_available = tables.Column(accessor='stats.available', verbose_name='Available',
|
||||
footer=lambda table: sum(r.stats['available'] for r in table.data))
|
||||
utilization = tables.TemplateColumn(template_code=RIR_UTILIZATION, verbose_name='Utilization')
|
||||
actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RIR
|
||||
fields = ('pk', 'name', 'aggregate_count', 'slug', 'actions')
|
||||
fields = ('pk', 'name', 'is_private', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved',
|
||||
'stats_deprecated', 'stats_available', 'utilization', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -125,13 +180,13 @@ class AggregateTable(BaseTable):
|
||||
prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate')
|
||||
rir = tables.Column(verbose_name='RIR')
|
||||
child_count = tables.Column(verbose_name='Prefixes')
|
||||
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
|
||||
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
|
||||
date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
|
||||
description = tables.Column(orderable=False, verbose_name='Description')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Aggregate
|
||||
fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description')
|
||||
fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description')
|
||||
|
||||
|
||||
#
|
||||
@@ -158,16 +213,17 @@ class RoleTable(BaseTable):
|
||||
class PrefixTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
|
||||
prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix')
|
||||
prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix', attrs={'th': {'style': 'padding-left: 17px'}})
|
||||
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
|
||||
tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
role = tables.Column(verbose_name='Role')
|
||||
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
|
||||
role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role')
|
||||
description = tables.Column(orderable=False, verbose_name='Description')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Prefix
|
||||
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'role', 'description')
|
||||
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not record.pk else '',
|
||||
}
|
||||
@@ -193,6 +249,7 @@ class PrefixBriefTable(BaseTable):
|
||||
class IPAddressTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
|
||||
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
|
||||
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
|
||||
tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
|
||||
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
|
||||
@@ -202,7 +259,7 @@ class IPAddressTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('pk', 'address', 'vrf', 'tenant', 'device', 'interface', 'description')
|
||||
fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
|
||||
}
|
||||
@@ -249,10 +306,11 @@ class VLANTable(BaseTable):
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
|
||||
name = tables.Column(verbose_name='Name')
|
||||
prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
|
||||
role = tables.Column(verbose_name='Role')
|
||||
role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLAN
|
||||
fields = ('pk', 'vid', 'site', 'group', 'name', 'tenant', 'status', 'role')
|
||||
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role')
|
||||
|
||||
@@ -51,11 +51,14 @@ urlpatterns = [
|
||||
# IP addresses
|
||||
url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'),
|
||||
url(r'^ip-addresses/add/$', views.IPAddressEditView.as_view(), name='ipaddress_add'),
|
||||
url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkAddView.as_view(), name='ipaddress_bulk_add'),
|
||||
url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
|
||||
url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
|
||||
url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/remove/$', views.ipaddress_remove, name='ipaddress_remove'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
|
||||
|
||||
# VLAN groups
|
||||
@@ -74,4 +77,8 @@ urlpatterns = [
|
||||
url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
|
||||
url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
|
||||
|
||||
# Services
|
||||
url(r'^services/(?P<pk>\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'),
|
||||
url(r'^services/(?P<pk>\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'),
|
||||
|
||||
]
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import netaddr
|
||||
from django_tables2 import RequestConfig
|
||||
import netaddr
|
||||
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.contrib import messages
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db.models import Count, Q
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from dcim.models import Device
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
|
||||
from . import filters, forms, tables
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
|
||||
from .models import (
|
||||
Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role,
|
||||
Service, VLAN, VLANGroup, VRF,
|
||||
)
|
||||
|
||||
|
||||
def add_available_prefixes(parent, prefix_list):
|
||||
@@ -31,24 +38,21 @@ def add_available_prefixes(parent, prefix_list):
|
||||
return prefix_list
|
||||
|
||||
|
||||
def add_available_ipaddresses(prefix, ipaddress_list):
|
||||
def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
|
||||
"""
|
||||
Annotate ranges of available IP addresses within a given prefix.
|
||||
Annotate ranges of available IP addresses within a given prefix. If is_pool is True, the first and last IP will be
|
||||
considered usable (regardless of mask length).
|
||||
"""
|
||||
|
||||
output = []
|
||||
prev_ip = None
|
||||
|
||||
# Ignore the "network address" for IPv4 prefixes larger than /31
|
||||
if prefix.version == 4 and prefix.prefixlen < 31:
|
||||
# Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31.
|
||||
if prefix.version == 4 and prefix.prefixlen < 31 and not is_pool:
|
||||
first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1)
|
||||
else:
|
||||
first_ip_in_prefix = netaddr.IPAddress(prefix.first)
|
||||
|
||||
# Ignore the broadcast address for IPv4 prefixes larger than /31
|
||||
if prefix.version == 4 and prefix.prefixlen < 31:
|
||||
last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1)
|
||||
else:
|
||||
first_ip_in_prefix = netaddr.IPAddress(prefix.first)
|
||||
last_ip_in_prefix = netaddr.IPAddress(prefix.last)
|
||||
|
||||
if not ipaddress_list:
|
||||
@@ -112,7 +116,7 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = VRF
|
||||
form_class = forms.VRFForm
|
||||
template_name = 'ipam/vrf_edit.html'
|
||||
cancel_url = 'ipam:vrf_list'
|
||||
obj_list_url = 'ipam:vrf_list'
|
||||
|
||||
|
||||
class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
@@ -149,17 +153,95 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
|
||||
class RIRListView(ObjectListView):
|
||||
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
|
||||
filter = filters.RIRFilter
|
||||
filter_form = forms.RIRFilterForm
|
||||
table = tables.RIRTable
|
||||
edit_permissions = ['ipam.change_rir', 'ipam.delete_rir']
|
||||
template_name = 'ipam/rir_list.html'
|
||||
|
||||
def alter_queryset(self, request):
|
||||
|
||||
if request.GET.get('family') == '6':
|
||||
family = 6
|
||||
denominator = 2 ** 64 # Count /64s for IPv6 rather than individual IPs
|
||||
else:
|
||||
family = 4
|
||||
denominator = 1
|
||||
|
||||
rirs = []
|
||||
for rir in self.queryset:
|
||||
|
||||
stats = {
|
||||
'total': 0,
|
||||
'active': 0,
|
||||
'reserved': 0,
|
||||
'deprecated': 0,
|
||||
'available': 0,
|
||||
}
|
||||
aggregate_list = Aggregate.objects.filter(family=family, rir=rir)
|
||||
for aggregate in aggregate_list:
|
||||
|
||||
queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))
|
||||
|
||||
# Find all consumed space for each prefix status (we ignore containers for this purpose).
|
||||
active_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_ACTIVE)])
|
||||
reserved_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_RESERVED)])
|
||||
deprecated_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_DEPRECATED)])
|
||||
|
||||
# Find all available prefixes by subtracting each of the existing prefix sets from the aggregate prefix.
|
||||
available_prefixes = (
|
||||
netaddr.IPSet([aggregate.prefix]) -
|
||||
netaddr.IPSet(active_prefixes) -
|
||||
netaddr.IPSet(reserved_prefixes) -
|
||||
netaddr.IPSet(deprecated_prefixes)
|
||||
)
|
||||
|
||||
# Add the size of each metric to the RIR total.
|
||||
stats['total'] += aggregate.prefix.size / denominator
|
||||
stats['active'] += netaddr.IPSet(active_prefixes).size / denominator
|
||||
stats['reserved'] += netaddr.IPSet(reserved_prefixes).size / denominator
|
||||
stats['deprecated'] += netaddr.IPSet(deprecated_prefixes).size / denominator
|
||||
stats['available'] += available_prefixes.size / denominator
|
||||
|
||||
# Calculate the percentage of total space for each prefix status.
|
||||
total = float(stats['total'])
|
||||
stats['percentages'] = {
|
||||
'active': float('{:.2f}'.format(stats['active'] / total * 100)) if total else 0,
|
||||
'reserved': float('{:.2f}'.format(stats['reserved'] / total * 100)) if total else 0,
|
||||
'deprecated': float('{:.2f}'.format(stats['deprecated'] / total * 100)) if total else 0,
|
||||
}
|
||||
stats['percentages']['available'] = (
|
||||
100 -
|
||||
stats['percentages']['active'] -
|
||||
stats['percentages']['reserved'] -
|
||||
stats['percentages']['deprecated']
|
||||
)
|
||||
rir.stats = stats
|
||||
rirs.append(rir)
|
||||
|
||||
return rirs
|
||||
|
||||
def extra_context(self):
|
||||
|
||||
totals = {
|
||||
'total': sum([rir.stats['total'] for rir in self.queryset]),
|
||||
'active': sum([rir.stats['active'] for rir in self.queryset]),
|
||||
'reserved': sum([rir.stats['reserved'] for rir in self.queryset]),
|
||||
'deprecated': sum([rir.stats['deprecated'] for rir in self.queryset]),
|
||||
'available': sum([rir.stats['available'] for rir in self.queryset]),
|
||||
}
|
||||
|
||||
return {
|
||||
'totals': totals,
|
||||
}
|
||||
|
||||
|
||||
class RIREditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.change_rir'
|
||||
model = RIR
|
||||
form_class = forms.RIRForm
|
||||
success_url = 'ipam:rir_list'
|
||||
cancel_url = 'ipam:rir_list'
|
||||
obj_list_url = 'ipam:rir_list'
|
||||
use_obj_view = False
|
||||
|
||||
|
||||
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@@ -208,7 +290,6 @@ def aggregate(request, pk):
|
||||
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
|
||||
|
||||
prefix_table = tables.PrefixTable(child_prefixes)
|
||||
prefix_table.model = Prefix
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
prefix_table.base_columns['pk'].visible = True
|
||||
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table)
|
||||
@@ -224,7 +305,7 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = Aggregate
|
||||
form_class = forms.AggregateForm
|
||||
template_name = 'ipam/aggregate_edit.html'
|
||||
cancel_url = 'ipam:aggregate_list'
|
||||
obj_list_url = 'ipam:aggregate_list'
|
||||
|
||||
|
||||
class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
@@ -270,8 +351,8 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.change_role'
|
||||
model = Role
|
||||
form_class = forms.RoleForm
|
||||
success_url = 'ipam:role_list'
|
||||
cancel_url = 'ipam:role_list'
|
||||
obj_list_url = 'ipam:role_list'
|
||||
use_obj_view = False
|
||||
|
||||
|
||||
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@@ -285,7 +366,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class PrefixListView(ObjectListView):
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'role')
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
|
||||
filter = filters.PrefixFilter
|
||||
filter_form = forms.PrefixFilterForm
|
||||
table = tables.PrefixTable
|
||||
@@ -334,7 +415,6 @@ def prefix(request, pk):
|
||||
if child_prefixes:
|
||||
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
|
||||
child_prefix_table = tables.PrefixTable(child_prefixes)
|
||||
child_prefix_table.model = Prefix
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
child_prefix_table.base_columns['pk'].visible = True
|
||||
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table)
|
||||
@@ -355,7 +435,7 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
form_class = forms.PrefixForm
|
||||
template_name = 'ipam/prefix_edit.html'
|
||||
fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
|
||||
cancel_url = 'ipam:prefix_list'
|
||||
obj_list_url = 'ipam:prefix_list'
|
||||
|
||||
|
||||
class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
@@ -393,10 +473,9 @@ def prefix_ipaddresses(request, pk):
|
||||
# Find all IPAddresses belonging to this Prefix
|
||||
ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_contained_or_equal=str(prefix.prefix))\
|
||||
.select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
|
||||
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses)
|
||||
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
|
||||
|
||||
ip_table = tables.IPAddressTable(ipaddresses)
|
||||
ip_table.model = IPAddress
|
||||
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
|
||||
ip_table.base_columns['pk'].visible = True
|
||||
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table)
|
||||
@@ -446,13 +525,80 @@ def ipaddress(request, pk):
|
||||
})
|
||||
|
||||
|
||||
@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
|
||||
def ipaddress_assign(request, pk):
|
||||
|
||||
ipaddress = get_object_or_404(IPAddress, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = forms.IPAddressAssignForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
interface = form.cleaned_data['interface']
|
||||
ipaddress.interface = interface
|
||||
ipaddress.save()
|
||||
messages.success(request, u"Assigned IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
|
||||
|
||||
if form.cleaned_data['set_as_primary']:
|
||||
device = interface.device
|
||||
if ipaddress.family == 4:
|
||||
device.primary_ip4 = ipaddress
|
||||
elif ipaddress.family == 6:
|
||||
device.primary_ip6 = ipaddress
|
||||
device.save()
|
||||
|
||||
return redirect('ipam:ipaddress', pk=ipaddress.pk)
|
||||
|
||||
else:
|
||||
form = forms.IPAddressAssignForm()
|
||||
|
||||
return render(request, 'ipam/ipaddress_assign.html', {
|
||||
'ipaddress': ipaddress,
|
||||
'form': form,
|
||||
'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
|
||||
})
|
||||
|
||||
|
||||
@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
|
||||
def ipaddress_remove(request, pk):
|
||||
|
||||
ipaddress = get_object_or_404(IPAddress, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ConfirmationForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
device = ipaddress.interface.device
|
||||
ipaddress.interface = None
|
||||
ipaddress.save()
|
||||
messages.success(request, u"Removed IP address {} from {}.".format(ipaddress, device))
|
||||
|
||||
if device.primary_ip4 == ipaddress.pk:
|
||||
device.primary_ip4 = None
|
||||
device.save()
|
||||
elif device.primary_ip6 == ipaddress.pk:
|
||||
device.primary_ip6 = None
|
||||
device.save()
|
||||
|
||||
return redirect('ipam:ipaddress', pk=ipaddress.pk)
|
||||
|
||||
else:
|
||||
form = ConfirmationForm()
|
||||
|
||||
return render(request, 'ipam/ipaddress_unassign.html', {
|
||||
'ipaddress': ipaddress,
|
||||
'form': form,
|
||||
'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
|
||||
})
|
||||
|
||||
|
||||
class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.change_ipaddress'
|
||||
model = IPAddress
|
||||
form_class = forms.IPAddressForm
|
||||
fields_initial = ['address', 'vrf']
|
||||
template_name = 'ipam/ipaddress_edit.html'
|
||||
cancel_url = 'ipam:ipaddress_list'
|
||||
obj_list_url = 'ipam:ipaddress_list'
|
||||
|
||||
|
||||
class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
@@ -461,6 +607,14 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
redirect_url = 'ipam:ipaddress_list'
|
||||
|
||||
|
||||
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
|
||||
permission_required = 'ipam.add_ipaddress'
|
||||
form = forms.IPAddressBulkAddForm
|
||||
model = IPAddress
|
||||
template_name = 'ipam/ipaddress_bulk_add.html'
|
||||
redirect_url = 'ipam:ipaddress_list'
|
||||
|
||||
|
||||
class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
permission_required = 'ipam.add_ipaddress'
|
||||
form = forms.IPAddressImportForm
|
||||
@@ -515,8 +669,8 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.change_vlangroup'
|
||||
model = VLANGroup
|
||||
form_class = forms.VLANGroupForm
|
||||
success_url = 'ipam:vlangroup_list'
|
||||
cancel_url = 'ipam:vlangroup_list'
|
||||
obj_list_url = 'ipam:vlangroup_list'
|
||||
use_obj_view = False
|
||||
|
||||
|
||||
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@@ -530,7 +684,7 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class VLANListView(ObjectListView):
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
|
||||
filter = filters.VLANFilter
|
||||
filter_form = forms.VLANFilterForm
|
||||
table = tables.VLANTable
|
||||
@@ -555,7 +709,7 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = VLAN
|
||||
form_class = forms.VLANForm
|
||||
template_name = 'ipam/vlan_edit.html'
|
||||
cancel_url = 'ipam:vlan_list'
|
||||
obj_list_url = 'ipam:vlan_list'
|
||||
|
||||
|
||||
class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
@@ -584,3 +738,24 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'ipam.delete_vlan'
|
||||
cls = VLAN
|
||||
default_redirect_url = 'ipam:vlan_list'
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.change_service'
|
||||
model = Service
|
||||
form_class = forms.ServiceForm
|
||||
template_name = 'ipam/service_edit.html'
|
||||
|
||||
def alter_obj(self, obj, args, kwargs):
|
||||
if 'device' in kwargs:
|
||||
obj.device = get_object_or_404(Device, pk=kwargs['device'])
|
||||
return obj
|
||||
|
||||
|
||||
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'ipam.delete_service'
|
||||
model = Service
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
# access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name.
|
||||
#
|
||||
# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
|
||||
ALLOWED_HOSTS = [os.environ.get('ALLOWED_HOSTS', '')]
|
||||
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(' ')
|
||||
|
||||
# PostgreSQL database configuration.
|
||||
DATABASE = {
|
||||
@@ -52,6 +52,10 @@ EMAIL = {
|
||||
# are permitted to access most data in NetBox (excluding secrets) but not make any changes.
|
||||
LOGIN_REQUIRED = os.environ.get('LOGIN_REQUIRED', False)
|
||||
|
||||
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
|
||||
# BASE_PATH = 'netbox/'
|
||||
BASE_PATH = os.environ.get('BASE_PATH', '')
|
||||
|
||||
# Setting this to True will display a "maintenance mode" banner at the top of every page.
|
||||
MAINTENANCE_MODE = os.environ.get('MAINTENANCE_MODE', False)
|
||||
|
||||
|
||||
@@ -52,6 +52,10 @@ EMAIL = {
|
||||
# are permitted to access most data in NetBox (excluding secrets) but not make any changes.
|
||||
LOGIN_REQUIRED = False
|
||||
|
||||
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
|
||||
# BASE_PATH = 'netbox/'
|
||||
BASE_PATH = ''
|
||||
|
||||
# Setting this to True will display a "maintenance mode" banner at the top of every page.
|
||||
MAINTENANCE_MODE = False
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ except ImportError:
|
||||
"the documentation.")
|
||||
|
||||
|
||||
VERSION = '1.6.0'
|
||||
VERSION = '1.8.0'
|
||||
|
||||
# Import local configuration
|
||||
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
|
||||
@@ -27,6 +27,9 @@ ADMINS = getattr(configuration, 'ADMINS', [])
|
||||
DEBUG = getattr(configuration, 'DEBUG', False)
|
||||
EMAIL = getattr(configuration, 'EMAIL', {})
|
||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
|
||||
if BASE_PATH:
|
||||
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
|
||||
MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
|
||||
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
|
||||
NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '')
|
||||
@@ -71,7 +74,7 @@ if LDAP_CONFIGURED:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. "
|
||||
"You can remove netbox/ldap.py to disable LDAP.")
|
||||
"You can remove netbox/ldap_config.py to disable LDAP.")
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -114,7 +117,8 @@ INSTALLED_APPS = (
|
||||
)
|
||||
|
||||
# Middleware
|
||||
MIDDLEWARE_CLASSES = (
|
||||
MIDDLEWARE = (
|
||||
'debug_toolbar.middleware.DebugToolbarMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
@@ -159,7 +163,7 @@ USE_TZ = True
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/1.8/howto/static-files/
|
||||
STATIC_ROOT = BASE_DIR + '/static/'
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_URL = '/{}static/'.format(BASE_PATH)
|
||||
STATICFILES_DIRS = (
|
||||
os.path.join(BASE_DIR, "project-static"),
|
||||
)
|
||||
@@ -173,8 +177,7 @@ MESSAGE_TAGS = {
|
||||
}
|
||||
|
||||
# Authentication URLs
|
||||
LOGIN_URL = '/login/'
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
LOGIN_URL = '/{}login/'.format(BASE_PATH)
|
||||
|
||||
# Secrets
|
||||
SECRETS_MIN_PUBKEY_SIZE = 2048
|
||||
@@ -183,12 +186,20 @@ SECRETS_MIN_PUBKEY_SIZE = 2048
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',)
|
||||
}
|
||||
if LOGIN_REQUIRED:
|
||||
REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',)
|
||||
|
||||
# Swagger settings (API docs)
|
||||
SWAGGER_SETTINGS = {
|
||||
'base_path': '{}/api/docs'.format(ALLOWED_HOSTS[0]),
|
||||
'base_path': '{}/{}api/docs'.format(ALLOWED_HOSTS[0], BASE_PATH),
|
||||
}
|
||||
|
||||
# Django debug toolbar
|
||||
INTERNAL_IPS = (
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
HOSTNAME = socket.gethostname()
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
from django.contrib import admin
|
||||
from django.views.defaults import page_not_found
|
||||
|
||||
from views import home, trigger_500, handle_500
|
||||
from views import home, handle_500, trigger_500
|
||||
from users.views import login, logout
|
||||
|
||||
|
||||
handler500 = handle_500
|
||||
|
||||
urlpatterns = [
|
||||
_patterns = [
|
||||
|
||||
# Default page
|
||||
url(r'^$', home, name='home'),
|
||||
@@ -35,10 +35,20 @@ urlpatterns = [
|
||||
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
|
||||
# Error testing
|
||||
url(r'^404/$', page_not_found),
|
||||
url(r'^500/$', trigger_500),
|
||||
|
||||
# Admin
|
||||
url(r'^admin/', include(admin.site.urls)),
|
||||
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
import debug_toolbar
|
||||
_patterns += [
|
||||
url(r'^__debug__/', include(debug_toolbar.urls)),
|
||||
]
|
||||
|
||||
# Prepend BASE_PATH
|
||||
urlpatterns = [
|
||||
url(r'^{}'.format(settings.BASE_PATH), include(_patterns))
|
||||
]
|
||||
|
||||
@@ -47,16 +47,20 @@ def home(request):
|
||||
})
|
||||
|
||||
|
||||
def trigger_500(request):
|
||||
"""Hot-wired method of triggering a server error to test reporting."""
|
||||
raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
|
||||
"person you are.")
|
||||
|
||||
|
||||
def handle_500(request):
|
||||
"""Custom server error handler"""
|
||||
"""
|
||||
Custom server error handler
|
||||
"""
|
||||
type_, error, traceback = sys.exc_info()
|
||||
return render(request, '500.html', {
|
||||
'exception': str(type_),
|
||||
'error': error,
|
||||
}, status=500)
|
||||
|
||||
|
||||
def trigger_500(request):
|
||||
"""
|
||||
Hot-wired method of triggering a server error to test reporting
|
||||
"""
|
||||
raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
|
||||
"person you are.")
|
||||
|
||||
@@ -13,7 +13,7 @@ body {
|
||||
}
|
||||
.container {
|
||||
width: auto;
|
||||
max-width: 1340px;
|
||||
max-width: 1600px;
|
||||
}
|
||||
.wrapper {
|
||||
min-height: 100%;
|
||||
@@ -35,7 +35,8 @@ footer p {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
/* Collapse the nav menu on displays less than 1200px wide */
|
||||
@media (max-width: 1199px) {
|
||||
.navbar-header {
|
||||
float: none;
|
||||
}
|
||||
@@ -58,7 +59,7 @@ footer p {
|
||||
max-height: none;
|
||||
}
|
||||
.navbar-nav {
|
||||
float: none!important;
|
||||
float: none !important;
|
||||
margin-top: 7.5px;
|
||||
}
|
||||
.navbar-nav>li {
|
||||
@@ -85,17 +86,27 @@ label.required {
|
||||
th.pk, td.pk {
|
||||
width: 30px;
|
||||
}
|
||||
tfoot td {
|
||||
font-weight: bold;
|
||||
}
|
||||
table.attr-table td:nth-child(1) {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
/* Paginator */
|
||||
div.paginator {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
nav ul.pagination {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
/* Racks */
|
||||
div.rack_header {
|
||||
margin-left: 36px;
|
||||
text-align: center;
|
||||
width: 200px;
|
||||
width: 230px;
|
||||
}
|
||||
ul.rack_legend {
|
||||
float: left;
|
||||
@@ -123,29 +134,16 @@ ul.rack {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
width: 230px;
|
||||
}
|
||||
ul.rack li {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
ul.rack_empty li {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #dddddd;
|
||||
height: 20px;
|
||||
}
|
||||
ul.rack li.empty:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
ul.rack_far_face {
|
||||
z-index: 100;
|
||||
}
|
||||
ul.rack_near_face {
|
||||
z-index: 200;
|
||||
}
|
||||
ul.rack li.h2u { height: 40px; }
|
||||
ul.rack li.h2u a, ul.rack li.h2u span { padding: 10px 0; }
|
||||
ul.rack li.h3u { height: 60px; }
|
||||
@@ -244,22 +242,9 @@ ul.rack li.h49u { height: 980px; }
|
||||
ul.rack li.h49u a, ul.rack li.h49u span { padding: 480px 0; }
|
||||
ul.rack li.h50u { height: 1000px; }
|
||||
ul.rack li.h50u a, ul.rack li.h50u span { padding: 490px 0; }
|
||||
ul.rack li.occupied a {
|
||||
color: #ffffff;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
ul.rack li.occupied a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
ul.rack li.occupied span {
|
||||
display: block;
|
||||
}
|
||||
ul.rack_near_face li.empty {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
ul.rack_near_face li.occupied {
|
||||
color: #474747;
|
||||
ul.rack_far_face {
|
||||
background-color: #f7f7f7;
|
||||
z-index: 100;
|
||||
}
|
||||
ul.rack_far_face li.occupied {
|
||||
background: repeating-linear-gradient(
|
||||
@@ -269,7 +254,6 @@ ul.rack_far_face li.occupied {
|
||||
#f0f0f0 7px,
|
||||
#f0f0f0 14px
|
||||
);
|
||||
color: #303030;
|
||||
}
|
||||
ul.rack_far_face li.blocked {
|
||||
background: repeating-linear-gradient(
|
||||
@@ -279,54 +263,46 @@ ul.rack_far_face li.blocked {
|
||||
#ffc7c7 7px,
|
||||
#ffc7c7 14px
|
||||
);
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
color: #303030;
|
||||
}
|
||||
ul.rack_near_face li.empty a {
|
||||
ul.rack_near_face {
|
||||
z-index: 200;
|
||||
}
|
||||
ul.rack_near_face li.occupied {
|
||||
border-top: 1px solid #474747;
|
||||
color: #474747;
|
||||
}
|
||||
ul.rack_near_face li.occupied:hover {
|
||||
background-image: url('../img/tint_20.png');
|
||||
}
|
||||
ul.rack_near_face li:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
ul.rack_near_face li.available a {
|
||||
color: #0000ff;
|
||||
display: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
ul.rack_near_face li.empty:hover {
|
||||
ul.rack_near_face li.available:hover {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
ul.rack_near_face li.empty:hover a {
|
||||
ul.rack_near_face li.available:hover a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Colors (from http://flatuicolors.com) */
|
||||
.teal { background-color: #1abc9c; }
|
||||
.green { background-color: #2ecc71; }
|
||||
.blue { background-color: #3498db; }
|
||||
.purple { background-color: #9b59b6; }
|
||||
.yellow { background-color: #f1c40f; }
|
||||
.orange { background-color: #e67e22; }
|
||||
.red { background-color: #e74c3c; }
|
||||
.light_gray { background-color: #dce2e3; }
|
||||
.medium_gray { background-color: #95a5a6; }
|
||||
.dark_gray { background-color: #34495e; }
|
||||
|
||||
/* Rack elevation coloring */
|
||||
ul.rack .teal { border-bottom: 1px solid #16a085; }
|
||||
ul.rack .teal:hover { background-color: #16a085; }
|
||||
ul.rack .green { border-bottom: 1px solid #27ae60; }
|
||||
ul.rack .green:hover { background-color: #27ae60; }
|
||||
ul.rack .blue { border-bottom: 1px solid #2980b9; }
|
||||
ul.rack .blue:hover { background-color: #2980b9; }
|
||||
ul.rack .purple { border-bottom: 1px solid #8e44ad; }
|
||||
ul.rack .purple:hover { background-color: #8e44ad; }
|
||||
ul.rack .yellow { border-bottom: 1px solid #f39c12; }
|
||||
ul.rack .yellow:hover { background-color: #f39c12; }
|
||||
ul.rack .orange { border-bottom: 1px solid #d35400; }
|
||||
ul.rack .orange:hover { background-color: #d35400; }
|
||||
ul.rack .red { border-bottom: 1px solid #c0392b; }
|
||||
ul.rack .red:hover { background-color: #c0392b; }
|
||||
ul.rack .light_gray { border-bottom: 1px solid #bdc3c7; }
|
||||
ul.rack .light_gray:hover { background-color: #bdc3c7; }
|
||||
ul.rack .medium_gray { border-bottom: 1px solid #7f8c8d; }
|
||||
ul.rack .medium_gray:hover { background-color: #7f8c8d; }
|
||||
ul.rack .dark_gray { border-bottom: 1px solid #2c3e50; }
|
||||
ul.rack .dark_gray:hover { background-color: #2c3e50; }
|
||||
ul.rack li.occupied a {
|
||||
color: #ffffff;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
ul.rack li.occupied a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
ul.rack li.occupied span {
|
||||
cursor: default;
|
||||
display: block;
|
||||
}
|
||||
li.occupied + li.available {
|
||||
border-top: 1px solid #474747;
|
||||
}
|
||||
|
||||
/* Misc */
|
||||
.banner-bottom {
|
||||
@@ -354,4 +330,4 @@ td .progress {
|
||||
}
|
||||
textarea {
|
||||
font-family: Consolas, Lucida Console, monospace;
|
||||
}
|
||||
}
|
||||
BIN
netbox/project-static/img/tint_20.png
Normal file
BIN
netbox/project-static/img/tint_20.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 B |
@@ -1,16 +1,30 @@
|
||||
$(document).ready(function() {
|
||||
|
||||
// "Select all" checkbox in a table header
|
||||
$('th input:checkbox[name=_all]').click(function (event) {
|
||||
$(this).parents('table').find('td input:checkbox').prop('checked', $(this).prop('checked'));
|
||||
// "Toggle all" checkbox (table header)
|
||||
$('#toggle_all').click(function (event) {
|
||||
$('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
|
||||
if ($(this).is(':checked')) {
|
||||
$('#select_all_box').removeClass('hidden');
|
||||
} else {
|
||||
$('#select_all').prop('checked', false);
|
||||
}
|
||||
});
|
||||
// Uncheck the "select all" checkbox if an item is unchecked
|
||||
// Uncheck the "toggle all" checkbox if an item is unchecked
|
||||
$('input:checkbox[name=pk]').click(function (event) {
|
||||
if (!$(this).attr('checked')) {
|
||||
$(this).parents('table').find('input:checkbox[name=_all]').prop('checked', false);
|
||||
$('#select_all, #toggle_all').prop('checked', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Simple "Toggle all" button (panel)
|
||||
$('button.toggle').click(function (event) {
|
||||
var selected = $(this).attr('selected');
|
||||
$(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected);
|
||||
$(this).attr('selected', !selected);
|
||||
$(this).children('span').toggleClass('glyphicon-unchecked glyphicon-check');
|
||||
return false;
|
||||
});
|
||||
|
||||
// Slugify
|
||||
function slugify(s, num_chars) {
|
||||
s = s.replace(/[^\-\.\w\s]/g, ''); // Remove unneeded chars
|
||||
@@ -25,13 +39,26 @@ $(document).ready(function() {
|
||||
});
|
||||
if (slug_field) {
|
||||
var slug_source = $('#id_' + slug_field.attr('slug-source'));
|
||||
slug_source.keyup(function() {
|
||||
slug_source.on('keyup change', function() {
|
||||
if (slug_field && !slug_field.attr('_changed')) {
|
||||
slug_field.val(slugify($(this).val(), 50));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Bulk edit nullification
|
||||
$('input:checkbox[name=_nullify]').click(function (event) {
|
||||
$('#id_' + this.value).toggle('disabled');
|
||||
});
|
||||
|
||||
// Set formaction and submit using a link
|
||||
$('a.formaction').click(function (event) {
|
||||
event.preventDefault();
|
||||
var form = $(this).closest('form');
|
||||
form.attr('action', $(this).attr('href'));
|
||||
form.submit();
|
||||
});
|
||||
|
||||
// API select widget
|
||||
$('select[filter-for]').change(function () {
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class UserKeyAdmin(admin.ModelAdmin):
|
||||
try:
|
||||
my_userkey = UserKey.objects.get(user=request.user)
|
||||
except UserKey.DoesNotExist:
|
||||
messages.error(request, "You do not have an active User Key.")
|
||||
messages.error(request, u"You do not have an active User Key.")
|
||||
return redirect('/admin/secrets/userkey/')
|
||||
|
||||
if 'activate' in request.POST:
|
||||
@@ -46,7 +46,7 @@ class UserKeyAdmin(admin.ModelAdmin):
|
||||
uk.activate(master_key)
|
||||
return redirect('/admin/secrets/userkey/')
|
||||
except ValueError:
|
||||
messages.error(request, "Invalid private key provided. Unable to retrieve master key.")
|
||||
messages.error(request, u"Invalid private key provided. Unable to retrieve master key.")
|
||||
else:
|
||||
form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)})
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ def userkey_required():
|
||||
try:
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
except UserKey.DoesNotExist:
|
||||
messages.warning(request, "This operation requires an active user key, but you don't have one.")
|
||||
messages.warning(request, u"This operation requires an active user key, but you don't have one.")
|
||||
return redirect('users:userkey')
|
||||
if not uk.is_active():
|
||||
messages.warning(request, "This operation is not available. Your user key has not been activated.")
|
||||
messages.warning(request, u"This operation is not available. Your user key has not been activated.")
|
||||
return redirect('users:userkey')
|
||||
return view(request, *args, **kwargs)
|
||||
return wrapped_view
|
||||
|
||||
@@ -17,7 +17,7 @@ class SecretFilter(django_filters.FilterSet):
|
||||
label='Role (ID)',
|
||||
)
|
||||
role = django_filters.ModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
name='role__slug',
|
||||
queryset=SecretRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
@@ -31,7 +31,7 @@ class SecretFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['name', 'role_id', 'role', 'device']
|
||||
fields = ['name']
|
||||
|
||||
def search(self, queryset, value):
|
||||
return queryset.filter(
|
||||
|
||||
@@ -5,7 +5,7 @@ from django import forms
|
||||
from django.db.models import Count
|
||||
|
||||
from dcim.models import Device
|
||||
from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, SlugField
|
||||
from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
|
||||
|
||||
from .models import Secret, SecretRole, UserKey
|
||||
|
||||
@@ -34,7 +34,7 @@ def validate_rsa_key(key, is_secret=True):
|
||||
# Secret roles
|
||||
#
|
||||
|
||||
class SecretRoleForm(forms.ModelForm, BootstrapMixin):
|
||||
class SecretRoleForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
@@ -46,25 +46,26 @@ class SecretRoleForm(forms.ModelForm, BootstrapMixin):
|
||||
# Secrets
|
||||
#
|
||||
|
||||
class SecretForm(forms.ModelForm, BootstrapMixin):
|
||||
class SecretForm(BootstrapMixin, forms.ModelForm):
|
||||
private_key = forms.CharField(required=False, widget=forms.HiddenInput())
|
||||
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
|
||||
widget=forms.TextInput(attrs={'class': 'requires-private-key'}))
|
||||
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)')
|
||||
widget=forms.PasswordInput(attrs={'class': 'requires-private-key'}))
|
||||
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
|
||||
widget=forms.PasswordInput())
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['role', 'name', 'plaintext', 'plaintext2']
|
||||
|
||||
def clean(self):
|
||||
|
||||
if self.cleaned_data['plaintext']:
|
||||
validate_rsa_key(self.cleaned_data['private_key'])
|
||||
|
||||
def clean_plaintext2(self):
|
||||
plaintext = self.cleaned_data['plaintext']
|
||||
plaintext2 = self.cleaned_data['plaintext2']
|
||||
if plaintext != plaintext2:
|
||||
raise forms.ValidationError("The two given plaintext values do not match. Please check your input.")
|
||||
if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
|
||||
raise forms.ValidationError({
|
||||
'plaintext2': "The two given plaintext values do not match. Please check your input."
|
||||
})
|
||||
|
||||
|
||||
class SecretFromCSVForm(forms.ModelForm):
|
||||
@@ -84,31 +85,29 @@ class SecretFromCSVForm(forms.ModelForm):
|
||||
return s
|
||||
|
||||
|
||||
class SecretImportForm(BulkImportForm, BootstrapMixin):
|
||||
class SecretImportForm(BootstrapMixin, BulkImportForm):
|
||||
private_key = forms.CharField(widget=forms.HiddenInput())
|
||||
csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'}))
|
||||
|
||||
|
||||
class SecretBulkEditForm(forms.Form, BootstrapMixin):
|
||||
class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
role = forms.ModelChoiceField(queryset=SecretRole.objects.all())
|
||||
role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)
|
||||
name = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
def secret_role_choices():
|
||||
role_choices = SecretRole.objects.annotate(secret_count=Count('secrets'))
|
||||
return [(r.slug, u'{} ({})'.format(r.name, r.secret_count)) for r in role_choices]
|
||||
class Meta:
|
||||
nullable_fields = ['name']
|
||||
|
||||
|
||||
class SecretFilterForm(forms.Form, BootstrapMixin):
|
||||
role = forms.MultipleChoiceField(required=False, choices=secret_role_choices)
|
||||
class SecretFilterForm(BootstrapMixin, forms.Form):
|
||||
role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')
|
||||
|
||||
|
||||
#
|
||||
# UserKeys
|
||||
#
|
||||
|
||||
class UserKeyForm(forms.ModelForm, BootstrapMixin):
|
||||
class UserKeyForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = UserKey
|
||||
|
||||
@@ -81,24 +81,34 @@ class UserKey(CreatedUpdatedModel):
|
||||
|
||||
def clean(self, *args, **kwargs):
|
||||
|
||||
# Validate the public key format and length.
|
||||
if self.public_key:
|
||||
|
||||
# Validate the public key format
|
||||
try:
|
||||
pubkey = RSA.importKey(self.public_key)
|
||||
except ValueError:
|
||||
raise ValidationError("Invalid RSA key format.")
|
||||
raise ValidationError({
|
||||
'public_key': "Invalid RSA key format."
|
||||
})
|
||||
except:
|
||||
raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're "
|
||||
"uploading a valid RSA public key in PEM format (no SSH/PGP).")
|
||||
# key.size() returns 1 less than the key modulus
|
||||
pubkey_length = pubkey.size() + 1
|
||||
|
||||
# Validate the public key length
|
||||
pubkey_length = pubkey.size() + 1 # key.size() returns 1 less than the key modulus
|
||||
if pubkey_length < settings.SECRETS_MIN_PUBKEY_SIZE:
|
||||
raise ValidationError("Insufficient key length. Keys must be at least {} bits long."
|
||||
.format(settings.SECRETS_MIN_PUBKEY_SIZE))
|
||||
raise ValidationError({
|
||||
'public_key': "Insufficient key length. Keys must be at least {} bits long.".format(
|
||||
settings.SECRETS_MIN_PUBKEY_SIZE
|
||||
)
|
||||
})
|
||||
# We can't use keys bigger than our master_key_cipher field can hold
|
||||
if pubkey_length > 4096:
|
||||
raise ValidationError("Public key size ({}) is too large. Maximum key size is 4096 bits."
|
||||
.format(pubkey_length))
|
||||
raise ValidationError({
|
||||
'public_key': "Public key size ({}) is too large. Maximum key size is 4096 bits.".format(
|
||||
pubkey_length
|
||||
)
|
||||
})
|
||||
|
||||
super(UserKey, self).clean()
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'secrets.change_secretrole'
|
||||
model = SecretRole
|
||||
form_class = forms.SecretRoleForm
|
||||
success_url = 'secrets:secretrole_list'
|
||||
cancel_url = 'secrets:secretrole_list'
|
||||
obj_list_url = 'secrets:secretrole_list'
|
||||
use_obj_view = False
|
||||
|
||||
|
||||
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
@@ -90,7 +90,7 @@ def secret_add(request, pk):
|
||||
secret.encrypt(master_key)
|
||||
secret.save()
|
||||
|
||||
messages.success(request, "Added new secret: {0}".format(secret))
|
||||
messages.success(request, u"Added new secret: {}.".format(secret))
|
||||
if '_addanother' in request.POST:
|
||||
return redirect('dcim:device_addsecret', pk=device.pk)
|
||||
else:
|
||||
@@ -135,7 +135,7 @@ def secret_edit(request, pk):
|
||||
else:
|
||||
secret = form.save()
|
||||
|
||||
messages.success(request, "Modified secret {0}".format(secret))
|
||||
messages.success(request, u"Modified secret {}.".format(secret))
|
||||
return redirect('secrets:secret', pk=secret.pk)
|
||||
|
||||
else:
|
||||
@@ -180,7 +180,7 @@ def secret_import(request):
|
||||
new_secrets.append(secret)
|
||||
|
||||
table = tables.SecretTable(new_secrets)
|
||||
messages.success(request, "Imported {} new secrets".format(len(new_secrets)))
|
||||
messages.success(request, u"Imported {} new secrets.".format(len(new_secrets)))
|
||||
|
||||
return render(request, 'import_success.html', {
|
||||
'table': table,
|
||||
|
||||
19
netbox/templates/404.html
Normal file
19
netbox/templates/404.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends '_base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row" style="margin-top: 150px;">
|
||||
<div class="col-sm-4 col-sm-offset-4">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong><i class="glyphicon glyphicon-warning-sign"></i> Page Not Found</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
The requested page does not exist.
|
||||
</div>
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'home' %}" class="btn btn-xs btn-primary">Home Page</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -26,7 +26,7 @@
|
||||
<pre><strong>{{ exception }}</strong><br />
|
||||
{{ error }}</pre>
|
||||
<div class="text-right">
|
||||
<a href="/" class="btn btn-primary">Home Page</a>
|
||||
<a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="stylesheet" href="{% static 'jquery-ui-1.11.4/jquery-ui.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/base.css' %}">
|
||||
<link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
|
||||
<meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-default navbar-fixed-top">
|
||||
@@ -20,7 +21,7 @@
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/">
|
||||
<a class="navbar-brand" href="{% url 'home' %}">
|
||||
<img src="{% static 'img/netbox_logo.png' %}" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -288,7 +289,7 @@
|
||||
<div class="col-xs-4 text-right">
|
||||
<p class="text-muted">
|
||||
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> ·
|
||||
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="/api/docs/">API</a> ·
|
||||
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'django.swagger.base.view' %}">API</a> ·
|
||||
<i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
<div class="col-sm-8 col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></li>
|
||||
<li><a href="{% url 'circuits:circuit_list' %}?provider={{ circuit.provider.slug }}">{{ circuit.provider }}</a></li>
|
||||
<li>{{ circuit.cid }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-sm-4 col-md-3">
|
||||
<form action="{% url 'circuits:circuit_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" />
|
||||
@@ -40,13 +40,14 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>{{ circuit.provider }} - {{ circuit.cid }}</h1>
|
||||
{% include 'inc/created_updated.html' with obj=circuit %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Circuit</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>Provider</td>
|
||||
<td>
|
||||
@@ -81,17 +82,6 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Speed</td>
|
||||
<td>
|
||||
{% if circuit.upstream_speed %}
|
||||
<i class="fa fa-arrow-down" title="Downstream"></i> {{ circuit.port_speed_human }}
|
||||
<i class="fa fa-arrow-up" title="Upstream"></i> {{ circuit.upstream_speed_human }}
|
||||
{% else %}
|
||||
{{ circuit.port_speed_human }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Commit Rate</td>
|
||||
<td>
|
||||
@@ -104,52 +94,9 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/created_updated.html' with obj=circuit %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Termination</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
<tr>
|
||||
<td>Site</td>
|
||||
<td>
|
||||
<a href="{% url 'dcim:site' slug=circuit.site.slug %}">{{ circuit.site }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Termination</td>
|
||||
<td>
|
||||
{% if circuit.interface %}
|
||||
<span><a href="{% url 'dcim:device' pk=circuit.interface.device.pk %}">{{ circuit.interface.device }}</a> {{ circuit.interface }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">Not defined</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cross-Connect</td>
|
||||
<td>
|
||||
{% if circuit.xconnect_id %}
|
||||
{{ circuit.xconnect_id }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Patch Panel/Port</td>
|
||||
<td>
|
||||
{% if circuit.pp_info %}
|
||||
{{ circuit.pp_info }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% with circuit.get_custom_fields as custom_fields %}
|
||||
{% include 'inc/custom_fields_panel.html' %}
|
||||
{% endwith %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Comments</strong>
|
||||
@@ -162,6 +109,10 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% include 'circuits/inc/circuit_termination.html' with termination=termination_a side='A' %}
|
||||
{% include 'circuits/inc/circuit_termination.html' with termination=termination_z side='Z' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,14 +3,21 @@
|
||||
|
||||
{% block title %}Circuit Bulk Edit{% endblock %}
|
||||
|
||||
{% block select_objects_table %}
|
||||
{% block selected_objects_table %}
|
||||
<tr>
|
||||
<th>Circuit</th>
|
||||
<th>Type</th>
|
||||
<th>Provider</th>
|
||||
<th>Port speed</th>
|
||||
<th>Commit rate</th>
|
||||
</tr>
|
||||
{% for circuit in selected_objects %}
|
||||
<tr>
|
||||
<td><a href="{% url 'circuits:circuit' pk=circuit.pk %}">{{ circuit }}</a></td>
|
||||
<td>{{ circuit.type }}</td>
|
||||
<td>{{ circuit.provider }}</td>
|
||||
<td>{{ circuit.port_speed }} Kbps</td>
|
||||
<td>{{ circuit.commit_rate }}</td>
|
||||
<td>{{ circuit.port_speed_human }}</td>
|
||||
<td>{{ circuit.commit_rate_human }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends 'utilities/obj_edit.html' %}
|
||||
{% load static from staticfiles %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
@@ -11,15 +10,6 @@
|
||||
{% render_field form.type %}
|
||||
{% render_field form.tenant %}
|
||||
{% render_field form.install_date %}
|
||||
{% render_field form.xconnect_id %}
|
||||
{% render_field form.pp_info %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Bandwidth</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.port_speed %}
|
||||
{% render_field form.upstream_speed %}
|
||||
{% render_field form.commit_rate %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,26 +21,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Termination</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.site %}
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li role="presentation" class="active"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
|
||||
<li role="presentation"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane active" id="select">
|
||||
{% render_field form.rack %}
|
||||
{% render_field form.device %}
|
||||
</div>
|
||||
<div class="tab-pane" id="search">
|
||||
{% render_field form.livesearch %}
|
||||
</div>
|
||||
</div>
|
||||
{% render_field form.interface %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Comments</strong></div>
|
||||
<div class="panel-body">
|
||||
@@ -58,7 +28,3 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script src="{% static 'js/livesearch.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -48,45 +48,20 @@
|
||||
<td>Name of tenant (optional)</td>
|
||||
<td>Strickland Propane</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Site</td>
|
||||
<td>Site name</td>
|
||||
<td>ASH-4</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Install Date</td>
|
||||
<td>Date in YYYY-MM-DD format (optional)</td>
|
||||
<td>2016-02-23</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Port Speed</td>
|
||||
<td>Physical speed in Kbps</td>
|
||||
<td>100000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Upstream Speed</td>
|
||||
<td>Upstream speed in Kbps (optional)</td>
|
||||
<td>20000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Commit rate</td>
|
||||
<td>Commited rate in Kbps (optional)</td>
|
||||
<td>2000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cross-connect ID</td>
|
||||
<td>ID of cross-connect (optional)</td>
|
||||
<td>937649</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Patch Panel</td>
|
||||
<td>Patch panel/port ID (optional)</td>
|
||||
<td>PP8371 ports 13/14</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,100000,,2000,937649,PP8371 ports 13/14</pre>
|
||||
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
25
netbox/templates/circuits/circuit_terminations_swap.html
Normal file
25
netbox/templates/circuits/circuit_terminations_swap.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
|
||||
{% block title %}Swap Circuit Terminations{% endblock %}
|
||||
|
||||
{% block message %}
|
||||
<p>Swap these terminations for circuit {{ circuit }}?</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>A side:</strong>
|
||||
{% if termination_a %}
|
||||
{{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Z side:</strong>
|
||||
{% if termination_z %}
|
||||
{{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
94
netbox/templates/circuits/circuittermination_edit.html
Normal file
94
netbox/templates/circuits/circuittermination_edit.html
Normal file
@@ -0,0 +1,94 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load staticfiles %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}
|
||||
Circuit {{ obj.circuit }} - Side {{ form.term_side.value }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="." method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
<h3>Circuit {{ obj.circuit }} - Side {{ form.term_side.value }}</h3>
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Location</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">Provider</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{{ obj.circuit.provider }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">Circuit</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{{ obj.circuit.cid }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">Termination</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{{ form.term_side.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% render_field form.site %}
|
||||
<div class="row">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li role="presentation" class="active"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
|
||||
<li role="presentation"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane active" id="select">
|
||||
{% render_field form.rack %}
|
||||
{% render_field form.device %}
|
||||
</div>
|
||||
<div class="tab-pane" id="search">
|
||||
{% render_field form.livesearch %}
|
||||
</div>
|
||||
</div>
|
||||
{% render_field form.interface %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Termination Details</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.port_speed %}
|
||||
{% render_field form.upstream_speed %}
|
||||
{% render_field form.xconnect_id %}
|
||||
{% render_field form.pp_info %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3 text-right">
|
||||
{% if obj.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
{% endif %}
|
||||
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script src="{% static 'js/livesearch.js' %}"></script>
|
||||
{% endblock %}
|
||||
95
netbox/templates/circuits/inc/circuit_termination.html
Normal file
95
netbox/templates/circuits/inc/circuit_termination.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="pull-right">
|
||||
{% if not termination and perms.circuits.add_circuittermination %}
|
||||
<a href="{% url 'circuits:circuittermination_add' circuit=circuit.pk %}?term_side={{ side }}" class="btn btn-xs btn-success">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span> Add
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if termination and perms.circuits.change_circuittermination %}
|
||||
<a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}" class="btn btn-xs btn-warning">
|
||||
<span class="fa fa-pencil" aria-hidden="true"></span> Edit
|
||||
</a>
|
||||
<a href="{% url 'circuits:circuit_terminations_swap' pk=circuit.pk %}" class="btn btn-xs btn-primary">
|
||||
<span class="fa fa-refresh" aria-hidden="true"></span> Swap
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if termination and perms.circuits.delete_circuittermination %}
|
||||
<a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}" class="btn btn-xs btn-danger">
|
||||
<span class="fa fa-trash" aria-hidden="true"></span> Delete
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<strong>Termination - {{ side }} Side</strong>
|
||||
</div>
|
||||
{% if termination %}
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>Site</td>
|
||||
<td>
|
||||
<a href="{% url 'dcim:site' slug=termination.site.slug %}">{{ termination.site }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Termination</td>
|
||||
<td>
|
||||
{% if termination.interface %}
|
||||
<span><a href="{% url 'dcim:device' pk=termination.interface.device.pk %}">{{ termination.interface.device }}</a> {{ termination.interface }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">Not defined</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Speed</td>
|
||||
<td>
|
||||
{% if termination.upstream_speed %}
|
||||
<i class="fa fa-arrow-down" title="Downstream"></i> {{ termination.port_speed_human }}
|
||||
<i class="fa fa-arrow-up" title="Upstream"></i> {{ termination.upstream_speed_human }}
|
||||
{% else %}
|
||||
{{ termination.port_speed_human }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>IP Addressing</td>
|
||||
<td>
|
||||
{% if termination.interface %}
|
||||
{% for ip in termination.interface.ip_addresses.all %}
|
||||
{% if not forloop.first %}<br />{% endif %}
|
||||
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
|
||||
{% empty %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cross-Connect</td>
|
||||
<td>
|
||||
{% if termination.xconnect_id %}
|
||||
{{ termination.xconnect_id }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Patch Panel/Port</td>
|
||||
<td>
|
||||
{% if termination.pp_info %}
|
||||
{{ termination.pp_info }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="panel-body">
|
||||
<span class="text-muted">None</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -6,13 +6,13 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
<div class="col-sm-8 col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
|
||||
<li>{{ provider }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-sm-4 col-md-3">
|
||||
<form action="{% url 'circuits:provider_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" />
|
||||
@@ -46,13 +46,14 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>{{ provider }}</h1>
|
||||
{% include 'inc/created_updated.html' with obj=provider %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Provider</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>ASN</td>
|
||||
<td>
|
||||
@@ -105,7 +106,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% with provider.custom_fields as custom_fields %}
|
||||
{% with provider.get_custom_fields as custom_fields %}
|
||||
{% include 'inc/custom_fields_panel.html' %}
|
||||
{% endwith %}
|
||||
<div class="panel panel-default">
|
||||
@@ -120,7 +121,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/created_updated.html' with obj=provider %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
@@ -134,14 +134,8 @@
|
||||
<a href="{% url 'circuits:circuit' pk=c.pk %}">{{ c.cid }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'dcim:site' slug=c.site.slug %}">{{ c.site }}</a>
|
||||
<a href="{% url 'circuits:circuit_list' %}?type={{ c.type.slug }}">{{ c.type }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if c.interface %}
|
||||
<a href="{% url 'dcim:device' pk=c.interface.device.pk %}">{{ c.interface.device }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ c.port_speed_human }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
@@ -149,6 +143,13 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.circuits.add_circuit %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'circuits:circuit_add' %}?provider={{ provider.pk }}" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add circuit
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
{% block title %}Provider Bulk Edit{% endblock %}
|
||||
|
||||
{% block select_objects_table %}
|
||||
{% block selected_objects_table %}
|
||||
<tr>
|
||||
<th>Provider</th>
|
||||
<th>Account</th>
|
||||
<th>ASN</th>
|
||||
</tr>
|
||||
{% for provider in selected_objects %}
|
||||
<tr>
|
||||
<td><a href="{% url 'circuits:provider' slug=provider.slug %}">{{ provider }}</a></td>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Delete device type components?{% endblock %}
|
||||
|
||||
{% block message %}
|
||||
<p>Are you sure you want to delete these components from <strong>{{ devicetype }}</strong>?</p>
|
||||
<ul>
|
||||
{% for o in selected_objects %}
|
||||
<li>{{ o }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
@@ -1,51 +0,0 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}{% if consoleport.pk %}Editing {{ consoleport.device }} {{ consoleport }}{% else %}Add a Console Port ({{ device }}){% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="." method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{% if consoleport.pk %}
|
||||
<strong>Editing {{ consoleport }}</strong>
|
||||
{% else %}
|
||||
<strong>Add a Console Port</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required">Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{% if consoleport %}{{ consoleport.device }}{% else %}{{ device }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% render_form form %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
{% if consoleport.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Save</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
|
||||
{% endif %}
|
||||
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,51 +0,0 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}{% if consoleserverport.pk %}Editing {{ consoleserverport.device }} {{ consoleserverport }}{% else %}Add a Console Server Port ({{ device }}){% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="." method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{% if consoleserverport.pk %}
|
||||
<strong>Editing {{ consoleserverport }}</strong>
|
||||
{% else %}
|
||||
<strong>Add a Console Server Port</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required">Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{% if consoleserverport %}{{ consoleserverport.device }}{% else %}{{ device }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% render_form form %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
{% if consoleserverport.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Save</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
|
||||
{% endif %}
|
||||
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -8,12 +8,12 @@
|
||||
{% block content %}
|
||||
{% include 'dcim/inc/_device_header.html' with active_tab='info' %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-5 col-lg-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Device</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>Tenant</td>
|
||||
<td>
|
||||
@@ -56,7 +56,7 @@
|
||||
<tr>
|
||||
<td>Device Type</td>
|
||||
<td>
|
||||
<span><a href="{% url 'dcim:devicetype' pk=device.device_type.pk %}">{{ device.device_type }}</a> ({{ device.device_type.u_height }}U)</span>
|
||||
<span><a href="{% url 'dcim:devicetype' pk=device.device_type.pk %}">{{ device.device_type.full_name }}</a> ({{ device.device_type.u_height }}U)</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -85,7 +85,7 @@
|
||||
<div class="panel-heading">
|
||||
<strong>Management</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>Role</td>
|
||||
<td>
|
||||
@@ -144,7 +144,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% with device.custom_fields as custom_fields %}
|
||||
{% with device.get_custom_fields as custom_fields %}
|
||||
{% include 'inc/custom_fields_panel.html' %}
|
||||
{% endwith %}
|
||||
{% if request.user.is_authenticated %}
|
||||
@@ -186,16 +186,44 @@
|
||||
{% include 'dcim/inc/_ipaddress.html' %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
{% elif interfaces or mgmt_interfaces %}
|
||||
<div class="panel-body text-muted">
|
||||
None found
|
||||
None assigned
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="panel-body">
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}">Create an interface</a> to assign an IP.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
{% if interfaces or mgmt_interfaces %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign IP address
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Services</strong>
|
||||
</div>
|
||||
{% if services %}
|
||||
<table class="table table-hover panel-body">
|
||||
{% for service in services %}
|
||||
{% include 'dcim/inc/_service.html' %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="panel-body text-muted">
|
||||
None
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_service %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Assign IP address
|
||||
<a href="{% url 'dcim:service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -210,7 +238,7 @@
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="alert-warning">
|
||||
<i class="fa fa-fw fa-warning"></i> No management interfaces defined!
|
||||
<i class="fa fa-fw fa-warning"></i> No management interfaces defined
|
||||
{% if perms.dcim.add_interface %}
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}?mgmt_only=1" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
@@ -222,7 +250,7 @@
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="alert-warning">
|
||||
<i class="fa fa-fw fa-warning"></i> No console ports defined!
|
||||
<i class="fa fa-fw fa-warning"></i> No console ports defined
|
||||
{% if perms.dcim.add_consoleport %}
|
||||
<a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
@@ -232,36 +260,31 @@
|
||||
{% for pp in power_ports %}
|
||||
{% include 'dcim/inc/_powerport.html' %}
|
||||
{% empty %}
|
||||
{% if not device.device_type.is_pdu %}
|
||||
<tr>
|
||||
<td colspan="5" class="alert-warning">
|
||||
<i class="fa fa-fw fa-warning"></i> No power ports defined!
|
||||
{% if perms.dcim.add_powerport %}
|
||||
<a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td colspan="5" class="alert-warning">
|
||||
<i class="fa fa-fw fa-warning"></i> No power ports defined
|
||||
{% if perms.dcim.add_powerport %}
|
||||
<a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
|
||||
<div class="panel-footer text-right">
|
||||
{% if perms.dcim.add_interface %}
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}?mgmt_only=1" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add interface
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interface
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleport %}
|
||||
<a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add console
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_powerport and not device.device_type.is_pdu %}
|
||||
{% if perms.dcim.add_powerport %}
|
||||
<a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add power
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -293,7 +316,7 @@
|
||||
<td>
|
||||
<a href="{% url 'dcim:rack' pk=rd.rack.pk %}">Rack {{ rd.rack }}</a>
|
||||
</td>
|
||||
<td>{{ rd.device_type }}</td>
|
||||
<td>{{ rd.device_type.full_name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
@@ -301,9 +324,8 @@
|
||||
<div class="panel-body text-muted">None found</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/created_updated.html' with obj=device %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-7 col-lg-6">
|
||||
{% if device_bays or device.device_type.is_parent_device %}
|
||||
{% if perms.dcim.delete_devicebay %}
|
||||
<form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}">
|
||||
@@ -312,6 +334,18 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Device Bays</strong>
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.change_devicebay and device_bays|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_devicebay and device_bays|length > 10 %}
|
||||
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
{% for devicebay in device_bays %}
|
||||
@@ -324,23 +358,19 @@
|
||||
</table>
|
||||
{% if perms.dcim.add_devicebay or perms.dcim.delete_devicebay %}
|
||||
<div class="panel-footer">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{% if device_bays and perms.dcim.delete_devicebay %}
|
||||
<button type="submit" class="btn btn-xs btn-danger">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if device_bays and perms.dcim.delete_devicebay %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_devicebay %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6 text-right">
|
||||
{% if perms.dcim.add_devicebay %}
|
||||
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add device bay
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -350,12 +380,24 @@
|
||||
{% endif %}
|
||||
{% if interfaces or device.device_type.is_network_device %}
|
||||
{% if perms.dcim.delete_interface %}
|
||||
<form method="post" action="{% url 'dcim:interface_bulk_delete' pk=device.pk %}">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Interfaces</strong>
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.change_interface and interfaces|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface and interfaces|length > 10 %}
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
{% for iface in interfaces %}
|
||||
@@ -368,23 +410,24 @@
|
||||
</table>
|
||||
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
|
||||
<div class="panel-footer">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{% if interfaces and perms.dcim.delete_interface %}
|
||||
<button type="submit" class="btn btn-xs btn-danger">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interfaces and perms.dcim.change_interface %}
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interfaces and perms.dcim.delete_interface %}
|
||||
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6 text-right">
|
||||
{% if perms.dcim.add_interface %}
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add interface
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -400,6 +443,18 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Console Server Ports</strong>
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.change_consoleserverport and cs_ports|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
|
||||
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
{% for csp in cs_ports %}
|
||||
@@ -412,23 +467,19 @@
|
||||
</table>
|
||||
{% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %}
|
||||
<div class="panel-footer">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{% if cs_ports and perms.dcim.delete_consoleserverport %}
|
||||
<button type="submit" class="btn btn-xs btn-danger">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if cs_ports and perms.dcim.delete_consoleserverport %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverport %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6 text-right">
|
||||
{% if perms.dcim.add_consoleserverport %}
|
||||
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add console server ports
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -444,6 +495,18 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Power Outlets</strong>
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.change_poweroutlet and cs_ports|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
|
||||
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
{% for po in power_outlets %}
|
||||
@@ -456,23 +519,19 @@
|
||||
</table>
|
||||
{% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %}
|
||||
<div class="panel-footer">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{% if power_outlets and perms.dcim.delete_poweroutlet %}
|
||||
<button type="submit" class="btn btn-xs btn-danger">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if power_outlets and perms.dcim.delete_poweroutlet %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlet %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6 text-right">
|
||||
{% if perms.dcim.add_poweroutlet %}
|
||||
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add power outlets
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -531,13 +590,13 @@ function toggleConnection(elem, api_url) {
|
||||
return false;
|
||||
}
|
||||
$(".consoleport-toggle").click(function() {
|
||||
return toggleConnection($(this), "/api/dcim/console-ports/");
|
||||
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/console-ports/");
|
||||
});
|
||||
$(".powerport-toggle").click(function() {
|
||||
return toggleConnection($(this), "/api/dcim/power-ports/");
|
||||
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/power-ports/");
|
||||
});
|
||||
$(".interface-toggle").click(function() {
|
||||
return toggleConnection($(this), "/api/dcim/interface-connections/");
|
||||
return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/interface-connections/");
|
||||
});
|
||||
</script>
|
||||
<script src="{% static 'js/graphs.js' %}"></script>
|
||||
|
||||
60
netbox/templates/dcim/device_bulk_add_component.html
Normal file
60
netbox/templates/dcim/device_bulk_add_component.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Add {{ component_name|title }}</h1>
|
||||
<form action="." method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% if request.POST.redirect_url %}
|
||||
<input type="hidden" name="redirect_url" value="{{ request.POST.redirect_url }}" />
|
||||
{% endif %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Selected Devices</strong></div>
|
||||
<table class="panel-body table table-hover">
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Type</th>
|
||||
<th>Role</th>
|
||||
</tr>
|
||||
{% for device in selected_devices %}
|
||||
<tr>
|
||||
<td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
|
||||
<td>{{ device.device_type.full_name }}</td>
|
||||
<td>{{ device.device_role }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>{{ component_name|title }} to Add</strong></div>
|
||||
<div class="panel-body">
|
||||
{% for field in form.visible_fields %}
|
||||
{% render_field field %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group text-right">
|
||||
<div class="col-md-12">
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -3,11 +3,18 @@
|
||||
|
||||
{% block title %}Device Bulk Edit{% endblock %}
|
||||
|
||||
{% block select_objects_table %}
|
||||
{% block selected_objects_table %}
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Type</th>
|
||||
<th>Role</th>
|
||||
<th>Tenant</th>
|
||||
<th>Serial</th>
|
||||
</tr>
|
||||
{% for device in selected_objects %}
|
||||
<tr>
|
||||
<td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>
|
||||
<td>{{ device.device_type }}</td>
|
||||
<td>{{ device.device_type.full_name }}</td>
|
||||
<td>{{ device.device_role }}</td>
|
||||
<td>{{ device.tenant }}</td>
|
||||
<td>{{ device.serial }}</td>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}{% if module %}Editing {{ module.device }} {{ module }}{% else %}Add a Module to {{ device }}{% endif %}{% endblock %}
|
||||
{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% block content %}{{ form.errors }}
|
||||
<form action="." method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
@@ -18,13 +18,13 @@
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>{% if module %}Editing {{ module.device }} {{ module }}{% else %}Add a Module to {{ device }}{% endif %}</strong>
|
||||
<strong>{{ component_type|title }}</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required">Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{% if module %}{{ module.device }}{% else %}{{ device }}{% endif %}</p>
|
||||
<p class="form-control-static">{{ parent }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% render_form form %}
|
||||
@@ -32,12 +32,8 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
{% if module.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Save</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
|
||||
{% endif %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
|
||||
<a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
<div class="panel-body">
|
||||
{% render_field form.platform %}
|
||||
{% render_field form.status %}
|
||||
{% if obj %}
|
||||
{% if obj.pk %}
|
||||
{% render_field form.primary_ip4 %}
|
||||
{% render_field form.primary_ip6 %}
|
||||
{% endif %}
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Position (U)</td>
|
||||
<td>Lowest rack unit occupied by the device (optional)</td>
|
||||
<td>Lowest-numbered rack unit occupied by the device (optional)</td>
|
||||
<td>21</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<div class="panel-heading">
|
||||
<strong>Chassis</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>Model</td>
|
||||
<td>{{ device.device_type }}</td>
|
||||
<td>{{ device.device_type.full_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Serial Number</td>
|
||||
@@ -127,7 +127,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{% if perms.dcim.add_module %}
|
||||
<a href="{% url 'dcim:module_add' pk=device.pk %}" class="btn btn-success">
|
||||
<a href="{% url 'dcim:module_add' device=device.pk %}" class="btn btn-success">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a Module
|
||||
</a>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<tr id="{{ iface }}">
|
||||
<td>{{ iface }}</td>
|
||||
{% if iface.connection %}
|
||||
{% with iface.get_connected_interface as connected_iface %}
|
||||
{% with iface.connected_interface as connected_iface %}
|
||||
<td class="configured_device" data="{{ connected_iface.device }}">
|
||||
<a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
|
||||
</td>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user