mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-30 17:17:46 -06:00
Compare commits
193 Commits
v2.0-beta2
...
v2.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7b0d22f86 | ||
|
|
5a1877087f | ||
|
|
50462ec15d | ||
|
|
1dd5e2c926 | ||
|
|
ebddc46bc0 | ||
|
|
138cbf9761 | ||
|
|
f21c6bca00 | ||
|
|
9aad8a7774 | ||
|
|
68b6c7d886 | ||
|
|
1c489e57cc | ||
|
|
6719578f14 | ||
|
|
d5587de316 | ||
|
|
77f28e3441 | ||
|
|
3fa63b774e | ||
|
|
713c7cd8e3 | ||
|
|
e6b4d87939 | ||
|
|
27c94d9874 | ||
|
|
eece8a0e26 | ||
|
|
fb85867d72 | ||
|
|
c454bfcd84 | ||
|
|
ad95b86fdd | ||
|
|
769232f368 | ||
|
|
9cf10eecd1 | ||
|
|
f927d5b8f5 | ||
|
|
7fa696dace | ||
|
|
feac93389c | ||
|
|
f7969d91b3 | ||
|
|
92aafb9043 | ||
|
|
f9328d53b4 | ||
|
|
f1cbc7da33 | ||
|
|
01becd21de | ||
|
|
7768b94279 | ||
|
|
3bc51c8e69 | ||
|
|
d206be91d5 | ||
|
|
6e69c9e375 | ||
|
|
f2846af4ec | ||
|
|
657eed1dc9 | ||
|
|
e351ab0171 | ||
|
|
779446da64 | ||
|
|
2ff0d7aa83 | ||
|
|
7ceb64b57b | ||
|
|
43e1e0dbc8 | ||
|
|
a1c12cfd77 | ||
|
|
aa6ca21a34 | ||
|
|
a49521d683 | ||
|
|
3be6e5b015 | ||
|
|
ca1725b98c | ||
|
|
d11dfe2ced | ||
|
|
ab30ba1e1b | ||
|
|
7f23cb9bf5 | ||
|
|
c9d3cf301e | ||
|
|
67282882fa | ||
|
|
73bf4f45c3 | ||
|
|
66ae62fb91 | ||
|
|
8bae804508 | ||
|
|
d87acc97c3 | ||
|
|
f9b2c59974 | ||
|
|
a870a3b918 | ||
|
|
008ed34553 | ||
|
|
e239045688 | ||
|
|
ed80bfaf02 | ||
|
|
473b35f9a3 | ||
|
|
45bb7eec0b | ||
|
|
58bb029666 | ||
|
|
0f97478b55 | ||
|
|
9efa70a551 | ||
|
|
ed65721085 | ||
|
|
83688fceb7 | ||
|
|
088f75ba0c | ||
|
|
188cfa08a9 | ||
|
|
f731900e2f | ||
|
|
b89bd24bed | ||
|
|
effda88b51 | ||
|
|
3844f70a4d | ||
|
|
8e333757f9 | ||
|
|
0fb12bcc9c | ||
|
|
44d78ef92a | ||
|
|
ebb6729a26 | ||
|
|
b1bcaa33e7 | ||
|
|
a35f8bddde | ||
|
|
8fbe7ba742 | ||
|
|
f039b0b6e9 | ||
|
|
9ad9ef7957 | ||
|
|
5c7db04465 | ||
|
|
838105fb65 | ||
|
|
5ca87c0f20 | ||
|
|
af4edff370 | ||
|
|
f40c048475 | ||
|
|
77247cccbe | ||
|
|
fcfcd77bfd | ||
|
|
b3667befb4 | ||
|
|
a6cb0e0a96 | ||
|
|
c047f943de | ||
|
|
79089cc47e | ||
|
|
3c631902e1 | ||
|
|
379c24a012 | ||
|
|
4035b87693 | ||
|
|
11d1a8c3cf | ||
|
|
7eb9c8265c | ||
|
|
572beb2311 | ||
|
|
d861d8bfb8 | ||
|
|
6791ff6192 | ||
|
|
9d9de6b2a3 | ||
|
|
1f7ef15ad1 | ||
|
|
16c582ec7a | ||
|
|
de58d0ecca | ||
|
|
010f6c7f1a | ||
|
|
aea5612c39 | ||
|
|
b8b912bdd5 | ||
|
|
e4ca88726e | ||
|
|
616f109671 | ||
|
|
8e0580ff96 | ||
|
|
4b2e7620dd | ||
|
|
b82f25c503 | ||
|
|
c174c0cc6d | ||
|
|
117da337c7 | ||
|
|
01da46f753 | ||
|
|
d17efce4f5 | ||
|
|
e7a6d1f532 | ||
|
|
f643f2c601 | ||
|
|
480faa6461 | ||
|
|
1fa084b6be | ||
|
|
1c86b00b5c | ||
|
|
10823e1c37 | ||
|
|
f73693206f | ||
|
|
861c8b29c0 | ||
|
|
17873706b7 | ||
|
|
5037046624 | ||
|
|
5c0614d656 | ||
|
|
697866d1ba | ||
|
|
38d826d152 | ||
|
|
13cc29cd8c | ||
|
|
401357b8cb | ||
|
|
599e1bb220 | ||
|
|
864fa17b75 | ||
|
|
a98c9ed0af | ||
|
|
8032aa1ad9 | ||
|
|
b01bf6089c | ||
|
|
f9a33bfc14 | ||
|
|
610b412506 | ||
|
|
09000ad9b3 | ||
|
|
f70f0f8d62 | ||
|
|
d5c3f9e780 | ||
|
|
b42dab3eef | ||
|
|
7cbea49c2d | ||
|
|
6dcc5a1169 | ||
|
|
53129125dd | ||
|
|
2d52b9fb39 | ||
|
|
863cbb785d | ||
|
|
ba1a4f06ff | ||
|
|
cf5be85dad | ||
|
|
d21b67446f | ||
|
|
3b48a270fc | ||
|
|
105e9da866 | ||
|
|
d3b16ba443 | ||
|
|
57fc6a3f50 | ||
|
|
abc51fdc5d | ||
|
|
e0ad2b4555 | ||
|
|
35a0a658a7 | ||
|
|
2c99a8bee4 | ||
|
|
1dd2bdcb8e | ||
|
|
9f67da00d1 | ||
|
|
82d53a8c3d | ||
|
|
f3eee25527 | ||
|
|
ee11775425 | ||
|
|
bcdf9ac5ca | ||
|
|
4accdf77f8 | ||
|
|
fc46f70153 | ||
|
|
e7cf7d58b8 | ||
|
|
d98e9e1838 | ||
|
|
369d3aa62e | ||
|
|
d4ac6dbfe4 | ||
|
|
91d35905fd | ||
|
|
78b0072051 | ||
|
|
7766e1f684 | ||
|
|
f89d91783b | ||
|
|
3ffe36e5ed | ||
|
|
be393a9d10 | ||
|
|
27eefd8705 | ||
|
|
097e0f38ff | ||
|
|
ce26b566a4 | ||
|
|
0e14bc1e02 | ||
|
|
ce6796ed9b | ||
|
|
c90cecc2fb | ||
|
|
b6bbcb0609 | ||
|
|
23f6832d9c | ||
|
|
88dace75a1 | ||
|
|
8eb140fd65 | ||
|
|
1f09f3d096 | ||
|
|
66be85a41f | ||
|
|
814c11167e | ||
|
|
57ddd5086f | ||
|
|
c171547037 |
@@ -45,6 +45,10 @@ sure to include:
|
||||
* Any error messages generated
|
||||
* Screenshots (if applicable)
|
||||
|
||||
* Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title.
|
||||
The issue will be reviewed by a moderator after submission and the appropriate
|
||||
labels will be applied.
|
||||
|
||||
* Keep in mind that we prioritize bugs based on their severity and how
|
||||
much work is required to resolve them. It may take some time for someone
|
||||
to address your issue.
|
||||
@@ -91,6 +95,10 @@ following:
|
||||
* Any third-party libraries or other resources which would be
|
||||
involved
|
||||
|
||||
* Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue title.
|
||||
The issue will be reviewed by a moderator after submission and the appropriate
|
||||
labels will be applied.
|
||||
|
||||
## Submitting Pull Requests
|
||||
|
||||
* Be sure to open an issue before starting work on a pull request, and
|
||||
|
||||
20
Dockerfile
20
Dockerfile
@@ -1,20 +0,0 @@
|
||||
FROM python:2.7-wheezy
|
||||
|
||||
WORKDIR /opt/netbox
|
||||
|
||||
ARG BRANCH=v2.0-beta1
|
||||
ARG URL=https://github.com/digitalocean/netbox.git
|
||||
RUN git clone --depth 1 $URL -b $BRANCH . && \
|
||||
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
|
||||
|
||||
ENTRYPOINT [ "/docker-entrypoint.sh" ]
|
||||
|
||||
ADD docker/gunicorn_config.py /opt/netbox/
|
||||
ADD docker/nginx.conf /etc/netbox-nginx/
|
||||
VOLUME ["/etc/netbox-nginx/"]
|
||||
@@ -10,7 +10,9 @@ Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https
|
||||
|
||||
### Build Status
|
||||
|
||||
| | python 2.7 |
|
||||
NetBox is built against both Python 2.7 and 3.5. Python 3.5 is recommended.
|
||||
|
||||
| | status |
|
||||
|-------------|------------|
|
||||
| **master** | [](https://travis-ci.org/digitalocean/netbox) |
|
||||
| **develop** | [](https://travis-ci.org/digitalocean/netbox) |
|
||||
@@ -29,5 +31,6 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for inst
|
||||
|
||||
## Alternative Installations
|
||||
|
||||
* [Docker container](http://netbox.readthedocs.io/en/stable/installation/docker/)
|
||||
* [Docker container](https://github.com/digitalocean/netbox-docker)
|
||||
* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))
|
||||
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant)
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:9.6
|
||||
container_name: postgres
|
||||
environment:
|
||||
POSTGRES_USER: netbox
|
||||
POSTGRES_PASSWORD: J5brHrAXFLQSif0K
|
||||
POSTGRES_DB: netbox
|
||||
netbox:
|
||||
build: .
|
||||
image: digitalocean/netbox
|
||||
links:
|
||||
- postgres
|
||||
container_name: netbox
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
SUPERUSER_NAME: admin
|
||||
SUPERUSER_EMAIL: admin@example.com
|
||||
SUPERUSER_PASSWORD: admin
|
||||
ALLOWED_HOSTS: localhost
|
||||
DB_NAME: netbox
|
||||
DB_USER: netbox
|
||||
DB_PASSWORD: J5brHrAXFLQSif0K
|
||||
DB_HOST: postgres
|
||||
SECRET_KEY: r8OwDznj!!dci#P9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNj
|
||||
EMAIL_SERVER: localhost
|
||||
EMAIL_PORT: 25
|
||||
EMAIL_USERNAME: foo
|
||||
EMAIL_PASSWORD: bar
|
||||
EMAIL_TIMEOUT: 10
|
||||
EMAIL_FROM: netbox@bar.com
|
||||
NETBOX_USERNAME: guest
|
||||
NETBOX_PASSWORD: guest
|
||||
volumes:
|
||||
- netbox-static-files:/opt/netbox/netbox/static
|
||||
nginx:
|
||||
image: nginx:1.11.1-alpine
|
||||
links:
|
||||
- netbox
|
||||
container_name: nginx
|
||||
command: nginx -g 'daemon off;' -c /etc/netbox-nginx/nginx.conf
|
||||
depends_on:
|
||||
- netbox
|
||||
ports:
|
||||
- 80:80
|
||||
volumes_from:
|
||||
- netbox
|
||||
volumes:
|
||||
netbox-static-files:
|
||||
driver: local
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# run db migrations (retry on error)
|
||||
while ! /opt/netbox/netbox/manage.py migrate 2>&1; do
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# create superuser silently
|
||||
if [[ -z ${SUPERUSER_NAME} || -z ${SUPERUSER_EMAIL} || -z ${SUPERUSER_PASSWORD} ]]; then
|
||||
SUPERUSER_NAME='admin'
|
||||
SUPERUSER_EMAIL='admin@example.com'
|
||||
SUPERUSER_PASSWORD='admin'
|
||||
echo "Using defaults: Username: ${SUPERUSER_NAME}, E-Mail: ${SUPERUSER_EMAIL}, Password: ${SUPERUSER_PASSWORD}"
|
||||
fi
|
||||
echo "from django.contrib.auth.models import User; User.objects.create_superuser('${SUPERUSER_NAME}', '${SUPERUSER_EMAIL}', '${SUPERUSER_PASSWORD}')" | python /opt/netbox/netbox/manage.py shell
|
||||
|
||||
# copy static files
|
||||
/opt/netbox/netbox/manage.py collectstatic --no-input
|
||||
|
||||
# start unicorn
|
||||
gunicorn --log-level debug --debug --error-logfile /dev/stderr --log-file /dev/stdout -c /opt/netbox/gunicorn_config.py netbox.wsgi
|
||||
@@ -1,5 +0,0 @@
|
||||
command = '/usr/bin/gunicorn'
|
||||
pythonpath = '/opt/netbox/netbox'
|
||||
bind = '0.0.0.0:8001'
|
||||
workers = 3
|
||||
user = 'root'
|
||||
@@ -1,35 +0,0 @@
|
||||
worker_processes 1;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65;
|
||||
gzip on;
|
||||
server_tokens off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
server_name localhost;
|
||||
|
||||
access_log off;
|
||||
|
||||
location /static/ {
|
||||
alias /opt/netbox/netbox/static/;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://netbox:8001;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
This guide demonstrates how to build and run NetBox as a Docker container. It assumes that the latest versions of [Docker](https://www.docker.com/) and [docker-compose](https://docs.docker.com/compose/) are already installed in your host.
|
||||
|
||||
# Quickstart
|
||||
|
||||
To get NetBox up and running:
|
||||
|
||||
```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.
|
||||
|
||||
Default credentials:
|
||||
|
||||
* Username: **admin**
|
||||
* Password: **admin**
|
||||
|
||||
# Configuration
|
||||
|
||||
You can configure the app at runtime using variables (see `docker-compose.yml`). Possible environment variables include:
|
||||
|
||||
* SUPERUSER_NAME
|
||||
* SUPERUSER_EMAIL
|
||||
* SUPERUSER_PASSWORD
|
||||
* ALLOWED_HOSTS
|
||||
* DB_NAME
|
||||
* DB_USER
|
||||
* DB_PASSWORD
|
||||
* DB_HOST
|
||||
* DB_PORT
|
||||
* SECRET_KEY
|
||||
* EMAIL_SERVER
|
||||
* EMAIL_PORT
|
||||
* EMAIL_USERNAME
|
||||
* EMAIL_PASSWORD
|
||||
* EMAIL_TIMEOUT
|
||||
* EMAIL_FROM
|
||||
* LOGIN_REQUIRED
|
||||
* MAINTENANCE_MODE
|
||||
* NETBOX_USERNAME
|
||||
* NETBOX_PASSWORD
|
||||
* PAGINATE_COUNT
|
||||
* TIME_ZONE
|
||||
* DATE_FORMAT
|
||||
* SHORT_DATE_FORMAT
|
||||
* TIME_FORMAT
|
||||
* SHORT_TIME_FORMAT
|
||||
* DATETIME_FORMAT
|
||||
* SHORT_DATETIME_FORMAT
|
||||
@@ -5,13 +5,14 @@
|
||||
Python 3:
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
|
||||
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
|
||||
# update-alternatives --install /usr/bin/python python /usr/bin/python3 1
|
||||
```
|
||||
|
||||
Python 2:
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
|
||||
# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
|
||||
```
|
||||
|
||||
**CentOS/RHEL**
|
||||
@@ -20,7 +21,9 @@ Python 3:
|
||||
|
||||
```no-highlight
|
||||
# yum install -y epel-release
|
||||
# yum install -y gcc python3 python3-devel python3-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
|
||||
# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
|
||||
# easy_install-3.4 pip
|
||||
# ln -s -f python3.4 /usr/bin/python
|
||||
```
|
||||
|
||||
Python 2:
|
||||
@@ -83,6 +86,14 @@ 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.)
|
||||
|
||||
Python 3:
|
||||
|
||||
```no-highlight
|
||||
# pip3 install -r requirements.txt
|
||||
```
|
||||
|
||||
Python 2:
|
||||
|
||||
```no-highlight
|
||||
# pip install -r requirements.txt
|
||||
```
|
||||
@@ -172,7 +183,7 @@ Superuser created successfully.
|
||||
# Collect Static Files
|
||||
|
||||
```no-highlight
|
||||
# ./manage.py collectstatic
|
||||
# ./manage.py collectstatic --no-input
|
||||
|
||||
You have requested to collect static files at the destination
|
||||
location as specified in your settings:
|
||||
|
||||
@@ -5,13 +5,14 @@ NetBox requires a PostgreSQL database to store data. (Please note that MySQL is
|
||||
**Debian/Ubuntu**
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y postgresql libpq-dev python-psycopg2
|
||||
# apt-get update
|
||||
# apt-get install -y postgresql libpq-dev
|
||||
```
|
||||
|
||||
**CentOS/RHEL**
|
||||
|
||||
```no-highlight
|
||||
# yum install -y postgresql postgresql-server postgresql-devel python-psycopg2
|
||||
# yum install -y postgresql postgresql-server postgresql-devel
|
||||
# postgresql-setup initdb
|
||||
```
|
||||
|
||||
|
||||
@@ -58,6 +58,14 @@ This script:
|
||||
* Applies any database migrations that were included in the release
|
||||
* Collects all static files to be served by the HTTP service
|
||||
|
||||
!!! note
|
||||
It's possible that the upgrade script will display a notice warning of unreflected database migrations:
|
||||
|
||||
Your models have changes that are not yet reflected in a migration, and so won't be applied.
|
||||
Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them.
|
||||
|
||||
This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are inentionally modifying the database schema.
|
||||
|
||||
# Restart the WSGI Service
|
||||
|
||||
Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `supervisorctl`:
|
||||
|
||||
@@ -25,7 +25,7 @@ server {
|
||||
|
||||
server_name netbox.example.com;
|
||||
|
||||
access_log off;
|
||||
client_max_body_size 25m;
|
||||
|
||||
location /static/ {
|
||||
alias /opt/netbox/netbox/static/;
|
||||
@@ -73,6 +73,9 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
|
||||
|
||||
Alias /static /opt/netbox/netbox/static
|
||||
|
||||
# Needed to allow token-based API authentication
|
||||
WSGIPassAuthorization on
|
||||
|
||||
<Directory /opt/netbox/netbox/static>
|
||||
Options Indexes FollowSymLinks MultiViews
|
||||
AllowOverride None
|
||||
|
||||
@@ -8,7 +8,6 @@ pages:
|
||||
- 'Web Server': 'installation/web-server.md'
|
||||
- 'LDAP (Optional)': 'installation/ldap.md'
|
||||
- 'Upgrading': 'installation/upgrading.md'
|
||||
- 'Alternate Install: Docker': 'installation/docker.md'
|
||||
- 'Configuration':
|
||||
- 'Mandatory Settings': 'configuration/mandatory-settings.md'
|
||||
- 'Optional Settings': 'configuration/optional-settings.md'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
||||
@@ -28,11 +30,14 @@ class NestedProviderSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
class WritableProviderSerializer(serializers.ModelSerializer):
|
||||
class WritableProviderSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
@@ -79,11 +84,14 @@ class NestedCircuitSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'cid']
|
||||
|
||||
|
||||
class WritableCircuitSerializer(serializers.ModelSerializer):
|
||||
class WritableCircuitSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
|
||||
fields = [
|
||||
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
@@ -22,4 +24,5 @@ router.register(r'circuit-types', views.CircuitTypeViewSet)
|
||||
router.register(r'circuits', views.CircuitViewSet)
|
||||
router.register(r'circuit-terminations', views.CircuitTerminationViewSet)
|
||||
|
||||
app_name = 'circuits-api'
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from circuits import filters
|
||||
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
|
||||
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_filters
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Count
|
||||
|
||||
from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES
|
||||
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea,
|
||||
SlugField,
|
||||
APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField,
|
||||
FilterChoiceField, Livesearch, SmallTextarea, SlugField,
|
||||
)
|
||||
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
@@ -83,12 +86,15 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitForm(BootstrapMixin, CustomFieldForm):
|
||||
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
comments = CommentField()
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
|
||||
fields = [
|
||||
'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
|
||||
'comments',
|
||||
]
|
||||
help_texts = {
|
||||
'cid': "Unique circuit ID",
|
||||
'install_date': "Format: YYYY-MM-DD",
|
||||
@@ -152,15 +158,18 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
# Circuit terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'rack'}
|
||||
)
|
||||
)
|
||||
rack = forms.ModelChoiceField(
|
||||
rack = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
label='Rack',
|
||||
widget=APISelect(
|
||||
@@ -168,8 +177,12 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
attrs={'filter-for': 'device', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
device = forms.ModelChoiceField(
|
||||
device = ChainedModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('rack', 'rack'),
|
||||
),
|
||||
required=False,
|
||||
label='Device',
|
||||
widget=APISelect(
|
||||
@@ -178,17 +191,13 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
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(),
|
||||
interface = ChainedModelChoiceField(
|
||||
queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
|
||||
'circuit_termination', 'connected_as_a', 'connected_as_b'
|
||||
),
|
||||
chains=(
|
||||
('device', 'device'),
|
||||
),
|
||||
required=False,
|
||||
label='Interface',
|
||||
widget=APISelect(
|
||||
@@ -199,8 +208,10 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = ['term_side', 'site', 'rack', 'device', 'livesearch', 'interface', 'port_speed', 'upstream_speed',
|
||||
'xconnect_id', 'pp_info']
|
||||
fields = [
|
||||
'term_side', 'site', 'rack', 'device', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id',
|
||||
'pp_info',
|
||||
]
|
||||
help_texts = {
|
||||
'port_speed': "Physical circuit speed",
|
||||
'xconnect_id': "ID of the local cross-connect",
|
||||
@@ -212,49 +223,17 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# Initialize helper selectors
|
||||
instance = kwargs.get('instance')
|
||||
if instance and instance.interface is not None:
|
||||
initial = kwargs.get('initial', {})
|
||||
initial['rack'] = instance.interface.device.rack
|
||||
initial['device'] = instance.interface.device
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super(CircuitTerminationForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# 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
|
||||
|
||||
# Limit rack choices
|
||||
if self.is_bound:
|
||||
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
|
||||
elif self.initial.get('site'):
|
||||
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
|
||||
else:
|
||||
self.fields['rack'].choices = []
|
||||
|
||||
# Limit device choices
|
||||
if self.is_bound and self.data.get('rack'):
|
||||
self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack'])
|
||||
elif self.initial.get('rack'):
|
||||
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
|
||||
else:
|
||||
self.fields['device'].choices = []
|
||||
|
||||
# Limit interface choices
|
||||
if self.is_bound and self.data.get('device'):
|
||||
interfaces = Interface.objects.filter(device=self.data['device']).exclude(
|
||||
form_factor__in=VIRTUAL_IFACE_TYPES
|
||||
).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__in=VIRTUAL_IFACE_TYPES
|
||||
).select_related(
|
||||
'circuit_termination', 'connected_as_a', 'connected_as_b'
|
||||
)
|
||||
self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
|
||||
else:
|
||||
interfaces = []
|
||||
# Mark connected interfaces as disabled
|
||||
self.fields['interface'].choices = [
|
||||
(iface.id, {
|
||||
'label': iface.name,
|
||||
'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
|
||||
}) for iface in interfaces
|
||||
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface'].queryset
|
||||
]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2017-04-19 17:17
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0007_circuit_add_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='circuittermination',
|
||||
name='interface',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_termination', to='dcim.Interface'),
|
||||
),
|
||||
]
|
||||
81
netbox/circuits/migrations/0009_unicode_literals.py
Normal file
81
netbox/circuits/migrations/0009_unicode_literals.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2017-05-24 15:34
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import dcim.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0008_circuittermination_interface_protect_on_delete'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='circuit',
|
||||
name='cid',
|
||||
field=models.CharField(max_length=50, verbose_name='Circuit ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='circuit',
|
||||
name='commit_rate',
|
||||
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='circuit',
|
||||
name='install_date',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Date installed'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='circuittermination',
|
||||
name='port_speed',
|
||||
field=models.PositiveIntegerField(verbose_name='Port speed (Kbps)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='circuittermination',
|
||||
name='pp_info',
|
||||
field=models.CharField(blank=True, max_length=100, verbose_name='Patch panel/port(s)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='circuittermination',
|
||||
name='term_side',
|
||||
field=models.CharField(choices=[('A', 'A'), ('Z', 'Z')], max_length=1, verbose_name='Termination'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='circuittermination',
|
||||
name='upstream_speed',
|
||||
field=models.PositiveIntegerField(blank=True, help_text='Upstream speed, if different from port speed', null=True, verbose_name='Upstream speed (Kbps)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='circuittermination',
|
||||
name='xconnect_id',
|
||||
field=models.CharField(blank=True, max_length=50, verbose_name='Cross-connect ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='provider',
|
||||
name='account',
|
||||
field=models.CharField(blank=True, max_length=30, verbose_name='Account number'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='provider',
|
||||
name='admin_contact',
|
||||
field=models.TextField(blank=True, verbose_name='Admin contact'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='provider',
|
||||
name='asn',
|
||||
field=dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='provider',
|
||||
name='noc_contact',
|
||||
field=models.TextField(blank=True, verbose_name='NOC contact'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='provider',
|
||||
name='portal_url',
|
||||
field=models.URLField(blank=True, verbose_name='Portal'),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
|
||||
from dcim.fields import ASNField
|
||||
@@ -110,7 +112,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||
unique_together = ['provider', 'cid']
|
||||
|
||||
def __str__(self):
|
||||
return u'{} {}'.format(self.provider, self.cid)
|
||||
return '{} {}'.format(self.provider, self.cid)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('circuits:circuit', args=[self.pk])
|
||||
@@ -150,10 +152,14 @@ 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)
|
||||
interface = models.OneToOneField(
|
||||
'dcim.Interface', related_name='circuit_termination', blank=True, null=True, on_delete=models.PROTECT
|
||||
)
|
||||
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')
|
||||
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)')
|
||||
|
||||
@@ -162,7 +168,7 @@ class CircuitTermination(models.Model):
|
||||
unique_together = ['circuit', 'term_side']
|
||||
|
||||
def __str__(self):
|
||||
return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
|
||||
return '{} (Side {})'.format(self.circuit, self.get_term_side_display())
|
||||
|
||||
def get_peer_termination(self):
|
||||
peer_side = 'Z' if self.term_side == 'A' else 'A'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from utilities.tables import BaseTable, SearchTable, ToggleColumn
|
||||
|
||||
from .models import Circuit, CircuitType, Provider
|
||||
|
||||
|
||||
@@ -79,7 +80,13 @@ class CircuitSearchTable(SearchTable):
|
||||
cid = tables.LinkColumn(verbose_name='ID')
|
||||
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||
a_side = tables.LinkColumn(
|
||||
'dcim:site', accessor=Accessor('termination_a.site'), args=[Accessor('termination_a.site.slug')]
|
||||
)
|
||||
z_side = tables.LinkColumn(
|
||||
'dcim:site', accessor=Accessor('termination_z.site'), args=[Accessor('termination_z.site.slug')]
|
||||
)
|
||||
|
||||
class Meta(SearchTable.Meta):
|
||||
model = Circuit
|
||||
fields = ('cid', 'type', 'provider', 'tenant', 'description')
|
||||
fields = ('cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf.urls import url
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = 'circuits'
|
||||
urlpatterns = [
|
||||
|
||||
# Providers
|
||||
@@ -11,7 +14,7 @@ urlpatterns = [
|
||||
url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'),
|
||||
url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
|
||||
url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
|
||||
url(r'^providers/(?P<slug>[\w-]+)/$', views.provider, name='provider'),
|
||||
url(r'^providers/(?P<slug>[\w-]+)/$', views.ProviderView.as_view(), name='provider'),
|
||||
url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'),
|
||||
url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'),
|
||||
|
||||
@@ -27,7 +30,7 @@ urlpatterns = [
|
||||
url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'),
|
||||
url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
|
||||
url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
|
||||
url(r'^circuits/(?P<pk>\d+)/$', views.circuit, name='circuit'),
|
||||
url(r'^circuits/(?P<pk>\d+)/$', views.CircuitView.as_view(), 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'),
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
|
||||
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, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
|
||||
|
||||
@@ -28,18 +30,23 @@ class ProviderListView(ObjectListView):
|
||||
template_name = 'circuits/provider_list.html'
|
||||
|
||||
|
||||
def provider(request, slug):
|
||||
class ProviderView(View):
|
||||
|
||||
provider = get_object_or_404(Provider, slug=slug)
|
||||
circuits = Circuit.objects.filter(provider=provider).select_related('type', 'tenant')\
|
||||
.prefetch_related('terminations__site')
|
||||
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
|
||||
def get(self, request, slug):
|
||||
|
||||
return render(request, 'circuits/provider.html', {
|
||||
'provider': provider,
|
||||
'circuits': circuits,
|
||||
'show_graphs': show_graphs,
|
||||
})
|
||||
provider = get_object_or_404(Provider, slug=slug)
|
||||
circuits = Circuit.objects.filter(provider=provider).select_related(
|
||||
'type', 'tenant'
|
||||
).prefetch_related(
|
||||
'terminations__site'
|
||||
)
|
||||
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
|
||||
|
||||
return render(request, 'circuits/provider.html', {
|
||||
'provider': provider,
|
||||
'circuits': circuits,
|
||||
'show_graphs': show_graphs,
|
||||
})
|
||||
|
||||
|
||||
class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
@@ -95,7 +102,7 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = CircuitType
|
||||
form_class = forms.CircuitTypeForm
|
||||
|
||||
def get_return_url(self, obj):
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('circuits:circuittype_list')
|
||||
|
||||
|
||||
@@ -117,32 +124,33 @@ class CircuitListView(ObjectListView):
|
||||
template_name = 'circuits/circuit_list.html'
|
||||
|
||||
|
||||
def circuit(request, pk):
|
||||
class CircuitView(View):
|
||||
|
||||
circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
|
||||
termination_a = CircuitTermination.objects.select_related(
|
||||
'site__region', 'interface__device'
|
||||
).filter(
|
||||
circuit=circuit, term_side=TERM_SIDE_A
|
||||
).first()
|
||||
termination_z = CircuitTermination.objects.select_related(
|
||||
'site__region', 'interface__device'
|
||||
).filter(
|
||||
circuit=circuit, term_side=TERM_SIDE_Z
|
||||
).first()
|
||||
def get(self, request, pk):
|
||||
|
||||
return render(request, 'circuits/circuit.html', {
|
||||
'circuit': circuit,
|
||||
'termination_a': termination_a,
|
||||
'termination_z': termination_z,
|
||||
})
|
||||
circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
|
||||
termination_a = CircuitTermination.objects.select_related(
|
||||
'site__region', 'interface__device'
|
||||
).filter(
|
||||
circuit=circuit, term_side=TERM_SIDE_A
|
||||
).first()
|
||||
termination_z = CircuitTermination.objects.select_related(
|
||||
'site__region', 'interface__device'
|
||||
).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,
|
||||
})
|
||||
|
||||
|
||||
class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'circuits.change_circuit'
|
||||
model = Circuit
|
||||
form_class = forms.CircuitForm
|
||||
fields_initial = ['provider']
|
||||
template_name = 'circuits/circuit_edit.html'
|
||||
default_return_url = 'circuits:circuit_list'
|
||||
|
||||
@@ -230,7 +238,6 @@ 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, request, url_args, url_kwargs):
|
||||
@@ -238,7 +245,7 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit'])
|
||||
return obj
|
||||
|
||||
def get_return_url(self, obj):
|
||||
def get_return_url(self, request, obj):
|
||||
return obj.circuit.get_absolute_url()
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
@@ -66,13 +68,13 @@ class NestedSiteSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
class WritableSiteSerializer(serializers.ModelSerializer):
|
||||
class WritableSiteSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
||||
'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
@@ -150,13 +152,13 @@ class NestedRackSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'name', 'display_name']
|
||||
|
||||
|
||||
class WritableRackSerializer(serializers.ModelSerializer):
|
||||
class WritableRackSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
|
||||
'comments',
|
||||
'comments', 'custom_fields',
|
||||
]
|
||||
# Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
|
||||
# prevents facility_id from being interpreted as a required field.
|
||||
@@ -263,13 +265,13 @@ class NestedDeviceTypeSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'manufacturer', 'model', 'slug']
|
||||
|
||||
|
||||
class WritableDeviceTypeSerializer(serializers.ModelSerializer):
|
||||
class WritableDeviceTypeSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
|
||||
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments',
|
||||
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
@@ -476,13 +478,13 @@ class DeviceSerializer(CustomFieldModelSerializer):
|
||||
}
|
||||
|
||||
|
||||
class WritableDeviceSerializer(serializers.ModelSerializer):
|
||||
class WritableDeviceSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
|
||||
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments',
|
||||
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields',
|
||||
]
|
||||
validators = []
|
||||
|
||||
@@ -490,7 +492,7 @@ class WritableDeviceSerializer(serializers.ModelSerializer):
|
||||
|
||||
# Validate uniqueness of (rack, position, face) since we omitted the automatically-created validator from Meta.
|
||||
if data.get('rack') and data.get('position') and data.get('face'):
|
||||
validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('rack', 'position', 'face'))
|
||||
validator = UniqueTogetherValidator(queryset=Device.objects.all(), fields=('rack', 'position', 'face'))
|
||||
validator.set_context(self)
|
||||
validator(data)
|
||||
|
||||
@@ -581,9 +583,18 @@ class WritablePowerPortSerializer(serializers.ModelSerializer):
|
||||
# Interfaces
|
||||
#
|
||||
|
||||
class NestedInterfaceSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class InterfaceSerializer(serializers.ModelSerializer):
|
||||
device = NestedDeviceSerializer()
|
||||
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
|
||||
lag = NestedInterfaceSerializer()
|
||||
connection = serializers.SerializerMethodField(read_only=True)
|
||||
connected_interface = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@@ -608,10 +619,12 @@ class InterfaceSerializer(serializers.ModelSerializer):
|
||||
class PeerInterfaceSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
|
||||
lag = NestedInterfaceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['id', 'url', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description']
|
||||
fields = ['id', 'url', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
|
||||
|
||||
|
||||
class WritableInterfaceSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
@@ -58,4 +60,5 @@ router.register(r'interface-connections', views.InterfaceConnectionViewSet)
|
||||
# Miscellaneous
|
||||
router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')
|
||||
|
||||
app_name = 'dcim-api'
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.mixins import ListModelMixin
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet, ViewSet
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
|
||||
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
|
||||
RackRole, Region, Site,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
RackReservation, RackRole, Region, Site,
|
||||
)
|
||||
from dcim import filters
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
@@ -323,6 +325,7 @@ class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
|
||||
serializer_class = serializers.InterfaceConnectionSerializer
|
||||
write_serializer_class = serializers.WritableInterfaceConnectionSerializer
|
||||
filter_class = filters.InterfaceConnectionFilter
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from netaddr import EUI, mac_unix_expanded
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_filters
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
@@ -8,9 +10,9 @@ from tenancy.models import Tenant
|
||||
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceTemplate, Manufacturer, InventoryItem,
|
||||
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
|
||||
RackRole, Region, Site, VIRTUAL_IFACE_TYPES,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
|
||||
InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES,
|
||||
)
|
||||
|
||||
|
||||
@@ -148,6 +150,33 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
|
||||
class RackReservationFilter(django_filters.FilterSet):
|
||||
id__in = NumericInFilter(name='id', lookup_expr='in')
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='rack__site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='rack__site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
group_id = NullableModelMultipleChoiceFilter(
|
||||
name='rack__group',
|
||||
queryset=RackGroup.objects.all(),
|
||||
label='Group (ID)',
|
||||
)
|
||||
group = NullableModelMultipleChoiceFilter(
|
||||
name='rack__group',
|
||||
queryset=RackGroup.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Group',
|
||||
)
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='rack',
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -158,6 +187,16 @@ class RackReservationFilter(django_filters.FilterSet):
|
||||
model = RackReservation
|
||||
fields = ['rack', 'user']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(rack__name__icontains=value) |
|
||||
Q(rack__facility_id__icontains=value) |
|
||||
Q(user__username__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
id__in = NumericInFilter(name='id', lookup_expr='in')
|
||||
@@ -336,10 +375,6 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Platform (slug)',
|
||||
)
|
||||
status = django_filters.BooleanFilter(
|
||||
name='status',
|
||||
label='Status',
|
||||
)
|
||||
is_console_server = django_filters.BooleanFilter(
|
||||
name='device_type__is_console_server',
|
||||
label='Is a console server',
|
||||
@@ -356,6 +391,9 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
method='_has_primary_ip',
|
||||
label='Has a primary IP',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=STATUS_CHOICES
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
@@ -401,7 +439,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
label='Device (ID)',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
name='device__name',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
@@ -441,6 +479,11 @@ class InterfaceFilter(DeviceComponentFilterSet):
|
||||
method='filter_type',
|
||||
label='Interface type',
|
||||
)
|
||||
lag_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='lag',
|
||||
queryset=Interface.objects.all(),
|
||||
label='LAG interface (ID)',
|
||||
)
|
||||
mac_address = django_filters.CharFilter(
|
||||
method='_mac_address',
|
||||
label='MAC address',
|
||||
@@ -550,6 +593,10 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
|
||||
label='Device',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceConnection
|
||||
fields = ['connection_status']
|
||||
|
||||
def filter_site(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from netaddr import EUI, AddrFormatError
|
||||
|
||||
from django import forms
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mptt.forms import TreeNodeChoiceField
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.contrib.postgres.forms.array import SimpleArrayField
|
||||
@@ -9,21 +10,22 @@ from django.db.models import Count, Q
|
||||
|
||||
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||
from ipam.models import IPAddress
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField,
|
||||
CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled,
|
||||
SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField,
|
||||
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, ExpandableNameField,
|
||||
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
|
||||
FilterTreeNodeMultipleChoiceField,
|
||||
)
|
||||
|
||||
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_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
|
||||
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
|
||||
RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
|
||||
SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES
|
||||
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate,
|
||||
RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES,
|
||||
SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,7 +83,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm):
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteForm(BootstrapMixin, CustomFieldForm):
|
||||
class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
|
||||
slug = SlugField()
|
||||
comments = CommentField()
|
||||
@@ -89,8 +91,8 @@ class SiteForm(BootstrapMixin, CustomFieldForm):
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
||||
'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
'name', 'slug', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'physical_address',
|
||||
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
]
|
||||
widgets = {
|
||||
'physical_address': SmallTextarea(attrs={'rows': 3}),
|
||||
@@ -185,16 +187,25 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm):
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackForm(BootstrapMixin, CustomFieldForm):
|
||||
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect(
|
||||
api_url='/api/dcim/rack-groups/?site_id={{site}}',
|
||||
))
|
||||
class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
group = ChainedModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/rack-groups/?site_id={{site}}',
|
||||
)
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
|
||||
'comments']
|
||||
fields = [
|
||||
'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'type', 'width', 'u_height',
|
||||
'desc_units', 'comments',
|
||||
]
|
||||
help_texts = {
|
||||
'site': "The site at which the rack exists",
|
||||
'name': "Organizational rack name",
|
||||
@@ -205,18 +216,6 @@ class RackForm(BootstrapMixin, CustomFieldForm):
|
||||
'site': forms.Select(attrs={'filter-for': 'group'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(RackForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit rack group choices
|
||||
if self.is_bound and self.data.get('site'):
|
||||
self.fields['group'].queryset = RackGroup.objects.filter(site__pk=self.data['site'])
|
||||
elif self.initial.get('site'):
|
||||
self.fields['group'].queryset = RackGroup.objects.filter(site=self.initial['site'])
|
||||
else:
|
||||
self.fields['group'].choices = []
|
||||
|
||||
|
||||
class RackFromCSVForm(forms.ModelForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
|
||||
@@ -272,6 +271,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
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)')
|
||||
desc_units = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Descending units')
|
||||
comments = CommentField(widget=SmallTextarea)
|
||||
|
||||
class Meta:
|
||||
@@ -330,6 +330,19 @@ class RackReservationForm(BootstrapMixin, forms.ModelForm):
|
||||
return unit_choices
|
||||
|
||||
|
||||
class RackReservationFilterForm(BootstrapMixin, forms.Form):
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.annotate(filter_count=Count('racks__reservations')),
|
||||
to_field_name='slug'
|
||||
)
|
||||
group_id = FilterChoiceField(
|
||||
queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__reservations')),
|
||||
label='Rack group',
|
||||
null_option=(0, 'None')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Manufacturers
|
||||
#
|
||||
@@ -362,7 +375,13 @@ 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)
|
||||
is_full_depth = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is full depth')
|
||||
interface_ordering = forms.ChoiceField(choices=add_blank_choice(IFACE_ORDERING_CHOICES), required=False)
|
||||
is_console_server = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is full depth')
|
||||
is_pdu = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is a PDU')
|
||||
is_network_device = forms.NullBooleanField(
|
||||
required=False, widget=BulkEditNullBooleanSelect, label='Is a network device'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = []
|
||||
@@ -471,6 +490,7 @@ class InterfaceTemplateCreateForm(DeviceComponentForm):
|
||||
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)
|
||||
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
|
||||
|
||||
class Meta:
|
||||
nullable_fields = []
|
||||
@@ -518,56 +538,89 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceForm(BootstrapMixin, CustomFieldForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
|
||||
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'position'}
|
||||
))
|
||||
position = forms.TypedChoiceField(required=False, empty_value=None,
|
||||
help_text="The lowest-numbered unit occupied by the device",
|
||||
widget=APISelect(api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}',
|
||||
disabled_indicator='device'))
|
||||
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
|
||||
widget=forms.Select(attrs={'filter-for': 'device_type'}))
|
||||
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), label='Device type', widget=APISelect(
|
||||
api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
|
||||
display_field='model'
|
||||
))
|
||||
class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'rack'}
|
||||
)
|
||||
)
|
||||
rack = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'position'}
|
||||
)
|
||||
)
|
||||
position = forms.TypedChoiceField(
|
||||
required=False,
|
||||
empty_value=None,
|
||||
help_text="The lowest-numbered unit occupied by the device",
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}',
|
||||
disabled_indicator='device'
|
||||
)
|
||||
)
|
||||
manufacturer = forms.ModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'device_type'}
|
||||
)
|
||||
)
|
||||
device_type = ChainedModelChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
chains=(
|
||||
('manufacturer', 'manufacturer'),
|
||||
),
|
||||
label='Device type',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
|
||||
display_field='model'
|
||||
)
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['name', 'device_role', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position',
|
||||
'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments']
|
||||
fields = [
|
||||
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status',
|
||||
'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments',
|
||||
]
|
||||
help_texts = {
|
||||
'device_role': "The function this device serves",
|
||||
'serial': "Chassis serial number",
|
||||
}
|
||||
widgets = {
|
||||
'face': forms.Select(attrs={'filter-for': 'position'}),
|
||||
'manufacturer': forms.Select(attrs={'filter-for': 'device_type'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# Initialize helper selectors
|
||||
instance = kwargs.get('instance')
|
||||
# Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
|
||||
if instance and hasattr(instance, 'device_type'):
|
||||
initial = kwargs.get('initial', {})
|
||||
initial['manufacturer'] = instance.device_type.manufacturer
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super(DeviceForm, self).__init__(*args, **kwargs)
|
||||
|
||||
if self.instance.pk:
|
||||
|
||||
# Initialize helper selections
|
||||
self.initial['site'] = self.instance.site
|
||||
self.initial['manufacturer'] = self.instance.device_type.manufacturer
|
||||
|
||||
# Compile list of choices for primary IPv4 and IPv6 addresses
|
||||
for family in [4, 6]:
|
||||
ip_choices = []
|
||||
interface_ips = IPAddress.objects.filter(family=family, interface__device=self.instance)
|
||||
ip_choices += [(ip.id, u'{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
|
||||
ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
|
||||
nat_ips = IPAddress.objects.filter(family=family, nat_inside__interface__device=self.instance)\
|
||||
.select_related('nat_inside__interface')
|
||||
ip_choices += [(ip.id, u'{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
|
||||
ip_choices += [(ip.id, '{} ({} 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
|
||||
@@ -582,14 +635,6 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
|
||||
self.fields['primary_ip6'].choices = []
|
||||
self.fields['primary_ip6'].widget.attrs['readonly'] = True
|
||||
|
||||
# Limit rack choices
|
||||
if self.is_bound and self.data.get('site'):
|
||||
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
|
||||
elif self.initial.get('site'):
|
||||
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
|
||||
else:
|
||||
self.fields['rack'].choices = []
|
||||
|
||||
# Rack position
|
||||
pk = self.instance.pk if self.instance.pk else None
|
||||
try:
|
||||
@@ -610,16 +655,6 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
|
||||
}) for p in position_choices
|
||||
]
|
||||
|
||||
# Limit device_type choices
|
||||
if self.is_bound:
|
||||
self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer__pk=self.data['manufacturer'])\
|
||||
.select_related('manufacturer')
|
||||
elif self.initial.get('manufacturer'):
|
||||
self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer=self.initial['manufacturer'])\
|
||||
.select_related('manufacturer')
|
||||
else:
|
||||
self.fields['device_type'].choices = []
|
||||
|
||||
# Disable rack assignment if this is a child device installed in a parent device
|
||||
if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
|
||||
self.fields['site'].disabled = True
|
||||
@@ -629,15 +664,24 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
|
||||
|
||||
|
||||
class BaseDeviceFromCSVForm(forms.ModelForm):
|
||||
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid device role.'})
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid manufacturer.'})
|
||||
device_role = forms.ModelChoiceField(
|
||||
queryset=DeviceRole.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid device role.'}
|
||||
)
|
||||
tenant = forms.ModelChoiceField(
|
||||
Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'}
|
||||
)
|
||||
manufacturer = forms.ModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid manufacturer.'}
|
||||
)
|
||||
model_name = forms.CharField()
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid platform.'})
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid platform.'}
|
||||
)
|
||||
status = forms.CharField()
|
||||
|
||||
class Meta:
|
||||
fields = []
|
||||
@@ -655,17 +699,28 @@ class BaseDeviceFromCSVForm(forms.ModelForm):
|
||||
except DeviceType.DoesNotExist:
|
||||
self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name))
|
||||
|
||||
def clean_status(self):
|
||||
status_choices = {s[1].lower(): s[0] for s in STATUS_CHOICES}
|
||||
try:
|
||||
return status_choices[self.cleaned_data['status'].lower()]
|
||||
except KeyError:
|
||||
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
|
||||
|
||||
|
||||
class DeviceFromCSVForm(BaseDeviceFromCSVForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={
|
||||
'invalid_choice': 'Invalid site name.',
|
||||
})
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(), to_field_name='name', error_messages={
|
||||
'invalid_choice': 'Invalid site name.',
|
||||
}
|
||||
)
|
||||
rack_name = forms.CharField(required=False)
|
||||
face = forms.CharField(required=False)
|
||||
|
||||
class Meta(BaseDeviceFromCSVForm.Meta):
|
||||
fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'rack_name', 'position', 'face']
|
||||
fields = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
|
||||
'site', 'rack_name', 'position', 'face',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -707,8 +762,8 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
|
||||
|
||||
class Meta(BaseDeviceFromCSVForm.Meta):
|
||||
fields = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'parent',
|
||||
'device_bay_name',
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
|
||||
'parent', 'device_bay_name',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
@@ -752,6 +807,13 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['tenant', 'platform']
|
||||
|
||||
|
||||
def device_status_choices():
|
||||
status_counts = {}
|
||||
for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
|
||||
|
||||
|
||||
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Device
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@@ -763,18 +825,21 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')),
|
||||
label='Rack group',
|
||||
)
|
||||
rack_id = FilterChoiceField(
|
||||
queryset=Rack.objects.annotate(filter_count=Count('devices')),
|
||||
label='Rack',
|
||||
null_option=(0, 'None'),
|
||||
)
|
||||
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',
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('devices')),
|
||||
to_field_name='slug',
|
||||
null_option=(0, 'None'),
|
||||
)
|
||||
manufacturer_id = FilterChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
label='Manufacturer',
|
||||
)
|
||||
manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer')
|
||||
device_type_id = FilterChoiceField(
|
||||
queryset=DeviceType.objects.select_related('manufacturer').order_by('model').annotate(
|
||||
filter_count=Count('instances'),
|
||||
@@ -786,14 +851,8 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
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',
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
|
||||
mac_address = forms.CharField(required=False, label='MAC address')
|
||||
|
||||
|
||||
#
|
||||
@@ -901,21 +960,32 @@ class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm):
|
||||
self.cleaned_data['csv'] = connection_list
|
||||
|
||||
|
||||
class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
rack = forms.ModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack',
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'rack'}
|
||||
)
|
||||
)
|
||||
rack = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
label='Rack',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
attrs={'filter-for': 'console_server', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
console_server = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
console_server = ChainedModelChoiceField(
|
||||
queryset=Device.objects.filter(device_type__is_console_server=True),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('rack', 'rack'),
|
||||
),
|
||||
label='Console Server',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
@@ -933,11 +1003,14 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
field_to_update='console_server',
|
||||
)
|
||||
)
|
||||
cs_port = forms.ModelChoiceField(
|
||||
cs_port = ChainedModelChoiceField(
|
||||
queryset=ConsoleServerPort.objects.all(),
|
||||
chains=(
|
||||
('device', 'console_server'),
|
||||
),
|
||||
label='Port',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/console-server-ports/?device_id={{device}}',
|
||||
api_url='/api/dcim/console-server-ports/?device_id={{console_server}}',
|
||||
disabled_indicator='connected_console',
|
||||
)
|
||||
)
|
||||
@@ -957,32 +1030,6 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
if not self.instance.pk:
|
||||
raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.")
|
||||
|
||||
# Initialize rack choices if site is set
|
||||
if self.initial.get('site'):
|
||||
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
|
||||
else:
|
||||
self.fields['rack'].choices = []
|
||||
|
||||
# Initialize console_server choices if rack or site is set
|
||||
if self.initial.get('rack'):
|
||||
self.fields['console_server'].queryset = Device.objects.filter(
|
||||
rack=self.initial['rack'], device_type__is_console_server=True
|
||||
)
|
||||
elif self.initial.get('site'):
|
||||
self.fields['console_server'].queryset = Device.objects.filter(
|
||||
site=self.initial['site'], rack__isnull=True, device_type__is_console_server=True
|
||||
)
|
||||
else:
|
||||
self.fields['console_server'].choices = []
|
||||
|
||||
# Initialize CS port choices if console_server is set
|
||||
if self.initial.get('console_server'):
|
||||
self.fields['cs_port'].queryset = ConsoleServerPort.objects.filter(
|
||||
device=self.initial['console_server']
|
||||
)
|
||||
else:
|
||||
self.fields['cs_port'].choices = []
|
||||
|
||||
|
||||
#
|
||||
# Console server ports
|
||||
@@ -1002,21 +1049,32 @@ class ConsoleServerPortCreateForm(DeviceComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
|
||||
class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
widget=forms.HiddenInput(),
|
||||
)
|
||||
rack = forms.ModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack',
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'rack'}
|
||||
)
|
||||
)
|
||||
rack = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
label='Rack',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
attrs={'filter-for': 'device', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
device = forms.ModelChoiceField(
|
||||
device = ChainedModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('rack', 'rack'),
|
||||
),
|
||||
label='Device',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
@@ -1034,8 +1092,11 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
|
||||
field_to_update='device'
|
||||
)
|
||||
)
|
||||
port = forms.ModelChoiceField(
|
||||
port = ChainedModelChoiceField(
|
||||
queryset=ConsolePort.objects.all(),
|
||||
chains=(
|
||||
('device', 'device'),
|
||||
),
|
||||
label='Port',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/console-ports/?device_id={{device}}',
|
||||
@@ -1057,30 +1118,6 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
|
||||
'connection_status': 'Status',
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Initialize rack choices if site is set
|
||||
if self.initial.get('site'):
|
||||
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
|
||||
else:
|
||||
self.fields['rack'].choices = []
|
||||
|
||||
# Initialize device choices if rack or site is set
|
||||
if self.initial.get('rack'):
|
||||
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
|
||||
elif self.initial.get('site'):
|
||||
self.fields['device'].queryset = Device.objects.filter(site=self.initial['site'], rack__isnull=True)
|
||||
else:
|
||||
self.fields['device'].choices = []
|
||||
|
||||
# Initialize port choices if device is set
|
||||
if self.initial.get('device'):
|
||||
self.fields['port'].queryset = ConsolePort.objects.filter(device=self.initial['device'])
|
||||
else:
|
||||
self.fields['port'].choices = []
|
||||
|
||||
|
||||
#
|
||||
# Power ports
|
||||
@@ -1172,18 +1209,32 @@ class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
|
||||
self.cleaned_data['csv'] = connection_list
|
||||
|
||||
|
||||
class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput())
|
||||
rack = forms.ModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack',
|
||||
class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'rack'}
|
||||
)
|
||||
)
|
||||
rack = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
label='Rack',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
attrs={'filter-for': 'pdu', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
pdu = forms.ModelChoiceField(
|
||||
pdu = ChainedModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('rack', 'rack'),
|
||||
),
|
||||
label='PDU',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
@@ -1201,11 +1252,14 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
field_to_update='pdu'
|
||||
)
|
||||
)
|
||||
power_outlet = forms.ModelChoiceField(
|
||||
power_outlet = ChainedModelChoiceField(
|
||||
queryset=PowerOutlet.objects.all(),
|
||||
chains=(
|
||||
('device', 'pdu'),
|
||||
),
|
||||
label='Outlet',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/power-outlets/?device_id={{device}}',
|
||||
api_url='/api/dcim/power-outlets/?device_id={{pdu}}',
|
||||
disabled_indicator='connected_port'
|
||||
)
|
||||
)
|
||||
@@ -1225,30 +1279,6 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
if not self.instance.pk:
|
||||
raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.")
|
||||
|
||||
# Initialize rack choices if site is set
|
||||
if self.initial.get('site'):
|
||||
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
|
||||
else:
|
||||
self.fields['rack'].choices = []
|
||||
|
||||
# Initialize pdu choices if rack or site is set
|
||||
if self.initial.get('rack'):
|
||||
self.fields['pdu'].queryset = Device.objects.filter(
|
||||
rack=self.initial['rack'], device_type__is_pdu=True
|
||||
)
|
||||
elif self.initial.get('site'):
|
||||
self.fields['pdu'].queryset = Device.objects.filter(
|
||||
site=self.initial['site'], rack__isnull=True, device_type__is_pdu=True
|
||||
)
|
||||
else:
|
||||
self.fields['pdu'].choices = []
|
||||
|
||||
# Initialize power outlet choices if pdu is set
|
||||
if self.initial.get('pdu'):
|
||||
self.fields['power_outlet'].queryset = PowerOutlet.objects.filter(device=self.initial['pdu'])
|
||||
else:
|
||||
self.fields['power_outlet'].choices = []
|
||||
|
||||
|
||||
#
|
||||
# Power outlets
|
||||
@@ -1268,21 +1298,32 @@ class PowerOutletCreateForm(DeviceComponentForm):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
|
||||
|
||||
class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
|
||||
class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
rack = forms.ModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack',
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'rack'}
|
||||
)
|
||||
)
|
||||
rack = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
label='Rack',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
attrs={'filter-for': 'device', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
device = forms.ModelChoiceField(
|
||||
device = ChainedModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('rack', 'rack'),
|
||||
),
|
||||
label='Device',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
@@ -1300,8 +1341,11 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
|
||||
field_to_update='device'
|
||||
)
|
||||
)
|
||||
port = forms.ModelChoiceField(
|
||||
port = ChainedModelChoiceField(
|
||||
queryset=PowerPort.objects.all(),
|
||||
chains=(
|
||||
('device', 'device'),
|
||||
),
|
||||
label='Port',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/power-ports/?device_id={{device}}',
|
||||
@@ -1323,30 +1367,6 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
|
||||
'connection_status': 'Status',
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(PowerOutletConnectionForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Initialize rack choices if site is set
|
||||
if self.initial.get('site'):
|
||||
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
|
||||
else:
|
||||
self.fields['rack'].choices = []
|
||||
|
||||
# Initialize device choices if rack or site is set
|
||||
if self.initial.get('rack'):
|
||||
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
|
||||
elif self.initial.get('site'):
|
||||
self.fields['device'].queryset = Device.objects.filter(site=self.initial['site'], rack__isnull=True)
|
||||
else:
|
||||
self.fields['device'].choices = []
|
||||
|
||||
# Initialize port choices if device is set
|
||||
if self.initial.get('device'):
|
||||
self.fields['port'].queryset = PowerPort.objects.filter(device=self.initial['device'])
|
||||
else:
|
||||
self.fields['port'].choices = []
|
||||
|
||||
|
||||
#
|
||||
# Interfaces
|
||||
@@ -1400,6 +1420,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
|
||||
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
|
||||
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
||||
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
class Meta:
|
||||
@@ -1409,9 +1430,16 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces which belong to the parent device.
|
||||
device = None
|
||||
if self.initial.get('device'):
|
||||
self.fields['lag'].queryset = Interface.objects.filter(
|
||||
device=self.initial['device'], form_factor=IFACE_FF_LAG
|
||||
try:
|
||||
device = Device.objects.get(pk=self.initial.get('device'))
|
||||
except Device.DoesNotExist:
|
||||
pass
|
||||
if device is not None:
|
||||
interface_ordering = device.device_type.interface_ordering
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
|
||||
device=device, form_factor=IFACE_FF_LAG
|
||||
)
|
||||
else:
|
||||
self.fields['lag'].choices = []
|
||||
@@ -1421,7 +1449,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
# Interface connections
|
||||
#
|
||||
|
||||
class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
|
||||
interface_a = forms.ChoiceField(
|
||||
choices=[],
|
||||
widget=SelectWithDisabled,
|
||||
@@ -1435,8 +1463,11 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
attrs={'filter-for': 'rack_b'}
|
||||
)
|
||||
)
|
||||
rack_b = forms.ModelChoiceField(
|
||||
rack_b = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
chains=(
|
||||
('site', 'site_b'),
|
||||
),
|
||||
label='Rack',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
@@ -1444,8 +1475,12 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
attrs={'filter-for': 'device_b', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
device_b = forms.ModelChoiceField(
|
||||
device_b = ChainedModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
chains=(
|
||||
('site', 'site_b'),
|
||||
('rack', 'rack_b'),
|
||||
),
|
||||
label='Device',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
@@ -1463,12 +1498,17 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
field_to_update='device_b'
|
||||
)
|
||||
)
|
||||
interface_b = forms.ModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
interface_b = ChainedModelChoiceField(
|
||||
queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
|
||||
'circuit_termination', 'connected_as_a', 'connected_as_b'
|
||||
),
|
||||
chains=(
|
||||
('device', 'device_b'),
|
||||
),
|
||||
label='Interface',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical',
|
||||
disabled_indicator='is_connected'
|
||||
disabled_indicator='connection'
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1490,31 +1530,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
(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.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 or site_b is set
|
||||
if self.initial.get('rack_b'):
|
||||
self.fields['device_b'].queryset = Device.objects.filter(rack=self.initial['rack_b'])
|
||||
elif self.initial.get('site_b'):
|
||||
self.fields['device_b'].queryset = Device.objects.filter(site=self.initial['site_b'], rack__isnull=True)
|
||||
else:
|
||||
self.fields['device_b'].choices = []
|
||||
|
||||
# Initialize interface_b choices if device_b is set
|
||||
if self.initial.get('device_b'):
|
||||
device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']).exclude(
|
||||
form_factor__in=VIRTUAL_IFACE_TYPES
|
||||
).select_related(
|
||||
'circuit_termination', 'connected_as_a', 'connected_as_b'
|
||||
)
|
||||
else:
|
||||
device_b_interfaces = []
|
||||
# Mark connected interfaces as disabled
|
||||
self.fields['interface_b'].choices = [
|
||||
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_b_interfaces
|
||||
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
|
||||
]
|
||||
|
||||
|
||||
@@ -1671,36 +1689,6 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
device = forms.CharField(required=False, label='Device name')
|
||||
|
||||
|
||||
#
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
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', 'tenant', 'status', 'interface', 'description']
|
||||
|
||||
def __init__(self, device, *args, **kwargs):
|
||||
|
||||
super(IPAddressForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
interfaces = device.interfaces.all()
|
||||
self.fields['interface'].queryset = interfaces
|
||||
self.fields['interface'].required = True
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
#
|
||||
# Inventory items
|
||||
#
|
||||
|
||||
27
netbox/dcim/migrations/0035_device_expand_status_choices.py
Normal file
27
netbox/dcim/migrations/0035_device_expand_status_choices.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-05-08 15:57
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0034_rename_module_to_inventoryitem'),
|
||||
]
|
||||
|
||||
# We convert the BooleanField to an IntegerField first as PostgreSQL does not provide a direct cast for boolean to
|
||||
# smallint (attempting to convert directly yields the error "cannot cast type boolean to smallint").
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='status',
|
||||
field=models.PositiveIntegerField(choices=[[1, b'Active'], [0, b'Offline'], [2, b'Planned'], [3, b'Staged'], [4, b'Failed'], [5, b'Inventory']], default=1, verbose_name=b'Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='status',
|
||||
field=models.PositiveSmallIntegerField(choices=[[1, b'Active'], [0, b'Offline'], [2, b'Planned'], [3, b'Staged'], [4, b'Failed'], [5, b'Inventory']], default=1, verbose_name=b'Status'),
|
||||
),
|
||||
]
|
||||
25
netbox/dcim/migrations/0036_add_ff_juniper_vcp.py
Normal file
25
netbox/dcim/migrations/0036_add_ff_juniper_vcp.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.1 on 2017-05-09 16:00
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0035_device_expand_status_choices'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [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)'], [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'], [5200, b'Juniper VCP']]], [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'], [200, b'Link Aggregation Group (LAG)']]], [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)'], [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'], [5200, b'Juniper VCP']]], [b'Other', [[32767, b'Other']]]], default=1200),
|
||||
),
|
||||
]
|
||||
209
netbox/dcim/migrations/0037_unicode_literals.py
Normal file
209
netbox/dcim/migrations/0037_unicode_literals.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2017-05-24 15:34
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import dcim.fields
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import utilities.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0036_add_ff_juniper_vcp'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='consoleport',
|
||||
name='connection_status',
|
||||
field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='consoleport',
|
||||
name='cs_port',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_console', to='dcim.ConsoleServerPort', verbose_name='Console server port'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='asset_tag',
|
||||
field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name='Asset tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='face',
|
||||
field=models.PositiveSmallIntegerField(blank=True, choices=[[0, 'Front'], [1, 'Rear']], null=True, verbose_name='Rack face'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='position',
|
||||
field=models.PositiveSmallIntegerField(blank=True, help_text='The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Position (U)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='primary_ip4',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip4_for', to='ipam.IPAddress', verbose_name='Primary IPv4'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='primary_ip6',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip6_for', to='ipam.IPAddress', verbose_name='Primary IPv6'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='serial',
|
||||
field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='status',
|
||||
field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [2, 'Planned'], [3, 'Staged'], [4, 'Failed'], [5, 'Inventory']], default=1, verbose_name='Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicebay',
|
||||
name='name',
|
||||
field=models.CharField(max_length=50, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='interface_ordering',
|
||||
field=models.PositiveSmallIntegerField(choices=[[1, 'Slot/position'], [2, 'Name (alphabetically)']], default=1),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='is_console_server',
|
||||
field=models.BooleanField(default=False, help_text='This type of device has console server ports', verbose_name='Is a console server'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='is_full_depth',
|
||||
field=models.BooleanField(default=True, help_text='Device consumes both front and rear rack faces', verbose_name='Is full depth'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='is_network_device',
|
||||
field=models.BooleanField(default=True, help_text='This type of device has network interfaces', verbose_name='Is a network device'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='is_pdu',
|
||||
field=models.BooleanField(default=False, help_text='This type of device has power outlets', verbose_name='Is a PDU'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='part_number',
|
||||
field=models.CharField(blank=True, help_text='Discrete part number (optional)', max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='subdevice_role',
|
||||
field=models.NullBooleanField(choices=[(None, 'None'), (True, 'Parent'), (False, 'Child')], default=None, help_text='Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name='Parent/child status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='u_height',
|
||||
field=models.PositiveSmallIntegerField(default=1, verbose_name='Height (U)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='lag',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_interfaces', to='dcim.Interface', verbose_name='Parent LAG'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='mac_address',
|
||||
field=dcim.fields.MACAddressField(blank=True, null=True, verbose_name='MAC Address'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='mgmt_only',
|
||||
field=models.BooleanField(default=False, help_text='This interface is used only for out-of-band management', verbose_name='OOB Management'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interfaceconnection',
|
||||
name='connection_status',
|
||||
field=models.BooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True, verbose_name='Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interfacetemplate',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interfacetemplate',
|
||||
name='mgmt_only',
|
||||
field=models.BooleanField(default=False, verbose_name='Management only'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='discovered',
|
||||
field=models.BooleanField(default=False, verbose_name='Discovered'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='name',
|
||||
field=models.CharField(max_length=50, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='part_id',
|
||||
field=models.CharField(blank=True, max_length=50, verbose_name='Part ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='serial',
|
||||
field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='platform',
|
||||
name='rpc_client',
|
||||
field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='RPC client'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerport',
|
||||
name='connection_status',
|
||||
field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rack',
|
||||
name='desc_units',
|
||||
field=models.BooleanField(default=False, help_text='Units are numbered top-to-bottom', verbose_name='Descending units'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rack',
|
||||
name='facility_id',
|
||||
field=utilities.fields.NullableCharField(blank=True, max_length=30, null=True, verbose_name='Facility ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rack',
|
||||
name='type',
|
||||
field=models.PositiveSmallIntegerField(blank=True, choices=[(100, '2-post frame'), (200, '4-post frame'), (300, '4-post cabinet'), (1000, 'Wall-mounted frame'), (1100, 'Wall-mounted cabinet')], null=True, verbose_name='Type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rack',
|
||||
name='u_height',
|
||||
field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Height (U)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rack',
|
||||
name='width',
|
||||
field=models.PositiveSmallIntegerField(choices=[(19, '19 inches'), (23, '23 inches')], default=19, help_text='Rail-to-rail width', verbose_name='Width'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='site',
|
||||
name='asn',
|
||||
field=dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='site',
|
||||
name='contact_email',
|
||||
field=models.EmailField(blank=True, max_length=254, verbose_name='Contact E-mail'),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
from collections import OrderedDict
|
||||
from itertools import count, groupby
|
||||
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
@@ -8,10 +10,10 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
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 django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
|
||||
from circuits.models import Circuit
|
||||
@@ -22,7 +24,6 @@ from utilities.fields import ColorField, NullableCharField
|
||||
from utilities.managers import NaturalOrderByManager
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
from utilities.utils import csv_format
|
||||
|
||||
from .fields import ASNField, MACAddressField
|
||||
|
||||
|
||||
@@ -101,6 +102,7 @@ IFACE_FF_STACKWISE = 5000
|
||||
IFACE_FF_STACKWISE_PLUS = 5050
|
||||
IFACE_FF_FLEXSTACK = 5100
|
||||
IFACE_FF_FLEXSTACK_PLUS = 5150
|
||||
IFACE_FF_JUNIPER_VCP = 5200
|
||||
# Other
|
||||
IFACE_FF_OTHER = 32767
|
||||
|
||||
@@ -162,6 +164,7 @@ IFACE_FF_CHOICES = [
|
||||
[IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
|
||||
[IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
|
||||
[IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
|
||||
[IFACE_FF_JUNIPER_VCP, 'Juniper VCP'],
|
||||
]
|
||||
],
|
||||
[
|
||||
@@ -177,13 +180,30 @@ VIRTUAL_IFACE_TYPES = [
|
||||
IFACE_FF_LAG,
|
||||
]
|
||||
|
||||
STATUS_ACTIVE = True
|
||||
STATUS_OFFLINE = False
|
||||
STATUS_OFFLINE = 0
|
||||
STATUS_ACTIVE = 1
|
||||
STATUS_PLANNED = 2
|
||||
STATUS_STAGED = 3
|
||||
STATUS_FAILED = 4
|
||||
STATUS_INVENTORY = 5
|
||||
STATUS_CHOICES = [
|
||||
[STATUS_ACTIVE, 'Active'],
|
||||
[STATUS_OFFLINE, 'Offline'],
|
||||
[STATUS_PLANNED, 'Planned'],
|
||||
[STATUS_STAGED, 'Staged'],
|
||||
[STATUS_FAILED, 'Failed'],
|
||||
[STATUS_INVENTORY, 'Inventory'],
|
||||
]
|
||||
|
||||
DEVICE_STATUS_CLASSES = {
|
||||
0: 'warning',
|
||||
1: 'success',
|
||||
2: 'info',
|
||||
3: 'primary',
|
||||
4: 'danger',
|
||||
5: 'default',
|
||||
}
|
||||
|
||||
CONNECTION_STATUS_PLANNED = False
|
||||
CONNECTION_STATUS_CONNECTED = True
|
||||
CONNECTION_STATUS_CHOICES = [
|
||||
@@ -211,7 +231,9 @@ class Region(MPTTModel):
|
||||
"""
|
||||
Sites can be grouped within geographic Regions.
|
||||
"""
|
||||
parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True)
|
||||
parent = TreeForeignKey(
|
||||
'self', null=True, blank=True, related_name='children', db_index=True, on_delete=models.CASCADE
|
||||
)
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
|
||||
@@ -314,7 +336,7 @@ class RackGroup(models.Model):
|
||||
"""
|
||||
name = models.CharField(max_length=50)
|
||||
slug = models.SlugField()
|
||||
site = models.ForeignKey('Site', related_name='rack_groups')
|
||||
site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
@@ -324,7 +346,7 @@ class RackGroup(models.Model):
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return u'{} - {}'.format(self.site.name, self.name)
|
||||
return '{} - {}'.format(self.site.name, self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
|
||||
@@ -388,7 +410,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
return self.display_name or super(Rack, self).__str__()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rack', args=[self.pk])
|
||||
@@ -444,8 +466,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
|
||||
@property
|
||||
def display_name(self):
|
||||
if self.facility_id:
|
||||
return u"{} ({})".format(self.name, self.facility_id)
|
||||
return self.name
|
||||
return "{} ({})".format(self.name, self.facility_id)
|
||||
elif self.name:
|
||||
return self.name
|
||||
return ""
|
||||
|
||||
def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False):
|
||||
"""
|
||||
@@ -545,7 +569,7 @@ class RackReservation(models.Model):
|
||||
ordering = ['created']
|
||||
|
||||
def __str__(self):
|
||||
return u"Reservation for rack {}".format(self.rack)
|
||||
return "Reservation for rack {}".format(self.rack)
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -555,7 +579,7 @@ class RackReservation(models.Model):
|
||||
invalid_units = [u for u in self.units if u not in self.rack.units]
|
||||
if invalid_units:
|
||||
raise ValidationError({
|
||||
'units': u"Invalid unit(s) for {}U rack: {}".format(
|
||||
'units': "Invalid unit(s) for {}U rack: {}".format(
|
||||
self.rack.u_height,
|
||||
', '.join([str(u) for u in invalid_units]),
|
||||
),
|
||||
@@ -573,6 +597,15 @@ class RackReservation(models.Model):
|
||||
)
|
||||
})
|
||||
|
||||
@property
|
||||
def unit_list(self):
|
||||
"""
|
||||
Express the assigned units as a string of summarized ranges. For example:
|
||||
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
|
||||
"""
|
||||
group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
|
||||
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
|
||||
|
||||
|
||||
#
|
||||
# Device Types
|
||||
@@ -700,7 +733,7 @@ class DeviceType(models.Model, CustomFieldModel):
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
return u'{} {}'.format(self.manufacturer.name, self.model)
|
||||
return '{} {}'.format(self.manufacturer.name, self.model)
|
||||
|
||||
@property
|
||||
def is_parent_device(self):
|
||||
@@ -779,13 +812,13 @@ class InterfaceManager(models.Manager):
|
||||
|
||||
def order_naturally(self, method=IFACE_ORDERING_POSITION):
|
||||
"""
|
||||
Naturally order interfaces by their name and numeric position. The sort method must be one of the defined
|
||||
Naturally order interfaces by their type and numeric position. The sort method must be one of the defined
|
||||
IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
|
||||
|
||||
To order interfaces naturally, the `name` field is split into five distinct components: leading text (name),
|
||||
slot, subslot, position, and channel:
|
||||
To order interfaces naturally, the `name` field is split into six distinct components: leading text (type),
|
||||
slot, subslot, position, channel, and virtual circuit:
|
||||
|
||||
{name}{slot}/{subslot}/{position}:{channel}
|
||||
{type}{slot}/{subslot}/{position}:{channel}.{vc}
|
||||
|
||||
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would
|
||||
be parsed as follows:
|
||||
@@ -795,21 +828,24 @@ class InterfaceManager(models.Manager):
|
||||
subslot = 0
|
||||
position = 1
|
||||
channel = None
|
||||
vc = 0
|
||||
|
||||
The chosen sorting method will determine which fields are ordered first in the query.
|
||||
The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of
|
||||
the prescribed fields.
|
||||
"""
|
||||
queryset = self.get_queryset()
|
||||
sql_col = '{}.name'.format(queryset.model._meta.db_table)
|
||||
ordering = {
|
||||
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_name'),
|
||||
IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel'),
|
||||
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'),
|
||||
IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'),
|
||||
}[method]
|
||||
return queryset.extra(select={
|
||||
'_name': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
|
||||
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_channel': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col),
|
||||
'_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
|
||||
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_channel': "COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)".format(sql_col),
|
||||
'_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col),
|
||||
}).order_by(*ordering)
|
||||
|
||||
|
||||
@@ -919,19 +955,26 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
|
||||
name = NullableCharField(max_length=64, blank=True, null=True, unique=True)
|
||||
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
|
||||
asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
|
||||
help_text='A unique tag used to identify this device')
|
||||
asset_tag = NullableCharField(
|
||||
max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
|
||||
help_text='A unique tag used to identify this device'
|
||||
)
|
||||
site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT)
|
||||
rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT)
|
||||
position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
|
||||
verbose_name='Position (U)',
|
||||
help_text='The lowest-numbered unit occupied by the device')
|
||||
position = models.PositiveSmallIntegerField(
|
||||
blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)',
|
||||
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,
|
||||
blank=True, null=True, verbose_name='Primary IPv4')
|
||||
primary_ip6 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL,
|
||||
blank=True, null=True, verbose_name='Primary IPv6')
|
||||
status = models.PositiveSmallIntegerField(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, blank=True, null=True,
|
||||
verbose_name='Primary IPv4'
|
||||
)
|
||||
primary_ip6 = models.OneToOneField(
|
||||
'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True,
|
||||
verbose_name='Primary IPv6'
|
||||
)
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
images = GenericRelation(ImageAttachment)
|
||||
@@ -943,7 +986,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
unique_together = ['rack', 'position', 'face']
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
return self.display_name or super(Device, self).__str__()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:device', args=[self.pk])
|
||||
@@ -1051,6 +1094,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.platform.name if self.platform else None,
|
||||
self.serial,
|
||||
self.asset_tag,
|
||||
self.get_status_display(),
|
||||
self.site.name,
|
||||
self.rack.name if self.rack else None,
|
||||
self.position,
|
||||
@@ -1061,12 +1105,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
def display_name(self):
|
||||
if self.name:
|
||||
return self.name
|
||||
elif self.position:
|
||||
return u"{} ({} U{})".format(self.device_type, self.rack.name, self.position)
|
||||
elif self.rack:
|
||||
return u"{} ({})".format(self.device_type, self.rack.name)
|
||||
else:
|
||||
return u"{} ({})".format(self.device_type, self.site.name)
|
||||
elif hasattr(self, 'device_type'):
|
||||
return "{}".format(self.device_type)
|
||||
return ""
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
@@ -1094,6 +1135,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
return Device.objects.filter(parent_bay__device=self.pk)
|
||||
|
||||
def get_status_class(self):
|
||||
return DEVICE_STATUS_CLASSES[self.status]
|
||||
|
||||
def get_rpc_client(self):
|
||||
"""
|
||||
Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined.
|
||||
@@ -1276,7 +1320,7 @@ class Interface(models.Model):
|
||||
# An interface's LAG must belong to the same device
|
||||
if self.lag and self.lag.device != self.device:
|
||||
raise ValidationError({
|
||||
'lag': u"The selected LAG interface ({}) belongs to a different device ({}).".format(
|
||||
'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
|
||||
self.lag.name, self.lag.device.name
|
||||
)
|
||||
})
|
||||
@@ -1284,14 +1328,14 @@ class Interface(models.Model):
|
||||
# A virtual interface cannot have a parent LAG
|
||||
if self.form_factor in VIRTUAL_IFACE_TYPES and self.lag is not None:
|
||||
raise ValidationError({
|
||||
'lag': u"{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
|
||||
'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
|
||||
})
|
||||
|
||||
# Only a LAG can have LAG members
|
||||
if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists():
|
||||
raise ValidationError({
|
||||
'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format(
|
||||
u", ".join([iface.name for iface in self.member_interfaces.all()])
|
||||
", ".join([iface.name for iface in self.member_interfaces.all()])
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1384,7 +1428,7 @@ class DeviceBay(models.Model):
|
||||
unique_together = ['device', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return u'{} - {}'.format(self.device.name, self.name)
|
||||
return '{} - {}'.format(self.device.name, self.name)
|
||||
|
||||
def clean(self):
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from utilities.tables import BaseTable, SearchTable, ToggleColumn
|
||||
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
|
||||
Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
|
||||
RackGroup, Region, Site,
|
||||
RackGroup, RackReservation, Region, Site,
|
||||
)
|
||||
|
||||
|
||||
@@ -64,6 +65,12 @@ RACK_ROLE = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
RACKRESERVATION_ACTIONS = """
|
||||
{% if perms.dcim.change_rackreservation %}
|
||||
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICEROLE_ACTIONS = """
|
||||
{% if perms.dcim.change_devicerole %}
|
||||
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
@@ -86,12 +93,8 @@ DEVICE_ROLE = """
|
||||
<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
|
||||
"""
|
||||
|
||||
STATUS_ICON = """
|
||||
{% if record.status %}
|
||||
<span class="glyphicon glyphicon-ok-sign text-success" title="Active" aria-hidden="true"></span>
|
||||
{% else %}
|
||||
<span class="glyphicon glyphicon-minus-sign text-danger" title="Offline" aria-hidden="true"></span>
|
||||
{% endif %}
|
||||
DEVICE_STATUS = """
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
"""
|
||||
|
||||
DEVICE_PRIMARY_IP = """
|
||||
@@ -247,6 +250,23 @@ class RackImportTable(BaseTable):
|
||||
fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height')
|
||||
|
||||
|
||||
#
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||
unit_list = tables.Column(orderable=False, verbose_name='Units')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackReservation
|
||||
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# Manufacturers
|
||||
#
|
||||
@@ -409,7 +429,7 @@ class PlatformTable(BaseTable):
|
||||
class DeviceTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(template_code=DEVICE_LINK)
|
||||
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
|
||||
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||
@@ -429,7 +449,7 @@ class DeviceTable(BaseTable):
|
||||
|
||||
class DeviceSearchTable(SearchTable):
|
||||
name = tables.TemplateColumn(template_code=DEVICE_LINK)
|
||||
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
|
||||
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||
@@ -446,6 +466,7 @@ class DeviceSearchTable(SearchTable):
|
||||
|
||||
class DeviceImportTable(BaseTable):
|
||||
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
|
||||
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
|
||||
@@ -455,7 +476,7 @@ class DeviceImportTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Device
|
||||
fields = ('name', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
|
||||
fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
|
||||
empty_text = False
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.forms import *
|
||||
from dcim.models import *
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import *
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf.urls import url
|
||||
|
||||
from extras.views import ImageAttachmentEditView
|
||||
from ipam.views import ServiceEditView
|
||||
from secrets.views import secret_add
|
||||
|
||||
from extras.views import ImageAttachmentEditView
|
||||
from .models import Device, Rack, Site
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = 'dcim'
|
||||
urlpatterns = [
|
||||
|
||||
# Regions
|
||||
@@ -21,7 +23,7 @@ urlpatterns = [
|
||||
url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'),
|
||||
url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
|
||||
url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
|
||||
url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
|
||||
url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
|
||||
url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
|
||||
url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
|
||||
url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
|
||||
@@ -39,16 +41,19 @@ urlpatterns = [
|
||||
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
|
||||
|
||||
# Rack reservations
|
||||
url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
|
||||
url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
|
||||
url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
|
||||
url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
|
||||
|
||||
# Racks
|
||||
url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
|
||||
url(r'^rack-elevations/$', views.RackElevationListView.as_view(), name='rack_elevation_list'),
|
||||
url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'),
|
||||
url(r'^racks/import/$', views.RackBulkImportView.as_view(), name='rack_import'),
|
||||
url(r'^racks/edit/$', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
|
||||
url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
|
||||
url(r'^racks/(?P<pk>\d+)/$', views.rack, name='rack'),
|
||||
url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'),
|
||||
url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
|
||||
url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
|
||||
url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'),
|
||||
@@ -65,7 +70,7 @@ urlpatterns = [
|
||||
url(r'^device-types/add/$', views.DeviceTypeEditView.as_view(), name='devicetype_add'),
|
||||
url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
|
||||
url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
|
||||
url(r'^device-types/(?P<pk>\d+)/$', views.devicetype, name='devicetype'),
|
||||
url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
|
||||
url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
|
||||
url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
|
||||
|
||||
@@ -113,12 +118,11 @@ urlpatterns = [
|
||||
url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
|
||||
url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
|
||||
url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
|
||||
url(r'^devices/(?P<pk>\d+)/$', views.device, name='device'),
|
||||
url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
|
||||
url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
|
||||
url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
|
||||
url(r'^devices/(?P<pk>\d+)/inventory/$', views.device_inventory, name='device_inventory'),
|
||||
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+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
|
||||
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
||||
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'),
|
||||
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
|
||||
from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue
|
||||
|
||||
@@ -14,12 +18,40 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
|
||||
def to_representation(self, obj):
|
||||
return obj
|
||||
|
||||
def to_internal_value(self, data):
|
||||
|
||||
content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
|
||||
custom_fields = {field.name: field for field in CustomField.objects.filter(obj_type=content_type)}
|
||||
|
||||
for field_name, value in data.items():
|
||||
|
||||
# Validate custom field name
|
||||
if field_name not in custom_fields:
|
||||
raise ValidationError("Invalid custom field for {} objects: {}".format(content_type, field_name))
|
||||
|
||||
# Validate selected choice
|
||||
cf = custom_fields[field_name]
|
||||
if cf.type == CF_TYPE_SELECT:
|
||||
valid_choices = [c.pk for c in cf.choices.all()]
|
||||
if value not in valid_choices:
|
||||
raise ValidationError("Invalid choice ({}) for field {}".format(value, field_name))
|
||||
|
||||
# Check for missing required fields
|
||||
missing_fields = []
|
||||
for field_name, field in custom_fields.items():
|
||||
if field.required and field_name not in data:
|
||||
missing_fields.append(field_name)
|
||||
if missing_fields:
|
||||
raise ValidationError("Missing required fields: {}".format(u", ".join(missing_fields)))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class CustomFieldModelSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Extends ModelSerializer to render any CustomFields and their values associated with an object.
|
||||
"""
|
||||
custom_fields = CustomFieldsSerializer()
|
||||
custom_fields = CustomFieldsSerializer(required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -34,16 +66,59 @@ class CustomFieldModelSerializer(serializers.ModelSerializer):
|
||||
|
||||
super(CustomFieldModelSerializer, self).__init__(*args, **kwargs)
|
||||
|
||||
# Retrieve the set of CustomFields which apply to this type of object
|
||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
||||
fields = CustomField.objects.filter(obj_type=content_type)
|
||||
if self.instance is not None:
|
||||
|
||||
# Populate CustomFieldValues for each instance from database
|
||||
try:
|
||||
for obj in self.instance:
|
||||
_populate_custom_fields(obj, fields)
|
||||
except TypeError:
|
||||
_populate_custom_fields(self.instance, fields)
|
||||
# Retrieve the set of CustomFields which apply to this type of object
|
||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
||||
fields = CustomField.objects.filter(obj_type=content_type)
|
||||
|
||||
# Populate CustomFieldValues for each instance from database
|
||||
try:
|
||||
for obj in self.instance:
|
||||
_populate_custom_fields(obj, fields)
|
||||
except TypeError:
|
||||
_populate_custom_fields(self.instance, fields)
|
||||
|
||||
def _save_custom_fields(self, instance, custom_fields):
|
||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
||||
for field_name, value in custom_fields.items():
|
||||
custom_field = CustomField.objects.get(name=field_name)
|
||||
CustomFieldValue.objects.update_or_create(
|
||||
field=custom_field,
|
||||
obj_type=content_type,
|
||||
obj_id=instance.pk,
|
||||
defaults={'serialized_value': value},
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
|
||||
custom_fields = validated_data.pop('custom_fields', None)
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
instance = super(CustomFieldModelSerializer, self).create(validated_data)
|
||||
|
||||
# Save custom fields
|
||||
if custom_fields is not None:
|
||||
self._save_custom_fields(instance, custom_fields)
|
||||
instance.custom_fields = custom_fields
|
||||
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
|
||||
custom_fields = validated_data.pop('custom_fields', None)
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
instance = super(CustomFieldModelSerializer, self).update(instance, validated_data)
|
||||
|
||||
# Save custom fields
|
||||
if custom_fields is not None:
|
||||
self._save_custom_fields(instance, custom_fields)
|
||||
instance.custom_fields = custom_fields
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class CustomFieldChoiceSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from rest_framework import serializers
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
|
||||
from dcim.models import Device, Rack, Site
|
||||
from extras.models import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
@@ -29,4 +31,5 @@ router.register(r'image-attachments', views.ImageAttachmentViewSet)
|
||||
# Recent activity
|
||||
router.register(r'recent-activity', views.RecentActivityViewSet)
|
||||
|
||||
app_name = 'extras-api'
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_filters
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from __future__ import unicode_literals
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
@@ -104,7 +105,7 @@ class CustomFieldForm(forms.ModelForm):
|
||||
obj_id=self.instance.pk)
|
||||
except CustomFieldValue.DoesNotExist:
|
||||
# Skip this field if none exists already and its value is empty
|
||||
if self.cleaned_data[field_name] in [None, u'']:
|
||||
if self.cleaned_data[field_name] in [None, '']:
|
||||
continue
|
||||
cfv = CustomFieldValue(
|
||||
field=self.fields[field_name].model,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from getpass import getpass
|
||||
from ncclient.transport.errors import AuthenticationError
|
||||
from paramiko import AuthenticationException
|
||||
@@ -6,7 +8,7 @@ from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from dcim.models import Device, InventoryItem, Site
|
||||
from dcim.models import Device, InventoryItem, Site, STATUS_ACTIVE
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -39,7 +41,7 @@ class Command(BaseCommand):
|
||||
self.password = getpass("Password: ")
|
||||
|
||||
# Attempt to inventory only active devices
|
||||
device_list = Device.objects.filter(status=True)
|
||||
device_list = Device.objects.filter(status=STATUS_ACTIVE)
|
||||
|
||||
# --site: Include only devices belonging to specified site(s)
|
||||
if options['site']:
|
||||
@@ -72,7 +74,7 @@ class Command(BaseCommand):
|
||||
|
||||
# Skip inactive devices
|
||||
if not device.status:
|
||||
self.stdout.write("Skipped (inactive)")
|
||||
self.stdout.write("Skipped (not active)")
|
||||
continue
|
||||
|
||||
# Skip devices without primary_ip set
|
||||
|
||||
91
netbox/extras/migrations/0007_unicode_literals.py
Normal file
91
netbox/extras/migrations/0007_unicode_literals.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2017-05-24 15:34
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import extras.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0006_add_imageattachments'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='default',
|
||||
field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.', max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='is_filterable',
|
||||
field=models.BooleanField(default=True, help_text='This field can be used to filter objects.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, help_text="Name of the field as displayed to users (if not provided, the field's name will be used)", max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='obj_type',
|
||||
field=models.ManyToManyField(help_text='The object(s) to which this field applies.', related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='required',
|
||||
field=models.BooleanField(default=False, help_text='Determines whether this field is required when creating new objects or editing an existing object.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='type',
|
||||
field=models.PositiveSmallIntegerField(choices=[(100, 'Text'), (200, 'Integer'), (300, 'Boolean (true/false)'), (400, 'Date'), (500, 'URL'), (600, 'Selection')], default=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='weight',
|
||||
field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customfieldchoice',
|
||||
name='weight',
|
||||
field=models.PositiveSmallIntegerField(default=100, help_text='Higher weights appear lower in the list'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='graph',
|
||||
name='link',
|
||||
field=models.URLField(blank=True, verbose_name='Link URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='graph',
|
||||
name='name',
|
||||
field=models.CharField(max_length=100, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='graph',
|
||||
name='source',
|
||||
field=models.CharField(max_length=500, verbose_name='Source URL'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='graph',
|
||||
name='type',
|
||||
field=models.PositiveSmallIntegerField(choices=[(100, 'Interface'), (200, 'Provider'), (300, 'Site')]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='imageattachment',
|
||||
name='image',
|
||||
field=models.ImageField(height_field='image_height', upload_to=extras.models.image_upload, width_field='image_width'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='topologymap',
|
||||
name='device_patterns',
|
||||
field=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.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='useraction',
|
||||
name='action',
|
||||
field=models.PositiveSmallIntegerField(choices=[(1, 'created'), (7, 'bulk created'), (2, 'imported'), (3, 'modified'), (4, 'bulk edited'), (5, 'deleted'), (6, 'bulk deleted')]),
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,4 @@
|
||||
from __future__ import unicode_literals
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
import graphviz
|
||||
@@ -13,6 +14,8 @@ from django.template import Template, Context
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from utilities.utils import foreground_color
|
||||
|
||||
|
||||
CUSTOMFIELD_MODELS = (
|
||||
'site', 'rack', 'devicetype', 'device', # DCIM
|
||||
@@ -156,16 +159,13 @@ 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:
|
||||
try:
|
||||
return self.choices.get(pk=int(serialized_value))
|
||||
except CustomFieldChoice.DoesNotExist:
|
||||
return None
|
||||
return self.choices.get(pk=int(serialized_value))
|
||||
return serialized_value
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class CustomFieldValue(models.Model):
|
||||
field = models.ForeignKey('CustomField', related_name='values')
|
||||
field = models.ForeignKey('CustomField', related_name='values', on_delete=models.CASCADE)
|
||||
obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
|
||||
obj_id = models.PositiveIntegerField()
|
||||
obj = GenericForeignKey('obj_type', 'obj_id')
|
||||
@@ -176,7 +176,7 @@ class CustomFieldValue(models.Model):
|
||||
unique_together = ['field', 'obj_type', 'obj_id']
|
||||
|
||||
def __str__(self):
|
||||
return u'{} {}'.format(self.obj, self.field)
|
||||
return '{} {}'.format(self.obj, self.field)
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
@@ -254,7 +254,9 @@ class Graph(models.Model):
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class ExportTemplate(models.Model):
|
||||
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
|
||||
content_type = models.ForeignKey(
|
||||
ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}, on_delete=models.CASCADE
|
||||
)
|
||||
name = models.CharField(max_length=100)
|
||||
description = models.CharField(max_length=200, blank=True)
|
||||
template_code = models.TextField()
|
||||
@@ -268,7 +270,7 @@ class ExportTemplate(models.Model):
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return u'{}: {}'.format(self.content_type, self.name)
|
||||
return '{}: {}'.format(self.content_type, self.name)
|
||||
|
||||
def to_response(self, context_dict, filename):
|
||||
"""
|
||||
@@ -294,7 +296,7 @@ class ExportTemplate(models.Model):
|
||||
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)
|
||||
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True, on_delete=models.CASCADE)
|
||||
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. "
|
||||
@@ -316,7 +318,8 @@ class TopologyMap(models.Model):
|
||||
|
||||
def render(self, img_format='png'):
|
||||
|
||||
from dcim.models import Device, InterfaceConnection
|
||||
from circuits.models import CircuitTermination
|
||||
from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
|
||||
|
||||
# Construct the graph
|
||||
graph = graphviz.Graph()
|
||||
@@ -334,9 +337,11 @@ class TopologyMap(models.Model):
|
||||
# Add each device to the graph
|
||||
devices = []
|
||||
for query in device_set.split(';'): # Split regexes on semicolons
|
||||
devices += Device.objects.filter(name__regex=query)
|
||||
devices += Device.objects.filter(name__regex=query).select_related('device_role')
|
||||
for d in devices:
|
||||
subgraph.node(d.name)
|
||||
bg_color = '#{}'.format(d.device_role.color)
|
||||
fg_color = '#{}'.format(foreground_color(d.device_role.color))
|
||||
subgraph.node(d.name, style='filled', fillcolor=bg_color, fontcolor=fg_color, fontname='sans')
|
||||
|
||||
# Add an invisible connection to each successive device in a set to enforce horizontal order
|
||||
for j in range(0, len(devices) - 1):
|
||||
@@ -350,13 +355,20 @@ class TopologyMap(models.Model):
|
||||
for query in device_set.split(';'): # Split regexes on semicolons
|
||||
device_superset = device_superset | Q(name__regex=query)
|
||||
|
||||
# Add all connections to the graph
|
||||
# Add all interface connections to the graph
|
||||
devices = Device.objects.filter(*(device_superset,))
|
||||
connections = InterfaceConnection.objects.filter(
|
||||
interface_a__device__in=devices, interface_b__device__in=devices
|
||||
)
|
||||
for c in connections:
|
||||
graph.edge(c.interface_a.device.name, c.interface_b.device.name)
|
||||
style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
|
||||
graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
|
||||
|
||||
# Add all circuits to the graph
|
||||
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
|
||||
peer_termination = termination.get_peer_termination()
|
||||
if peer_termination is not None and peer_termination.interface.device in devices:
|
||||
graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
|
||||
|
||||
return graph.pipe(format=img_format)
|
||||
|
||||
@@ -384,7 +396,7 @@ class ImageAttachment(models.Model):
|
||||
"""
|
||||
An uploaded image which is associated with an object.
|
||||
"""
|
||||
content_type = models.ForeignKey(ContentType)
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
parent = GenericForeignKey('content_type', 'object_id')
|
||||
image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width')
|
||||
@@ -415,6 +427,16 @@ class ImageAttachment(models.Model):
|
||||
# before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.)
|
||||
self.image.name = _name
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
"""
|
||||
Wrapper around `image.size` to suppress an OSError in case the file is inaccessible.
|
||||
"""
|
||||
try:
|
||||
return self.image.size
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
#
|
||||
# User actions
|
||||
@@ -482,8 +504,8 @@ class UserAction(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
if self.message:
|
||||
return u'{} {}'.format(self.user, self.message)
|
||||
return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
|
||||
return '{} {}'.format(self.user, self.message)
|
||||
return '{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
|
||||
|
||||
def icon(self):
|
||||
if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import unicode_literals
|
||||
import re
|
||||
import time
|
||||
|
||||
from ncclient import manager
|
||||
import paramiko
|
||||
import re
|
||||
import xmltodict
|
||||
import time
|
||||
|
||||
|
||||
CONNECT_TIMEOUT = 5 # seconds
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
from __future__ import unicode_literals
|
||||
from datetime import date
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.models import Site
|
||||
|
||||
from extras.models import (
|
||||
CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE,
|
||||
CF_TYPE_SELECT, CF_TYPE_URL,
|
||||
)
|
||||
from users.models import Token
|
||||
from utilities.tests import HttpStatusMixin
|
||||
|
||||
|
||||
class CustomFieldTestCase(TestCase):
|
||||
class CustomFieldTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@@ -95,3 +102,209 @@ class CustomFieldTestCase(TestCase):
|
||||
|
||||
# Delete the custom field
|
||||
cf.delete()
|
||||
|
||||
|
||||
class CustomFieldAPITest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
content_type = ContentType.objects.get_for_model(Site)
|
||||
|
||||
# Text custom field
|
||||
self.cf_text = CustomField(type=CF_TYPE_TEXT, name='magic_word')
|
||||
self.cf_text.save()
|
||||
self.cf_text.obj_type = [content_type]
|
||||
self.cf_text.save()
|
||||
|
||||
# Integer custom field
|
||||
self.cf_integer = CustomField(type=CF_TYPE_INTEGER, name='magic_number')
|
||||
self.cf_integer.save()
|
||||
self.cf_integer.obj_type = [content_type]
|
||||
self.cf_integer.save()
|
||||
|
||||
# Boolean custom field
|
||||
self.cf_boolean = CustomField(type=CF_TYPE_BOOLEAN, name='is_magic')
|
||||
self.cf_boolean.save()
|
||||
self.cf_boolean.obj_type = [content_type]
|
||||
self.cf_boolean.save()
|
||||
|
||||
# Date custom field
|
||||
self.cf_date = CustomField(type=CF_TYPE_DATE, name='magic_date')
|
||||
self.cf_date.save()
|
||||
self.cf_date.obj_type = [content_type]
|
||||
self.cf_date.save()
|
||||
|
||||
# URL custom field
|
||||
self.cf_url = CustomField(type=CF_TYPE_URL, name='magic_url')
|
||||
self.cf_url.save()
|
||||
self.cf_url.obj_type = [content_type]
|
||||
self.cf_url.save()
|
||||
|
||||
# Select custom field
|
||||
self.cf_select = CustomField(type=CF_TYPE_SELECT, name='magic_choice')
|
||||
self.cf_select.save()
|
||||
self.cf_select.obj_type = [content_type]
|
||||
self.cf_select.save()
|
||||
self.cf_select_choice1 = CustomFieldChoice(field=self.cf_select, value='Foo')
|
||||
self.cf_select_choice1.save()
|
||||
self.cf_select_choice2 = CustomFieldChoice(field=self.cf_select, value='Bar')
|
||||
self.cf_select_choice2.save()
|
||||
self.cf_select_choice3 = CustomFieldChoice(field=self.cf_select, value='Baz')
|
||||
self.cf_select_choice3.save()
|
||||
|
||||
self.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
|
||||
def test_get_obj_without_custom_fields(self):
|
||||
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.site.name)
|
||||
self.assertEqual(response.data['custom_fields'], {
|
||||
'magic_word': None,
|
||||
'magic_number': None,
|
||||
'is_magic': None,
|
||||
'magic_date': None,
|
||||
'magic_url': None,
|
||||
'magic_choice': None,
|
||||
})
|
||||
|
||||
def test_get_obj_with_custom_fields(self):
|
||||
|
||||
CUSTOM_FIELD_VALUES = [
|
||||
(self.cf_text, 'Test string'),
|
||||
(self.cf_integer, 1234),
|
||||
(self.cf_boolean, True),
|
||||
(self.cf_date, date(2016, 6, 23)),
|
||||
(self.cf_url, 'http://example.com/'),
|
||||
(self.cf_select, self.cf_select_choice1.pk),
|
||||
]
|
||||
for field, value in CUSTOM_FIELD_VALUES:
|
||||
cfv = CustomFieldValue(field=field, obj=self.site)
|
||||
cfv.value = value
|
||||
cfv.save()
|
||||
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.site.name)
|
||||
self.assertEqual(response.data['custom_fields'].get('magic_word'), CUSTOM_FIELD_VALUES[0][1])
|
||||
self.assertEqual(response.data['custom_fields'].get('magic_number'), CUSTOM_FIELD_VALUES[1][1])
|
||||
self.assertEqual(response.data['custom_fields'].get('is_magic'), CUSTOM_FIELD_VALUES[2][1])
|
||||
self.assertEqual(response.data['custom_fields'].get('magic_date'), CUSTOM_FIELD_VALUES[3][1])
|
||||
self.assertEqual(response.data['custom_fields'].get('magic_url'), CUSTOM_FIELD_VALUES[4][1])
|
||||
self.assertEqual(response.data['custom_fields'].get('magic_choice'), {
|
||||
'value': self.cf_select_choice1.pk, 'label': 'Foo'
|
||||
})
|
||||
|
||||
def test_set_custom_field_text(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'custom_fields': {
|
||||
'magic_word': 'Foo bar baz',
|
||||
}
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['custom_fields'].get('magic_word'), data['custom_fields']['magic_word'])
|
||||
cfv = self.site.custom_field_values.get(field=self.cf_text)
|
||||
self.assertEqual(cfv.value, data['custom_fields']['magic_word'])
|
||||
|
||||
def test_set_custom_field_integer(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'custom_fields': {
|
||||
'magic_number': 42,
|
||||
}
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['custom_fields'].get('magic_number'), data['custom_fields']['magic_number'])
|
||||
cfv = self.site.custom_field_values.get(field=self.cf_integer)
|
||||
self.assertEqual(cfv.value, data['custom_fields']['magic_number'])
|
||||
|
||||
def test_set_custom_field_boolean(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'custom_fields': {
|
||||
'is_magic': 0,
|
||||
}
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['custom_fields'].get('is_magic'), data['custom_fields']['is_magic'])
|
||||
cfv = self.site.custom_field_values.get(field=self.cf_boolean)
|
||||
self.assertEqual(cfv.value, data['custom_fields']['is_magic'])
|
||||
|
||||
def test_set_custom_field_date(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'custom_fields': {
|
||||
'magic_date': '2017-04-25',
|
||||
}
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['custom_fields'].get('magic_date'), data['custom_fields']['magic_date'])
|
||||
cfv = self.site.custom_field_values.get(field=self.cf_date)
|
||||
self.assertEqual(cfv.value.isoformat(), data['custom_fields']['magic_date'])
|
||||
|
||||
def test_set_custom_field_url(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'custom_fields': {
|
||||
'magic_url': 'http://example.com/2/',
|
||||
}
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['custom_fields'].get('magic_url'), data['custom_fields']['magic_url'])
|
||||
cfv = self.site.custom_field_values.get(field=self.cf_url)
|
||||
self.assertEqual(cfv.value, data['custom_fields']['magic_url'])
|
||||
|
||||
def test_set_custom_field_select(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'custom_fields': {
|
||||
'magic_choice': self.cf_select_choice2.pk,
|
||||
}
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['custom_fields'].get('magic_choice'), data['custom_fields']['magic_choice'])
|
||||
cfv = self.site.custom_field_values.get(field=self.cf_select)
|
||||
self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf.urls import url
|
||||
|
||||
from extras import views
|
||||
|
||||
|
||||
app_name = 'extras'
|
||||
urlpatterns = [
|
||||
|
||||
# Image attachments
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
@@ -18,7 +20,7 @@ class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
imageattachment.parent = get_object_or_404(model, pk=kwargs['object_id'])
|
||||
return imageattachment
|
||||
|
||||
def get_return_url(self, imageattachment):
|
||||
def get_return_url(self, request, imageattachment):
|
||||
return imageattachment.parent.get_absolute_url()
|
||||
|
||||
|
||||
@@ -26,5 +28,5 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'dcim.delete_imageattachment'
|
||||
model = ImageAttachment
|
||||
|
||||
def get_return_url(self, imageattachment):
|
||||
return imageattachment.obj.get_absolute_url()
|
||||
def get_return_url(self, request, imageattachment):
|
||||
return imageattachment.parent.get_absolute_url()
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
|
||||
import os
|
||||
import random
|
||||
|
||||
charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
|
||||
random.seed = (os.urandom(2048))
|
||||
print(''.join(random.choice(charset) for c in range(50)))
|
||||
secure_random = random.SystemRandom()
|
||||
print(''.join(secure_random.sample(charset, 50)))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
@@ -31,11 +33,11 @@ class NestedVRFSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'name', 'rd']
|
||||
|
||||
|
||||
class WritableVRFSerializer(serializers.ModelSerializer):
|
||||
class WritableVRFSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description']
|
||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
|
||||
|
||||
|
||||
#
|
||||
@@ -96,11 +98,11 @@ class NestedAggregateSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'family', 'prefix']
|
||||
|
||||
|
||||
class WritableAggregateSerializer(serializers.ModelSerializer):
|
||||
class WritableAggregateSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['id', 'prefix', 'rir', 'date_added', 'description']
|
||||
fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
|
||||
|
||||
|
||||
#
|
||||
@@ -169,11 +171,11 @@ class NestedVLANSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'vid', 'name', 'display_name']
|
||||
|
||||
|
||||
class WritableVLANSerializer(serializers.ModelSerializer):
|
||||
class WritableVLANSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
|
||||
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields']
|
||||
validators = []
|
||||
|
||||
def validate(self, data):
|
||||
@@ -216,11 +218,14 @@ class NestedPrefixSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'family', 'prefix']
|
||||
|
||||
|
||||
class WritablePrefixSerializer(serializers.ModelSerializer):
|
||||
class WritablePrefixSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description']
|
||||
fields = [
|
||||
'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
@@ -252,11 +257,11 @@ IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
|
||||
IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
|
||||
|
||||
|
||||
class WritableIPAddressSerializer(serializers.ModelSerializer):
|
||||
class WritableIPAddressSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside']
|
||||
fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', 'custom_fields']
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
@@ -37,4 +39,5 @@ router.register(r'vlans', views.VLANViewSet)
|
||||
# Services
|
||||
router.register(r'services', views.ServiceViewSet)
|
||||
|
||||
app_name = 'ipam-api'
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from netaddr import IPNetwork
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_filters
|
||||
from netaddr import IPNetwork
|
||||
from netaddr.core import AddrFormatError
|
||||
@@ -8,8 +10,10 @@ from dcim.models import Site, Device, Interface
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
|
||||
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
from .models import (
|
||||
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
|
||||
VLAN_STATUS_CHOICES, VLANGroup, VRF,
|
||||
)
|
||||
|
||||
|
||||
class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
@@ -153,10 +157,13 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=PREFIX_STATUS_CHOICES
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['family', 'status']
|
||||
fields = ['family']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -237,10 +244,13 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
queryset=Interface.objects.all(),
|
||||
label='Interface (ID)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=IPADDRESS_STATUS_CHOICES
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['family', 'status']
|
||||
fields = ['family']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -337,10 +347,13 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=VLAN_STATUS_CHOICES
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['name', 'vid', 'status']
|
||||
fields = ['name', 'vid']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from netaddr import IPNetwork, AddrFormatError
|
||||
|
||||
from django import forms
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count
|
||||
|
||||
from dcim.models import Site, Rack, Device, Interface
|
||||
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch,
|
||||
SlugField, add_blank_choice,
|
||||
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, ChainedModelChoiceField, CSVDataField,
|
||||
ExpandableIPAddressField, FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice,
|
||||
)
|
||||
|
||||
from .models import (
|
||||
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
|
||||
VLANGroup, VLAN_STATUS_CHOICES, VRF,
|
||||
@@ -32,11 +35,11 @@ IPADDRESS_MASK_LENGTH_CHOICES = PREFIX_MASK_LENGTH_CHOICES + [(128, 128)]
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFForm(BootstrapMixin, CustomFieldForm):
|
||||
class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
|
||||
fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant']
|
||||
labels = {
|
||||
'rd': "RD",
|
||||
}
|
||||
@@ -61,6 +64,9 @@ class VRFImportForm(BootstrapMixin, BulkImportForm):
|
||||
class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
enforce_unique = forms.NullBooleanField(
|
||||
required=False, widget=BulkEditNullBooleanSelect, label='Enforce unique space'
|
||||
)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
class Meta:
|
||||
@@ -160,30 +166,36 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class PrefixForm(BootstrapMixin, CustomFieldForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
|
||||
widget=forms.Select(attrs={'filter-for': 'vlan', 'nullable': 'true'}))
|
||||
vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN',
|
||||
widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}',
|
||||
display_field='display_name'))
|
||||
class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label='Site',
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'vlan', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
vlan = ChainedModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
label='VLAN',
|
||||
widget=APISelect(
|
||||
api_url='/api/ipam/vlans/?site_id={{site}}', display_field='display_name'
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'is_pool', 'description']
|
||||
fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(PrefixForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
# Initialize field without choices to avoid pulling all VLANs from the database
|
||||
if self.is_bound and self.data.get('site'):
|
||||
self.fields['vlan'].queryset = VLAN.objects.filter(site__pk=self.data['site'])
|
||||
elif self.initial.get('site'):
|
||||
self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site'])
|
||||
else:
|
||||
self.fields['vlan'].queryset = VLAN.objects.filter(site=None)
|
||||
|
||||
|
||||
class PrefixFromCSVForm(forms.ModelForm):
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
|
||||
@@ -194,14 +206,16 @@ class PrefixFromCSVForm(forms.ModelForm):
|
||||
error_messages={'invalid_choice': 'Site not found.'})
|
||||
vlan_group_name = forms.CharField(required=False)
|
||||
vlan_vid = forms.IntegerField(required=False)
|
||||
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in PREFIX_STATUS_CHOICES])
|
||||
status = forms.CharField()
|
||||
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid role.'})
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'is_pool',
|
||||
'description']
|
||||
fields = [
|
||||
'prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status', 'role', 'is_pool',
|
||||
'description',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -210,35 +224,38 @@ class PrefixFromCSVForm(forms.ModelForm):
|
||||
site = self.cleaned_data.get('site')
|
||||
vlan_group_name = self.cleaned_data.get('vlan_group_name')
|
||||
vlan_vid = self.cleaned_data.get('vlan_vid')
|
||||
|
||||
# Validate VLAN
|
||||
vlan_group = None
|
||||
|
||||
# Validate VLAN group
|
||||
if vlan_group_name:
|
||||
try:
|
||||
vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name)
|
||||
except VLANGroup.DoesNotExist:
|
||||
self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
|
||||
if vlan_vid and vlan_group:
|
||||
if site:
|
||||
self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
|
||||
else:
|
||||
self.add_error('vlan_group_name', "Invalid global VLAN group ({}).".format(vlan_group_name))
|
||||
|
||||
# Validate VLAN
|
||||
if vlan_vid:
|
||||
try:
|
||||
self.instance.vlan = VLAN.objects.get(group=vlan_group, vid=vlan_vid)
|
||||
self.instance.vlan = VLAN.objects.get(site=site, group=vlan_group, vid=vlan_vid)
|
||||
except VLAN.DoesNotExist:
|
||||
self.add_error('vlan_vid', "Invalid VLAN ID ({} - {}).".format(vlan_group, vlan_vid))
|
||||
elif vlan_vid and site:
|
||||
try:
|
||||
self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid)
|
||||
except VLAN.DoesNotExist:
|
||||
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
|
||||
if site:
|
||||
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
|
||||
elif vlan_group:
|
||||
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for group {}.".format(vlan_vid, vlan_group_name))
|
||||
elif not vlan_group_name:
|
||||
self.add_error('vlan_vid', "Invalid global VLAN ID ({}).".format(vlan_vid))
|
||||
except VLAN.MultipleObjectsReturned:
|
||||
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
|
||||
elif vlan_vid:
|
||||
self.add_error('vlan_vid', "Must specify site and/or VLAN group when assigning a VLAN.")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Assign Prefix status by name
|
||||
self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
|
||||
|
||||
return super(PrefixFromCSVForm, self).save(*args, **kwargs)
|
||||
def clean_status(self):
|
||||
status_choices = {s[1].lower(): s[0] for s in PREFIX_STATUS_CHOICES}
|
||||
try:
|
||||
return status_choices[self.cleaned_data['status'].lower()]
|
||||
except KeyError:
|
||||
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
|
||||
|
||||
|
||||
class PrefixImportForm(BootstrapMixin, BulkImportForm):
|
||||
@@ -252,6 +269,7 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
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)
|
||||
is_pool = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is a pool')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
class Meta:
|
||||
@@ -262,7 +280,7 @@ def prefix_status_choices():
|
||||
status_counts = {}
|
||||
for status in Prefix.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 PREFIX_STATUS_CHOICES]
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
|
||||
|
||||
|
||||
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
@@ -302,131 +320,197 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressForm(BootstrapMixin, CustomFieldForm):
|
||||
nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
|
||||
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')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
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):
|
||||
super(IPAddressForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
if self.instance.nat_inside:
|
||||
|
||||
nat_inside = self.instance.nat_inside
|
||||
# If the IP is assigned to an interface, populate site/device fields accordingly
|
||||
if self.instance.nat_inside.interface:
|
||||
self.initial['nat_site'] = self.instance.nat_inside.interface.device.site.pk
|
||||
self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk
|
||||
self.fields['nat_device'].queryset = Device.objects.filter(
|
||||
site=nat_inside.interface.device.site
|
||||
)
|
||||
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
|
||||
interface__device=nat_inside.interface.device
|
||||
)
|
||||
else:
|
||||
self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
|
||||
|
||||
else:
|
||||
|
||||
# Initialize nat_device choices if nat_site is set
|
||||
if self.is_bound and self.data.get('nat_site'):
|
||||
self.fields['nat_device'].queryset = Device.objects.filter(site__pk=self.data['nat_site'])
|
||||
elif self.initial.get('nat_site'):
|
||||
self.fields['nat_device'].queryset = Device.objects.filter(site=self.initial['nat_site'])
|
||||
else:
|
||||
self.fields['nat_device'].choices = []
|
||||
|
||||
# Initialize nat_inside choices if nat_device is set
|
||||
if self.is_bound and self.data.get('nat_device'):
|
||||
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
|
||||
interface__device__pk=self.data['nat_device'])
|
||||
elif self.initial.get('nat_device'):
|
||||
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
|
||||
interface__device__pk=self.initial['nat_device'])
|
||||
else:
|
||||
self.fields['nat_inside'].choices = []
|
||||
|
||||
|
||||
class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
|
||||
address = ExpandableIPAddressField()
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
|
||||
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(
|
||||
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
|
||||
interface_site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label='Site',
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'rack'}
|
||||
attrs={'filter-for': 'interface_rack'}
|
||||
)
|
||||
)
|
||||
rack = forms.ModelChoiceField(
|
||||
interface_rack = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack',
|
||||
chains=(
|
||||
('site', 'interface_site'),
|
||||
),
|
||||
required=False,
|
||||
label='Rack',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
api_url='/api/dcim/racks/?site_id={{interface_site}}',
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'device', 'nullable': 'true'}
|
||||
attrs={'filter-for': 'interface_device', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
device = forms.ModelChoiceField(
|
||||
interface_device = ChainedModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
label='Device',
|
||||
chains=(
|
||||
('site', 'interface_site'),
|
||||
('rack', 'interface_rack'),
|
||||
),
|
||||
required=False,
|
||||
label='Device',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
|
||||
api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}',
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'interface'}
|
||||
)
|
||||
)
|
||||
livesearch = forms.CharField(
|
||||
interface = ChainedModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
chains=(
|
||||
('device', 'interface_device'),
|
||||
),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
|
||||
)
|
||||
)
|
||||
nat_site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label='Site',
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'nat_rack'}
|
||||
)
|
||||
)
|
||||
nat_rack = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
chains=(
|
||||
('site', 'nat_site'),
|
||||
),
|
||||
required=False,
|
||||
label='Rack',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{nat_site}}',
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'nat_device', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
nat_device = ChainedModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
chains=(
|
||||
('site', 'nat_site'),
|
||||
('rack', 'nat_rack'),
|
||||
),
|
||||
required=False,
|
||||
label='Device',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/devices/?site_id={{nat_site}}&rack_id={{nat_rack}}',
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'nat_inside'}
|
||||
)
|
||||
)
|
||||
nat_inside = ChainedModelChoiceField(
|
||||
queryset=IPAddress.objects.all(),
|
||||
chains=(
|
||||
('interface__device', 'nat_device'),
|
||||
),
|
||||
required=False,
|
||||
label='IP Address',
|
||||
widget=APISelect(
|
||||
api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}',
|
||||
display_field='address'
|
||||
)
|
||||
)
|
||||
livesearch = forms.CharField(
|
||||
required=False,
|
||||
label='Search',
|
||||
widget=Livesearch(
|
||||
query_key='q',
|
||||
query_url='dcim-api:device-list',
|
||||
field_to_update='device'
|
||||
query_url='ipam-api:ipaddress-list',
|
||||
field_to_update='nat_inside',
|
||||
obj_label='address'
|
||||
)
|
||||
)
|
||||
interface = forms.ModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
label='Interface',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/interfaces/?device_id={{device}}'
|
||||
)
|
||||
)
|
||||
set_as_primary = forms.BooleanField(
|
||||
label='Set as primary IP for device',
|
||||
required=False
|
||||
)
|
||||
primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack',
|
||||
'nat_inside', 'tenant_group', 'tenant',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(IPAddressAssignForm, self).__init__(*args, **kwargs)
|
||||
# Initialize helper selectors
|
||||
instance = kwargs.get('instance')
|
||||
initial = kwargs.get('initial', {})
|
||||
if instance and instance.interface is not None:
|
||||
initial['interface_site'] = instance.interface.device.site
|
||||
initial['interface_rack'] = instance.interface.device.rack
|
||||
initial['interface_device'] = instance.interface.device
|
||||
if instance and instance.nat_inside is not None:
|
||||
initial['nat_site'] = instance.nat_inside.device.site
|
||||
initial['nat_rack'] = instance.nat_inside.device.rack
|
||||
initial['nat_device'] = instance.nat_inside.device
|
||||
kwargs['initial'] = initial
|
||||
|
||||
self.fields['rack'].choices = []
|
||||
self.fields['device'].choices = []
|
||||
self.fields['interface'].choices = []
|
||||
super(IPAddressForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
# Initialize primary_for_device if IP address is already assigned
|
||||
if self.instance.interface is not None:
|
||||
device = self.instance.interface.device
|
||||
if (
|
||||
self.instance.address.version == 4 and device.primary_ip4 == self.instance or
|
||||
self.instance.address.version == 6 and device.primary_ip6 == self.instance
|
||||
):
|
||||
self.initial['primary_for_device'] = True
|
||||
|
||||
def clean(self):
|
||||
super(IPAddressForm, self).clean()
|
||||
|
||||
# Primary IP assignment is only available if an interface has been assigned.
|
||||
if self.cleaned_data.get('primary_for_device') and not self.cleaned_data.get('interface'):
|
||||
self.add_error(
|
||||
'primary_for_device', "Only IP addresses assigned to an interface can be designated as primary IPs."
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
|
||||
|
||||
# Assign this IPAddress as the primary for the associated Device.
|
||||
if self.cleaned_data['primary_for_device']:
|
||||
device = self.cleaned_data['interface'].device
|
||||
if ipaddress.address.version == 4:
|
||||
device.primary_ip4 = ipaddress
|
||||
else:
|
||||
device.primary_ip6 = ipaddress
|
||||
device.save()
|
||||
|
||||
# Clear assignment as primary for device if set.
|
||||
else:
|
||||
try:
|
||||
if ipaddress.address.version == 4:
|
||||
device = ipaddress.primary_ip4_for
|
||||
device.primary_ip4 = None
|
||||
else:
|
||||
device = ipaddress.primary_ip6_for
|
||||
device.primary_ip6 = None
|
||||
device.save()
|
||||
except Device.DoesNotExist:
|
||||
pass
|
||||
|
||||
return ipaddress
|
||||
|
||||
|
||||
class IPAddressPatternForm(BootstrapMixin, forms.Form):
|
||||
pattern = ExpandableIPAddressField(label='Address pattern')
|
||||
|
||||
|
||||
class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['address', 'status', 'vrf', 'description', 'tenant_group', 'tenant']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(IPAddressBulkAddForm, self).__init__(*args, **kwargs)
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
|
||||
class IPAddressFromCSVForm(forms.ModelForm):
|
||||
@@ -434,7 +518,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
|
||||
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])
|
||||
status = forms.CharField()
|
||||
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)
|
||||
@@ -442,7 +526,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['address', 'vrf', 'tenant', 'status_name', 'device', 'interface_name', 'is_primary', 'description']
|
||||
fields = ['address', 'vrf', 'tenant', 'status', 'device', 'interface_name', 'is_primary', 'description']
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -465,10 +549,14 @@ 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, *args, **kwargs):
|
||||
def clean_status(self):
|
||||
status_choices = {s[1].lower(): s[0] for s in IPADDRESS_STATUS_CHOICES}
|
||||
try:
|
||||
return status_choices[self.cleaned_data['status'].lower()]
|
||||
except KeyError:
|
||||
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
|
||||
|
||||
# Assign status by name
|
||||
self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Set interface
|
||||
if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
|
||||
@@ -503,7 +591,7 @@ 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]
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
|
||||
|
||||
|
||||
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
@@ -552,14 +640,29 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANForm(BootstrapMixin, CustomFieldForm):
|
||||
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect(
|
||||
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
|
||||
))
|
||||
class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'group', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
group = ChainedModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
label='Group',
|
||||
widget=APISelect(
|
||||
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
|
||||
fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant']
|
||||
help_texts = {
|
||||
'site': "Leave blank if this VLAN spans multiple sites",
|
||||
'group': "VLAN group (optional)",
|
||||
@@ -568,45 +671,58 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
|
||||
'status': "Operational status of this VLAN",
|
||||
'role': "The primary function of this VLAN",
|
||||
}
|
||||
widgets = {
|
||||
'site': forms.Select(attrs={'filter-for': 'group', 'nullable': 'true'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(VLANForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit VLAN group choices
|
||||
if self.is_bound and self.data.get('site'):
|
||||
self.fields['group'].queryset = VLANGroup.objects.filter(site__pk=self.data['site'])
|
||||
elif self.initial.get('site'):
|
||||
self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site'])
|
||||
else:
|
||||
self.fields['group'].queryset = VLANGroup.objects.filter(site=None)
|
||||
|
||||
|
||||
class VLANFromCSVForm(forms.ModelForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
|
||||
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,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
|
||||
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid role.'})
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Site not found.'}
|
||||
)
|
||||
group_name = forms.CharField(required=False)
|
||||
tenant = forms.ModelChoiceField(
|
||||
Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'}
|
||||
)
|
||||
status = forms.CharField()
|
||||
role = forms.ModelChoiceField(
|
||||
queryset=Role.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid role.'}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status_name', 'role', 'description']
|
||||
fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
|
||||
|
||||
def clean(self):
|
||||
|
||||
super(VLANFromCSVForm, self).clean()
|
||||
|
||||
# Validate VLANGroup
|
||||
group_name = self.cleaned_data.get('group_name')
|
||||
if group_name:
|
||||
try:
|
||||
VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name)
|
||||
except VLANGroup.DoesNotExist:
|
||||
self.add_error('group_name', "Invalid VLAN group {}.".format(group_name))
|
||||
|
||||
def clean_status(self):
|
||||
status_choices = {s[1].lower(): s[0] for s in VLAN_STATUS_CHOICES}
|
||||
try:
|
||||
return status_choices[self.cleaned_data['status'].lower()]
|
||||
except KeyError:
|
||||
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
m = super(VLANFromCSVForm, self).save(commit=False)
|
||||
# Assign VLAN status by name
|
||||
m.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
|
||||
|
||||
vlan = super(VLANFromCSVForm, self).save(commit=False)
|
||||
|
||||
# Assign VLANGroup by site and name
|
||||
if self.cleaned_data['group_name']:
|
||||
vlan.group = VLANGroup.objects.get(site=self.cleaned_data['site'], name=self.cleaned_data['group_name'])
|
||||
|
||||
if kwargs.get('commit'):
|
||||
m.save()
|
||||
return m
|
||||
vlan.save()
|
||||
return vlan
|
||||
|
||||
|
||||
class VLANImportForm(BootstrapMixin, BulkImportForm):
|
||||
@@ -623,14 +739,14 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['group', 'tenant', 'role', 'description']
|
||||
nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
|
||||
|
||||
|
||||
def vlan_status_choices():
|
||||
status_counts = {}
|
||||
for status in VLAN.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 VLAN_STATUS_CHOICES]
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
|
||||
|
||||
|
||||
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db.models import Lookup, Transform, IntegerField
|
||||
from django.db.models.lookups import BuiltinLookup
|
||||
|
||||
|
||||
133
netbox/ipam/migrations/0016_unicode_literals.py
Normal file
133
netbox/ipam/migrations/0016_unicode_literals.py
Normal file
@@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2017-05-24 15:34
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import ipam.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0015_global_vlans'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='aggregate',
|
||||
name='family',
|
||||
field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aggregate',
|
||||
name='rir',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aggregates', to='ipam.RIR', verbose_name='RIR'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ipaddress',
|
||||
name='address',
|
||||
field=ipam.fields.IPAddressField(help_text='IPv4 or IPv6 address (with mask)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ipaddress',
|
||||
name='family',
|
||||
field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ipaddress',
|
||||
name='nat_inside',
|
||||
field=models.OneToOneField(blank=True, help_text='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='NAT (Inside)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ipaddress',
|
||||
name='status',
|
||||
field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated'), (5, 'DHCP')], default=1, verbose_name='Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ipaddress',
|
||||
name='vrf',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='ipam.VRF', verbose_name='VRF'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='prefix',
|
||||
name='family',
|
||||
field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='prefix',
|
||||
name='is_pool',
|
||||
field=models.BooleanField(default=False, help_text='All IP addresses within this prefix are considered usable', verbose_name='Is a pool'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='prefix',
|
||||
name='prefix',
|
||||
field=ipam.fields.IPNetworkField(help_text='IPv4 or IPv6 network with mask'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='prefix',
|
||||
name='role',
|
||||
field=models.ForeignKey(blank=True, help_text='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, 'Container'), (1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, help_text='Operational status of this prefix', verbose_name='Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='prefix',
|
||||
name='vlan',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VLAN', verbose_name='VLAN'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='prefix',
|
||||
name='vrf',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VRF', verbose_name='VRF'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rir',
|
||||
name='is_private',
|
||||
field=models.BooleanField(default=False, help_text='IP space managed by this RIR is considered private', verbose_name='Private'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='service',
|
||||
name='device',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='service',
|
||||
name='ipaddresses',
|
||||
field=models.ManyToManyField(blank=True, related_name='services', to='ipam.IPAddress', verbose_name='IP addresses'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='service',
|
||||
name='port',
|
||||
field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], verbose_name='Port number'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='service',
|
||||
name='protocol',
|
||||
field=models.PositiveSmallIntegerField(choices=[(6, 'TCP'), (17, 'UDP')]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='vlan',
|
||||
name='status',
|
||||
field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, verbose_name='Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='vlan',
|
||||
name='vid',
|
||||
field=models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)], verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='vrf',
|
||||
name='enforce_unique',
|
||||
field=models.BooleanField(default=True, help_text='Prevent duplicate prefixes/IP addresses within this VRF', verbose_name='Enforce unique space'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='vrf',
|
||||
name='rd',
|
||||
field=models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher'),
|
||||
),
|
||||
]
|
||||
@@ -1,12 +1,14 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from netaddr import IPNetwork, cidr_merge
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
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.expressions import RawSQL
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
|
||||
from dcim.models import Interface
|
||||
@@ -15,7 +17,6 @@ from tenancy.models import Tenant
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
from utilities.sql import NullsFirstQuerySet
|
||||
from utilities.utils import csv_format
|
||||
|
||||
from .fields import IPNetworkField, IPAddressField
|
||||
|
||||
|
||||
@@ -499,7 +500,7 @@ class VLANGroup(models.Model):
|
||||
def __str__(self):
|
||||
if self.site is None:
|
||||
return self.name
|
||||
return u'{} - {}'.format(self.site.name, self.name)
|
||||
return '{} - {}'.format(self.site.name, self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
|
||||
@@ -538,7 +539,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
||||
verbose_name_plural = 'VLANs'
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
return self.display_name or super(VLAN, self).__str__()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:vlan', args=[self.pk])
|
||||
@@ -565,7 +566,9 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return u'{} ({})'.format(self.vid, self.name)
|
||||
if self.vid and self.name:
|
||||
return "{} ({})".format(self.vid, self.name)
|
||||
return None
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CHOICE_CLASSES[self.status]
|
||||
@@ -591,4 +594,4 @@ class Service(CreatedUpdatedModel):
|
||||
unique_together = ['device', 'protocol', 'port']
|
||||
|
||||
def __str__(self):
|
||||
return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
|
||||
return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from utilities.tables import BaseTable, SearchTable, ToggleColumn
|
||||
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
|
||||
|
||||
|
||||
@@ -70,9 +71,18 @@ IPADDRESS_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
|
||||
{% elif perms.ipam.add_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }}</a>
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
|
||||
{% else %}
|
||||
{{ record.0 }}
|
||||
{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
IPADDRESS_DEVICE = """
|
||||
{% if record.interface %}
|
||||
<a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
|
||||
({{ record.interface.name }})
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -281,12 +291,14 @@ class IPAddressTable(BaseTable):
|
||||
status = tables.TemplateColumn(STATUS_LABEL)
|
||||
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
|
||||
tenant = tables.TemplateColumn(TENANT_LINK)
|
||||
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
|
||||
interface = tables.Column(orderable=False)
|
||||
nat_inside = tables.LinkColumn(
|
||||
'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
|
||||
)
|
||||
device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description')
|
||||
fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'nat_inside', 'device', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from netaddr import IPNetwork
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from netaddr import IPNetwork
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import netaddr
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from ipam.models import IPAddress, Prefix, VRF
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class TestPrefix(TestCase):
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf.urls import url
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
app_name = 'ipam'
|
||||
urlpatterns = [
|
||||
|
||||
# VRFs
|
||||
@@ -11,7 +14,7 @@ urlpatterns = [
|
||||
url(r'^vrfs/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'),
|
||||
url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
|
||||
url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
|
||||
url(r'^vrfs/(?P<pk>\d+)/$', views.vrf, name='vrf'),
|
||||
url(r'^vrfs/(?P<pk>\d+)/$', views.VRFView.as_view(), name='vrf'),
|
||||
url(r'^vrfs/(?P<pk>\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'),
|
||||
url(r'^vrfs/(?P<pk>\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'),
|
||||
|
||||
@@ -27,7 +30,7 @@ urlpatterns = [
|
||||
url(r'^aggregates/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
|
||||
url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
|
||||
url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
|
||||
url(r'^aggregates/(?P<pk>\d+)/$', views.aggregate, name='aggregate'),
|
||||
url(r'^aggregates/(?P<pk>\d+)/$', views.AggregateView.as_view(), name='aggregate'),
|
||||
url(r'^aggregates/(?P<pk>\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'),
|
||||
url(r'^aggregates/(?P<pk>\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
|
||||
|
||||
@@ -43,10 +46,10 @@ urlpatterns = [
|
||||
url(r'^prefixes/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'),
|
||||
url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
|
||||
url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
|
||||
url(r'^prefixes/(?P<pk>\d+)/$', views.prefix, name='prefix'),
|
||||
url(r'^prefixes/(?P<pk>\d+)/$', views.PrefixView.as_view(), name='prefix'),
|
||||
url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'),
|
||||
url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'),
|
||||
url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.prefix_ipaddresses, name='prefix_ipaddresses'),
|
||||
url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
|
||||
|
||||
# IP addresses
|
||||
url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'),
|
||||
@@ -55,10 +58,8 @@ urlpatterns = [
|
||||
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+)/$', views.IPAddressView.as_view(), 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
|
||||
@@ -73,7 +74,7 @@ urlpatterns = [
|
||||
url(r'^vlans/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'),
|
||||
url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
|
||||
url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
|
||||
url(r'^vlans/(?P<pk>\d+)/$', views.vlan, name='vlan'),
|
||||
url(r'^vlans/(?P<pk>\d+)/$', views.VLANView.as_view(), name='vlan'),
|
||||
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'),
|
||||
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django_tables2 import RequestConfig
|
||||
import netaddr
|
||||
|
||||
from django.conf import settings
|
||||
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, redirect, render
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
|
||||
from dcim.models import Device
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.views import (
|
||||
BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
|
||||
from . import filters, forms, tables
|
||||
from .models import (
|
||||
Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role,
|
||||
@@ -99,18 +98,20 @@ class VRFListView(ObjectListView):
|
||||
template_name = 'ipam/vrf_list.html'
|
||||
|
||||
|
||||
def vrf(request, pk):
|
||||
class VRFView(View):
|
||||
|
||||
vrf = get_object_or_404(VRF.objects.all(), pk=pk)
|
||||
prefix_table = tables.PrefixBriefTable(
|
||||
list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role'))
|
||||
)
|
||||
prefix_table.exclude = ('vrf',)
|
||||
def get(self, request, pk):
|
||||
|
||||
return render(request, 'ipam/vrf.html', {
|
||||
'vrf': vrf,
|
||||
'prefix_table': prefix_table,
|
||||
})
|
||||
vrf = get_object_or_404(VRF.objects.all(), pk=pk)
|
||||
prefix_table = tables.PrefixBriefTable(
|
||||
list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role'))
|
||||
)
|
||||
prefix_table.exclude = ('vrf',)
|
||||
|
||||
return render(request, 'ipam/vrf.html', {
|
||||
'vrf': vrf,
|
||||
'prefix_table': prefix_table,
|
||||
})
|
||||
|
||||
|
||||
class VRFEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
@@ -244,7 +245,7 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = RIR
|
||||
form_class = forms.RIRForm
|
||||
|
||||
def get_return_url(self, obj):
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('ipam:rir_list')
|
||||
|
||||
|
||||
@@ -284,37 +285,44 @@ class AggregateListView(ObjectListView):
|
||||
}
|
||||
|
||||
|
||||
def aggregate(request, pk):
|
||||
class AggregateView(View):
|
||||
|
||||
aggregate = get_object_or_404(Aggregate, pk=pk)
|
||||
def get(self, request, pk):
|
||||
|
||||
# Find all child prefixes contained by this aggregate
|
||||
child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))\
|
||||
.select_related('site', 'role').annotate_depth(limit=0)
|
||||
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
|
||||
aggregate = get_object_or_404(Aggregate, pk=pk)
|
||||
|
||||
prefix_table = tables.PrefixTable(child_prefixes)
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
prefix_table.base_columns['pk'].visible = True
|
||||
# Find all child prefixes contained by this aggregate
|
||||
child_prefixes = Prefix.objects.filter(
|
||||
prefix__net_contained_or_equal=str(aggregate.prefix)
|
||||
).select_related(
|
||||
'site', 'role'
|
||||
).annotate_depth(
|
||||
limit=0
|
||||
)
|
||||
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
|
||||
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(prefix_table)
|
||||
prefix_table = tables.PrefixTable(child_prefixes)
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
prefix_table.base_columns['pk'].visible = True
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
'add': request.user.has_perm('ipam.add_prefix'),
|
||||
'change': request.user.has_perm('ipam.change_prefix'),
|
||||
'delete': request.user.has_perm('ipam.delete_prefix'),
|
||||
}
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(prefix_table)
|
||||
|
||||
return render(request, 'ipam/aggregate.html', {
|
||||
'aggregate': aggregate,
|
||||
'prefix_table': prefix_table,
|
||||
'permissions': permissions,
|
||||
})
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
'add': request.user.has_perm('ipam.add_prefix'),
|
||||
'change': request.user.has_perm('ipam.change_prefix'),
|
||||
'delete': request.user.has_perm('ipam.delete_prefix'),
|
||||
}
|
||||
|
||||
return render(request, 'ipam/aggregate.html', {
|
||||
'aggregate': aggregate,
|
||||
'prefix_table': prefix_table,
|
||||
'permissions': permissions,
|
||||
})
|
||||
|
||||
|
||||
class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
@@ -370,7 +378,7 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = Role
|
||||
form_class = forms.RoleForm
|
||||
|
||||
def get_return_url(self, obj):
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('ipam:role_list')
|
||||
|
||||
|
||||
@@ -397,66 +405,120 @@ class PrefixListView(ObjectListView):
|
||||
return self.queryset.annotate_depth(limit=limit)
|
||||
|
||||
|
||||
def prefix(request, pk):
|
||||
class PrefixView(View):
|
||||
|
||||
prefix = get_object_or_404(Prefix.objects.select_related(
|
||||
'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
|
||||
), pk=pk)
|
||||
def get(self, request, pk):
|
||||
|
||||
try:
|
||||
aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
|
||||
except Aggregate.DoesNotExist:
|
||||
aggregate = None
|
||||
prefix = get_object_or_404(Prefix.objects.select_related(
|
||||
'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
|
||||
), pk=pk)
|
||||
|
||||
# Count child IP addresses
|
||||
ipaddress_count = IPAddress.objects.filter(vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix))\
|
||||
.count()
|
||||
try:
|
||||
aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
|
||||
except Aggregate.DoesNotExist:
|
||||
aggregate = None
|
||||
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.filter(Q(vrf=prefix.vrf) | Q(vrf__isnull=True))\
|
||||
.filter(prefix__net_contains=str(prefix.prefix))\
|
||||
.select_related('site', 'role').annotate_depth()
|
||||
parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
|
||||
parent_prefix_table.exclude = ('vrf',)
|
||||
# Count child IP addresses
|
||||
ipaddress_count = IPAddress.objects.filter(
|
||||
vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix)
|
||||
).count()
|
||||
|
||||
# Duplicate prefixes table
|
||||
duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
|
||||
.select_related('site', 'role')
|
||||
duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
|
||||
duplicate_prefix_table.exclude = ('vrf',)
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.filter(
|
||||
Q(vrf=prefix.vrf) | Q(vrf__isnull=True)
|
||||
).filter(
|
||||
prefix__net_contains=str(prefix.prefix)
|
||||
).select_related(
|
||||
'site', 'role'
|
||||
).annotate_depth()
|
||||
parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
|
||||
parent_prefix_table.exclude = ('vrf',)
|
||||
|
||||
# Child prefixes table
|
||||
child_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix))\
|
||||
.select_related('site', 'role').annotate_depth(limit=0)
|
||||
if child_prefixes:
|
||||
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
|
||||
child_prefix_table = tables.PrefixTable(child_prefixes)
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
child_prefix_table.base_columns['pk'].visible = True
|
||||
# Duplicate prefixes table
|
||||
duplicate_prefixes = Prefix.objects.filter(
|
||||
vrf=prefix.vrf, prefix=str(prefix.prefix)
|
||||
).exclude(
|
||||
pk=prefix.pk
|
||||
).select_related(
|
||||
'site', 'role'
|
||||
)
|
||||
duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
|
||||
duplicate_prefix_table.exclude = ('vrf',)
|
||||
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(child_prefix_table)
|
||||
# Child prefixes table
|
||||
child_prefixes = Prefix.objects.filter(
|
||||
vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix)
|
||||
).select_related(
|
||||
'site', 'role'
|
||||
).annotate_depth(limit=0)
|
||||
if child_prefixes:
|
||||
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
|
||||
child_prefix_table = tables.PrefixTable(child_prefixes)
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
child_prefix_table.base_columns['pk'].visible = True
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
'add': request.user.has_perm('ipam.add_prefix'),
|
||||
'change': request.user.has_perm('ipam.change_prefix'),
|
||||
'delete': request.user.has_perm('ipam.delete_prefix'),
|
||||
}
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(child_prefix_table)
|
||||
|
||||
return render(request, 'ipam/prefix.html', {
|
||||
'prefix': prefix,
|
||||
'aggregate': aggregate,
|
||||
'ipaddress_count': ipaddress_count,
|
||||
'parent_prefix_table': parent_prefix_table,
|
||||
'child_prefix_table': child_prefix_table,
|
||||
'duplicate_prefix_table': duplicate_prefix_table,
|
||||
'permissions': permissions,
|
||||
'return_url': prefix.get_absolute_url(),
|
||||
})
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
'add': request.user.has_perm('ipam.add_prefix'),
|
||||
'change': request.user.has_perm('ipam.change_prefix'),
|
||||
'delete': request.user.has_perm('ipam.delete_prefix'),
|
||||
}
|
||||
|
||||
return render(request, 'ipam/prefix.html', {
|
||||
'prefix': prefix,
|
||||
'aggregate': aggregate,
|
||||
'ipaddress_count': ipaddress_count,
|
||||
'parent_prefix_table': parent_prefix_table,
|
||||
'child_prefix_table': child_prefix_table,
|
||||
'duplicate_prefix_table': duplicate_prefix_table,
|
||||
'permissions': permissions,
|
||||
'return_url': prefix.get_absolute_url(),
|
||||
})
|
||||
|
||||
|
||||
class PrefixIPAddressesView(View):
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
|
||||
|
||||
# Find all IPAddresses belonging to this Prefix
|
||||
ipaddresses = IPAddress.objects.filter(
|
||||
vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix)
|
||||
).select_related(
|
||||
'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
|
||||
)
|
||||
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
|
||||
|
||||
ip_table = tables.IPAddressTable(ipaddresses)
|
||||
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
|
||||
ip_table.base_columns['pk'].visible = True
|
||||
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(ip_table)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
'add': request.user.has_perm('ipam.add_ipaddress'),
|
||||
'change': request.user.has_perm('ipam.change_ipaddress'),
|
||||
'delete': request.user.has_perm('ipam.delete_ipaddress'),
|
||||
}
|
||||
|
||||
return render(request, 'ipam/prefix_ipaddresses.html', {
|
||||
'prefix': prefix,
|
||||
'ip_table': ip_table,
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf or '0', prefix.prefix),
|
||||
})
|
||||
|
||||
|
||||
class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
@@ -464,7 +526,6 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = Prefix
|
||||
form_class = forms.PrefixForm
|
||||
template_name = 'ipam/prefix_edit.html'
|
||||
fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
|
||||
default_return_url = 'ipam:prefix_list'
|
||||
|
||||
|
||||
@@ -499,153 +560,65 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
default_return_url = 'ipam:prefix_list'
|
||||
|
||||
|
||||
def prefix_ipaddresses(request, pk):
|
||||
|
||||
prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
|
||||
|
||||
# Find all IPAddresses belonging to this Prefix
|
||||
ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix))\
|
||||
.select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
|
||||
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
|
||||
|
||||
ip_table = tables.IPAddressTable(ipaddresses)
|
||||
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
|
||||
ip_table.base_columns['pk'].visible = True
|
||||
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(ip_table)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
'add': request.user.has_perm('ipam.add_ipaddress'),
|
||||
'change': request.user.has_perm('ipam.change_ipaddress'),
|
||||
'delete': request.user.has_perm('ipam.delete_ipaddress'),
|
||||
}
|
||||
|
||||
return render(request, 'ipam/prefix_ipaddresses.html', {
|
||||
'prefix': prefix,
|
||||
'ip_table': ip_table,
|
||||
'permissions': permissions,
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressListView(ObjectListView):
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
|
||||
filter = filters.IPAddressFilter
|
||||
filter_form = forms.IPAddressFilterForm
|
||||
table = tables.IPAddressTable
|
||||
template_name = 'ipam/ipaddress_list.html'
|
||||
|
||||
|
||||
def ipaddress(request, pk):
|
||||
class IPAddressView(View):
|
||||
|
||||
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
|
||||
def get(self, request, pk):
|
||||
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))\
|
||||
.select_related('site', 'role')
|
||||
parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes))
|
||||
parent_prefixes_table.exclude = ('vrf',)
|
||||
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
|
||||
|
||||
# Duplicate IPs table
|
||||
duplicate_ips = IPAddress.objects.filter(vrf=ipaddress.vrf, address=str(ipaddress.address))\
|
||||
.exclude(pk=ipaddress.pk).select_related('interface__device', 'nat_inside')
|
||||
duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips))
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.filter(
|
||||
vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip)
|
||||
).select_related(
|
||||
'site', 'role'
|
||||
)
|
||||
parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes))
|
||||
parent_prefixes_table.exclude = ('vrf',)
|
||||
|
||||
# Related IP table
|
||||
related_ips = IPAddress.objects.select_related('interface__device').exclude(address=str(ipaddress.address))\
|
||||
.filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address))
|
||||
related_ips_table = tables.IPAddressBriefTable(list(related_ips))
|
||||
# Duplicate IPs table
|
||||
duplicate_ips = IPAddress.objects.filter(
|
||||
vrf=ipaddress.vrf, address=str(ipaddress.address)
|
||||
).exclude(
|
||||
pk=ipaddress.pk
|
||||
).select_related(
|
||||
'interface__device', 'nat_inside'
|
||||
)
|
||||
duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips))
|
||||
|
||||
return render(request, 'ipam/ipaddress.html', {
|
||||
'ipaddress': ipaddress,
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
'duplicate_ips_table': duplicate_ips_table,
|
||||
'related_ips_table': related_ips_table,
|
||||
})
|
||||
# Related IP table
|
||||
related_ips = IPAddress.objects.select_related(
|
||||
'interface__device'
|
||||
).exclude(
|
||||
address=str(ipaddress.address)
|
||||
).filter(
|
||||
vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
|
||||
)
|
||||
related_ips_table = tables.IPAddressBriefTable(list(related_ips))
|
||||
|
||||
|
||||
@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:
|
||||
assert False, form.errors
|
||||
|
||||
else:
|
||||
form = forms.IPAddressAssignForm()
|
||||
|
||||
return render(request, 'ipam/ipaddress_assign.html', {
|
||||
'ipaddress': ipaddress,
|
||||
'form': form,
|
||||
'return_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,
|
||||
'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
|
||||
})
|
||||
return render(request, 'ipam/ipaddress.html', {
|
||||
'ipaddress': ipaddress,
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
'duplicate_ips_table': duplicate_ips_table,
|
||||
'related_ips_table': related_ips_table,
|
||||
})
|
||||
|
||||
|
||||
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'
|
||||
default_return_url = 'ipam:ipaddress_list'
|
||||
|
||||
@@ -658,8 +631,9 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
|
||||
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
|
||||
permission_required = 'ipam.add_ipaddress'
|
||||
form = forms.IPAddressBulkAddForm
|
||||
model = IPAddress
|
||||
pattern_form = forms.IPAddressPatternForm
|
||||
model_form = forms.IPAddressBulkAddForm
|
||||
pattern_target = 'address'
|
||||
template_name = 'ipam/ipaddress_bulk_add.html'
|
||||
default_return_url = 'ipam:ipaddress_list'
|
||||
|
||||
@@ -718,7 +692,7 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = VLANGroup
|
||||
form_class = forms.VLANGroupForm
|
||||
|
||||
def get_return_url(self, obj):
|
||||
def get_return_url(self, request, obj):
|
||||
return reverse('ipam:vlangroup_list')
|
||||
|
||||
|
||||
@@ -741,17 +715,21 @@ class VLANListView(ObjectListView):
|
||||
template_name = 'ipam/vlan_list.html'
|
||||
|
||||
|
||||
def vlan(request, pk):
|
||||
class VLANView(View):
|
||||
|
||||
vlan = get_object_or_404(VLAN.objects.select_related('site__region', 'tenant__group', 'role'), pk=pk)
|
||||
prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
|
||||
prefix_table = tables.PrefixBriefTable(list(prefixes))
|
||||
prefix_table.exclude = ('vlan',)
|
||||
def get(self, request, pk):
|
||||
|
||||
return render(request, 'ipam/vlan.html', {
|
||||
'vlan': vlan,
|
||||
'prefix_table': prefix_table,
|
||||
})
|
||||
vlan = get_object_or_404(VLAN.objects.select_related(
|
||||
'site__region', 'tenant__group', 'role'
|
||||
), pk=pk)
|
||||
prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
|
||||
prefix_table = tables.PrefixBriefTable(list(prefixes))
|
||||
prefix_table.exclude = ('vlan',)
|
||||
|
||||
return render(request, 'ipam/vlan.html', {
|
||||
'vlan': vlan,
|
||||
'prefix_table': prefix_table,
|
||||
})
|
||||
|
||||
|
||||
class VLANEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
@@ -807,7 +785,7 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
|
||||
return obj
|
||||
|
||||
def get_return_url(self, obj):
|
||||
def get_return_url(self, request, obj):
|
||||
return obj.device.get_absolute_url()
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django import forms
|
||||
|
||||
from utilities.forms import BootstrapMixin
|
||||
|
||||
@@ -13,7 +13,7 @@ except ImportError:
|
||||
)
|
||||
|
||||
|
||||
VERSION = '2.0-beta2'
|
||||
VERSION = '2.0.4'
|
||||
|
||||
# Import local configuration
|
||||
ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
|
||||
@@ -112,6 +112,7 @@ INSTALLED_APPS = (
|
||||
'django.contrib.humanize',
|
||||
'corsheaders',
|
||||
'debug_toolbar',
|
||||
'django_filters',
|
||||
'django_tables2',
|
||||
'mptt',
|
||||
'rest_framework',
|
||||
@@ -180,8 +181,8 @@ STATICFILES_DIRS = (
|
||||
)
|
||||
|
||||
# Media
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||
MEDIA_URL = '/{}media/'.format(BASE_PATH)
|
||||
|
||||
# Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.)
|
||||
DATA_UPLOAD_MAX_NUMBER_FIELDS = None
|
||||
|
||||
@@ -1,42 +1,47 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework_swagger.views import get_swagger_view
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
from django.contrib import admin
|
||||
from django.views.static import serve
|
||||
|
||||
from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500
|
||||
from users.views import login, logout
|
||||
from netbox.views import APIRootView, handle_500, HomeView, SearchView, trigger_500
|
||||
from users.views import LoginView, LogoutView
|
||||
|
||||
|
||||
handler500 = handle_500
|
||||
swagger_view = get_swagger_view(title='NetBox API')
|
||||
|
||||
_patterns = [
|
||||
|
||||
# Base views
|
||||
url(r'^$', home, name='home'),
|
||||
url(r'^$', HomeView.as_view(), name='home'),
|
||||
url(r'^search/$', SearchView.as_view(), name='search'),
|
||||
|
||||
# Login/logout
|
||||
url(r'^login/$', login, name='login'),
|
||||
url(r'^logout/$', logout, name='logout'),
|
||||
url(r'^login/$', LoginView.as_view(), name='login'),
|
||||
url(r'^logout/$', LogoutView.as_view(), name='logout'),
|
||||
|
||||
# Apps
|
||||
url(r'^circuits/', include('circuits.urls', namespace='circuits')),
|
||||
url(r'^dcim/', include('dcim.urls', namespace='dcim')),
|
||||
url(r'^extras/', include('extras.urls', namespace='extras')),
|
||||
url(r'^ipam/', include('ipam.urls', namespace='ipam')),
|
||||
url(r'^secrets/', include('secrets.urls', namespace='secrets')),
|
||||
url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
|
||||
url(r'^user/', include('users.urls', namespace='user')),
|
||||
url(r'^circuits/', include('circuits.urls')),
|
||||
url(r'^dcim/', include('dcim.urls')),
|
||||
url(r'^extras/', include('extras.urls')),
|
||||
url(r'^ipam/', include('ipam.urls')),
|
||||
url(r'^secrets/', include('secrets.urls')),
|
||||
url(r'^tenancy/', include('tenancy.urls')),
|
||||
url(r'^user/', include('users.urls')),
|
||||
|
||||
# API
|
||||
url(r'^api/$', APIRootView.as_view(), name='api-root'),
|
||||
url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')),
|
||||
url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')),
|
||||
url(r'^api/extras/', include('extras.api.urls', namespace='extras-api')),
|
||||
url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')),
|
||||
url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')),
|
||||
url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
|
||||
url(r'^api/docs/', include('rest_framework_swagger.urls')),
|
||||
url(r'^api/circuits/', include('circuits.api.urls')),
|
||||
url(r'^api/dcim/', include('dcim.api.urls')),
|
||||
url(r'^api/extras/', include('extras.api.urls')),
|
||||
url(r'^api/ipam/', include('ipam.api.urls')),
|
||||
url(r'^api/secrets/', include('secrets.api.urls')),
|
||||
url(r'^api/tenancy/', include('tenancy.api.urls')),
|
||||
url(r'^api/docs/', swagger_view, name='api_docs'),
|
||||
|
||||
# Serving static media in Django to pipe it through LoginRequiredMiddleware
|
||||
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
|
||||
@@ -45,7 +50,7 @@ _patterns = [
|
||||
url(r'^500/$', trigger_500),
|
||||
|
||||
# Admin
|
||||
url(r'^admin/', include(admin.site.urls)),
|
||||
url(r'^admin/', admin.site.urls),
|
||||
|
||||
]
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
from collections import OrderedDict
|
||||
import sys
|
||||
|
||||
from rest_framework.views import APIView
|
||||
@@ -13,7 +15,7 @@ from circuits.tables import CircuitSearchTable, ProviderSearchTable
|
||||
from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter
|
||||
from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site
|
||||
from dcim.tables import DeviceSearchTable, DeviceTypeSearchTable, RackSearchTable, SiteSearchTable
|
||||
from extras.models import UserAction
|
||||
from extras.models import TopologyMap, UserAction
|
||||
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
|
||||
from ipam.tables import AggregateSearchTable, IPAddressSearchTable, PrefixSearchTable, VLANSearchTable, VRFSearchTable
|
||||
@@ -27,129 +29,133 @@ from .forms import SearchForm
|
||||
|
||||
|
||||
SEARCH_MAX_RESULTS = 15
|
||||
SEARCH_TYPES = {
|
||||
SEARCH_TYPES = OrderedDict((
|
||||
# Circuits
|
||||
'provider': {
|
||||
('provider', {
|
||||
'queryset': Provider.objects.all(),
|
||||
'filter': ProviderFilter,
|
||||
'table': ProviderSearchTable,
|
||||
'url': 'circuits:provider_list',
|
||||
},
|
||||
'circuit': {
|
||||
'queryset': Circuit.objects.select_related('type', 'provider', 'tenant'),
|
||||
}),
|
||||
('circuit', {
|
||||
'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'),
|
||||
'filter': CircuitFilter,
|
||||
'table': CircuitSearchTable,
|
||||
'url': 'circuits:circuit_list',
|
||||
},
|
||||
}),
|
||||
# DCIM
|
||||
'site': {
|
||||
('site', {
|
||||
'queryset': Site.objects.select_related('region', 'tenant'),
|
||||
'filter': SiteFilter,
|
||||
'table': SiteSearchTable,
|
||||
'url': 'dcim:site_list',
|
||||
},
|
||||
'rack': {
|
||||
}),
|
||||
('rack', {
|
||||
'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'),
|
||||
'filter': RackFilter,
|
||||
'table': RackSearchTable,
|
||||
'url': 'dcim:rack_list',
|
||||
},
|
||||
'devicetype': {
|
||||
}),
|
||||
('devicetype', {
|
||||
'queryset': DeviceType.objects.select_related('manufacturer'),
|
||||
'filter': DeviceTypeFilter,
|
||||
'table': DeviceTypeSearchTable,
|
||||
'url': 'dcim:devicetype_list',
|
||||
},
|
||||
'device': {
|
||||
}),
|
||||
('device', {
|
||||
'queryset': Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'),
|
||||
'filter': DeviceFilter,
|
||||
'table': DeviceSearchTable,
|
||||
'url': 'dcim:device_list',
|
||||
},
|
||||
}),
|
||||
# IPAM
|
||||
'vrf': {
|
||||
('vrf', {
|
||||
'queryset': VRF.objects.select_related('tenant'),
|
||||
'filter': VRFFilter,
|
||||
'table': VRFSearchTable,
|
||||
'url': 'ipam:vrf_list',
|
||||
},
|
||||
'aggregate': {
|
||||
}),
|
||||
('aggregate', {
|
||||
'queryset': Aggregate.objects.select_related('rir'),
|
||||
'filter': AggregateFilter,
|
||||
'table': AggregateSearchTable,
|
||||
'url': 'ipam:aggregate_list',
|
||||
},
|
||||
'prefix': {
|
||||
}),
|
||||
('prefix', {
|
||||
'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
|
||||
'filter': PrefixFilter,
|
||||
'table': PrefixSearchTable,
|
||||
'url': 'ipam:prefix_list',
|
||||
},
|
||||
'ipaddress': {
|
||||
}),
|
||||
('ipaddress', {
|
||||
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
|
||||
'filter': IPAddressFilter,
|
||||
'table': IPAddressSearchTable,
|
||||
'url': 'ipam:ipaddress_list',
|
||||
},
|
||||
'vlan': {
|
||||
}),
|
||||
('vlan', {
|
||||
'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'),
|
||||
'filter': VLANFilter,
|
||||
'table': VLANSearchTable,
|
||||
'url': 'ipam:vlan_list',
|
||||
},
|
||||
}),
|
||||
# Secrets
|
||||
'secret': {
|
||||
('secret', {
|
||||
'queryset': Secret.objects.select_related('role', 'device'),
|
||||
'filter': SecretFilter,
|
||||
'table': SecretSearchTable,
|
||||
'url': 'secrets:secret_list',
|
||||
},
|
||||
}),
|
||||
# Tenancy
|
||||
'tenant': {
|
||||
('tenant', {
|
||||
'queryset': Tenant.objects.select_related('group'),
|
||||
'filter': TenantFilter,
|
||||
'table': TenantSearchTable,
|
||||
'url': 'tenancy:tenant_list',
|
||||
},
|
||||
}
|
||||
}),
|
||||
))
|
||||
|
||||
|
||||
def home(request):
|
||||
class HomeView(View):
|
||||
template_name = 'home.html'
|
||||
|
||||
stats = {
|
||||
def get(self, request):
|
||||
|
||||
# Organization
|
||||
'site_count': Site.objects.count(),
|
||||
'tenant_count': Tenant.objects.count(),
|
||||
stats = {
|
||||
|
||||
# DCIM
|
||||
'rack_count': Rack.objects.count(),
|
||||
'device_count': Device.objects.count(),
|
||||
'interface_connections_count': InterfaceConnection.objects.count(),
|
||||
'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(),
|
||||
'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(),
|
||||
# Organization
|
||||
'site_count': Site.objects.count(),
|
||||
'tenant_count': Tenant.objects.count(),
|
||||
|
||||
# IPAM
|
||||
'vrf_count': VRF.objects.count(),
|
||||
'aggregate_count': Aggregate.objects.count(),
|
||||
'prefix_count': Prefix.objects.count(),
|
||||
'ipaddress_count': IPAddress.objects.count(),
|
||||
'vlan_count': VLAN.objects.count(),
|
||||
# DCIM
|
||||
'rack_count': Rack.objects.count(),
|
||||
'device_count': Device.objects.count(),
|
||||
'interface_connections_count': InterfaceConnection.objects.count(),
|
||||
'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(),
|
||||
'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(),
|
||||
|
||||
# Circuits
|
||||
'provider_count': Provider.objects.count(),
|
||||
'circuit_count': Circuit.objects.count(),
|
||||
# IPAM
|
||||
'vrf_count': VRF.objects.count(),
|
||||
'aggregate_count': Aggregate.objects.count(),
|
||||
'prefix_count': Prefix.objects.count(),
|
||||
'ipaddress_count': IPAddress.objects.count(),
|
||||
'vlan_count': VLAN.objects.count(),
|
||||
|
||||
# Secrets
|
||||
'secret_count': Secret.objects.count(),
|
||||
# Circuits
|
||||
'provider_count': Provider.objects.count(),
|
||||
'circuit_count': Circuit.objects.count(),
|
||||
|
||||
}
|
||||
# Secrets
|
||||
'secret_count': Secret.objects.count(),
|
||||
|
||||
return render(request, 'home.html', {
|
||||
'search_form': SearchForm(),
|
||||
'stats': stats,
|
||||
'recent_activity': UserAction.objects.select_related('user')[:50]
|
||||
})
|
||||
}
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'search_form': SearchForm(),
|
||||
'stats': stats,
|
||||
'topology_maps': TopologyMap.objects.filter(site__isnull=True),
|
||||
'recent_activity': UserAction.objects.select_related('user')[:50]
|
||||
})
|
||||
|
||||
|
||||
class SearchView(View):
|
||||
@@ -204,7 +210,7 @@ class APIRootView(APIView):
|
||||
exclude_from_schema = True
|
||||
|
||||
def get_view_name(self):
|
||||
return u"API Root"
|
||||
return "API Root"
|
||||
|
||||
def get(self, request, format=None):
|
||||
|
||||
@@ -233,5 +239,6 @@ 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.")
|
||||
raise Exception(
|
||||
"Congratulations, you've triggered an exception! Go tell all your friends what an exceptional person you are."
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
|
||||
@@ -74,6 +74,13 @@ footer p {
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the nav search bar on displays less than 1600px wide */
|
||||
@media (max-width: 1599px) {
|
||||
#navbar_search {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
label {
|
||||
font-weight: normal;
|
||||
@@ -316,6 +323,16 @@ li.occupied + li.available {
|
||||
border-top: 1px solid #474747;
|
||||
}
|
||||
|
||||
/* Devices */
|
||||
table.component-list tr.ipaddress td {
|
||||
background-color: #eeffff;
|
||||
padding-bottom: 4px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
table.component-list tr.ipaddress:hover td {
|
||||
background-color: #e6f7f7;
|
||||
}
|
||||
|
||||
/* Misc */
|
||||
.banner-bottom {
|
||||
margin-bottom: 50px;
|
||||
|
||||
@@ -80,7 +80,7 @@ $(document).ready(function() {
|
||||
child_field.append($("<option></option>").attr("value", "").text("---------"));
|
||||
|
||||
if ($(this).val() || $(this).attr('nullable') == 'true') {
|
||||
var api_url = child_field.attr('api-url');
|
||||
var api_url = child_field.attr('api-url') + '&limit=1000';
|
||||
var disabled_indicator = child_field.attr('disabled-indicator');
|
||||
var initial_value = child_field.attr('initial');
|
||||
var display_field = child_field.attr('display-field') || 'name';
|
||||
@@ -88,20 +88,21 @@ $(document).ready(function() {
|
||||
// Determine the filter fields needed to make an API call
|
||||
var filter_regex = /\{\{([a-z_]+)\}\}/g;
|
||||
var match;
|
||||
var rendered_url = api_url;
|
||||
while (match = filter_regex.exec(api_url)) {
|
||||
var filter_field = $('#id_' + match[1]);
|
||||
if (filter_field.val()) {
|
||||
api_url = api_url.replace(match[0], filter_field.val());
|
||||
} else if ($(this).attr('nullable') == 'true') {
|
||||
api_url = api_url.replace(match[0], '0');
|
||||
rendered_url = rendered_url.replace(match[0], filter_field.val());
|
||||
} else if (filter_field.attr('nullable') == 'true') {
|
||||
rendered_url = rendered_url.replace(match[0], '0');
|
||||
}
|
||||
}
|
||||
|
||||
// If all URL variables have been replaced, make the API call
|
||||
if (api_url.search('{{') < 0) {
|
||||
console.log(child_name + ": Fetching " + api_url);
|
||||
if (rendered_url.search('{{') < 0) {
|
||||
console.log(child_name + ": Fetching " + rendered_url);
|
||||
$.ajax({
|
||||
url: api_url,
|
||||
url: rendered_url,
|
||||
dataType: 'json',
|
||||
success: function(response, status) {
|
||||
$.each(response.results, function(index, choice) {
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
$(document).ready(function() {
|
||||
|
||||
// Unlocking a secret
|
||||
$('button.unlock-secret').click(function() {
|
||||
$('button.unlock-secret').click(function(event) {
|
||||
var secret_id = $(this).attr('secret-id');
|
||||
unlock_secret(secret_id);
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Locking a secret
|
||||
$('button.lock-secret').click(function() {
|
||||
$('button.lock-secret').click(function(event) {
|
||||
var secret_id = $(this).attr('secret-id');
|
||||
lock_secret(secret_id);
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Adding/editing a secret
|
||||
$('form').submit(function(event) {
|
||||
$(this).find('.requires-session-key').each(function() {
|
||||
if (this.value && document.cookie.indexOf('session_key') == -1) {
|
||||
console.log('Field ' + this.value + ' requires a session key');
|
||||
$('#privkey_modal').modal('show');
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Retrieve a session key
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import admin, messages
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from .forms import ActivateUserKeyForm
|
||||
from .models import UserKey, SecretRole, Secret
|
||||
from .models import UserKey
|
||||
|
||||
|
||||
@admin.register(UserKey)
|
||||
@@ -10,7 +12,7 @@ class UserKeyAdmin(admin.ModelAdmin):
|
||||
actions = ['activate_selected']
|
||||
list_display = ['user', 'is_filled', 'is_active', 'created']
|
||||
fields = ['user', 'public_key', 'is_active', 'last_updated']
|
||||
readonly_fields = ['is_active', 'last_updated']
|
||||
readonly_fields = ['user', 'is_active', 'last_updated']
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
# Don't allow a user to modify an existing public key directly.
|
||||
@@ -34,8 +36,8 @@ class UserKeyAdmin(admin.ModelAdmin):
|
||||
try:
|
||||
my_userkey = UserKey.objects.get(user=request.user)
|
||||
except UserKey.DoesNotExist:
|
||||
messages.error(request, u"You do not have an active User Key.")
|
||||
return redirect('/admin/secrets/userkey/')
|
||||
messages.error(request, "You do not have an active User Key.")
|
||||
return redirect('admin:secrets_userkey_changelist')
|
||||
|
||||
if 'activate' in request.POST:
|
||||
form = ActivateUserKeyForm(request.POST)
|
||||
@@ -44,9 +46,9 @@ class UserKeyAdmin(admin.ModelAdmin):
|
||||
master_key = my_userkey.get_master_key(form.cleaned_data['secret_key'])
|
||||
for uk in form.cleaned_data['_selected_action']:
|
||||
uk.activate(master_key)
|
||||
return redirect('/admin/secrets/userkey/')
|
||||
return redirect('admin:secrets_userkey_changelist')
|
||||
except ValueError:
|
||||
messages.error(request, u"Invalid private key provided. Unable to retrieve master key.")
|
||||
messages.error(request, "Invalid private key provided. Unable to retrieve master key.")
|
||||
else:
|
||||
form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)})
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
@@ -22,4 +24,5 @@ router.register(r'secrets', views.SecretViewSet)
|
||||
router.register(r'get-session-key', views.GetSessionKeyViewSet, base_name='get-session-key')
|
||||
router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, base_name='generate-rsa-key-pair')
|
||||
|
||||
app_name = 'secrets-api'
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from __future__ import unicode_literals
|
||||
import base64
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
from django.http import HttpResponseBadRequest
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet, ViewSet
|
||||
|
||||
from django.http import HttpResponseBadRequest
|
||||
|
||||
from secrets import filters
|
||||
from secrets.exceptions import InvalidKey
|
||||
from secrets.models import Secret, SecretRole, SessionKey, UserKey
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import redirect
|
||||
|
||||
@@ -14,10 +16,10 @@ def userkey_required():
|
||||
try:
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
except UserKey.DoesNotExist:
|
||||
messages.warning(request, u"This operation requires an active user key, but you don't have one.")
|
||||
messages.warning(request, "This operation requires an active user key, but you don't have one.")
|
||||
return redirect('user:userkey')
|
||||
if not uk.is_active():
|
||||
messages.warning(request, u"This operation is not available. Your user key has not been activated.")
|
||||
messages.warning(request, "This operation is not available. Your user key has not been activated.")
|
||||
return redirect('user:userkey')
|
||||
return view(request, *args, **kwargs)
|
||||
return wrapped_view
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
|
||||
class InvalidKey(Exception):
|
||||
"""
|
||||
Raised when a provided key is invalid.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_filters
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from Crypto.Cipher import PKCS1_OAEP
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
@@ -6,7 +8,6 @@ from django.db.models import Count
|
||||
|
||||
from dcim.models import Device
|
||||
from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
|
||||
|
||||
from .models import Secret, SecretRole, UserKey
|
||||
|
||||
|
||||
@@ -48,7 +49,7 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class SecretForm(BootstrapMixin, forms.ModelForm):
|
||||
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
|
||||
widget=forms.PasswordInput())
|
||||
widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}))
|
||||
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
|
||||
widget=forms.PasswordInput())
|
||||
|
||||
@@ -82,7 +83,7 @@ class SecretFromCSVForm(forms.ModelForm):
|
||||
|
||||
|
||||
class SecretImportForm(BootstrapMixin, BulkImportForm):
|
||||
csv = CSVDataField(csv_form=SecretFromCSVForm)
|
||||
csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-session-key'}))
|
||||
|
||||
|
||||
class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.contrib.auth.hashers import PBKDF2PasswordHasher
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.6 on 2017-03-14 17:19
|
||||
# Generated by Django 1.11 on 2017-04-27 15:26
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['user__username'],
|
||||
'ordering': ['userkey__user__username'],
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
||||
20
netbox/secrets/migrations/0003_unicode_literals.py
Normal file
20
netbox/secrets/migrations/0003_unicode_literals.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2017-05-24 15:34
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('secrets', '0002_userkey_add_session_key'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userkey',
|
||||
name='public_key',
|
||||
field=models.TextField(verbose_name='RSA public key'),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
import os
|
||||
|
||||
from Crypto.Cipher import AES, PKCS1_OAEP, XOR
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
@@ -6,13 +8,12 @@ from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password, check_password
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import force_bytes, python_2_unicode_compatible
|
||||
|
||||
from dcim.models import Device
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
|
||||
from .exceptions import InvalidKey
|
||||
from .hashers import SecretValidationHasher
|
||||
|
||||
@@ -69,7 +70,7 @@ class UserKey(CreatedUpdatedModel):
|
||||
copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's
|
||||
matching (private) decryption key.
|
||||
"""
|
||||
user = models.OneToOneField(User, related_name='user_key', editable=False)
|
||||
user = models.OneToOneField(User, related_name='user_key', editable=False, on_delete=models.CASCADE)
|
||||
public_key = models.TextField(verbose_name='RSA public key')
|
||||
master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False)
|
||||
|
||||
@@ -195,7 +196,7 @@ class SessionKey(models.Model):
|
||||
key = None
|
||||
|
||||
class Meta:
|
||||
ordering = ['user__username']
|
||||
ordering = ['userkey__user__username']
|
||||
|
||||
def __str__(self):
|
||||
return self.userkey.user.username
|
||||
@@ -283,7 +284,7 @@ class Secret(CreatedUpdatedModel):
|
||||
A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum
|
||||
of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
|
||||
"""
|
||||
device = models.ForeignKey(Device, related_name='secrets')
|
||||
device = models.ForeignKey(Device, related_name='secrets', on_delete=models.CASCADE)
|
||||
role = models.ForeignKey('SecretRole', related_name='secrets', on_delete=models.PROTECT)
|
||||
name = models.CharField(max_length=100, blank=True)
|
||||
ciphertext = models.BinaryField(editable=False, max_length=65568) # 16B IV + 2B pad length + {62-65550}B padded
|
||||
@@ -301,8 +302,8 @@ class Secret(CreatedUpdatedModel):
|
||||
|
||||
def __str__(self):
|
||||
if self.role and self.device:
|
||||
return u'{} for {}'.format(self.role, self.device)
|
||||
return u'Secret'
|
||||
return '{} for {}'.format(self.role, self.device)
|
||||
return 'Secret'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('secrets:secret', args=[self.pk])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from utilities.tables import BaseTable, SearchTable, ToggleColumn
|
||||
|
||||
@@ -22,8 +23,9 @@ class SecretRoleTable(BaseTable):
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
secret_count = tables.Column(verbose_name='Secrets')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = SecretRole
|
||||
@@ -44,6 +46,7 @@ class SecretTable(BaseTable):
|
||||
|
||||
|
||||
class SecretSearchTable(SearchTable):
|
||||
device = tables.LinkColumn()
|
||||
|
||||
class Meta(SearchTable.Meta):
|
||||
model = Secret
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django import template
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user