Compare commits

...

115 Commits

Author SHA1 Message Date
Jeremy Stretch
b1bcaa33e7 Merge pull request #1148 from digitalocean/develop
Release v2.0.0
2017-05-09 15:09:28 -04:00
Jeremy Stretch
a35f8bddde PEP8 fix 2017-05-09 14:44:32 -04:00
Jeremy Stretch
8fbe7ba742 Release v2.0.0! 2017-05-09 14:29:11 -04:00
Jeremy Stretch
f039b0b6e9 Closes #960: Added form factor for Juniper VCP interfaces 2017-05-09 12:00:49 -04:00
Jeremy Stretch
9ad9ef7957 Fixed incorrect API URL in IPAddressForm 2017-05-09 11:11:30 -04:00
Jeremy Stretch
5c7db04465 Closes #853: Add 'status' field to device bulk import form 2017-05-09 10:25:30 -04:00
Jeremy Stretch
838105fb65 Merging v2.0 development into mainline (#1145)
Merging v2.0 development into mainline
2017-05-08 15:06:57 -04:00
Jeremy Stretch
5ca87c0f20 Merge branch 'develop' into v2-develop 2017-05-08 15:02:06 -04:00
Jeremy Stretch
af4edff370 Related to #1144: Allow multiple status selections when filtering device list 2017-05-08 14:56:25 -04:00
Jeremy Stretch
f40c048475 Fixes #1144: Allow multiple status selections for Prefix, IP address, and VLAN filters 2017-05-08 14:32:29 -04:00
Jeremy Stretch
77247cccbe Closes #154: Expand device status field options 2017-05-08 13:55:19 -04:00
Jeremy Stretch
fcfcd77bfd Moved LAG members list to the description column 2017-05-05 15:37:42 -04:00
Jeremy Stretch
b3667befb4 Removed reduntant title block 2017-05-05 15:24:58 -04:00
Jeremy Stretch
a6cb0e0a96 Updated console/power connection icons 2017-05-03 17:24:57 -04:00
Jeremy Stretch
c047f943de Fixes #403: Record console/power/interface connects and disconnects as user actions 2017-05-03 17:12:34 -04:00
Jeremy Stretch
79089cc47e Introduced an object import template 2017-05-03 15:41:36 -04:00
Jeremy Stretch
3c631902e1 Closes #1100: Add a "view all" link to completed bulk import views is_pool for prefixes 2017-05-03 15:27:26 -04:00
Jeremy Stretch
379c24a012 Fixed typo in template 2017-05-03 14:32:27 -04:00
Brian Ellwood
4035b87693 Allow responsive tables (#1124)
* Make tables responsive #1115

Resolves #1115
2017-05-03 14:30:05 -04:00
Jeremy Stretch
11d1a8c3cf Merge pull request #1128 from digitalocean/readme-branches
Fix misleading build matrix
2017-05-03 14:21:07 -04:00
Jeremy Stretch
7eb9c8265c Fixes #1132: Prompt user to unlock session key when importing secrets 2017-05-03 11:47:28 -04:00
Matt Layher
572beb2311 Fix misleading build matrix
At one point, I had intended to have a matrix of build badges for each different branch and Python version combination.  It seems this is not possible with Travis.

This change replaces "python 2.7" with "status" and clarifies that both Python 2.7 and 3.5 are tested, but Python 3.5 is recommended.
2017-05-02 20:39:43 -04:00
Jeremy Stretch
d861d8bfb8 Fixes #1118: Allow designating an IP as primary for a device while editing the IP 2017-05-02 16:46:23 -04:00
Jeremy Stretch
6791ff6192 Fixes #1125: Include MAC addresses on a device's interface list 2017-05-02 15:01:27 -04:00
Jeremy Stretch
9d9de6b2a3 Fixes #1126: Fix error when editing a user key via admin UI 2017-05-02 14:50:36 -04:00
Jeremy Stretch
1f7ef15ad1 Fixes #1116: Correct object links on recursive deletion error 2017-05-02 11:43:11 -04:00
Jeremy Stretch
16c582ec7a Enable stale .pyc cleanup in upgrade.sh 2017-05-01 16:53:51 -04:00
Jeremy Stretch
de58d0ecca Fixes #1114: Suppress OSError when attempting to access a delete image attachment 2017-04-28 14:26:17 -04:00
Jeremy Stretch
010f6c7f1a Fixes #1113: Fixes server error when attempting to delete an image attachment 2017-04-28 14:05:02 -04:00
Jeremy Stretch
aea5612c39 Closes #1110: Expand bulk edit forms to include boolean fields (e.g. toggle is_pool for prefixes) 2017-04-28 12:32:27 -04:00
Jeremy Stretch
b8b912bdd5 Post-release version adjustment 2017-04-27 15:42:24 -04:00
Jeremy Stretch
e4ca88726e Release v2.0-beta3 2017-04-27 15:37:15 -04:00
Jeremy Stretch
616f109671 Merge branch 'develop' into v2-develop
Conflicts:
	netbox/ipam/forms.py
2017-04-27 15:29:40 -04:00
Jeremy Stretch
8e0580ff96 Improved upgrade script 2017-04-27 14:42:52 -04:00
Jeremy Stretch
4b2e7620dd Switched user nav menu with search form 2017-04-27 13:27:16 -04:00
Jeremy Stretch
b82f25c503 Merge branch 'writable-custom-fields' into v2-develop 2017-04-27 13:05:44 -04:00
Jeremy Stretch
c174c0cc6d Converted all necessary serializers to CustomFieldModelSerializers 2017-04-27 12:50:43 -04:00
Jeremy Stretch
117da337c7 Corrected tests and improved validation 2017-04-27 12:46:04 -04:00
Jeremy Stretch
01da46f753 Fixes #1107: Corrected exception on creating/deleting image attachments 2017-04-27 11:32:08 -04:00
Jeremy Stretch
d17efce4f5 Fixes #1111: Correct database ordering of SessionKey model 2017-04-27 11:27:34 -04:00
Jeremy Stretch
e7a6d1f532 Fixes #1104: Fix VLAN assignment on prefix import 2017-04-26 13:28:09 -04:00
Jeremy Stretch
f643f2c601 Fixes #1103: Correct handling of validation errors when creating IP addresses in bulk 2017-04-26 13:21:38 -04:00
Jeremy Stretch
480faa6461 Removed deprecated IPAddressAssignForm 2017-04-26 13:03:18 -04:00
Jeremy Stretch
1fa084b6be Fixes #1101: Fix AJAX scripting for device component selection forms 2017-04-26 12:53:14 -04:00
Jeremy Stretch
1c86b00b5c Added custom field API tests 2017-04-25 14:53:18 -04:00
Jeremy Stretch
10823e1c37 Got rudimentary custom field creates/updates working 2017-04-25 13:00:28 -04:00
Jeremy Stretch
f73693206f Merge branch 'develop' into v2-develop
Conflicts:
	netbox/circuits/models.py
	netbox/netbox/settings.py
	upgrade.sh
2017-04-21 15:07:48 -04:00
Jeremy Stretch
861c8b29c0 Post-release version bump 2017-04-21 14:56:36 -04:00
Jeremy Stretch
17873706b7 Merge pull request #1094 from digitalocean/develop
Release v1.9.6
2017-04-21 14:52:53 -04:00
Jeremy Stretch
5037046624 Release v1.9.6 2017-04-21 14:47:31 -04:00
Jeremy Stretch
5c0614d656 #1090: Python3 tweaks for installation on CentOS 2017-04-21 14:37:47 -04:00
Jeremy Stretch
697866d1ba #1090: Tweaked docs for Python3 on Ubuntu 2017-04-21 13:30:18 -04:00
Jeremy Stretch
38d826d152 Fixes #1092: Increase randomness in SECRET_KEY generation tool 2017-04-21 10:32:10 -04:00
Jeremy Stretch
13cc29cd8c Closes #951: Provide a side-by-side view of rack elevations 2017-04-20 13:07:22 -04:00
Jeremy Stretch
401357b8cb Closes #1084: Include custom fields when creating IP addresses in bulk 2017-04-19 14:50:58 -04:00
Jeremy Stretch
599e1bb220 Fixes #1071: Protect assigned circuit termination when an interface is deleted 2017-04-19 13:19:30 -04:00
Jeremy Stretch
864fa17b75 Closes #1008: Moved Docker components into their own repository 2017-04-19 10:58:42 -04:00
Jeremy Stretch
a98c9ed0af Corrected invalid API URL name 2017-04-17 15:52:23 -04:00
Jeremy Stretch
8032aa1ad9 Fixes #1078: Increase default limit for number of objects returned by web form API call 2017-04-17 15:50:00 -04:00
Jeremy Stretch
b01bf6089c Merge branch 'develop' into v2-develop
Conflicts:
	netbox/dcim/forms.py
	netbox/dcim/views.py
	netbox/ipam/forms.py
	netbox/templates/_base.html
	netbox/utilities/views.py
2017-04-13 15:42:50 -04:00
Jeremy Stretch
f9a33bfc14 Fixes #1074: Require ncclient 0.5.3 (Python 3 fix) 2017-04-13 15:34:35 -04:00
Jeremy Stretch
610b412506 #878: Layout tweaks 2017-04-13 15:09:08 -04:00
Jeremy Stretch
09000ad9b3 Closes #1001: Merged IP interface assignment into ipam.IPAddressForm 2017-04-13 14:54:17 -04:00
Jeremy Stretch
f70f0f8d62 Improved handling of return_url for object edit/delete views; removed manual definitions of initial data fields 2017-04-13 13:11:23 -04:00
Jeremy Stretch
d5c3f9e780 #878: Show assigned IP addresses in device interfaces list 2017-04-12 22:02:23 -04:00
Jeremy Stretch
b42dab3eef Differentiate between LAG and virtual interfaces in device interface list 2017-04-12 16:06:36 -04:00
Jeremy Stretch
7cbea49c2d Fixes #1072: Order LAG interfaces naturally on bulk interface edit form 2017-04-12 15:51:14 -04:00
Jeremy Stretch
6dcc5a1169 Merge pull request #1070 from bellwood/patch-1
Python3 fixes for CentOS/RHEL
2017-04-12 15:25:36 -04:00
bellwood
53129125dd Python3 fixes for CentOS/RHEL
1) python3 should be python34
2) python34-pip does does exist, you must install python34-setuptools and then: easy_install-3.4 pip
2017-04-12 09:42:48 -04:00
Jeremy Stretch
2d52b9fb39 Fixes #1059: Allow filtering of interface connections via API 2017-04-10 16:15:36 -04:00
Jeremy Stretch
863cbb785d Merge pull request #1064 from eliezerlp/v2-develop
Pointing Dockerfile to 'v2-beta' branch instead of a particular tag
2017-04-10 10:57:18 -04:00
Jeremy Stretch
ba1a4f06ff Replace tabs with spaces 2017-04-10 10:55:05 -04:00
Jeremy Stretch
cf5be85dad Closes #1061: Escape all messages by default (complements #1062) 2017-04-10 10:54:35 -04:00
Eliezer Paiewonsky
d21b67446f Pointing Dockerfile to 'v2-beta' branch
Was pointing to a particular tag instead.
2017-04-10 10:27:32 -04:00
Jeremy Stretch
3b48a270fc Merge pull request #1062 from asteinhauser/develop
XSS flaw bugfix
2017-04-10 10:14:31 -04:00
Anthony Steinhauser
105e9da866 XSS flaw bugfix 2017-04-10 16:00:22 +02:00
Jeremy Stretch
d3b16ba443 Fixes #1057: Corrected VLAN validation during prefix import 2017-04-07 14:50:08 -04:00
Jeremy Stretch
57fc6a3f50 Merge branch 'develop' into v2-develop
Conflicts:
	netbox/netbox/settings.py
	netbox/netbox/urls.py
	requirements.txt
2017-04-06 17:01:13 -04:00
Jeremy Stretch
abc51fdc5d Post-release version bump 2017-04-06 16:36:42 -04:00
Jeremy Stretch
e0ad2b4555 Merge pull request #1054 from digitalocean/develop
Release v1.9.5
2017-04-06 16:35:15 -04:00
Jeremy Stretch
35a0a658a7 Release v1.9.5 2017-04-06 16:34:00 -04:00
Jeremy Stretch
2c99a8bee4 Closes #1052: Added rack reservation list and bulk delete views 2017-04-06 16:26:48 -04:00
Jeremy Stretch
1dd2bdcb8e Fixes #1047: Correct ordering of numbered subinterfaces 2017-04-06 15:13:20 -04:00
Jeremy Stretch
9f67da00d1 Colored nodes in topology maps 2017-04-06 14:12:30 -04:00
Jeremy Stretch
82d53a8c3d Fixes #1049: Prompt user if missing session key when adding/editing a secret 2017-04-06 13:55:40 -04:00
Jeremy Stretch
f3eee25527 Fixes #1051: Upgraded django-rest-swagger 2017-04-06 11:54:13 -04:00
Jeremy Stretch
ee11775425 Fixes #1051: Upgraded django-rest-swagger 2017-04-06 09:40:09 -04:00
Jeremy Stretch
bcdf9ac5ca Merge pull request #1046 from digitalocean/component-filter-by-name
Fixes #1045
2017-04-06 09:14:41 -04:00
Jeremy Stretch
4accdf77f8 Closes #578: Show topology maps not assigned to a site on the home view 2017-04-05 17:33:39 -04:00
Jeremy Stretch
fc46f70153 Closes #430: Include circuits when rendering topology maps 2017-04-05 17:24:40 -04:00
Zach Moody
e7cf7d58b8 Fixes #1045 2017-04-05 15:29:53 -05:00
Jeremy Stretch
d98e9e1838 Resolved RemovedInDjango20Warning deprecation warnings 2017-04-05 14:40:25 -04:00
Jeremy Stretch
369d3aa62e Rearranged URL namespaces to satisfy deprecation warnings 2017-04-05 14:26:33 -04:00
Jeremy Stretch
d4ac6dbfe4 Fixes #1043: Corrected queryset in WritableDeviceSerializer validation 2017-04-05 13:38:23 -04:00
Jeremy Stretch
91d35905fd Reset version 2017-04-05 12:11:48 -04:00
Jeremy Stretch
78b0072051 Limit <v2.0 installations to Django 1.10 2017-04-05 11:34:04 -04:00
Jeremy Stretch
7766e1f684 Fixes #1037: Fixed error on VLAN import with duplicate VLAN group names 2017-04-05 10:13:19 -04:00
Jeremy Stretch
f89d91783b Merge pull request #1035 from digitalocean/develop
Release v1.9.4-r1
2017-04-04 15:50:28 -04:00
Jeremy Stretch
3ffe36e5ed Merge pull request #1032 from digitalocean/develop
Release v1.9.4
2017-04-04 12:01:58 -04:00
Jeremy Stretch
be393a9d10 Merge pull request #989 from digitalocean/develop
Release v1.9.3
2017-03-23 16:27:06 -04:00
Jeremy Stretch
27eefd8705 Merge pull request #966 from digitalocean/develop
Release v1.9.2
2017-03-14 17:14:19 -04:00
Jeremy Stretch
097e0f38ff Merge pull request #949 from digitalocean/develop
Release v1.9.1
2017-03-08 14:40:16 -05:00
Jeremy Stretch
ce26b566a4 Merge pull request #939 from digitalocean/develop
Release v1.9.0-r1
2017-03-03 11:28:02 -05:00
Jeremy Stretch
0e14bc1e02 Merge pull request #933 from digitalocean/develop
Release v1.9.0
2017-03-02 13:27:10 -05:00
Jeremy Stretch
ce6796ed9b Merge pull request #870 from digitalocean/develop
Release v1.8.4
2017-02-03 13:59:02 -05:00
Jeremy Stretch
c90cecc2fb Merge pull request #849 from digitalocean/develop
Release v1.8.3
2017-01-26 13:58:52 -05:00
Jeremy Stretch
b6bbcb0609 Merge pull request #814 from digitalocean/develop
Release v1.8.2
2017-01-18 16:23:28 -05:00
Jeremy Stretch
23f6832d9c Merge pull request #774 from digitalocean/develop
Release v1.8.1
2017-01-04 15:30:54 -05:00
Jeremy Stretch
88dace75a1 Merge pull request #766 from digitalocean/develop
Release v1.8.0
2017-01-03 15:13:36 -05:00
Jeremy Stretch
8eb140fd65 Merge pull request #736 from digitalocean/develop
Release v1.7.3
2016-12-08 12:34:53 -05:00
Jeremy Stretch
1f09f3d096 Merge pull request #728 from digitalocean/develop
Release v1.7.2-r1
2016-12-06 15:38:52 -05:00
Jeremy Stretch
66be85a41f Merge pull request #726 from digitalocean/develop
Release v1.7.2
2016-12-06 14:55:19 -05:00
Jeremy Stretch
814c11167e Merge pull request #694 from digitalocean/develop
Release v1.7.1
2016-11-15 12:34:09 -05:00
Jeremy Stretch
57ddd5086f Merge pull request #666 from digitalocean/develop
Release v1.7.0
2016-11-03 15:12:33 -04:00
Jeremy Stretch
c171547037 Merge pull request #625 from digitalocean/develop
Release v1.6.3
2016-10-19 16:25:50 -04:00
112 changed files with 2301 additions and 1821 deletions

View File

@@ -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/"]

View File

@@ -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** | [![Build Status](https://travis-ci.org/digitalocean/netbox.svg?branch=master)](https://travis-ci.org/digitalocean/netbox) |
| **develop** | [![Build Status](https://travis-ci.org/digitalocean/netbox.svg?branch=develop)](https://travis-ci.org/digitalocean/netbox) |
@@ -29,5 +31,5 @@ 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))

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
command = '/usr/bin/gunicorn'
pythonpath = '/opt/netbox/netbox'
bind = '0.0.0.0:8001'
workers = 3
user = 'root'

View File

@@ -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"';
}
}
}

View File

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

View File

@@ -6,6 +6,7 @@ Python 3:
```no-highlight
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
# update-alternatives --install /usr/bin/python python /usr/bin/python3 1
```
Python 2:
@@ -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:

View File

@@ -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
```

View File

@@ -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'

View File

@@ -28,11 +28,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 +82,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',
]
#

View File

@@ -22,4 +22,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

View File

@@ -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'),
),
]

View File

@@ -1,6 +1,6 @@
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
@@ -150,10 +150,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)')

View File

@@ -3,6 +3,7 @@ from django.conf.urls import url
from . import views
app_name = 'circuits'
urlpatterns = [
# Providers

View File

@@ -1,10 +1,10 @@
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 extras.models import Graph, GRAPH_TYPE_PROVIDER
from utilities.forms import ConfirmationForm
@@ -95,7 +95,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')
@@ -142,7 +142,6 @@ 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 +229,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 +236,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()

View File

@@ -66,13 +66,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 +150,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 +263,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 +476,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 +490,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)

View File

@@ -58,4 +58,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

View File

@@ -2,16 +2,16 @@ 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 +323,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
#

View File

@@ -8,9 +8,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 +148,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 +185,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 +373,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 +389,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 +437,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)',
@@ -550,6 +586,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

View File

@@ -1,6 +1,5 @@
import re
from mptt.forms import TreeNodeChoiceField
import re
from django import forms
from django.contrib.postgres.forms.array import SimpleArrayField
@@ -11,9 +10,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from ipam.models import IPAddress
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, CommentField, CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField,
Livesearch, SelectWithDisabled, SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField,
)
from .formfields import MACAddressFormField
@@ -21,9 +20,9 @@ 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,
)
@@ -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 = []
@@ -520,27 +540,32 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
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'
))
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')
)
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', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face',
'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments',
]
help_texts = {
'device_role': "The function this device serves",
'serial': "Chassis serial number",
@@ -629,15 +654,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_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in STATUS_CHOICES])
class Meta:
fields = []
@@ -655,17 +689,24 @@ class BaseDeviceFromCSVForm(forms.ModelForm):
except DeviceType.DoesNotExist:
self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name))
def clean_status_name(self):
return dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
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_name', 'site', 'rack_name', 'position', 'face',
]
def clean(self):
@@ -707,8 +748,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_name', 'parent', 'device_bay_name',
]
def clean(self):
@@ -752,6 +793,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], u'{} ({})'.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')
@@ -771,10 +819,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
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 +831,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')
#
@@ -1400,6 +1439,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 +1449,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 = []
@@ -1671,36 +1718,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
#

View 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'),
),
]

View 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),
),
]

View File

@@ -1,4 +1,5 @@
from collections import OrderedDict
from itertools import count, groupby
from mptt.models import MPTTModel, TreeForeignKey
@@ -8,10 +9,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
@@ -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']
@@ -573,6 +595,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
@@ -783,9 +814,9 @@ class InterfaceManager(models.Manager):
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:
slot, subslot, position, channel, and virtual circuit:
{name}{slot}/{subslot}/{position}:{channel}
{name}{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 +826,23 @@ 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.
"""
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', '_name'),
IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel', '_vc'),
}[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),
'_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 +952,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)
@@ -1051,6 +1091,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,
@@ -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.

View File

@@ -6,7 +6,7 @@ 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 +64,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 +92,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 +249,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 +428,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 +448,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 +465,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 +475,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

View File

@@ -8,6 +8,7 @@ from .models import Device, Rack, Site
from . import views
app_name = 'dcim'
urlpatterns = [
# Regions
@@ -39,11 +40,14 @@ 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'),
@@ -118,7 +122,6 @@ urlpatterns = [
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+)/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}),

View File

@@ -6,17 +6,20 @@ from operator import attrgetter
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.core.paginator import EmptyPage, PageNotAnInteger
from django.db.models import Count
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.safestring import mark_safe
from django.views.generic import View
from ipam.models import Prefix, IPAddress, Service, VLAN
from ipam.models import Prefix, Service, VLAN
from circuits.models import Circuit
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE, UserAction
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
@@ -124,13 +127,13 @@ class ComponentCreateView(View):
class ComponentEditView(ObjectEditView):
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return obj.device.get_absolute_url()
class ComponentDeleteView(ObjectDeleteView):
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return obj.device.get_absolute_url()
@@ -149,7 +152,7 @@ class RegionEditView(PermissionRequiredMixin, ObjectEditView):
model = Region
form_class = forms.RegionForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('dcim:region_list')
@@ -242,7 +245,7 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
model = RackGroup
form_class = forms.RackGroupForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('dcim:rackgroup_list')
@@ -268,7 +271,7 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
model = RackRole
form_class = forms.RackRoleForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('dcim:rackrole_list')
@@ -291,6 +294,46 @@ class RackListView(ObjectListView):
template_name = 'dcim/rack_list.html'
class RackElevationListView(View):
"""
Display a set of rack elevations side-by-side.
"""
def get(self, request):
racks = Rack.objects.select_related(
'site', 'group', 'tenant', 'role'
).prefetch_related(
'devices__device_type'
)
racks = filters.RackFilter(request.GET, racks).qs
total_count = racks.count()
# Pagination
paginator = EnhancedPaginator(racks, 25)
page_number = request.GET.get('page', 1)
try:
page = paginator.page(page_number)
except PageNotAnInteger:
page = paginator.page(1)
except EmptyPage:
page = paginator.page(paginator.num_pages)
# Determine rack face
if request.GET.get('face') == '1':
face_id = 1
else:
face_id = 0
return render(request, 'dcim/rack_elevation_list.html', {
'paginator': paginator,
'page': page,
'total_count': total_count,
'face_id': face_id,
'filter_form': forms.RackFilterForm(request.GET),
})
def rack(request, pk):
rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
@@ -360,6 +403,14 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Rack reservations
#
class RackReservationListView(ObjectListView):
queryset = RackReservation.objects.all()
filter = filters.RackReservationFilter
filter_form = forms.RackReservationFilterForm
table = tables.RackReservationTable
template_name = 'dcim/rackreservation_list.html'
class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackreservation'
model = RackReservation
@@ -371,7 +422,7 @@ class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
obj.user = request.user
return obj
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return obj.rack.get_absolute_url()
@@ -379,10 +430,16 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rackreservation'
model = RackReservation
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return obj.rack.get_absolute_url()
class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackreservation'
cls = RackReservation
default_return_url = 'dcim:rackreservation_list'
#
# Manufacturers
#
@@ -398,7 +455,7 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
model = Manufacturer
form_class = forms.ManufacturerForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('dcim:manufacturer_list')
@@ -618,7 +675,7 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
model = DeviceRole
form_class = forms.DeviceRoleForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('dcim:devicerole_list')
@@ -643,7 +700,7 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
model = Platform
form_class = forms.PlatformForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('dcim:platform_list')
@@ -686,19 +743,15 @@ def device(request, pk):
interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
.filter(device=device, mgmt_only=False)\
.select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'circuit_termination__circuit')
'circuit_termination__circuit').prefetch_related('ip_addresses')
mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
.filter(device=device, mgmt_only=True)\
.select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'circuit_termination__circuit')
'circuit_termination__circuit').prefetch_related('ip_addresses')
device_bays = natsorted(
DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
key=attrgetter('name')
)
# Gather relevant device objects
ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\
.order_by('address')
services = Service.objects.filter(device=device)
secrets = device.secrets.all()
@@ -729,7 +782,6 @@ def device(request, pk):
'interfaces': interfaces,
'mgmt_interfaces': mgmt_interfaces,
'device_bays': device_bays,
'ip_addresses': ip_addresses,
'services': services,
'secrets': secrets,
'related_devices': related_devices,
@@ -741,7 +793,6 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_device'
model = Device
form_class = forms.DeviceForm
fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
template_name = 'dcim/device_edit.html'
default_return_url = 'dcim:device_list'
@@ -842,12 +893,16 @@ def consoleport_connect(request, pk):
form = forms.ConsolePortConnectionForm(request.POST, instance=consoleport)
if form.is_valid():
consoleport = form.save()
messages.success(request, u"Connected {} {} to {} {}.".format(
consoleport.device,
consoleport.name,
consoleport.cs_port.device,
consoleport.cs_port.name,
))
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
consoleport.device.get_absolute_url(),
escape(consoleport.device),
escape(consoleport.name),
consoleport.cs_port.device.get_absolute_url(),
escape(consoleport.cs_port.device),
escape(consoleport.cs_port.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleport.device.pk)
else:
@@ -871,17 +926,28 @@ def consoleport_disconnect(request, pk):
consoleport = get_object_or_404(ConsolePort, pk=pk)
if not consoleport.cs_port:
messages.warning(request, u"Cannot disconnect console port {}: It is not connected to anything."
.format(consoleport))
messages.warning(
request, u"Cannot disconnect console port {}: It is not connected to anything.".format(consoleport)
)
return redirect('dcim:device', pk=consoleport.device.pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
cs_port = consoleport.cs_port
consoleport.cs_port = None
consoleport.connection_status = None
consoleport.save()
messages.success(request, u"Console port {} has been disconnected.".format(consoleport))
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
consoleport.device.get_absolute_url(),
escape(consoleport.device),
escape(consoleport.name),
cs_port.device.get_absolute_url(),
escape(cs_port.device),
escape(cs_port.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleport.device.pk)
else:
@@ -916,6 +982,7 @@ class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.ConsoleConnectionImportForm
table = tables.ConsoleConnectionTable
template_name = 'dcim/console_connections_import.html'
default_return_url = 'dcim:console_connections_list'
#
@@ -943,12 +1010,16 @@ def consoleserverport_connect(request, pk):
consoleport.cs_port = consoleserverport
consoleport.connection_status = form.cleaned_data['connection_status']
consoleport.save()
messages.success(request, u"Connected {} {} to {} {}.".format(
consoleport.device,
consoleport.name,
consoleserverport.device,
consoleserverport.name,
))
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
consoleport.device.get_absolute_url(),
escape(consoleport.device),
escape(consoleport.name),
consoleserverport.device.get_absolute_url(),
escape(consoleserverport.device),
escape(consoleserverport.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleserverport.device.pk)
else:
@@ -972,8 +1043,9 @@ def consoleserverport_disconnect(request, pk):
consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk)
if not hasattr(consoleserverport, 'connected_console'):
messages.warning(request, u"Cannot disconnect console server port {}: Nothing is connected to it."
.format(consoleserverport))
messages.warning(
request, u"Cannot disconnect console server port {}: Nothing is connected to it.".format(consoleserverport)
)
return redirect('dcim:device', pk=consoleserverport.device.pk)
if request.method == 'POST':
@@ -983,7 +1055,16 @@ def consoleserverport_disconnect(request, pk):
consoleport.cs_port = None
consoleport.connection_status = None
consoleport.save()
messages.success(request, u"Console server port {} has been disconnected.".format(consoleserverport))
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
consoleport.device.get_absolute_url(),
escape(consoleport.device),
escape(consoleport.name),
consoleserverport.device.get_absolute_url(),
escape(consoleserverport.device),
escape(consoleserverport.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleserverport.device.pk)
else:
@@ -1035,12 +1116,16 @@ def powerport_connect(request, pk):
form = forms.PowerPortConnectionForm(request.POST, instance=powerport)
if form.is_valid():
powerport = form.save()
messages.success(request, u"Connected {} {} to {} {}.".format(
powerport.device,
powerport.name,
powerport.power_outlet.device,
powerport.power_outlet.name,
))
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
powerport.device.get_absolute_url(),
escape(powerport.device),
escape(powerport.name),
powerport.power_outlet.device.get_absolute_url(),
escape(powerport.power_outlet.device),
escape(powerport.power_outlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=powerport.device.pk)
else:
@@ -1064,17 +1149,28 @@ def powerport_disconnect(request, pk):
powerport = get_object_or_404(PowerPort, pk=pk)
if not powerport.power_outlet:
messages.warning(request, u"Cannot disconnect power port {}: It is not connected to an outlet."
.format(powerport))
messages.warning(
request, u"Cannot disconnect power port {}: It is not connected to an outlet.".format(powerport)
)
return redirect('dcim:device', pk=powerport.device.pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
power_outlet = powerport.power_outlet
powerport.power_outlet = None
powerport.connection_status = None
powerport.save()
messages.success(request, u"Power port {} has been disconnected.".format(powerport))
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
powerport.device.get_absolute_url(),
escape(powerport.device),
escape(powerport.name),
power_outlet.device.get_absolute_url(),
escape(power_outlet.device),
escape(power_outlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=powerport.device.pk)
else:
@@ -1109,6 +1205,7 @@ class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.PowerConnectionImportForm
table = tables.PowerConnectionTable
template_name = 'dcim/power_connections_import.html'
default_return_url = 'dcim:power_connections_list'
#
@@ -1136,12 +1233,16 @@ def poweroutlet_connect(request, pk):
powerport.power_outlet = poweroutlet
powerport.connection_status = form.cleaned_data['connection_status']
powerport.save()
messages.success(request, u"Connected {} {} to {} {}.".format(
powerport.device,
powerport.name,
poweroutlet.device,
poweroutlet.name,
))
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
powerport.device.get_absolute_url(),
escape(powerport.device),
escape(powerport.name),
poweroutlet.device.get_absolute_url(),
escape(poweroutlet.device),
escape(poweroutlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=poweroutlet.device.pk)
else:
@@ -1165,7 +1266,9 @@ def poweroutlet_disconnect(request, pk):
poweroutlet = get_object_or_404(PowerOutlet, pk=pk)
if not hasattr(poweroutlet, 'connected_port'):
messages.warning(request, u"Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet))
messages.warning(
request, u"Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet)
)
return redirect('dcim:device', pk=poweroutlet.device.pk)
if request.method == 'POST':
@@ -1175,7 +1278,16 @@ def poweroutlet_disconnect(request, pk):
powerport.power_outlet = None
powerport.connection_status = None
powerport.save()
messages.success(request, u"Power outlet {} has been disconnected.".format(poweroutlet))
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
powerport.device.get_absolute_url(),
escape(powerport.device),
escape(powerport.name),
poweroutlet.device.get_absolute_url(),
escape(poweroutlet.device),
escape(poweroutlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=poweroutlet.device.pk)
else:
@@ -1441,13 +1553,19 @@ def interfaceconnection_add(request, pk):
if request.method == 'POST':
form = forms.InterfaceConnectionForm(device, request.POST)
if form.is_valid():
interfaceconnection = form.save()
messages.success(request, u"Connected {} {} to {} {}.".format(
interfaceconnection.interface_a.device,
interfaceconnection.interface_a,
interfaceconnection.interface_b.device,
interfaceconnection.interface_b,
))
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
interfaceconnection.interface_a.device.get_absolute_url(),
escape(interfaceconnection.interface_a.device),
escape(interfaceconnection.interface_a.name),
interfaceconnection.interface_b.device.get_absolute_url(),
escape(interfaceconnection.interface_b.device),
escape(interfaceconnection.interface_b.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, interfaceconnection, msg)
if '_addanother' in request.POST:
base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk})
device_b = interfaceconnection.interface_b.device
@@ -1485,12 +1603,16 @@ def interfaceconnection_delete(request, pk):
form = forms.InterfaceConnectionDeletionForm(request.POST)
if form.is_valid():
interfaceconnection.delete()
messages.success(request, u"Deleted the connection between {} {} and {} {}.".format(
interfaceconnection.interface_a.device,
interfaceconnection.interface_a,
interfaceconnection.interface_b.device,
interfaceconnection.interface_b,
))
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
interfaceconnection.interface_a.device.get_absolute_url(),
escape(interfaceconnection.interface_a.device),
escape(interfaceconnection.interface_a.name),
interfaceconnection.interface_b.device.get_absolute_url(),
escape(interfaceconnection.interface_b.device),
escape(interfaceconnection.interface_b.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, interfaceconnection, msg)
if form.cleaned_data['device']:
return redirect('dcim:device', pk=form.cleaned_data['device'].pk)
else:
@@ -1520,6 +1642,7 @@ class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView
form = forms.InterfaceConnectionImportForm
table = tables.InterfaceConnectionTable
template_name = 'dcim/interface_connections_import.html'
default_return_url = 'dcim:interface_connections_list'
#
@@ -1553,47 +1676,6 @@ class InterfaceConnectionsListView(ObjectListView):
template_name = 'dcim/interface_connections_list.html'
#
# IP addresses
#
@permission_required(['dcim.change_device', 'ipam.add_ipaddress'])
def ipaddress_assign(request, pk):
device = get_object_or_404(Device, pk=pk)
if request.method == 'POST':
form = forms.IPAddressForm(device, request.POST)
if form.is_valid():
ipaddress = form.save(commit=False)
ipaddress.interface = form.cleaned_data['interface']
ipaddress.save()
form.save_custom_fields()
messages.success(request, u"Added new IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
if form.cleaned_data['set_as_primary']:
if ipaddress.family == 4:
device.primary_ip4 = ipaddress
elif ipaddress.family == 6:
device.primary_ip6 = ipaddress
device.save()
if '_addanother' in request.POST:
return redirect('dcim:ipaddress_assign', pk=device.pk)
else:
return redirect('dcim:device', pk=device.pk)
else:
form = forms.IPAddressForm(device)
return render(request, 'dcim/ipaddress_assign.html', {
'device': device,
'form': form,
'return_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
#
# Inventory items
#

View File

@@ -1,6 +1,8 @@
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue
@@ -14,12 +16,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(u"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(u"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(u"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 +64,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):

View File

@@ -29,4 +29,5 @@ router.register(r'image-attachments', views.ImageAttachmentViewSet)
# Recent activity
router.register(r'recent-activity', views.RecentActivityViewSet)
app_name = 'extras-api'
urlpatterns = router.urls

View File

@@ -6,7 +6,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 +39,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 +72,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

View File

@@ -156,16 +156,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')
@@ -254,7 +251,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()
@@ -294,7 +293,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,6 +315,7 @@ class TopologyMap(models.Model):
def render(self, img_format='png'):
from circuits.models import CircuitTermination
from dcim.models import Device, InterfaceConnection
# Construct the graph
@@ -334,9 +334,10 @@ 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)
fillcolor = '#{}'.format(d.device_role.color)
subgraph.node(d.name, style='filled', fillcolor=fillcolor)
# Add an invisible connection to each successive device in a set to enforce horizontal order
for j in range(0, len(devices) - 1):
@@ -350,7 +351,7 @@ 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
@@ -358,6 +359,12 @@ class TopologyMap(models.Model):
for c in connections:
graph.edge(c.interface_a.device.name, c.interface_b.device.name)
# 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 +391,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 +422,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

View File

@@ -1,7 +1,12 @@
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
@@ -9,9 +14,11 @@ 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'])

View File

@@ -3,6 +3,7 @@ from django.conf.urls import url
from extras import views
app_name = 'extras'
urlpatterns = [
# Image attachments

View File

@@ -18,7 +18,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 +26,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()

View File

@@ -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)))

View File

@@ -31,11 +31,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 +96,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 +169,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 +216,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 +255,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']
#

View File

@@ -37,4 +37,5 @@ router.register(r'vlans', views.VLANViewSet)
# Services
router.register(r'services', views.ServiceViewSet)
app_name = 'ipam-api'
urlpatterns = router.urls

View File

@@ -9,7 +9,10 @@ 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 +156,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 +243,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 +346,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():

View File

@@ -5,8 +5,8 @@ from dcim.models import Site, Rack, Device, Interface
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch,
SlugField, add_blank_choice,
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, CSVDataField, ExpandableIPAddressField,
FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice,
)
from .models import (
@@ -61,6 +61,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:
@@ -210,28 +213,32 @@ 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
vlan = 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):
@@ -252,6 +259,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:
@@ -302,21 +310,47 @@ 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 IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
interface_site = forms.ModelChoiceField(
queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(
attrs={'filter-for': 'interface_rack'}
)
)
interface_rack = forms.ModelChoiceField(
queryset=Rack.objects.all(), required=False, label='Rack', widget=APISelect(
api_url='/api/dcim/racks/?site_id={{interface_site}}', display_field='display_name',
attrs={'filter-for': 'interface_device', 'nullable': 'true'}
)
)
interface_device = forms.ModelChoiceField(
queryset=Device.objects.all(), required=False, label='Device', widget=APISelect(
api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}',
display_field='display_name', attrs={'filter-for': 'interface'}
)
)
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'
)
)
primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description']
fields = ['address', 'vrf', 'tenant', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside']
widgets = {
'interface': APISelect(api_url='/api/dcim/interfaces/?device_id={{interface_device}}'),
'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address')
}
@@ -325,8 +359,46 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
self.fields['vrf'].empty_label = 'Global'
if self.instance.nat_inside:
# If an interface has been assigned, initialize site, rack, and device
if self.instance.interface:
self.initial['interface_site'] = self.instance.interface.device.site
self.initial['interface_rack'] = self.instance.interface.device.rack
self.initial['interface_device'] = self.instance.interface.device
# Limit rack choices
if self.is_bound and self.data.get('interface_site'):
self.fields['interface_rack'].queryset = Rack.objects.filter(site__pk=self.data['interface_site'])
elif self.initial.get('interface_site'):
self.fields['interface_rack'].queryset = Rack.objects.filter(site=self.initial['interface_site'])
else:
self.fields['interface_rack'].choices = []
# Limit device choices
if self.is_bound and self.data.get('interface_rack'):
self.fields['interface_device'].queryset = Device.objects.filter(rack=self.data['interface_rack'])
elif self.initial.get('interface_rack'):
self.fields['interface_device'].queryset = Device.objects.filter(rack=self.initial['interface_rack'])
else:
self.fields['interface_device'].choices = []
# Limit interface choices
if self.is_bound and self.data.get('interface_device'):
self.fields['interface'].queryset = Interface.objects.filter(device=self.data['interface_device'])
elif self.initial.get('interface_device'):
self.fields['interface'].queryset = Interface.objects.filter(device=self.initial['interface_device'])
else:
self.fields['interface'].choices = []
# 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
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:
@@ -340,9 +412,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
)
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'])
@@ -350,7 +420,6 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
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(
@@ -361,72 +430,53 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
else:
self.fields['nat_inside'].choices = []
def clean(self):
super(IPAddressForm, self).clean()
class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
address = ExpandableIPAddressField()
# 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 IPAddressBulkAddForm(BootstrapMixin, CustomFieldForm):
address_pattern = ExpandableIPAddressField(label='Address Pattern')
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)
pattern_map = ('address_pattern', 'address')
class IPAddressAssignForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = forms.ModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
display_field='display_name',
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name',
attrs={'filter-for': 'interface'}
)
)
livesearch = forms.CharField(
required=False,
label='Device',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device-list',
field_to_update='device'
)
)
interface = forms.ModelChoiceField(
queryset=Interface.objects.all(),
label='Interface',
widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{device}}'
)
)
set_as_primary = forms.BooleanField(
label='Set as primary IP for device',
required=False
)
def __init__(self, *args, **kwargs):
super(IPAddressAssignForm, self).__init__(*args, **kwargs)
self.fields['rack'].choices = []
self.fields['device'].choices = []
self.fields['interface'].choices = []
class Meta:
model = IPAddress
fields = ['address_pattern', 'vrf', 'tenant', 'status', 'description']
class IPAddressFromCSVForm(forms.ModelForm):
@@ -586,27 +636,51 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
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.'})
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_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.'})
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_name', 'role', 'description']
def clean(self):
super(VLANFromCSVForm, self).clean()
# Validate VLANGroup
group_name = self.cleaned_data.get('group_name')
if group_name:
try:
vlan_group = 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 save(self, *args, **kwargs):
m = super(VLANFromCSVForm, self).save(commit=False)
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'])
# Assign VLAN status by name
m.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
vlan.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
if kwargs.get('commit'):
m.save()
return m
vlan.save()
return vlan
class VLANImportForm(BootstrapMixin, BulkImportForm):

View File

@@ -3,10 +3,10 @@ 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

View File

@@ -3,6 +3,7 @@ from django.conf.urls import url
from . import views
app_name = 'ipam'
urlpatterns = [
# VRFs
@@ -57,8 +58,6 @@ urlpatterns = [
url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'),
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
url(r'^ip-addresses/(?P<pk>\d+)/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
url(r'^ip-addresses/(?P<pk>\d+)/remove/$', views.ipaddress_remove, name='ipaddress_remove'),
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
# VLAN groups

View File

@@ -5,9 +5,9 @@ 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.urls import reverse
from dcim.models import Device
from utilities.forms import ConfirmationForm
@@ -244,7 +244,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')
@@ -370,7 +370,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')
@@ -464,7 +464,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'
@@ -572,80 +571,10 @@ def ipaddress(request, pk):
})
@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
def ipaddress_assign(request, pk):
ipaddress = get_object_or_404(IPAddress, pk=pk)
if request.method == 'POST':
form = forms.IPAddressAssignForm(request.POST)
if form.is_valid():
interface = form.cleaned_data['interface']
ipaddress.interface = interface
ipaddress.save()
messages.success(request, u"Assigned IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
if form.cleaned_data['set_as_primary']:
device = interface.device
if ipaddress.family == 4:
device.primary_ip4 = ipaddress
elif ipaddress.family == 6:
device.primary_ip6 = ipaddress
device.save()
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
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}),
})
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'
@@ -659,7 +588,7 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
permission_required = 'ipam.add_ipaddress'
form = forms.IPAddressBulkAddForm
model = IPAddress
model_form = forms.IPAddressForm
template_name = 'ipam/ipaddress_bulk_add.html'
default_return_url = 'ipam:ipaddress_list'
@@ -718,7 +647,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')
@@ -807,7 +736,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()

View File

@@ -13,7 +13,7 @@ except ImportError:
)
VERSION = '2.0-beta2'
VERSION = '2.0.0'
# Import local configuration
ALLOWED_HOSTS = DATABASE = SECRET_KEY = None

View File

@@ -1,3 +1,5 @@
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
@@ -8,6 +10,7 @@ from users.views import login, logout
handler500 = handle_500
swagger_view = get_swagger_view(title='NetBox API')
_patterns = [
@@ -20,23 +23,23 @@ _patterns = [
url(r'^logout/$', logout, 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 +48,7 @@ _patterns = [
url(r'^500/$', trigger_500),
# Admin
url(r'^admin/', include(admin.site.urls)),
url(r'^admin/', admin.site.urls),
]

View File

@@ -13,7 +13,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
@@ -148,6 +148,7 @@ def home(request):
return render(request, 'home.html', {
'search_form': SearchForm(),
'stats': stats,
'topology_maps': TopologyMap.objects.filter(site__isnull=True),
'recent_activity': UserAction.objects.select_related('user')[:50]
})

View File

@@ -316,6 +316,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;

View File

@@ -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) {

View File

@@ -1,15 +1,28 @@
$(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) {
if (
$(this).find('input.requires-session-key').filter(function() {return this.value == ""}) &&
document.cookie.indexOf('session_key') == -1
) {
$('#privkey_modal').modal('show');
event.preventDefault();
}
});
// Retrieve a session key

View File

@@ -2,7 +2,7 @@ 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 +10,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.

View File

@@ -22,4 +22,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

View File

@@ -48,7 +48,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 +82,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):

View File

@@ -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(

View File

@@ -6,8 +6,8 @@ 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
@@ -69,7 +69,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 +195,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 +283,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

View File

@@ -3,6 +3,7 @@ from django.conf.urls import url
from . import views
app_name = 'secrets'
urlpatterns = [
# Secret roles

View File

@@ -3,10 +3,10 @@ import base64
from django.contrib import messages
from django.contrib.auth.decorators import permission_required, login_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.db import transaction, IntegrityError
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.decorators import method_decorator
from dcim.models import Device
@@ -14,7 +14,17 @@ from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, Obje
from . import filters, forms, tables
from .decorators import userkey_required
from .models import SecretRole, Secret, SessionKey, UserKey
from .models import SecretRole, Secret, SessionKey
def get_session_key(request):
"""
Extract and decode the session key sent with a request. Returns None if no session key was provided.
"""
session_key = request.COOKIES.get('session_key', None)
if session_key is not None:
return base64.b64decode(session_key)
return session_key
#
@@ -32,7 +42,7 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
model = SecretRole
form_class = forms.SecretRoleForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('secrets:secretrole_list')
@@ -73,14 +83,13 @@ def secret_add(request, pk):
device = get_object_or_404(Device, pk=pk)
secret = Secret(device=device)
uk = UserKey.objects.get(user=request.user)
session_key = get_session_key(request)
if request.method == 'POST':
form = forms.SecretForm(request.POST, instance=secret)
if form.is_valid():
# We need a valid session key in order to create a Secret
session_key = base64.b64decode(request.COOKIES.get('session_key', None))
if session_key is None:
form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
@@ -119,13 +128,13 @@ def secret_add(request, pk):
def secret_edit(request, pk):
secret = get_object_or_404(Secret, pk=pk)
session_key = get_session_key(request)
if request.method == 'POST':
form = forms.SecretForm(request.POST, instance=secret)
if form.is_valid():
# Re-encrypt the Secret if a plaintext and session key have been provided.
session_key = base64.b64decode(request.COOKIES.get('session_key', None))
if form.cleaned_data['plaintext'] and session_key is not None:
# Retrieve the master key using the provided session key
@@ -212,6 +221,7 @@ def secret_import(request):
return render(request, 'import_success.html', {
'table': table,
'return_url': 'secrets:secret_list',
})
except IntegrityError as e:
@@ -222,7 +232,7 @@ def secret_import(request):
return render(request, 'secrets/secret_import.html', {
'form': form,
'return_url': reverse('secrets:secret_list'),
'return_url': 'secrets:secret_list',
})

View File

@@ -58,6 +58,7 @@
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Racks <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'dcim:rack_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Racks</a></li>
<li><a href="{% url 'dcim:rack_elevation_list' %}"><i class="fa fa-bars" aria-hidden="true"></i> Rack Elevations</a></li>
{% if perms.dcim.add_rack %}
<li><a href="{% url 'dcim:rack_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack</a></li>
<li><a href="{% url 'dcim:rack_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Racks</a></li>
@@ -72,6 +73,8 @@
{% if perms.dcim.add_rackrole %}
<li><a href="{% url 'dcim:rackrole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Role</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'dcim:rackreservation_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Rack Reservations</a></li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/' %} active{% endif %}">
@@ -240,16 +243,6 @@
{% endif %}
</ul>
{% endif %}
<form action="{% url 'search' %}" method="get" class="navbar-form navbar-right" role="search">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search">
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<i class="fa fa-search" aria-hidden="true"></i>
</button>
</span>
</div>
</form>
<ul class="nav navbar-nav navbar-right">
{% if request.user.is_authenticated %}
<li class="dropdown">
@@ -269,13 +262,23 @@
<li><a href="{% url 'login' %}?next={{ request.path }}"><i class="fa fa-sign-in" aria-hidden="true"></i> Log in</a></li>
{% endif %}
</ul>
<form action="{% url 'search' %}" method="get" class="navbar-form navbar-right" role="search">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search">
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<i class="fa fa-search" aria-hidden="true"></i>
</button>
</span>
</div>
</form>
</div>
</div>
</nav>
<div class="container wrapper">
<div class="container wrapper">
{% if settings.BANNER_TOP %}
<div class="alert alert-info text-center" role="alert">
{{ settings.BANNER_TOP|safe }}
{{ settings.BANNER_TOP|safe }}
</div>
{% endif %}
{% if settings.MAINTENANCE_MODE %}
@@ -284,24 +287,24 @@
<p>NetBox is currently in maintenance mode. Functionality may be limited.</p>
</div>
{% endif %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissable" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
{{ message|safe }}
</div>
{% endfor %}
{% block content %}{% endblock %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissable" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
{{ message }}
</div>
{% endfor %}
{% block content %}{% endblock %}
<div class="push"></div>
{% if settings.BANNER_BOTTOM %}
<div class="alert alert-info text-center banner-bottom" role="alert">
{% if settings.BANNER_BOTTOM %}
<div class="alert alert-info text-center banner-bottom" role="alert">
{{ settings.BANNER_BOTTOM|safe }}
</div>
{% endif %}
</div>
<footer class="footer">
<div class="container">
</div>
<footer class="footer">
<div class="container">
<div class="row">
<div class="col-xs-4">
<p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
@@ -312,14 +315,14 @@
<div class="col-xs-4 text-right">
<p class="text-muted">
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'django.swagger.base.view' %}">API</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'api_docs' %}">API</a> &middot;
<i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a> &middot;
<i class="fa fa-fw fa-support text-primary"></i> <a href="https://github.com/digitalocean/netbox/wiki">Help</a>
</p>
</div>
</div>
</div>
</footer>
</div>
</footer>
<script type="text/javascript">
var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
</script>

View File

@@ -1,72 +1,57 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Circuit Import{% endblock %}
{% block content %}
<h1>Circuit Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Circuit ID</td>
<td>Alphanumeric circuit identifier</td>
<td>IC-603122</td>
</tr>
<tr>
<td>Provider</td>
<td>Name of circuit provider</td>
<td>TeliaSonera</td>
</tr>
<tr>
<td>Type</td>
<td>Circuit type</td>
<td>Transit</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Strickland Propane</td>
</tr>
<tr>
<td>Install Date</td>
<td>Date in YYYY-MM-DD format (optional)</td>
<td>2016-02-23</td>
</tr>
<tr>
<td>Commit rate</td>
<td>Commited rate in Kbps (optional)</td>
<td>2000</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Primary for voice</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000,Primary for voice</pre>
</div>
</div>
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Circuit ID</td>
<td>Alphanumeric circuit identifier</td>
<td>IC-603122</td>
</tr>
<tr>
<td>Provider</td>
<td>Name of circuit provider</td>
<td>TeliaSonera</td>
</tr>
<tr>
<td>Type</td>
<td>Circuit type</td>
<td>Transit</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Strickland Propane</td>
</tr>
<tr>
<td>Install Date</td>
<td>Date in YYYY-MM-DD format (optional)</td>
<td>2016-02-23</td>
</tr>
<tr>
<td>Commit rate</td>
<td>Commited rate in Kbps (optional)</td>
<td>2000</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Primary for voice</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000,Primary for voice</pre>
{% endblock %}

View File

@@ -1,62 +1,47 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Provider Import{% endblock %}
{% block content %}
<h1>Provider Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Provider's proper name</td>
<td>Level 3</td>
</tr>
<tr>
<td>Slug</td>
<td>URL-friendly name</td>
<td>level3</td>
</tr>
<tr>
<td>ASN</td>
<td>Autonomous system number (optional)</td>
<td>3356</td>
</tr>
<tr>
<td>Account</td>
<td>Account number (optional)</td>
<td>08931544</td>
</tr>
<tr>
<td>Portal URL</td>
<td>Customer service portal URL (optional)</td>
<td>https://mylevel3.net</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>Level 3,level3,3356,08931544,https://mylevel3.net</pre>
</div>
</div>
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Provider's proper name</td>
<td>Level 3</td>
</tr>
<tr>
<td>Slug</td>
<td>URL-friendly name</td>
<td>level3</td>
</tr>
<tr>
<td>ASN</td>
<td>Autonomous system number (optional)</td>
<td>3356</td>
</tr>
<tr>
<td>Account</td>
<td>Account number (optional)</td>
<td>08931544</td>
</tr>
<tr>
<td>Portal URL</td>
<td>Customer service portal URL (optional)</td>
<td>https://mylevel3.net</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>Level 3,level3,3356,08931544,https://mylevel3.net</pre>
{% endblock %}

View File

@@ -1,61 +1,47 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Console Connections Import{% endblock %}
{% block content %}
<h1>Console Connections Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Console server</td>
<td>Device name or {ID}</td>
<td>abc1-cs3</td>
</tr>
<tr>
<td>Console server port</td>
<td>Full CS port name</td>
<td>Port 35</td>
</tr>
<tr>
<td>Device</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Console Port</td>
<td>Console port name</td>
<td>Console</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>planned</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-cs3,Port 35,abc1-switch7,Console,planned</pre>
</div>
</div>
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Console server</td>
<td>Device name or {ID}</td>
<td>abc1-cs3</td>
</tr>
<tr>
<td>Console server port</td>
<td>Full CS port name</td>
<td>Port 35</td>
</tr>
<tr>
<td>Device</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Console Port</td>
<td>Console port name</td>
<td>Console</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>planned</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-cs3,Port 35,abc1-switch7,Console,planned</pre>
{% endblock %}

View File

@@ -123,11 +123,7 @@
<tr>
<td>Status</td>
<td>
{% if device.status %}
<span class="label label-success">{{ device.get_status_display }}</span>
{% else %}
<span class="label label-danger">{{ device.get_status_display }}</span>
{% endif %}
<span class="label label-{{ device.get_status_class }}">{{ device.get_status_display }}</span>
</td>
</tr>
<tr>
@@ -194,35 +190,6 @@
{% endif %}
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>IP Addresses</strong>
</div>
{% if ip_addresses %}
<table class="table table-hover panel-body">
{% for ip in ip_addresses %}
{% include 'dcim/inc/ipaddress.html' %}
{% endfor %}
</table>
{% elif interfaces or mgmt_interfaces %}
<div class="panel-body text-muted">
None assigned
</div>
{% else %}
<div class="panel-body">
<a href="{% url 'dcim:interface_add' pk=device.pk %}">Create an interface</a> to assign an IP.
</div>
{% endif %}
{% if perms.ipam.add_ipaddress %}
{% if interfaces or mgmt_interfaces %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign IP address
</a>
</div>
{% endif %}
{% endif %}
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Services</strong>
@@ -250,7 +217,7 @@
<div class="panel-heading">
<strong>Critical Connections</strong>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body component-list">
{% for iface in mgmt_interfaces %}
{% include 'dcim/inc/interface.html' with icon='wrench' %}
{% empty %}
@@ -389,7 +356,7 @@
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body component-list">
{% for devicebay in device_bays %}
{% include 'dcim/inc/devicebay.html' with selectable=True %}
{% empty %}
@@ -430,6 +397,9 @@
<div class="panel-heading">
<strong>Interfaces</strong>
<div class="pull-right">
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
</button>
{% if perms.dcim.change_interface and interfaces|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
@@ -442,7 +412,7 @@
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
<table id="interfaces_table" class="table table-hover panel-body component-list">
{% for iface in interfaces %}
{% include 'dcim/inc/interface.html' with selectable=True %}
{% empty %}
@@ -499,7 +469,7 @@
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body component-list">
{% for csp in cs_ports %}
{% include 'dcim/inc/consoleserverport.html' with selectable=True %}
{% empty %}
@@ -551,7 +521,7 @@
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body component-list">
{% for po in power_outlets %}
{% include 'dcim/inc/poweroutlet.html' with selectable=True %}
{% empty %}
@@ -642,6 +612,18 @@ $(".powerport-toggle").click(function() {
$(".interface-toggle").click(function() {
return toggleConnection($(this), "dcim/interface-connections/");
});
// Toggle the display of IP addresses under interfaces
$('button.toggle-ips').click(function() {
var selected = $(this).attr('selected');
if (selected) {
$('#interfaces_table tr.ipaddress').hide();
} else {
$('#interfaces_table tr.ipaddress').show();
}
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
return false;
});
</script>
<script src="{% static 'js/graphs.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>

View File

@@ -55,8 +55,8 @@
<div class="panel panel-default">
<div class="panel-heading"><strong>Management</strong></div>
<div class="panel-body">
{% render_field form.platform %}
{% render_field form.status %}
{% render_field form.platform %}
{% if obj.pk %}
{% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %}

View File

@@ -12,8 +12,12 @@
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
<div class="col-md-12 text-right">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %}
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
{% endif %}
</div>
</div>
</form>
<h4>CSV Format</h4>
@@ -66,6 +70,11 @@
<td>Unique alphanumeric tag (optional)</td>
<td>ABC123456</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Site</td>
<td>Site name</td>
@@ -89,7 +98,7 @@
</tbody>
</table>
<h4>Example</h4>
<pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,ABC123456,Ashburn-VA,R101,21,Rear</pre>
<pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,ABC123456,Active,Ashburn-VA,R101,21,Rear</pre>
</div>
</div>
{% endblock %}

View File

@@ -12,8 +12,12 @@
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
<div class="col-md-12 text-right">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %}
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
{% endif %}
</div>
</div>
</form>
<h4>CSV Format</h4>

View File

@@ -1,4 +1,4 @@
<tr{% if cp.cs_port and not cp.connection_status %} class="info"{% endif %}>
<tr class="consoleport{% if cp.cs_port and not cp.connection_status %} info{% endif %}">
{% if selectable and perms.dcim.change_consoleport or perms.dcim.delete_consoleport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ cp.pk }}" />
@@ -20,7 +20,7 @@
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if perms.dcim.change_consoleport %}
{% if cp.cs_port %}
{% if cp.connection_status %}
@@ -33,11 +33,11 @@
</a>
{% endif %}
<a href="{% url 'dcim:consoleport_disconnect' pk=cp.pk %}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-remove" aria-hidden="true" title="Delete connection"></i>
<i class="glyphicon glyphicon-resize-full" aria-hidden="true" title="Delete connection"></i>
</a>
{% else %}
<a href="{% url 'dcim:consoleport_connect' pk=cp.pk %}" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Connect"></i>
<i class="glyphicon glyphicon-resize-small" aria-hidden="true" title="Connect"></i>
</a>
{% endif %}
<a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}" class="btn btn-info btn-xs">

View File

@@ -1,4 +1,4 @@
<tr{% if csp.connected_console and not csp.connected_console.connection_status %} class="info"{% endif %}>
<tr class="consoleserverport{% if csp.connected_console and not csp.connected_console.connection_status %} info{% endif %}">
{% if selectable and perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ csp.pk }}" />
@@ -7,6 +7,7 @@
<td>
<i class="fa fa-fw fa-keyboard-o"></i> {{ csp.name }}
</td>
<td></td>
{% if csp.connected_console %}
<td>
<a href="{% url 'dcim:device' pk=csp.connected_console.device.pk %}">{{ csp.connected_console.device }}</a>
@@ -19,7 +20,7 @@
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if perms.dcim.change_consoleserverport %}
{% if csp.connected_console %}
{% if csp.connected_console.connection_status %}
@@ -32,11 +33,11 @@
</a>
{% endif %}
<a href="{% url 'dcim:consoleserverport_disconnect' pk=csp.pk %}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-remove" aria-hidden="true" title="Delete connection"></i>
<i class="glyphicon glyphicon-resize-full" aria-hidden="true" title="Delete connection"></i>
</a>
{% else %}
<a href="{% url 'dcim:consoleserverport_connect' pk=csp.pk %}" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Connect"></i>
<i class="glyphicon glyphicon-resize-small" aria-hidden="true" title="Connect"></i>
</a>
{% endif %}
<a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}" class="btn btn-info btn-xs">

View File

@@ -1,4 +1,4 @@
<tr>
<tr class="devicebay">
{% if selectable and perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
@@ -19,7 +19,7 @@
<span class="text-muted">Vacant</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if perms.dcim.change_devicebay %}
{% if devicebay.installed_device %}
<a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">

View File

@@ -1,4 +1,4 @@
<tr{% if iface.connection and not iface.connection.connection_status %} class="info"{% endif %}>
<tr class="interface{% if iface.connection and not iface.connection.connection_status %} info{% endif %}">
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ iface.pk }}" />
@@ -12,14 +12,14 @@
{% if iface.description %}
<i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i>
{% endif %}
{% if iface.is_lag %}
<br /><small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
{% endif %}
</td>
<td>
<small>{{ iface.mac_address|default:'' }}</small>
</td>
{% if iface.is_virtual %}
<td>{{ iface.mac_address|default:"" }}</td>
{% if iface.is_lag %}
<td colspan="2" class="text-muted">
LAG interface<br />
<small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
</td>
{% elif iface.is_virtual %}
<td colspan="2" class="text-muted">Virtual interface</td>
{% elif iface.connection %}
{% with iface.connected_interface as connected_iface %}
@@ -51,7 +51,7 @@
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if show_graphs %}
{% if iface.circuit_termination or iface.connection %}
<button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface-graphs' pk=iface.pk %}" title="Show graphs">
@@ -59,6 +59,11 @@
</button>
{% endif %}
{% endif %}
{% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?interface_site={{ device.site.pk }}&interface_rack={{ device.rack.pk }}&interface_device={{ device.pk }}&interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.dcim.change_interface %}
{% if not iface.is_virtual %}
{% if iface.connection %}
@@ -71,19 +76,19 @@
<i class="fa fa-plug" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Delete connection">
<i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Disconnect">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% elif iface.circuit_termination and perms.circuits.change_circuittermination %}
<button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</button>
<a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
<i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}
{% endif %}
@@ -104,3 +109,41 @@
{% endif %}
</td>
</tr>
{% for ip in iface.ip_addresses.all %}
<tr class="ipaddress">
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
<td></td>
{% endif %}
<td colspan="2">
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
{% if ip.description %}
<i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
{% endif %}
{% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
<span class="label label-success">Primary</span>
{% endif %}
</td>
<td class="text-right">
{% if ip.vrf %}
<a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
{% else %}
<span class="text-muted">Global</span>
{% endif %}
</td>
<td>
<span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
</td>
<td class="text-right">
{% if perms.ipam.edit_ipaddress %}
<a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
</a>
{% endif %}
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}

View File

@@ -1,21 +0,0 @@
<tr>
<td>
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
</td>
<td>
{{ ip.vrf|default:"Global" }}
</td>
<td>{{ ip.interface }}</td>
<td>
{% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
<span class="label label-success">Primary</span>
{% endif %}
</td>
<td class="text-right">
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
</a>
{% endif %}
</td>
</tr>

View File

@@ -1,4 +1,4 @@
<tr{% if po.connected_port and not po.connected_port.connection_status %} class="info"{% endif %}>
<tr class="poweroutlet{% if po.connected_port and not po.connected_port.connection_status %} info{% endif %}">
{% if selectable and perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ po.pk }}" />
@@ -7,6 +7,7 @@
<td>
<i class="fa fa-fw fa-bolt"></i> {{ po.name }}
</td>
<td></td>
{% if po.connected_port %}
<td>
<a href="{% url 'dcim:device' pk=po.connected_port.device.pk %}">{{ po.connected_port.device }}</a>
@@ -19,7 +20,7 @@
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if perms.dcim.change_poweroutlet %}
{% if po.connected_port %}
{% if po.connected_port.connection_status %}
@@ -32,11 +33,11 @@
</a>
{% endif %}
<a href="{% url 'dcim:poweroutlet_disconnect' pk=po.pk %}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-remove" aria-hidden="true" title="Delete connection"></i>
<i class="glyphicon glyphicon-resize-full" aria-hidden="true" title="Delete connection"></i>
</a>
{% else %}
<a href="{% url 'dcim:poweroutlet_connect' pk=po.pk %}" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Connect"></i>
<i class="glyphicon glyphicon-resize-small" aria-hidden="true" title="Connect"></i>
</a>
{% endif %}
<a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}" class="btn btn-info btn-xs">

View File

@@ -1,4 +1,4 @@
<tr{% if pp.power_outlet and not pp.connection_status %} class="info"{% endif %}>
<tr class="powerport{% if pp.power_outlet and not pp.connection_status %} info{% endif %}">
{% if selectable and perms.dcim.change_powerport or perms.dcim.delete_powerport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ pp.pk }}" />
@@ -20,7 +20,7 @@
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if perms.dcim.change_powerport %}
{% if pp.power_outlet %}
{% if pp.connection_status %}
@@ -33,11 +33,11 @@
</a>
{% endif %}
<a href="{% url 'dcim:powerport_disconnect' pk=pp.pk %}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-remove" aria-hidden="true" title="Delete connection"></i>
<i class="glyphicon glyphicon-resize-full" aria-hidden="true" title="Delete connection"></i>
</a>
{% else %}
<a href="{% url 'dcim:powerport_connect' pk=pp.pk %}" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Connect"></i>
<i class="glyphicon glyphicon-resize-small" aria-hidden="true" title="Connect"></i>
</a>
{% endif %}
<a href="{% url 'dcim:powerport_edit' pk=pp.pk %}" class="btn btn-info btn-xs">

View File

@@ -1,69 +1,47 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Interface Connections Import{% endblock %}
{% block content %}
<h1>Interface Connections Import</h1>
<div class="row">
<div class="col-md-6">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Device A</td>
<td>Device name or {ID}</td>
<td>abc1-core1</td>
</tr>
<tr>
<td>Interface A</td>
<td>Interface name</td>
<td>xe-0/0/6</td>
</tr>
<tr>
<td>Device B</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Interface B</td>
<td>Interface name</td>
<td>xe-0/0/0</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>planned</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-core1,xe-0/0/6,abc1-switch7,xe-0/0/0,planned</pre>
</div>
</div>
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Device A</td>
<td>Device name or {ID}</td>
<td>abc1-core1</td>
</tr>
<tr>
<td>Interface A</td>
<td>Interface name</td>
<td>xe-0/0/6</td>
</tr>
<tr>
<td>Device B</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Interface B</td>
<td>Interface name</td>
<td>xe-0/0/0</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>planned</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-core1,xe-0/0/6,abc1-switch7,xe-0/0/0,planned</pre>
{% endblock %}

View File

@@ -1,61 +1,47 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Power Connections Import{% endblock %}
{% block content %}
<h1>Power Connections Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>PDU</td>
<td>Device name or {ID}</td>
<td>abc1-pdu1</td>
</tr>
<tr>
<td>Power Outlet</td>
<td>Power outlet name</td>
<td>AC4</td>
</tr>
<tr>
<td>Device</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Power Port</td>
<td>Power port name</td>
<td>PSU0</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>connected</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-pdu1,AC4,abc1-switch7,PSU0,connected</pre>
</div>
</div>
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>PDU</td>
<td>Device name or {ID}</td>
<td>abc1-pdu1</td>
</tr>
<tr>
<td>Power Outlet</td>
<td>Power outlet name</td>
<td>AC4</td>
</tr>
<tr>
<td>Device</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Power Port</td>
<td>Power port name</td>
<td>PSU0</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>connected</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-pdu1,AC4,abc1-switch7,PSU0,connected</pre>
{% endblock %}

View File

@@ -224,7 +224,7 @@
</tr>
{% for resv in reservations %}
<tr>
<td>{{ resv.units|join:', ' }}</td>
<td>{{ resv.unit_list }}</td>
<td>
{{ resv.description }}<br />
<small>{{ resv.user }} &middot; {{ resv.created }}</small>

View File

@@ -0,0 +1,50 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="btn-group pull-right" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face=0 %}" class="btn btn-default{% if request.GET.face != '1' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face=1 %}" class="btn btn-default{% if request.GET.face == '1' %} active{% endif %}">Rear</a>
</div>
<h1>{% block title %}Rack Elevations{% endblock %}</h1>
<div class="row">
{% if page %}
<div class="col-md-9">
<div style="white-space: nowrap; overflow-x: scroll;">
{% for rack in page %}
<div style="display: inline-block; width: 266px">
<div class="rack_header">
<h4>{{ rack.name }}</h4>
</div>
{% if face_id %}
{% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_rear_elevation secondary_face=rack.get_front_elevation face_id=1 %}
{% else %}
{% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_front_elevation secondary_face=rack.get_rear_elevation face_id=0 %}
{% endif %}
<div class="clearfix"></div>
<div class="rack_header">
<h4>{{ rack.name }}</h4>
</div>
</div>
{% endfor %}
</div>
{% include 'paginator.html' %}
</div>
{% else %}
<div class="col-md-9">
<p>No racks found</p>
</div>
{% endif %}
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(function() {
$('[data-toggle="popover"]').popover()
})
</script>
{% endblock %}

View File

@@ -1,87 +1,72 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Rack Import{% endblock %}
{% block content %}
<h1>Rack Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Site</td>
<td>Name of the assigned site</td>
<td>DC-4</td>
</tr>
<tr>
<td>Group</td>
<td>Rack group name (optional)</td>
<td>Cage 1400</td>
</tr>
<tr>
<td>Name</td>
<td>Internal rack name</td>
<td>R101</td>
</tr>
<tr>
<td>Facility ID</td>
<td>Rack ID assigned by the facility (optional)</td>
<td>J12.100</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role (optional)</td>
<td>Compute</td>
</tr>
<tr>
<td>Type</td>
<td>Rack type (optional)</td>
<td>4-post cabinet</td>
</tr>
<tr>
<td>Width</td>
<td>Rail-to-rail width (19 or 23 inches)</td>
<td>19</td>
</tr>
<tr>
<td>Height</td>
<td>Height in rack units</td>
<td>42</td>
</tr>
<tr>
<td>Descending units</td>
<td>Units are numbered top-to-bottom</td>
<td>False</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42,False</pre>
</div>
</div>
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Site</td>
<td>Name of the assigned site</td>
<td>DC-4</td>
</tr>
<tr>
<td>Group</td>
<td>Rack group name (optional)</td>
<td>Cage 1400</td>
</tr>
<tr>
<td>Name</td>
<td>Internal rack name</td>
<td>R101</td>
</tr>
<tr>
<td>Facility ID</td>
<td>Rack ID assigned by the facility (optional)</td>
<td>J12.100</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role (optional)</td>
<td>Compute</td>
</tr>
<tr>
<td>Type</td>
<td>Rack type (optional)</td>
<td>4-post cabinet</td>
</tr>
<tr>
<td>Width</td>
<td>Rail-to-rail width (19 or 23 inches)</td>
<td>19</td>
</tr>
<tr>
<td>Height</td>
<td>Height in rack units</td>
<td>42</td>
</tr>
<tr>
<td>Descending units</td>
<td>Units are numbered top-to-bottom</td>
<td>False</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42,False</pre>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<h1>{% block title %}Rack Reservations{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackreservation_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -224,7 +224,7 @@
<table class="table table-hover panel-body">
{% for rg in rack_groups %}
<tr>
<td><i class="fa fa-fw fa-folder"></i> <a href="{{ rg.get_absolute_url }}">{{ rg.name }}</a></td>
<td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg.name }}</a></td>
<td>{{ rg.rack_count }}</td>
</tr>
{% endfor %}
@@ -257,7 +257,7 @@
<table class="table table-hover panel-body">
{% for tm in topology_maps %}
<tr>
<td><i class="fa fa-fw fa-map"></i> <a href="{% url 'extras-api:topologymap-render' pk=tm.pk %}" target="_blank">{{ tm }}</a></td>
<td><i class="fa fa-fw fa-map-o"></i> <a href="{% url 'extras-api:topologymap-render' pk=tm.pk %}" target="_blank">{{ tm }}</a></td>
<td>{{ tm.description }}</td>
</tr>
{% endfor %}

View File

@@ -48,6 +48,20 @@
</div>
</div>
</div>
{% if perms.secrets %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Secrets</strong>
</div>
<div class="list-group">
<div class="list-group-item">
<span class="badge pull-right">{{ stats.secret_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
<p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-sm-6 col-md-4">
<div class="panel panel-default">
@@ -101,20 +115,25 @@
</div>
</div>
<div class="col-sm-6 col-md-4">
{% if perms.secrets %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Secrets</strong>
</div>
<div class="list-group">
<div class="list-group-item">
<span class="badge pull-right">{{ stats.secret_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
<p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Global Topology Maps</strong>
</div>
{% endif %}
{% if topology_maps %}
<table class="table table-hover panel-body">
{% for tm in topology_maps %}
<tr>
<td><i class="fa fa-fw fa-map-o"></i> <a href="{% url 'extras-api:topologymap-render' pk=tm.pk %}" target="_blank">{{ tm }}</a></td>
<td>{{ tm.description }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="panel-body text-muted">
None
</div>
{% endif %}
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Recent Activity</strong>

View File

@@ -1,13 +1,14 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}Import Completed{% endblock %}
{% block content %}
<h1>Import Completed</h1>
{% render_table table %}
<a href="{{ request.path }}" class="btn btn-primary">
<span class="fa fa-download" aria-hidden="true"></span>
Import more
</a>
<h1>{% block title %}Import Completed{% endblock %}</h1>
{% render_table table %}
<a href="{{ request.path }}" class="btn btn-primary">
<span class="fa fa-download" aria-hidden="true"></span>
Import more
</a>
{% if return_url %}
<a href="{% url return_url %}" class="btn btn-default">View All</a>
{% endif %}
{% endblock %}

View File

@@ -7,12 +7,12 @@
<th></th>
</tr>
{% for attachment in images %}
<tr>
<tr{% if not attachment.size %} class="danger"{% endif %}>
<td>
<i class="fa fa-image"></i>
<a href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
</td>
<td>{{ attachment.image.size|filesizeformat }}</td>
<td>{{ attachment.size|filesizeformat }}</td>
<td>{{ attachment.created }}</td>
<td class="text-right">
{% if perms.extras.change_imageattachment %}

View File

@@ -1,57 +1,42 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Aggregate Import{% endblock %}
{% block content %}
<h1>Aggregate Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Prefix</td>
<td>IPv4 or IPv6 network</td>
<td>172.16.0.0/12</td>
</tr>
<tr>
<td>RIR</td>
<td>Name of RIR</td>
<td>RFC 1918</td>
</tr>
<tr>
<td>Date Added</td>
<td>Date in YYYY-MM-DD format (optional)</td>
<td>2016-02-23</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Private IPv4 space</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>172.16.0.0/12,RFC 1918,2016-02-23,Private IPv4 space</pre>
</div>
</div>
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Prefix</td>
<td>IPv4 or IPv6 network</td>
<td>172.16.0.0/12</td>
</tr>
<tr>
<td>RIR</td>
<td>Name of RIR</td>
<td>RFC 1918</td>
</tr>
<tr>
<td>Date Added</td>
<td>Date in YYYY-MM-DD format (optional)</td>
<td>2016-02-23</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Private IPv4 space</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>172.16.0.0/12,RFC 1918,2016-02-23,Private IPv4 space</pre>
{% endblock %}

View File

@@ -98,14 +98,8 @@
<td>
{% if ipaddress.interface %}
<span><a href="{% url 'dcim:device' pk=ipaddress.interface.device.pk %}">{{ ipaddress.interface.device }}</a> ({{ ipaddress.interface }})</span>
{% if perms.dcim.change_device and perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_remove' pk=ipaddress.pk %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-remove"></i> Remove</a>
{% endif %}
{% else %}
<span class="text-muted">None</span>
{% if perms.dcim.change_device and perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_assign' pk=ipaddress.pk %}" class="btn btn-xs btn-primary"><i class="glyphicon glyphicon-plus"></i> Assign</a>
{% endif %}
{% endif %}
</td>
</tr>

View File

@@ -10,13 +10,21 @@
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>IP Address</strong></div>
<div class="panel-heading"><strong>IP Addresses</strong></div>
<div class="panel-body">
{% render_field form.address %}
{% render_field form.address_pattern %}
{% render_field form.vrf %}
{% render_field form.tenant %}
{% render_field form.status %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -16,39 +16,21 @@
{% render_field form.vrf %}
{% render_field form.tenant %}
{% render_field form.status %}
{% if obj.pk %}
<div class="form-group">
<label class="col-md-3 control-label">Device</label>
<div class="col-md-9">
<p class="form-control-static">
{% if obj.interface %}
<a href="{% url 'dcim:device' pk=obj.interface.device.pk %}">{{ obj.interface.device }}</a>
<a href="{% url 'ipam:ipaddress_remove' pk=obj.pk %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-remove"></i> Remove</a>
{% else %}
<span class="text-muted">None</span>
{% if obj.pk %}
<a href="{% url 'ipam:ipaddress_assign' pk=obj.pk %}" class="btn btn-xs btn-primary"><i class="glyphicon glyphicon-plus"></i> Assign</a>
{% endif %}
{% endif %}
</p>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">Interface</label>
<div class="col-md-9">
<p class="form-control-static">
{% if obj.interface %}
{{ obj.interface }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</p>
</div>
</div>
{% endif %}
{% render_field form.description %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interface Assignment</strong>
</div>
<div class="panel-body">
{% render_field form.interface_site %}
{% render_field form.interface_rack %}
{% render_field form.interface_device %}
{% render_field form.interface %}
{% render_field form.primary_for_device %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>NAT IP (Inside)</strong></div>
<div class="panel-body">

View File

@@ -1,77 +1,62 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}IP Address Import{% endblock %}
{% block content %}
<h1>IP Address Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Address</td>
<td>IPv4 or IPv6 address</td>
<td>192.0.2.42/24</td>
</tr>
<tr>
<td>VRF</td>
<td>VRF route distinguisher (optional)</td>
<td>65000:123</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>ABC01</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Device</td>
<td>Device name (optional)</td>
<td>switch12</td>
</tr>
<tr>
<td>Interface</td>
<td>Interface name (optional)</td>
<td>ge-0/0/31</td>
</tr>
<tr>
<td>Is Primary</td>
<td>If "true", IP will be primary for device (optional)</td>
<td>True</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Management IP</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>192.0.2.42/24,65000:123,ABC01,Active,switch12,ge-0/0/31,True,Management IP</pre>
</div>
</div>
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Address</td>
<td>IPv4 or IPv6 address</td>
<td>192.0.2.42/24</td>
</tr>
<tr>
<td>VRF</td>
<td>VRF route distinguisher (optional)</td>
<td>65000:123</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>ABC01</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Device</td>
<td>Device name (optional)</td>
<td>switch12</td>
</tr>
<tr>
<td>Interface</td>
<td>Interface name (optional)</td>
<td>ge-0/0/31</td>
</tr>
<tr>
<td>Is Primary</td>
<td>If "true", IP will be primary for device (optional)</td>
<td>True</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Management IP</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>192.0.2.42/24,65000:123,ABC01,Active,switch12,ge-0/0/31,True,Management IP</pre>
{% endblock %}

View File

@@ -1,87 +1,72 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Prefix Import{% endblock %}
{% block content %}
<h1>Prefix Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Prefix</td>
<td>IPv4 or IPv6 network</td>
<td>192.168.42.0/24</td>
</tr>
<tr>
<td>VRF</td>
<td>VRF route distinguisher (optional)</td>
<td>65000:123</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>ABC01</td>
</tr>
<tr>
<td>Site</td>
<td>Name of assigned site (optional)</td>
<td>HQ</td>
</tr>
<tr>
<td>VLAN Group</td>
<td>Name of group for VLAN selection (optional)</td>
<td>Customers</td>
</tr>
<tr>
<td>VLAN ID</td>
<td>Numeric VLAN ID (optional)</td>
<td>801</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role (optional)</td>
<td>Customer</td>
</tr>
<tr>
<td>Is a pool</td>
<td>True if all IPs are considered usable</td>
<td>False</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>7th floor WiFi</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>192.168.42.0/24,65000:123,ABC01,HQ,Customers,801,Active,Customer,False,7th floor WiFi</pre>
</div>
</div>
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Prefix</td>
<td>IPv4 or IPv6 network</td>
<td>192.168.42.0/24</td>
</tr>
<tr>
<td>VRF</td>
<td>VRF route distinguisher (optional)</td>
<td>65000:123</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>ABC01</td>
</tr>
<tr>
<td>Site</td>
<td>Name of assigned site (optional)</td>
<td>HQ</td>
</tr>
<tr>
<td>VLAN Group</td>
<td>Name of group for VLAN selection (optional)</td>
<td>Customers</td>
</tr>
<tr>
<td>VLAN ID</td>
<td>Numeric VLAN ID (optional)</td>
<td>801</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role (optional)</td>
<td>Customer</td>
</tr>
<tr>
<td>Is a pool</td>
<td>True if all IPs are considered usable</td>
<td>False</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>7th floor WiFi</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>192.168.42.0/24,65000:123,ABC01,HQ,Customers,801,Active,Customer,False,7th floor WiFi</pre>
{% endblock %}

View File

@@ -6,15 +6,10 @@
{% block content %}
<div class="pull-right">
<a href="{% url 'ipam:prefix_list' %}{% querystring_toggle request expand='on' %}" class="btn btn-default">
{% if 'expand' in request.GET %}
<span class="fa fa-chevron-right" aria-hidden="true"></span>
Collapse all
{% else %}
<span class="fa fa-chevron-down" aria-hidden="true"></span>
Expand all
{% endif %}
</a>
<div class="btn-group" role="group">
<a href="{% url 'ipam:prefix_list' %}{% querystring request expand=None page=1 %}" class="btn btn-default{% if not request.GET.expand %} active{% endif %}">Collapse</a>
<a href="{% url 'ipam:prefix_list' %}{% querystring request expand='on' page=1 %}" class="btn btn-default{% if request.GET.expand %} active{% endif %}">Expand</a>
</div>
{% if perms.ipam.add_prefix %}
<a href="{% url 'ipam:prefix_add' %}" class="btn btn-primary">
<span class="fa fa-plus" aria-hidden="true"></span>

View File

@@ -1,77 +1,62 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}VLAN Import{% endblock %}
{% block content %}
<h1>VLAN Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Site</td>
<td>Name of assigned site</td>
<td>LAS2</td>
</tr>
<tr>
<td>Group</td>
<td>Name of VLAN group (optional)</td>
<td>Backend Network</td>
</tr>
<tr>
<td>ID</td>
<td>Configured VLAN ID</td>
<td>1400</td>
</tr>
<tr>
<td>Name</td>
<td>Configured VLAN name</td>
<td>Cameras</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Internal</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role (optional)</td>
<td>Security</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Security team only</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>LAS2,Backend Network,1400,Cameras,Internal,Active,Security,Security team only</pre>
</div>
</div>
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Site</td>
<td>Name of assigned site</td>
<td>LAS2</td>
</tr>
<tr>
<td>Group</td>
<td>Name of VLAN group (optional)</td>
<td>Backend Network</td>
</tr>
<tr>
<td>ID</td>
<td>Configured VLAN ID</td>
<td>1400</td>
</tr>
<tr>
<td>Name</td>
<td>Configured VLAN name</td>
<td>Cameras</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Internal</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role (optional)</td>
<td>Security</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Security team only</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>LAS2,Backend Network,1400,Cameras,Internal,Active,Security,Security team only</pre>
{% endblock %}

View File

@@ -1,62 +1,47 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}VRF Import{% endblock %}
{% block content %}
<h1>VRF Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Name of VRF</td>
<td>Customer_ABC</td>
</tr>
<tr>
<td>RD</td>
<td>Route distinguisher</td>
<td>65000:123456</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>ABC01</td>
</tr>
<tr>
<td>Enforce uniqueness</td>
<td>Prevent duplicate prefixes/IP addresses</td>
<td>True</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Native VRF for customer ABC</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>Customer_ABC,65000:123456,ABC01,True,Native VRF for customer ABC</pre>
</div>
</div>
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Name of VRF</td>
<td>Customer_ABC</td>
</tr>
<tr>
<td>RD</td>
<td>Route distinguisher</td>
<td>65000:123456</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>ABC01</td>
</tr>
<tr>
<td>Enforce uniqueness</td>
<td>Prevent duplicate prefixes/IP addresses</td>
<td>True</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Native VRF for customer ABC</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>Customer_ABC,65000:123456,ABC01,True,Native VRF for customer ABC</pre>
{% endblock %}

View File

@@ -1,34 +1,27 @@
{% load django_tables2 %}
{% load helpers %}
{# Custom pagination controls to render nicely with Bootstrap CSS. smart_pages requires EnhancedPaginator. #}
<div class="paginator pull-right">
{% if table.paginator.num_pages > 1 %}
<div class="paginator pull-right" style="margin-top: 20px">
{% if paginator.num_pages > 1 %}
<nav>
<ul class="pagination pull-right">
{% if table.page.has_previous %}
<li><a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}">&laquo;</a></li>
{% if page.has_previous %}
<li><a href="{% querystring request page=page.previous_page_number %}">&laquo;</a></li>
{% endif %}
{% for p in table.page.smart_pages %}
{% for p in page.smart_pages %}
{% if p %}
<li{% ifequal table.page.number p %} class="active"{% endifequal %}><a href="{% querystring table.prefixed_page_field=p %}">{{ p }}</a></li>
<li{% ifequal page.number p %} class="active"{% endifequal %}><a href="{% querystring request page=p %}">{{ p }}</a></li>
{% else %}
<li class="disabled"><span>&hellip;</span></li>
{% endif %}
{% endfor %}
{% if table.page.has_next %}
<li><a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}">&raquo;</a></li>
{% if page.has_next %}
<li><a href="{% querystring request page=page.next_page_number %}">&raquo;</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
<div class="clearfix"></div>
<div class="text-right text-muted">
Showing {{ table.page.start_index }}-{{ table.page.end_index }} of {{ total }}
{% if total == 1 %}
{{ table.data.verbose_name }}
{% else %}
{{ table.data.verbose_name_plural }}
{% endif %}
Showing {{ page.start_index }}-{{ page.end_index }} of {{ total_count }}
</div>
</div>

View File

@@ -21,6 +21,6 @@
{% block pagination %}
{% if not hide_paginator %}
{% include 'paginator.html' %}
{% include 'table_paginator.html' %}
{% endif %}
{% endblock pagination %}

View File

@@ -39,8 +39,16 @@
{% if secret.pk %}
<div class="form-group">
<label class="col-md-3 control-label required">Current Plaintext</label>
<div class="col-md-9">
<p class="form-control-static">********</p>
<div class="col-md-7">
<p class="form-control-static" id="secret_{{ secret.pk }}">********</p>
</div>
<div class="col-md-2 text-right">
<button class="btn btn-xs btn-success unlock-secret" secret-id="{{ secret.pk }}">
<i class="fa fa-lock"></i> Unlock
</button>
<button class="btn btn-xs btn-danger lock-secret collapse" secret-id="{{ secret.pk }}">
<i class="fa fa-unlock-alt"></i> Lock
</button>
</div>
</div>
{% endif %}

View File

@@ -20,10 +20,14 @@
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
<div class="form-group">
<div class="col-md-12 text-right">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %}
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
{% endif %}
</div>
</div>
</form>
</div>
<div class="col-md-6">

View File

@@ -1,10 +1,10 @@
{% extends 'django_tables2/table.html' %}
{% extends 'django_tables2/bootstrap-responsive.html' %}
{% load django_tables2 %}
{# Extends the stock django_tables2 template to provide custom formatting of the pagination controls #}
{% block pagination %}
{% if not hide_paginator %}
{% include 'paginator.html' %}
{% include 'table_paginator.html' %}
{% endif %}
{% endblock pagination %}

View File

@@ -0,0 +1,34 @@
{% load django_tables2 %}
{# Custom pagination controls to render nicely with Bootstrap CSS. smart_pages requires EnhancedPaginator. #}
<div class="paginator pull-right">
{% if table.paginator.num_pages > 1 %}
<nav>
<ul class="pagination pull-right">
{% if table.page.has_previous %}
<li><a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}">&laquo;</a></li>
{% endif %}
{% for p in table.page.smart_pages %}
{% if p %}
<li{% ifequal table.page.number p %} class="active"{% endifequal %}><a href="{% querystring table.prefixed_page_field=p %}">{{ p }}</a></li>
{% else %}
<li class="disabled"><span>&hellip;</span></li>
{% endif %}
{% endfor %}
{% if table.page.has_next %}
<li><a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}">&raquo;</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
<div class="clearfix"></div>
<div class="text-right text-muted">
Showing {{ table.page.start_index }}-{{ table.page.end_index }} of {{ total }}
{% if total == 1 %}
{{ table.data.verbose_name }}
{% else %}
{{ table.data.verbose_name_plural }}
{% endif %}
</div>
</div>

View File

@@ -1,57 +1,42 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Tenant Import{% endblock %}
{% block content %}
<h1>Tenant Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Tenant name</td>
<td>WIDG01</td>
</tr>
<tr>
<td>Slug</td>
<td>URL-friendly name</td>
<td>widg01</td>
</tr>
<tr>
<td>Group</td>
<td>Tenant group (optional)</td>
<td>Customers</td>
</tr>
<tr>
<td>Description</td>
<td>Long-form name or other text (optional)</td>
<td>Widgets Inc.</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>WIDG01,widg01,Customers,Widgets Inc.</pre>
</div>
</div>
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Tenant name</td>
<td>WIDG01</td>
</tr>
<tr>
<td>Slug</td>
<td>URL-friendly name</td>
<td>widg01</td>
</tr>
<tr>
<td>Group</td>
<td>Tenant group (optional)</td>
<td>Customers</td>
</tr>
<tr>
<td>Description</td>
<td>Long-form name or other text (optional)</td>
<td>Widgets Inc.</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>WIDG01,widg01,Customers,Widgets Inc.</pre>
{% endblock %}

View File

@@ -6,13 +6,13 @@
<div class="col-md-6 col-md-offset-3">
<form action="." method="post" class="form">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="panel panel-{{ panel_class|default:"danger" }}">
<div class="panel-heading">{% block title %}{% endblock %}</div>
<div class="panel-body">
{% block message %}<p>Are you sure?</p>{% endblock %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="form-group">
<div class="checkbox{% if form.confirm.errors %} has-error{% endif %}">
<label for="{{ form.confirm.id_for_label }}">

View File

@@ -0,0 +1,34 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block content %}
<h1>{% block title %}{% endblock %}</h1>
<div class="row">
<div class="col-md-6">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<div class="col-md-12 text-right">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %}
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
{% endif %}
</div>
</div>
</form>
</div>
<div class="col-md-6">
{% block instructions %}{% endblock %}
</div>
</div>
{% endblock %}

View File

@@ -43,8 +43,8 @@ class NestedTenantSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'slug']
class WritableTenantSerializer(serializers.ModelSerializer):
class WritableTenantSerializer(CustomFieldModelSerializer):
class Meta:
model = Tenant
fields = ['id', 'name', 'slug', 'group', 'description', 'comments']
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']

View File

@@ -18,4 +18,5 @@ router.APIRootView = TenancyRootView
router.register(r'tenant-groups', views.TenantGroupViewSet)
router.register(r'tenants', views.TenantViewSet)
app_name = 'tenancy-api'
urlpatterns = router.urls

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