Compare commits

...

112 Commits

Author SHA1 Message Date
Jeremy Stretch
7a64404299 Merge pull request #1614 from digitalocean/develop
Release v2.2.2
2017-10-17 11:24:02 -04:00
Jeremy Stretch
2afa6ed2cb Release v2.2.2 2017-10-17 11:21:58 -04:00
Jeremy Stretch
34f1a9ebfb Fixes #1579: Devices already assigned to a cluster cannot be added to a different cluster 2017-10-17 09:59:35 -04:00
Jeremy Stretch
6f2f8697ae Fixes #1609: Added missing virtual_machine field to IP address interface serializer 2017-10-17 09:23:53 -04:00
Jeremy Stretch
6ec9d1d6ce Merge pull request #1598 from candlerb/candlerb/1498
Avoid creating repeated graph nodes where device matches multiple regexps
2017-10-16 17:16:46 -04:00
Jeremy Stretch
047f22e110 Fixes #1605: Added clusters and virtual machines to object list for global search 2017-10-16 16:44:15 -04:00
root
5fc3eac0f6 Avoid creating repeated graph nodes where device matches multiple regexps
Fixes #1498
2017-10-16 10:13:39 +00:00
Jeremy Stretch
34259d5d9d Removed deprecated xstr and expand_pattern functions 2017-10-13 14:29:55 -04:00
Jeremy Stretch
91b6ebb0c0 Closes #1580: Allow cluster assignment when bulk importing devices 2017-10-13 14:19:41 -04:00
Jeremy Stretch
60b4f1f89f Fixes #1585: Fixed slug-based filtering of virtual machines 2017-10-13 12:14:19 -04:00
Jeremy Stretch
023ff6834a Designated new Docker build repo; removed stale Heroku build repo 2017-10-13 11:50:06 -04:00
Jeremy Stretch
d00cab0b0a Merge branch 'develop' of github.com:digitalocean/netbox into develop 2017-10-13 10:54:24 -04:00
Jeremy Stretch
17493ff655 Closes #1587: Add primary IP column for virtual machines in global search results 2017-10-13 10:53:25 -04:00
Jeremy Stretch
6c27e6c4fe Fixes #1584: Colorized virtual machine role column 2017-10-13 10:45:34 -04:00
Jeremy Stretch
4cb0be4df3 Fixes #1582: Add virtual_machine attribute to IPAddress 2017-10-13 10:42:45 -04:00
Jeremy Stretch
40f555a3b6 Merge pull request #1545 from digitalocean/mdl-ldap-docs-formatting
Minor LDAP documentation formatting cleanup
2017-10-12 23:42:29 -04:00
Jeremy Stretch
881fdbe893 Post-release version bump 2017-10-12 16:39:51 -04:00
Jeremy Stretch
2bda399982 Merge pull request #1577 from digitalocean/develop
Release v2.2.1
2017-10-12 16:11:17 -04:00
Jeremy Stretch
75d840fa1a PEP8 fix 2017-10-12 16:07:13 -04:00
Jeremy Stretch
800bdd8fc5 Release v2.2.1 2017-10-12 16:04:01 -04:00
Jeremy Stretch
b04ade8060 Fixes #1576: Move PostgreSQL validation logic into the relevant migration 2017-10-12 16:02:15 -04:00
Jeremy Stretch
7f4d96f33e Post-release version bump 2017-10-12 14:01:52 -04:00
Jeremy Stretch
74731bc6ae Merge pull request #1575 from digitalocean/develop
Release v2.2.0
2017-10-12 14:01:28 -04:00
Jeremy Stretch
9d8daca54d Release v2.2.0 2017-10-12 13:54:04 -04:00
Jeremy Stretch
bb898b719f Added reports CLI documentation 2017-10-12 13:48:08 -04:00
Jeremy Stretch
3a0b57b50f Fixed typo 2017-10-12 13:47:44 -04:00
Ryan Breaker
38d2333165 Add meta tags for charset and convert some tabs to spaces for consistency (#1574)
* Add meta tag for charset.

* Add meta tag for charset in 500.html and convert some tabs to spaces for consistency.
2017-10-12 13:39:28 -04:00
Jeremy Stretch
a7f0b5adb3 Refreshed installation docs 2017-10-12 13:38:23 -04:00
Jeremy Stretch
109fff0fa6 Reorganized the docs 2017-10-12 10:59:39 -04:00
Jeremy Stretch
8c591e7248 Fixed up reports documentation 2017-10-11 17:05:01 -04:00
Jeremy Stretch
0dafa10e27 Merge branch 'develop-2.2' into develop
Conflicts:
	netbox/netbox/settings.py
2017-10-11 16:45:18 -04:00
Jeremy Stretch
60e3ff0bf0 Updated version number for v2.2 build 2017-10-11 16:42:44 -04:00
Jeremy Stretch
6052405eb7 Added info about API _choices endpoints 2017-10-11 16:41:16 -04:00
Jeremy Stretch
17a45109f4 Bypass PostgreSQL 9.4 check if database is inaccessible 2017-10-11 16:28:05 -04:00
Jeremy Stretch
392b45e6cb Added warning for PostgreSQL 9.4 requirement 2017-10-11 16:20:50 -04:00
Jeremy Stretch
f27e1ba885 Check that PostgreSQL is 9.4 or higher on initialization 2017-10-11 16:16:44 -04:00
Jeremy Stretch
990c9d6f7c Updated package requirements 2017-10-11 15:11:51 -04:00
Jeremy Stretch
b301b8f6f2 Branded the browsable API template 2017-10-11 14:47:43 -04:00
Jeremy Stretch
924cf9bfd4 Removed obsolete docker build file 2017-10-11 14:39:51 -04:00
Jeremy Stretch
7e6573d191 Added missing permission class to reports API viewset 2017-10-11 14:37:25 -04:00
Jeremy Stretch
fdae3a3f31 Introduced the REPORTS_ROOT config parameter; Python2 fixes 2017-10-11 14:03:35 -04:00
Jeremy Stretch
51194e20f2 Improved template for when no reports are found 2017-10-11 13:30:38 -04:00
Jeremy Stretch
043f2cb214 Tweaked PostgreSQL version number in example 2017-10-11 13:12:49 -04:00
Jeremy Stretch
651d462456 Post-release version bump 2017-10-11 13:04:02 -04:00
Jeremy Stretch
7cb287d6c6 Merge pull request #1572 from digitalocean/develop
Release v2.1.6
2017-10-11 13:02:32 -04:00
Jeremy Stretch
d16d89028f Release v2.1.6 2017-10-11 12:59:43 -04:00
Jeremy Stretch
baa277c592 Updated install docs to install pip using setuptools 2017-10-11 12:22:35 -04:00
Jeremy Stretch
007f930fed Merge branch 'develop' into develop-2.2
Conflicts:
	netbox/utilities/api.py
2017-10-11 10:47:21 -04:00
Jeremy Stretch
f253f164a3 Closes #1510: Added ability to search by name when adding devices to a cluster 2017-10-11 10:14:09 -04:00
Jeremy Stretch
abfe71bb04 #1556: Swapped label and value 2017-10-10 17:47:53 -04:00
Jeremy Stretch
0c645b12d1 Moved querysets to separate files; tweaked interface ordering logic (#1523) 2017-10-10 17:23:41 -04:00
Jeremy Stretch
f824d1eb3b Closes #1556: Added API endpoints listing static field choices for each app 2017-10-10 16:41:35 -04:00
Jeremy Stretch
afbbe1148f Moved core API classes out of utilities 2017-10-10 14:21:20 -04:00
Jeremy Stretch
8403e91fc9 Fixes #1567: Prompt user for session key when importing secrets 2017-10-10 13:22:40 -04:00
Jeremy Stretch
c300879995 Fixes #1527: Replace deprecated pycrypto library with pycryptodome 2017-10-09 17:40:15 -04:00
Jeremy Stretch
ea1d298eb3 Closes #494: Include asset tag in device info pop-up on rack elevation 2017-10-09 16:25:52 -04:00
Jeremy Stretch
d17b36519a Closes #1479: Added CARP IP address role 2017-10-09 16:03:45 -04:00
Jeremy Stretch
f4f0896470 Added bulk import view for secret roles 2017-10-09 15:56:17 -04:00
Jeremy Stretch
2c9b41db75 Added bulk import view for circuit types 2017-10-09 15:52:22 -04:00
Jeremy Stretch
a38cd449c5 Added bulk import views for cluster types and groups 2017-10-09 15:49:48 -04:00
Jeremy Stretch
977cad3830 Added bulk import views for RIRs, roles, and VLAN groups 2017-10-09 15:42:06 -04:00
Jeremy Stretch
987587b5f3 Added NAPALM driver to platform table; removed legacy RPC client 2017-10-09 15:30:48 -04:00
Jeremy Stretch
9351f686b1 Added bulk import views for rack roles, device roles, and platforms 2017-10-09 15:28:46 -04:00
Jeremy Stretch
42e557bd3f Added bulk import view for tenant groups 2017-10-09 15:09:40 -04:00
Jeremy Stretch
35c2c8e8de #1444: Added a serial number field to the rack model 2017-10-09 15:01:57 -04:00
Jeremy Stretch
960e1d49c2 Closes #1551: Added CPAK, CFP2, and CFP4 100GE interface form factors 2017-10-09 14:45:10 -04:00
Jeremy Stretch
100d979d3e Fixes #1554: Don't require form_factor when creating an interface assigned to a virtual machine 2017-10-09 14:27:31 -04:00
Jeremy Stretch
b734599f40 Closes #1557: Added filtering for virtual machine interfaces 2017-10-09 13:59:53 -04:00
Jeremy Stretch
b41b4fc584 Closes #1506: Extended rack facility ID field from 30 to 50 characters 2017-10-09 13:44:22 -04:00
Jeremy Stretch
b69bf58c63 Closes #1548: Automatically populate tenant assignment when adding an IP address from the prefix view 2017-10-09 13:40:28 -04:00
Jeremy Stretch
a73e598a6e #1561: Selected related primary IPs for devices under global search 2017-10-09 13:32:30 -04:00
huzichunjohn
af2de6776d Closes #1561: Add Primary IP for a device to Search Results (#1565) 2017-10-09 13:28:39 -04:00
Jeremy Stretch
98195c9e74 Fixes #1550: Corrected interface connections link in navigation menu 2017-10-06 15:39:39 -04:00
Jeremy Stretch
97188ad85b Fixes #1563: Made necessary updates for Django REST Framework v3.7.0 2017-10-06 15:26:56 -04:00
Matt Layher
a01c9ff379 Minor LDAP documentation formatting cleanup 2017-09-29 13:22:42 -04:00
Jeremy Stretch
aa8f734bd1 Merge pull request #1537 from digitalocean/develop
Release v2.1.5
2017-09-25 14:52:43 -04:00
Jeremy Stretch
f6d1163ddd Merge pull request #1461 from digitalocean/develop
Release v2.1.4
2017-08-30 14:43:01 -04:00
Jeremy Stretch
5be30bd278 Merge pull request #1428 from digitalocean/develop
Release v2.1.3
2017-08-15 15:52:34 -04:00
Jeremy Stretch
fa7b7288c9 Merge pull request #1398 from digitalocean/develop
Release v2.1.2
2017-08-04 10:54:29 -04:00
Jeremy Stretch
9cc03aaa9a Merge pull request #1387 from digitalocean/develop
Release v2.1.1
2017-08-02 14:22:30 -04:00
Jeremy Stretch
1bda56ea23 Merge pull request #1372 from digitalocean/develop
Release v2.1.0
2017-07-25 11:21:44 -04:00
Jeremy Stretch
64a34ced72 Merge pull request #1346 from digitalocean/develop
Release v2.0.10
2017-07-14 10:09:16 -04:00
Jeremy Stretch
e05d379101 Merge pull request #1327 from digitalocean/develop
Release v2.0.9
2017-07-10 09:43:59 -04:00
Jeremy Stretch
a355783377 Merge pull request #1316 from digitalocean/develop
Release v2.0.8
2017-07-05 14:36:08 -04:00
Jeremy Stretch
88239e0b0d Merge pull request #1278 from digitalocean/develop
Release v2.0.7
2017-06-15 14:26:38 -04:00
Jeremy Stretch
5c63a499d5 Merge pull request #1259 from digitalocean/develop
Release v2.0.6
2017-06-12 09:51:15 -04:00
Jeremy Stretch
50496b1a59 Merge pull request #1251 from digitalocean/develop
Release v2.0.5
2017-06-08 10:10:41 -04:00
Jeremy Stretch
f7b0d22f86 Merge pull request #1230 from digitalocean/develop
Release v2.0.4
2017-05-25 14:45:13 -04:00
Jeremy Stretch
ad95b86fdd Merge pull request #1201 from digitalocean/develop
Release v2.0.3
2017-05-18 14:37:19 -04:00
Jeremy Stretch
43e1e0dbc8 Merge pull request #1181 from digitalocean/develop
Release v2.0.2
2017-05-15 13:23:33 -04:00
Jeremy Stretch
f731900e2f Merge pull request #1154 from digitalocean/develop
Release v2.0.1
2017-05-09 22:47:52 -04:00
Jeremy Stretch
b1bcaa33e7 Merge pull request #1148 from digitalocean/develop
Release v2.0.0
2017-05-09 15:09:28 -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
e0ad2b4555 Merge pull request #1054 from digitalocean/develop
Release v1.9.5
2017-04-06 16:35:15 -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
89 changed files with 1245 additions and 644 deletions

View File

@@ -31,6 +31,5 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for inst
## Alternative Installations
* [Docker container](https://github.com/digitalocean/netbox-docker)
* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant)
* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))

View File

@@ -88,10 +88,10 @@ The base serializer is used to represent the default view of a model. This inclu
"vid": 101,
"name": "Users-Floor1",
"tenant": null,
"status": [
1,
"Active"
],
"status": {
"value": 1,
"label": "Active"
},
"role": {
"id": 9,
"url": "http://localhost:8000/api/ipam/roles/9/",
@@ -122,6 +122,37 @@ When a base serializer includes one or more nested serializers, the hierarchical
}
```
## Static Choice Fields
Some model fields, such as the `status` field in the above example, utilize static integers corresponding to static choices. The available choices can be retrieved from the read-only `_choices` endpoint within each app. A specific `model:field` tuple may optionally be specified in the URL.
Each choice includes a human-friendly label and its corresponding numeric value. For example, `GET /api/ipam/_choices/prefix:status/` will return:
```
[
{
"value": 0,
"label": "Container"
},
{
"value": 1,
"label": "Active"
},
{
"value": 2,
"label": "Reserved"
},
{
"value": 3,
"label": "Deprecated"
}
]
```
Thus, to set a prefix's status to "Reserved," it would be assigned the integer `2`.
A request for `GET /api/ipam/_choices/` will return choices for _all_ fields belonging to models within the IPAM app.
# Pagination
API responses which contain a list of objects (for example, a request to `/api/dcim/devices/`) will be paginated to avoid unnecessary overhead. The root JSON object will contain the following attributes:

View File

@@ -145,7 +145,7 @@ An API consumer can request an arbitrary number of objects by appending the "lim
Default: $BASE_DIR/netbox/media/
The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media` directory within the base NetBox installation path.
The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media/` directory within the base NetBox installation path.
---
@@ -207,6 +207,14 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
---
## REPORTS_ROOT
Default: $BASE_DIR/netbox/reports/
The file path to the location where custom reports will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path.
---
## TIME_ZONE
Default: UTC

View File

@@ -6,6 +6,7 @@ NetBox is an open source web application designed to help manage and document co
* **Equipment racks** - Organized by group and site
* **Devices** - Types of devices and where they are installed
* **Connections** - Network, console, and power connections among devices
* **Virtualization** - Virtual machines and clusters
* **Data circuits** - Long-haul communications circuits and providers
* **Secrets** - Encrypted storage of sensitive credentials
@@ -46,7 +47,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP Service | nginx or Apache |
| WSGI Service | gunicorn or uWSGI |
| Application | Django/Python |
| Database | PostgreSQL |
| Database | PostgreSQL 9.4+ |
# Getting Started

View File

@@ -55,7 +55,7 @@ LDAP_IGNORE_CERT_ERRORS = True
## User Authentication
!!! info
When using Windows Server, `2012 AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
```python
from django_auth_ldap.config import LDAPSearch
@@ -79,7 +79,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
# User Groups for Permissions
!!! Info
When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for AUTH_LDAP_GROUP_TYPE.
When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`.
```python
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

View File

@@ -1,17 +1,24 @@
# Installation
This section of the documentation discusses installing and configuring the NetBox application.
!!! note
Python 3 is strongly encouraged for new installations. Support for Python 2 will be discontinued in the near future. This documentation includes a guide on [migrating from Python 2 to Python 3](migrating-to-python3).
**Ubuntu**
Python 3:
```no-highlight
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
# apt-get install -y python3 python3-dev python3-setuptools build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
# easy_install3 pip
```
Python 2:
```no-highlight
# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
# apt-get install -y python2.7 python-dev python-setuptools build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
# easy_install pip
```
**CentOS**
@@ -22,14 +29,14 @@ Python 3:
# yum install -y epel-release
# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
# easy_install-3.4 pip
# ln -s -f python3.4 /usr/bin/python
```
Python 2:
```no-highlight
# yum install -y epel-release
# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
# yum install -y gcc python2 python-devel python-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
# easy_install pip
```
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
@@ -97,6 +104,9 @@ Python 2:
# pip install -r requirements.txt
```
!!! note
If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip -V` or `pip3 -V`.
### NAPALM Automation
As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3:
@@ -158,13 +168,13 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
# Run Database Migrations
!!! warning
The examples on the rest of this page call the `python` executable, which will be Python2 on most systems. Replace this with `python3` if you're running NetBox on Python3.
The examples on the rest of this page call the `python3` executable. Replace this with `python2` or `python` if you're using Python 2.
Before NetBox can run, we need to install the database schema. This is done by running `python manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
```no-highlight
# cd /opt/netbox/netbox/
# python manage.py migrate
# python3 manage.py migrate
Operations to perform:
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
Running migrations:
@@ -182,7 +192,7 @@ If this step results in a PostgreSQL authentication error, ensure that the usern
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
```no-highlight
# python manage.py createsuperuser
# python3 manage.py createsuperuser
Username: admin
Email address: admin@example.com
Password:
@@ -193,7 +203,7 @@ Superuser created successfully.
# Collect Static Files
```no-highlight
# python manage.py collectstatic --no-input
# python3 manage.py collectstatic --no-input
You have requested to collect static files at the destination
location as specified in your settings:
@@ -214,7 +224,7 @@ NetBox ships with some initial data to help you get started: RIR definitions, co
This step is optional. It's perfectly fine to start using NetBox without using this initial data if you'd rather create everything from scratch.
```no-highlight
# python manage.py loaddata initial_data
# python3 manage.py loaddata initial_data
Installed 43 object(s) from 4 fixture(s)
```
@@ -223,7 +233,7 @@ Installed 43 object(s) from 4 fixture(s)
At this point, NetBox should be able to run. We can verify this by starting a development instance:
```no-highlight
# python manage.py runserver 0.0.0.0:8000 --insecure
# python3 manage.py runserver 0.0.0.0:8000 --insecure
Performing system checks...
System check identified no issues (0 silenced).

View File

@@ -1,15 +1,16 @@
NetBox requires a PostgreSQL 9.4 or higher database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.6/static/datatype-net-types.html).)
NetBox requires a PostgreSQL database to store data. This can be hosted locally or on a remote server. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/current/static/datatype-net-types.html).)
!!! note
The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 6.9. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 7.4. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
!!! warning
NetBox v2.2 and later requires PostgreSQL 9.4 or higher.
# Installation
NetBox v2.2 or later requires PostgreSQL 9.4 or higher.
**Ubuntu**
If a recent enough version of PostgreSQL is not available through your distribution's package manager, consider installing from an official [PostgreSQL repository](https://wiki.postgresql.org/wiki/Apt).
If a recent enough version of PostgreSQL is not available through your distribution's package manager, you'll need to install it from an official [PostgreSQL repository](https://wiki.postgresql.org/wiki/Apt).
```no-highlight
# apt-get update
@@ -18,22 +19,26 @@ If a recent enough version of PostgreSQL is not available through your distribut
**CentOS**
CentOS 7.4 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6.
```no-highlight
# yum install -y postgresql postgresql-server postgresql-devel
# postgresql-setup initdb
# yum install https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/pgdg-centos96-9.6-3.noarch.rpm
# yum install postgresql96 postgresql96-server postgresql96-devel
# /usr/pgsql-9.6/bin/postgresql96-setup initdb
```
CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example:
CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/9.6/data/pg_hba.conf`. For example:
```no-highlight
host all all 127.0.0.1/32 md5
host all all ::1/128 md5
```
Then, start the service:
Then, start the service and enable it to run at boot:
```no-highlight
# systemctl start postgresql
# systemctl start postgresql-9.6
# systemctl enable postgresql-9.6
```
# Database Creation
@@ -45,7 +50,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a
```no-highlight
# sudo -u postgres psql
psql (9.3.13)
psql (9.4.5)
Type "help" for help.
postgres=# CREATE DATABASE netbox;
@@ -57,10 +62,10 @@ GRANT
postgres=# \q
```
You can verify that authentication works issuing the following command and providing the configured password:
You can verify that authentication works issuing the following command and providing the configured password. (Replace `localhost` with your database server if using a remote database.)
```no-highlight
# psql -U netbox -h localhost -W
# psql -U netbox -W -h localhost netbox
```
If successful, you will enter a `postgres` prompt. Type `\q` to exit.
If successful, you will enter a `netbox` prompt. Type `\q` to exit.

View File

@@ -12,7 +12,7 @@ A NetBox report is a mechanism for validating the integrity of data within NetBo
## Writing Reports
Reports must be saved as files in the `netbox/reports/` path within the NetBox installation path. Each file created within this path is considered a separate module. Each module holds one or more reports, each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test.
Reports must be saved as files in the [`REPORTS_ROOT`](../configuration/optional-settings/#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test.
!!! warning
The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file.
@@ -106,14 +106,24 @@ Once a report has been run, its associated results will be included in the repor
### Via the API
To run a report via the API, simply issue a POST request. Reports are identified by their module and class name.
To run a report via the API, simply issue a POST request to its `run` endpoint. Reports are identified by their module and class name.
```
POST /api/extras/reports/<module>.<name>/
POST /api/extras/reports/<module>.<name>/run/
```
Our example report above would be called as:
```
POST /api/extras/reports/devices.DeviceConnectionsReport/
POST /api/extras/reports/devices.DeviceConnectionsReport/run/
```
### Via the CLI
Reports can be run on the CLI by invoking the management command:
```
python3 manage.py runreport <module>
```
One or more report modules may be specified.

View File

@@ -25,10 +25,9 @@ pages:
- 'Authentication': 'api/authentication.md'
- 'Working with Secrets': 'api/working-with-secrets.md'
- 'Examples': 'api/examples.md'
- 'Shell':
- 'Introduction': 'shell/intro.md'
- 'Miscellaneous':
- 'Reports': 'miscellaneous/reports.md'
- 'Shell': 'miscellaneous/shell.md'
- 'Development':
- 'Utility Views': 'development/utility-views.md'

View File

@@ -16,6 +16,9 @@ class CircuitsRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = CircuitsRootView
# Field choices
router.register(r'_choices', views.CircuitsFieldChoicesViewSet, base_name='field-choice')
# Providers
router.register(r'providers', views.ProviderViewSet)

View File

@@ -11,10 +11,20 @@ from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
from extras.models import Graph, GRAPH_TYPE_PROVIDER
from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet
from utilities.api import WritableSerializerMixin
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
from . import serializers
#
# Field choices
#
class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(CircuitTermination, ['term_side']),
)
#
# Providers
#

View File

@@ -85,6 +85,17 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug']
class CircuitTypeCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = CircuitType
fields = ['name', 'slug']
help_texts = {
'name': 'Name of circuit type',
}
#
# Circuits
#

View File

@@ -21,6 +21,7 @@ urlpatterns = [
# Circuit types
url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),

View File

@@ -114,6 +114,13 @@ class CircuitTypeEditView(CircuitTypeCreateView):
permission_required = 'circuits.change_circuittype'
class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuittype'
model_form = forms.CircuitTypeCSVForm
table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list'
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
cls = CircuitType

View File

@@ -142,8 +142,8 @@ class RackSerializer(CustomFieldModelSerializer):
class Meta:
model = Rack
fields = [
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height',
'desc_units', 'comments', 'custom_fields',
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
'u_height', 'desc_units', 'comments', 'custom_fields',
]
@@ -160,8 +160,8 @@ class WritableRackSerializer(CustomFieldModelSerializer):
class Meta:
model = Rack
fields = [
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
'comments', 'custom_fields',
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
'desc_units', '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.

View File

@@ -16,6 +16,9 @@ class DCIMRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = DCIMRootView
# Field choices
router.register(r'_choices', views.DCIMFieldChoicesViewSet, base_name='field-choice')
# Sites
router.register(r'regions', views.RegionViewSet)
router.register(r'sites', views.SiteViewSet)

View File

@@ -20,11 +20,27 @@ from dcim import filters
from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from utilities.api import IsAuthenticatedOrLoginNotRequired, ServiceUnavailable, WritableSerializerMixin
from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ServiceUnavailable, WritableSerializerMixin
from .exceptions import MissingFilterException
from . import serializers
#
# Field choices
#
class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(Device, ['face', 'status']),
(ConsolePort, ['connection_status']),
(Interface, ['form_factor']),
(InterfaceConnection, ['connection_status']),
(InterfaceTemplate, ['form_factor']),
(PowerPort, ['connection_status']),
(Rack, ['type', 'width']),
)
#
# Regions
#

View File

@@ -66,6 +66,9 @@ IFACE_FF_10GE_X2 = 1320
IFACE_FF_25GE_SFP28 = 1350
IFACE_FF_40GE_QSFP_PLUS = 1400
IFACE_FF_100GE_CFP = 1500
IFACE_FF_100GE_CFP2 = 1510
IFACE_FF_100GE_CFP4 = 1520
IFACE_FF_100GE_CPAK = 1550
IFACE_FF_100GE_QSFP28 = 1600
# Wireless
IFACE_FF_80211A = 2600
@@ -93,15 +96,13 @@ IFACE_FF_JUNIPER_VCP = 5200
# Other
IFACE_FF_OTHER = 32767
VIFACE_FF_CHOICES = [
[IFACE_FF_VIRTUAL, 'Virtual'],
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
]
IFACE_FF_CHOICES = [
[
'Virtual interfaces',
VIFACE_FF_CHOICES,
[
[IFACE_FF_VIRTUAL, 'Virtual'],
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
],
],
[
'Ethernet (fixed)',
@@ -124,6 +125,9 @@ IFACE_FF_CHOICES = [
[IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
[IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
[IFACE_FF_100GE_CFP, 'CFP (100GE)'],
[IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'],
[IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'],
[IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'],
[IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
]
],

View File

@@ -159,7 +159,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = Rack
fields = ['type', 'width', 'u_height', 'desc_units']
fields = ['serial', 'type', 'width', 'u_height', 'desc_units']
def search(self, queryset, name, value):
if not value.strip():
@@ -167,6 +167,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
return queryset.filter(
Q(name__icontains=value) |
Q(facility_id__icontains=value) |
Q(serial__icontains=value.strip()) |
Q(comments__icontains=value)
)

View File

@@ -17,6 +17,7 @@ from utilities.forms import (
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
SlugField, FilterTreeNodeMultipleChoiceField,
)
from virtualization.models import Cluster
from .formfields import MACAddressFormField
from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, ConsolePort,
@@ -212,6 +213,18 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug', 'color']
class RackRoleCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = RackRole
fields = ['name', 'slug', 'color']
help_texts = {
'name': 'Name of rack role',
'color': 'RGB color in hexadecimal (e.g. 00ff00)'
}
#
# Racks
#
@@ -232,8 +245,8 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta:
model = Rack
fields = [
'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'type', 'width', 'u_height',
'desc_units', 'comments',
'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width',
'u_height', 'desc_units', 'comments',
]
help_texts = {
'site': "The site at which the rack exists",
@@ -293,7 +306,8 @@ class RackCSVForm(forms.ModelForm):
class Meta:
model = Rack
fields = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
'desc_units',
]
help_texts = {
'name': 'Rack name',
@@ -321,6 +335,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
role = forms.ModelChoiceField(queryset=RackRole.objects.all(), required=False)
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
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)')
@@ -328,7 +343,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
comments = CommentField(widget=SmallTextarea)
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'comments']
nullable_fields = ['group', 'tenant', 'role', 'serial', 'comments']
class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -620,6 +635,18 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug', 'color', 'vm_role']
class DeviceRoleCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = DeviceRole
fields = ['name', 'slug', 'color', 'vm_role']
help_texts = {
'name': 'Name of device role',
'color': 'RGB color in hexadecimal (e.g. 00ff00)'
}
#
# Platforms
#
@@ -632,6 +659,17 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug', 'napalm_driver', 'rpc_client']
class PlatformCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = Platform
fields = ['name', 'slug', 'napalm_driver']
help_texts = {
'name': 'Platform name',
}
#
# Devices
#
@@ -863,11 +901,20 @@ class DeviceCSVForm(BaseDeviceCSVForm):
required=False,
help_text='Mounted rack face'
)
cluster = forms.ModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
required=False,
help_text='Virtualization cluster',
error_messages={
'invalid_choice': 'Invalid cluster name.',
}
)
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face',
'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster',
]
def clean(self):
@@ -903,11 +950,19 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
device_bay_name = forms.CharField(
help_text='Name of device bay',
)
cluster = forms.ModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
help_text='Virtualization cluster',
error_messages={
'invalid_choice': 'Invalid cluster name.',
}
)
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'parent', 'device_bay_name',
'parent', 'device_bay_name', 'cluster',
]
def clean(self):
@@ -938,7 +993,7 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
class Meta:
nullable_fields = ['tenant', 'platform']
nullable_fields = ['tenant', 'platform', 'serial']
def device_status_choices():

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-10-09 17:43
from __future__ import unicode_literals
from django.db import migrations
import utilities.fields
class Migration(migrations.Migration):
dependencies = [
('dcim', '0045_devicerole_vm_role'),
]
operations = [
migrations.AlterField(
model_name='rack',
name='facility_id',
field=utilities.fields.NullableCharField(blank=True, max_length=50, null=True, verbose_name='Facility ID'),
),
]

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-10-09 18:43
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0046_rack_lengthen_facility_id'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-10-09 18:50
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0047_more_100ge_form_factors'),
]
operations = [
migrations.AddField(
model_name='rack',
name='serial',
field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'),
),
]

View File

@@ -13,7 +13,6 @@ from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Count, Q, ObjectDoesNotExist
from django.db.models.expressions import RawSQL
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
@@ -27,6 +26,7 @@ from utilities.models import CreatedUpdatedModel
from utilities.utils import csv_format
from .constants import *
from .fields import ASNField, MACAddressField
from .querysets import InterfaceQuerySet
#
@@ -217,11 +217,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
Each Rack is assigned to a Site and (optionally) a RackGroup.
"""
name = models.CharField(max_length=50)
facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID')
facility_id = NullableCharField(max_length=50, blank=True, null=True, verbose_name='Facility ID')
site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT)
role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT)
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type')
width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width',
help_text='Rail-to-rail width')
@@ -236,7 +237,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
objects = RackManager()
csv_headers = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
'desc_units',
]
class Meta:
@@ -685,72 +687,6 @@ class PowerOutletTemplate(models.Model):
return self.name
class InterfaceQuerySet(models.QuerySet):
def order_naturally(self, method=IFACE_ORDERING_POSITION):
"""
Naturally order interfaces by their type and numeric position. The sort method must be one of the defined
IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
To order interfaces naturally, the `name` field is split into six distinct components: leading text (type),
slot, subslot, position, channel, and virtual circuit:
{type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc}
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3
would be parsed as follows:
name = 'GigabitEthernet'
slot = 1
subslot = 2
position = 3
subposition = 0
channel = None
vc = 0
The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of
the prescribed fields.
"""
sql_col = '{}.name'.format(self.model._meta.db_table)
ordering = {
IFACE_ORDERING_POSITION: (
'_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_type', '_id', 'name',
),
IFACE_ORDERING_NAME: (
'_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name',
),
}[method]
TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')"
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)"
SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)\/') AS integer)"
SUBSLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/)([0-9]+)') AS integer)"
POSITION_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{2}}([0-9]+)') AS integer)"
SUBPOSITION_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{3}}([0-9]+)') AS integer)"
CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)"
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)"
fields = {
'_type': RawSQL(TYPE_RE.format(sql_col), []),
'_id': RawSQL(ID_RE.format(sql_col), []),
'_slot': RawSQL(SLOT_RE.format(sql_col), []),
'_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []),
'_position': RawSQL(POSITION_RE.format(sql_col), []),
'_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []),
'_channel': RawSQL(CHANNEL_RE.format(sql_col), []),
'_vc': RawSQL(VC_RE.format(sql_col), []),
}
return self.annotate(**fields).order_by(*ordering)
def connectable(self):
"""
Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
wireless).
"""
return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES)
@python_2_unicode_compatible
class InterfaceTemplate(models.Model):
"""
@@ -1303,9 +1239,9 @@ class Interface(models.Model):
raise ValidationError("An interface must belong to either a device or a virtual machine.")
# VM interfaces must be virtual
if self.virtual_machine and self.form_factor not in VIRTUAL_IFACE_TYPES:
if self.virtual_machine and self.form_factor is not IFACE_FF_VIRTUAL:
raise ValidationError({
'form_factor': "Virtual machines cannot have physical interfaces."
'form_factor': "Virtual machines can only have virtual interfaces."
})
# Virtual interfaces cannot be connected

72
netbox/dcim/querysets.py Normal file
View File

@@ -0,0 +1,72 @@
from __future__ import unicode_literals
from django.db.models import QuerySet
from django.db.models.expressions import RawSQL
from .constants import IFACE_ORDERING_NAME, IFACE_ORDERING_POSITION, NONCONNECTABLE_IFACE_TYPES
class InterfaceQuerySet(QuerySet):
def order_naturally(self, method=IFACE_ORDERING_POSITION):
"""
Naturally order interfaces by their type and numeric position. The sort method must be one of the defined
IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
To order interfaces naturally, the `name` field is split into six distinct components: leading text (type),
slot, subslot, position, channel, and virtual circuit:
{type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc}
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3
would be parsed as follows:
name = 'GigabitEthernet'
slot = 1
subslot = 2
position = 3
subposition = 0
channel = None
vc = 0
The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of
the prescribed fields.
"""
sql_col = '{}.name'.format(self.model._meta.db_table)
ordering = {
IFACE_ORDERING_POSITION: (
'_slot', '_subslot', '_position', '_subposition', '_channel', '_type', '_vc', '_id', 'name',
),
IFACE_ORDERING_NAME: (
'_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name',
),
}[method]
TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')"
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)"
SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)\/') AS integer)"
SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/)([0-9]+)') AS integer), 0)"
POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{2}}([0-9]+)') AS integer), 0)"
SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{3}}([0-9]+)') AS integer), 0)"
CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)"
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)"
fields = {
'_type': RawSQL(TYPE_RE.format(sql_col), []),
'_id': RawSQL(ID_RE.format(sql_col), []),
'_slot': RawSQL(SLOT_RE.format(sql_col), []),
'_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []),
'_position': RawSQL(POSITION_RE.format(sql_col), []),
'_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []),
'_channel': RawSQL(CHANNEL_RE.format(sql_col), []),
'_vc': RawSQL(VC_RE.format(sql_col), []),
}
return self.annotate(**fields).order_by(*ordering)
def connectable(self):
"""
Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
wireless).
"""
return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES)

View File

@@ -381,13 +381,12 @@ class PlatformTable(BaseTable):
name = tables.LinkColumn(verbose_name='Name')
device_count = tables.Column(verbose_name='Devices')
slug = tables.Column(verbose_name='Slug')
rpc_client = tables.Column(accessor='get_rpc_client_display', orderable=False, verbose_name='RPC Client')
actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}},
verbose_name='')
class Meta(BaseTable.Meta):
model = Platform
fields = ('pk', 'name', 'device_count', 'slug', 'rpc_client', 'actions')
fields = ('pk', 'name', 'device_count', 'slug', 'napalm_driver', 'actions')
#

View File

@@ -39,6 +39,7 @@ urlpatterns = [
# Rack roles
url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'),
url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
@@ -106,12 +107,14 @@ urlpatterns = [
# Device roles
url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
url(r'^device-roles/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
# Platforms
url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'),
url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'),
url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),

View File

@@ -1,5 +1,4 @@
from __future__ import unicode_literals
from copy import deepcopy
import re
from natsort import natsorted
from operator import attrgetter
@@ -35,32 +34,6 @@ from .models import (
)
EXPANSION_PATTERN = '\[(\d+-\d+)\]'
def xstr(s):
"""
Replace None with an empty string (for CSV export)
"""
return '' if s is None else str(s)
def expand_pattern(string):
"""
Expand a numeric pattern into a list of strings. Examples:
'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3']
'xe-0/[0-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7']
"""
lead, pattern, remnant = re.split(EXPANSION_PATTERN, string, maxsplit=1)
x, y = pattern.split('-')
for i in range(int(x), int(y) + 1):
if remnant:
for string in expand_pattern(remnant):
yield "{0}{1}{2}".format(lead, i, string)
else:
yield "{0}{1}".format(lead, i)
class BulkDisconnectView(View):
"""
An extendable view for disconnection console/power/interface components in bulk.
@@ -273,6 +246,13 @@ class RackRoleEditView(RackRoleCreateView):
permission_required = 'dcim.change_rackrole'
class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_rackrole'
model_form = forms.RackRoleCSVForm
table = tables.RackRoleTable
default_return_url = 'dcim:rackrole_list'
class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackrole'
cls = RackRole
@@ -738,6 +718,13 @@ class DeviceRoleEditView(DeviceRoleCreateView):
permission_required = 'dcim.change_devicerole'
class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_devicerole'
model_form = forms.DeviceRoleCSVForm
table = tables.DeviceRoleTable
default_return_url = 'dcim:devicerole_list'
class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicerole'
cls = DeviceRole
@@ -769,6 +756,13 @@ class PlatformEditView(PlatformCreateView):
permission_required = 'dcim.change_platform'
class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_platform'
model_form = forms.PlatformCSVForm
table = tables.PlatformTable
default_return_url = 'dcim:platform_list'
class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_platform'
cls = Platform

View File

@@ -16,6 +16,9 @@ class ExtrasRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = ExtrasRootView
# Field choices
router.register(r'_choices', views.ExtrasFieldChoicesViewSet, base_name='field-choice')
# Graphs
router.register(r'graphs', views.GraphViewSet)

View File

@@ -10,12 +10,27 @@ from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from extras import filters
from extras.models import ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
from extras.reports import get_report, get_reports
from utilities.api import WritableSerializerMixin
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, WritableSerializerMixin
from . import serializers
#
# Field choices
#
class ExtrasFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(CustomField, ['type']),
(Graph, ['type']),
)
#
# Custom fields
#
class CustomFieldModelViewSet(ModelViewSet):
"""
Include the applicable set of CustomFields in the ModelViewSet context.
@@ -46,6 +61,10 @@ class CustomFieldModelViewSet(ModelViewSet):
return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field')
#
# Graphs
#
class GraphViewSet(WritableSerializerMixin, ModelViewSet):
queryset = Graph.objects.all()
serializer_class = serializers.GraphSerializer
@@ -53,12 +72,20 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet):
filter_class = filters.GraphFilter
#
# Export templates
#
class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
queryset = ExportTemplate.objects.all()
serializer_class = serializers.ExportTemplateSerializer
filter_class = filters.ExportTemplateFilter
#
# Topology maps
#
class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
queryset = TopologyMap.objects.select_related('site')
serializer_class = serializers.TopologyMapSerializer
@@ -85,13 +112,22 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
return response
#
# Image attachments
#
class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer
write_serializer_class = serializers.WritableImageAttachmentSerializer
#
# Reports
#
class ReportViewSet(ViewSet):
permission_classes = [IsAuthenticatedOrLoginNotRequired]
_ignore_model_permissions = True
exclude_from_schema = True
lookup_value_regex = '[^/]+' # Allow dots
@@ -162,6 +198,10 @@ class ReportViewSet(ViewSet):
return Response(serializer.data)
#
# User activity
#
class RecentActivityViewSet(ReadOnlyModelViewSet):
"""
List all UserActions to provide a log of recent activity.

View File

@@ -12,7 +12,6 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('reports', nargs='+', help="Report(s) to run")
# parser.add_argument('--verbose', action='store_true', default=False, help="Print all logs")
def handle(self, *args, **options):
@@ -22,7 +21,7 @@ class Command(BaseCommand):
# Run reports
for module_name, report_list in reports:
for report in report_list:
if module_name in options['reports'] or report.full_namel in options['reports']:
if module_name in options['reports'] or report.full_name in options['reports']:
# Run the report and create a new ReportResult
self.stdout.write(

View File

@@ -1,11 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-09-26 21:25
from __future__ import unicode_literals
from distutils.version import StrictVersion
from django.conf import settings
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
from django.db import connection, migrations, models
import django.db.models.deletion
from django.db.utils import OperationalError
def verify_postgresql_version(apps, schema_editor):
"""
Verify that PostgreSQL is version 9.4 or higher.
"""
try:
with connection.cursor() as cursor:
cursor.execute("SELECT VERSION()")
row = cursor.fetchone()
pg_version = row[0].split()[1]
if StrictVersion(pg_version) < StrictVersion('9.4.0'):
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))
# Skip if the database is missing (e.g. for CI testing) or misconfigured.
except OperationalError:
pass
class Migration(migrations.Migration):
@@ -16,6 +35,7 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RunPython(verify_postgresql_version),
migrations.CreateModel(
name='ReportResult',
fields=[

View File

@@ -274,6 +274,7 @@ class TopologyMap(models.Model):
# Construct the graph
graph = graphviz.Graph()
graph.graph_attr['ranksep'] = '1'
seen = set()
for i, device_set in enumerate(self.device_sets):
subgraph = graphviz.Graph(name='sg{}'.format(i))
@@ -288,6 +289,9 @@ class TopologyMap(models.Model):
devices = []
for query in device_set.strip(';').split(';'): # Split regexes on semicolons
devices += Device.objects.filter(name__regex=query).select_related('device_role')
# Remove duplicate devices
devices = [d for d in devices if d.id not in seen]
seen.update([d.id for d in devices])
for d in devices:
bg_color = '#{}'.format(d.device_role.color)
fg_color = '#{}'.format(foreground_color(d.device_role.color))

View File

@@ -1,13 +1,14 @@
from __future__ import unicode_literals
from collections import OrderedDict
import importlib
import inspect
import pkgutil
from django.conf import settings
from django.utils import timezone
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING
from .models import ReportResult
import reports as custom_reports
def is_report(obj):
@@ -42,9 +43,9 @@ def get_reports():
"""
module_list = []
# Iterate through all modules within the reports path. These are the user-defined files in which reports are
# Iterate through all modules within the reports path. These are the user-created files in which reports are
# defined.
for importer, module_name, is_pkg in pkgutil.walk_packages(custom_reports.__path__):
for importer, module_name, is_pkg in pkgutil.walk_packages([settings.REPORTS_ROOT]):
module = importlib.import_module('reports.{}'.format(module_name))
report_list = [cls() for _, cls in inspect.getmembers(module, is_report)]
module_list.append((module_name, report_list))

View File

@@ -240,12 +240,22 @@ class WritablePrefixSerializer(CustomFieldModelSerializer):
# IP addresses
#
class IPAddressInterfaceSerializer(InterfaceSerializer):
virtual_machine = NestedVirtualMachineSerializer()
class Meta(InterfaceSerializer.Meta):
fields = [
'id', 'device', 'virtual_machine', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address',
'mgmt_only', 'description', 'is_connected', 'interface_connection', 'circuit_termination',
]
class IPAddressSerializer(CustomFieldModelSerializer):
vrf = NestedVRFSerializer()
tenant = NestedTenantSerializer()
status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES)
interface = InterfaceSerializer()
interface = IPAddressInterfaceSerializer()
class Meta:
model = IPAddress
@@ -262,6 +272,7 @@ class NestedIPAddressSerializer(serializers.ModelSerializer):
model = IPAddress
fields = ['id', 'url', 'family', 'address']
IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()

View File

@@ -16,6 +16,9 @@ class IPAMRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = IPAMRootView
# Field choices
router.register(r'_choices', views.IPAMFieldChoicesViewSet, base_name='field-choice')
# VRFs
router.register(r'vrfs', views.VRFViewSet)

View File

@@ -12,10 +12,24 @@ from django.shortcuts import get_object_or_404
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from ipam import filters
from extras.api.views import CustomFieldModelViewSet
from utilities.api import WritableSerializerMixin
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
from . import serializers
#
# Field choices
#
class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(Aggregate, ['family']),
(Prefix, ['family', 'status']),
(IPAddress, ['family', 'status', 'role']),
(VLAN, ['status']),
(Service, ['protocol']),
)
#
# VRFs
#
@@ -137,7 +151,7 @@ class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = IPAddress.objects.select_related(
'vrf__tenant', 'tenant', 'nat_inside'
).prefetch_related(
'interface__device'
'interface__device', 'interface__virtual_machine'
)
serializer_class = serializers.IPAddressSerializer
write_serializer_class = serializers.WritableIPAddressSerializer

View File

@@ -39,6 +39,7 @@ IPADDRESS_ROLE_VIP = 40
IPADDRESS_ROLE_VRRP = 41
IPADDRESS_ROLE_HSRP = 42
IPADDRESS_ROLE_GLBP = 43
IPADDRESS_ROLE_CARP = 44
IPADDRESS_ROLE_CHOICES = (
(IPADDRESS_ROLE_LOOPBACK, 'Loopback'),
(IPADDRESS_ROLE_SECONDARY, 'Secondary'),
@@ -47,6 +48,7 @@ IPADDRESS_ROLE_CHOICES = (
(IPADDRESS_ROLE_VRRP, 'VRRP'),
(IPADDRESS_ROLE_HSRP, 'HSRP'),
(IPADDRESS_ROLE_GLBP, 'GLBP'),
(IPADDRESS_ROLE_CARP, 'CARP'),
)
# VLAN statuses

View File

@@ -97,6 +97,17 @@ class RIRForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug', 'is_private']
class RIRCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = RIR
fields = ['name', 'slug', 'is_private']
help_texts = {
'name': 'RIR name',
}
class RIRFilterForm(BootstrapMixin, forms.Form):
is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[
('', '---------'),
@@ -169,6 +180,17 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug']
class RoleCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = Role
fields = ['name', 'slug']
help_texts = {
'name': 'Role name',
}
#
# Prefixes
#
@@ -718,6 +740,26 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
fields = ['site', 'name', 'slug']
class VLANGroupCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent site',
error_messages={
'invalid_choice': 'Site not found.',
}
)
slug = SlugField()
class Meta:
model = VLANGroup
fields = ['site', 'name', 'slug']
help_texts = {
'name': 'Name of VLAN group',
}
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('vlan_groups')),

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-10-09 20:02
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0019_virtualization'),
]
operations = [
migrations.AlterField(
model_name='ipaddress',
name='role',
field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP'), (44, 'CARP')], help_text='The functional role of this IP', null=True, verbose_name='Role'),
),
]

View File

@@ -2,12 +2,11 @@ from __future__ import unicode_literals
import netaddr
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Q
from django.db.models.expressions import RawSQL
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
@@ -16,10 +15,10 @@ from dcim.models import Interface
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel
from utilities.sql import NullsFirstQuerySet
from utilities.utils import csv_format
from .constants import *
from .fields import IPNetworkField, IPAddressField
from .querysets import PrefixQuerySet
@python_2_unicode_compatible
@@ -190,41 +189,6 @@ class Role(models.Model):
return self.vlans.count()
class PrefixQuerySet(NullsFirstQuerySet):
def annotate_depth(self, limit=None):
"""
Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable
to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces
performance issues at scale.
Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet
modifications.
"""
queryset = self
stack = []
for p in queryset:
try:
prev_p = stack[-1]
except IndexError:
prev_p = None
if prev_p is not None:
while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix:
stack.pop()
try:
prev_p = stack[-1]
except IndexError:
prev_p = None
break
if prev_p is not None:
prev_p.has_children = True
stack.append(p)
p.depth = len(stack) - 1
if limit is None:
return queryset
return list(filter(lambda p: p.depth <= limit, queryset))
@python_2_unicode_compatible
class Prefix(CreatedUpdatedModel, CustomFieldModel):
"""
@@ -476,7 +440,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
self.get_status_display(),
self.get_role_display(),
self.device.identifier if self.device else None,
self.virtual_machine.name if self.device else None,
self.virtual_machine.name if self.virtual_machine else None,
self.interface.name if self.interface else None,
is_primary,
self.description,
@@ -488,6 +452,12 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
return self.interface.device
return None
@property
def virtual_machine(self):
if self.interface:
return self.interface.virtual_machine
return None
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]

38
netbox/ipam/querysets.py Normal file
View File

@@ -0,0 +1,38 @@
from __future__ import unicode_literals
from utilities.sql import NullsFirstQuerySet
class PrefixQuerySet(NullsFirstQuerySet):
def annotate_depth(self, limit=None):
"""
Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable
to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces
performance issues at scale.
Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet
modifications.
"""
queryset = self
stack = []
for p in queryset:
try:
prev_p = stack[-1]
except IndexError:
prev_p = None
if prev_p is not None:
while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix:
stack.pop()
try:
prev_p = stack[-1]
except IndexError:
prev_p = None
break
if prev_p is not None:
prev_p.has_children = True
stack.append(p)
p.depth = len(stack) - 1
if limit is None:
return queryset
return list(filter(lambda p: p.depth <= limit, queryset))

View File

@@ -71,7 +71,7 @@ IPADDRESS_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
{% elif perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}{% if prefix.tenant %}&tenant={{ prefix.tenant.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
{% else %}
{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
{% endif %}

View File

@@ -21,6 +21,7 @@ urlpatterns = [
# RIRs
url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'),
url(r'^rirs/add/$', views.RIRCreateView.as_view(), name='rir_add'),
url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'),
url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'),
@@ -37,6 +38,7 @@ urlpatterns = [
# Roles
url(r'^roles/$', views.RoleListView.as_view(), name='role_list'),
url(r'^roles/add/$', views.RoleCreateView.as_view(), name='role_add'),
url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'),
url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'),
@@ -65,6 +67,7 @@ urlpatterns = [
# VLAN groups
url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'),
url(r'^vlan-groups/add/$', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
url(r'^vlan-groups/import/$', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),

View File

@@ -261,6 +261,13 @@ class RIREditView(RIRCreateView):
permission_required = 'ipam.change_rir'
class RIRBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_rir'
model_form = forms.RIRCSVForm
table = tables.RIRTable
default_return_url = 'ipam:rir_list'
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_rir'
cls = RIR
@@ -407,6 +414,13 @@ class RoleEditView(RoleCreateView):
permission_required = 'ipam.change_role'
class RoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_role'
model_form = forms.RoleCSVForm
table = tables.RoleTable
default_return_url = 'ipam:role_list'
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_role'
cls = Role
@@ -745,6 +759,13 @@ class VLANGroupEditView(VLANGroupCreateView):
permission_required = 'ipam.change_vlangroup'
class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vlangroup'
model_form = forms.VLANGroupCSVForm
table = tables.VLANGroupTable
default_return_url = 'ipam:vlangroup_list'
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlangroup'
cls = VLANGroup

147
netbox/netbox/api.py Normal file
View File

@@ -0,0 +1,147 @@
from __future__ import unicode_literals
from rest_framework import authentication, exceptions
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.utils import formatting
from users.models import Token
#
# Renderers
#
class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
"""
Override the built-in BrowsableAPIRenderer to disable HTML forms.
"""
def show_form_for_method(self, *args, **kwargs):
return False
#
# Authentication
#
class TokenAuthentication(authentication.TokenAuthentication):
"""
A custom authentication scheme which enforces Token expiration times.
"""
model = Token
def authenticate_credentials(self, key):
model = self.get_model()
try:
token = model.objects.select_related('user').get(key=key)
except model.DoesNotExist:
raise exceptions.AuthenticationFailed("Invalid token")
# Enforce the Token's expiration time, if one has been set.
if token.is_expired:
raise exceptions.AuthenticationFailed("Token expired")
if not token.user.is_active:
raise exceptions.AuthenticationFailed("User inactive")
return token.user, token
class TokenPermissions(DjangoModelPermissions):
"""
Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
for unsafe requests (POST/PUT/PATCH/DELETE).
"""
def __init__(self):
# LOGIN_REQUIRED determines whether read-only access is provided to anonymous users.
from django.conf import settings
self.authenticated_users_only = settings.LOGIN_REQUIRED
super(TokenPermissions, self).__init__()
def has_permission(self, request, view):
# If token authentication is in use, verify that the token allows write operations (for unsafe methods).
if request.method not in SAFE_METHODS and isinstance(request.auth, Token):
if not request.auth.write_enabled:
return False
return super(TokenPermissions, self).has_permission(request, view)
#
# Pagination
#
class OptionalLimitOffsetPagination(LimitOffsetPagination):
"""
Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects
matching a query, but retains the same format as a paginated request. The limit can only be disabled if
MAX_PAGE_SIZE has been set to 0 or None.
"""
def paginate_queryset(self, queryset, request, view=None):
try:
self.count = queryset.count()
except (AttributeError, TypeError):
self.count = len(queryset)
self.limit = self.get_limit(request)
self.offset = self.get_offset(request)
self.request = request
if self.limit and self.count > self.limit and self.template is not None:
self.display_page_controls = True
if self.count == 0 or self.offset > self.count:
return list()
if self.limit:
return list(queryset[self.offset:self.offset + self.limit])
else:
return list(queryset[self.offset:])
def get_limit(self, request):
from django.conf import settings
if self.limit_query_param:
try:
limit = int(request.query_params[self.limit_query_param])
if limit < 0:
raise ValueError()
# Enforce maximum page size, if defined
if settings.MAX_PAGE_SIZE:
if limit == 0:
return settings.MAX_PAGE_SIZE
else:
return min(limit, settings.MAX_PAGE_SIZE)
return limit
except (KeyError, ValueError):
pass
return self.default_limit
#
# Miscellaneous
#
def get_view_name(view_cls, suffix=None):
"""
Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`.
"""
if hasattr(view_cls, 'queryset'):
# Determine the model name from the queryset.
name = view_cls.queryset.model._meta.verbose_name
name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word
else:
# Replicate DRF's built-in behavior.
name = view_cls.__name__
name = formatting.remove_trailing_string(name, 'View')
name = formatting.remove_trailing_string(name, 'ViewSet')
name = formatting.camelcase_to_spaces(name)
if suffix:
name += ' ' + suffix
return name

View File

@@ -1,79 +0,0 @@
import os
#########################
# #
# Required settings #
# #
#########################
# This is a list of valid fully-qualified domain names (FQDNs) for the NetBox server. NetBox will not permit write
# access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name.
#
# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(' ')
# PostgreSQL database configuration.
DATABASE = {
'NAME': os.environ.get('DB_NAME', 'netbox'), # Database name
'USER': os.environ.get('DB_USER', ''), # PostgreSQL username
'PASSWORD': os.environ.get('DB_PASSWORD', ''), # PostgreSQL password
'HOST': os.environ.get('DB_HOST', 'localhost'), # Database server
'PORT': os.environ.get('DB_PORT', ''), # Database port (leave blank for default)
}
# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file.
# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and
# symbols. NetBox will not run without this defined. For more information, see
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY
SECRET_KEY = os.environ.get('SECRET_KEY', '')
#########################
# #
# Optional settings #
# #
#########################
# Specify one or more name and email address tuples representing NetBox administrators. These people will be notified of
# application errors (assuming correct email settings are provided).
ADMINS = [
# ['John Doe', 'jdoe@example.com'],
]
# Email settings
EMAIL = {
'SERVER': os.environ.get('EMAIL_SERVER', 'localhost'),
'PORT': os.environ.get('EMAIL_PORT', 25),
'USERNAME': os.environ.get('EMAIL_USERNAME', ''),
'PASSWORD': os.environ.get('EMAIL_PASSWORD', ''),
'TIMEOUT': os.environ.get('EMAIL_TIMEOUT', 10), # seconds
'FROM_EMAIL': os.environ.get('EMAIL_FROM', ''),
}
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
# are permitted to access most data in NetBox (excluding secrets) but not make any changes.
LOGIN_REQUIRED = os.environ.get('LOGIN_REQUIRED', False)
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
# BASE_PATH = 'netbox/'
BASE_PATH = os.environ.get('BASE_PATH', '')
# Setting this to True will display a "maintenance mode" banner at the top of every page.
MAINTENANCE_MODE = os.environ.get('MAINTENANCE_MODE', False)
# Credentials that NetBox will use to access live devices.
NAPALM_USERNAME = os.environ.get('NAPALM_USERNAME', '')
NAPALM_PASSWORD = os.environ.get('NAPALM_PASSWORD', '')
# Determine how many objects to display per page within a list. (Default: 50)
PAGINATE_COUNT = os.environ.get('PAGINATE_COUNT', 50)
# Time zone (default: UTC)
TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC')
# Date/time formatting. See the following link for supported formats:
# https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date
DATE_FORMAT = os.environ.get('DATE_FORMAT', 'N j, Y')
SHORT_DATE_FORMAT = os.environ.get('SHORT_DATE_FORMAT', 'Y-m-d')
TIME_FORMAT = os.environ.get('TIME_FORMAT', 'g:i a')
SHORT_TIME_FORMAT = os.environ.get('SHORT_TIME_FORMAT', 'H:i:s')
DATETIME_FORMAT = os.environ.get('DATETIME_FORMAT', 'N j, Y g:i a')
SHORT_DATETIME_FORMAT = os.environ.get('SHORT_DATETIME_FORMAT', 'Y-m-d H:i')

View File

@@ -118,6 +118,10 @@ PAGINATE_COUNT = 50
# prefer IPv4 instead.
PREFER_IPV4 = False
# The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
# this setting is derived from the installed location.
# REPORTS_ROOT = '/opt/netbox/netbox/reports'
# Time zone (default: UTC)
TIME_ZONE = 'UTC'

View File

@@ -30,6 +30,10 @@ OBJ_TYPE_CHOICES = (
('Tenancy', (
('tenant', 'Tenants'),
)),
('Virtualization', (
('cluster', 'Clusters'),
('virtualmachine', 'Virtual machines'),
)),
)

View File

@@ -13,7 +13,7 @@ except ImportError:
)
VERSION = '2.1.6-dev'
VERSION = '2.2.2'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -54,6 +54,7 @@ NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30)
NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {})
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
@@ -211,34 +212,26 @@ REST_FRAMEWORK = {
'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
'utilities.api.TokenAuthentication',
'netbox.api.TokenAuthentication',
),
'DEFAULT_FILTER_BACKENDS': (
'rest_framework.filters.DjangoFilterBackend',
'django_filters.rest_framework.DjangoFilterBackend',
),
'DEFAULT_PAGINATION_CLASS': 'utilities.api.OptionalLimitOffsetPagination',
'DEFAULT_PAGINATION_CLASS': 'netbox.api.OptionalLimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': (
'utilities.api.TokenPermissions',
'netbox.api.TokenPermissions',
),
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
'utilities.api.FormlessBrowsableAPIRenderer',
'netbox.api.FormlessBrowsableAPIRenderer',
),
'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
'PAGE_SIZE': PAGINATE_COUNT,
'VIEW_NAME_FUNCTION': 'utilities.api.get_view_name',
'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
}
# Django debug toolbar
# Disable the templates panel by default due to a performance issue in Django 1.11; see
# https://github.com/jazzband/django-debug-toolbar/issues/910
DEBUG_TOOLBAR_CONFIG = {
'DISABLE_PANELS': [
'debug_toolbar.panels.redirects.RedirectsPanel',
'debug_toolbar.panels.templates.TemplatesPanel',
],
}
INTERNAL_IPS = (
'127.0.0.1',
'::1',

View File

@@ -14,7 +14,7 @@ from circuits.models import Circuit, Provider
from circuits.tables import CircuitTable, ProviderTable
from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter
from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site
from dcim.tables import DeviceTable, DeviceTypeTable, RackTable, SiteTable
from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable
from extras.models import TopologyMap, UserAction
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
@@ -27,7 +27,7 @@ from tenancy.models import Tenant
from tenancy.tables import TenantTable
from virtualization.filters import ClusterFilter, VirtualMachineFilter
from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineTable
from virtualization.tables import ClusterTable, VirtualMachineDetailTable
from .forms import SearchForm
@@ -67,10 +67,10 @@ SEARCH_TYPES = OrderedDict((
}),
('device', {
'queryset': Device.objects.select_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
),
'filter': DeviceFilter,
'table': DeviceTable,
'table': DeviceDetailTable,
'url': 'dcim:device_list',
}),
# IPAM
@@ -126,9 +126,11 @@ SEARCH_TYPES = OrderedDict((
'url': 'virtualization:cluster_list',
}),
('virtualmachine', {
'queryset': VirtualMachine.objects.select_related('cluster', 'tenant', 'platform'),
'queryset': VirtualMachine.objects.select_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
),
'filter': VirtualMachineFilter,
'table': VirtualMachineTable,
'table': VirtualMachineDetailTable,
'url': 'virtualization:virtualmachine_list',
}),
))

View File

@@ -18,7 +18,7 @@ $(document).ready(function() {
$('form').submit(function(event) {
$(this).find('.requires-session-key').each(function() {
if (this.value && document.cookie.indexOf('session_key') == -1) {
console.log('Field ' + this.value + ' requires a session key');
console.log('Field ' + this.name + ' requires a session key');
$('#privkey_modal').modal('show');
event.preventDefault();
return false;

View File

@@ -16,6 +16,9 @@ class SecretsRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = SecretsRootView
# Field choices
router.register(r'_choices', views.SecretsFieldChoicesViewSet, base_name='field-choice')
# Secrets
router.register(r'secret-roles', views.SecretRoleViewSet)
router.register(r'secrets', views.SecretViewSet)

View File

@@ -12,7 +12,7 @@ from django.http import HttpResponseBadRequest
from secrets import filters
from secrets.exceptions import InvalidKey
from secrets.models import Secret, SecretRole, SessionKey, UserKey
from utilities.api import WritableSerializerMixin
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
from . import serializers
@@ -22,6 +22,14 @@ ERR_PRIVKEY_MISSING = "Private key was not provided."
ERR_PRIVKEY_INVALID = "Invalid private key."
#
# Field choices
#
class SecretsFieldChoicesViewSet(FieldChoicesViewSet):
fields = ()
#
# Secret Roles
#

View File

@@ -43,6 +43,17 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug', 'users', 'groups']
class SecretRoleCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = SecretRole
fields = ['name', 'slug']
help_texts = {
'name': 'Name of secret role',
}
#
# Secrets
#

View File

@@ -1,8 +1,9 @@
from __future__ import unicode_literals
import os
from Crypto.Cipher import AES, PKCS1_OAEP, XOR
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA
from Crypto.Util import strxor
from django.conf import settings
from django.contrib.auth.hashers import make_password, check_password
@@ -16,6 +17,7 @@ from dcim.models import Device
from utilities.models import CreatedUpdatedModel
from .exceptions import InvalidKey
from .hashers import SecretValidationHasher
from .querysets import UserKeyQuerySet
def generate_random_key(bits=256):
@@ -45,24 +47,6 @@ def decrypt_master_key(master_key_cipher, private_key):
return cipher.decrypt(master_key_cipher)
def xor_keys(key_a, key_b):
"""
Return the binary XOR of two given keys.
"""
xor = XOR.new(key_a)
return xor.encrypt(key_b)
class UserKeyQuerySet(models.QuerySet):
def active(self):
return self.filter(master_key_cipher__isnull=False)
def delete(self):
# Disable bulk deletion to avoid accidentally wiping out all copies of the master key.
raise Exception("Bulk deletion has been disabled.")
@python_2_unicode_compatible
class UserKey(CreatedUpdatedModel):
"""
@@ -98,7 +82,7 @@ class UserKey(CreatedUpdatedModel):
# Validate the public key format
try:
pubkey = RSA.importKey(self.public_key)
pubkey = RSA.import_key(self.public_key)
except ValueError:
raise ValidationError({
'public_key': "Invalid RSA key format."
@@ -108,7 +92,7 @@ class UserKey(CreatedUpdatedModel):
"uploading a valid RSA public key in PEM format (no SSH/PGP).")
# Validate the public key length
pubkey_length = pubkey.size() + 1 # key.size() returns 1 less than the key modulus
pubkey_length = pubkey.size_in_bits()
if pubkey_length < settings.SECRETS_MIN_PUBKEY_SIZE:
raise ValidationError({
'public_key': "Insufficient key length. Keys must be at least {} bits long.".format(
@@ -214,7 +198,7 @@ class SessionKey(models.Model):
self.hash = make_password(self.key)
# Encrypt master key using the session key
self.cipher = xor_keys(self.key, master_key)
self.cipher = strxor.strxor(self.key, master_key)
super(SessionKey, self).save(*args, **kwargs)
@@ -225,14 +209,14 @@ class SessionKey(models.Model):
raise InvalidKey("Invalid session key")
# Decrypt master key using provided session key
master_key = xor_keys(session_key, bytes(self.cipher))
master_key = strxor.strxor(session_key, bytes(self.cipher))
return master_key
def get_session_key(self, master_key):
# Recover session key using the master key
session_key = xor_keys(master_key, bytes(self.cipher))
session_key = strxor.strxor(master_key, bytes(self.cipher))
# Validate the recovered session key
if not check_password(session_key, self.hash):

View File

@@ -0,0 +1,13 @@
from __future__ import unicode_literals
from django.db.models import QuerySet
class UserKeyQuerySet(QuerySet):
def active(self):
return self.filter(master_key_cipher__isnull=False)
def delete(self):
# Disable bulk deletion to avoid accidentally wiping out all copies of the master key.
raise Exception("Bulk deletion has been disabled.")

View File

@@ -11,6 +11,7 @@ urlpatterns = [
# Secret roles
url(r'^secret-roles/$', views.SecretRoleListView.as_view(), name='secretrole_list'),
url(r'^secret-roles/add/$', views.SecretRoleCreateView.as_view(), name='secretrole_add'),
url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
url(r'^secret-roles/(?P<slug>[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'),

View File

@@ -52,6 +52,13 @@ class SecretRoleEditView(SecretRoleCreateView):
permission_required = 'secrets.change_secretrole'
class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'secrets.add_secretrole'
model_form = forms.SecretRoleCSVForm
table = tables.SecretRoleTable
default_return_url = 'secrets:secretrole_list'
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secretrole'
cls = SecretRole
@@ -196,7 +203,9 @@ class SecretBulkImportView(BulkImportView):
permission_required = 'ipam.add_vlan'
model_form = forms.SecretCSVForm
table = tables.SecretTable
template_name = 'secrets/secret_import.html'
default_return_url = 'secrets:secret_list'
widget_attrs = {'class': 'requires-session-key'}
master_key = None

View File

@@ -3,9 +3,10 @@
<html lang="en">
<head>
<title>Server Error</title>
<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
<title>Server Error</title>
<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
<meta charset="UTF-8">
</head>
<body>

View File

@@ -3,12 +3,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{% block title %}Home{% endblock %} - NetBox</title>
<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
<title>{% block title %}Home{% endblock %} - NetBox</title>
<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
<link rel="stylesheet" href="{% static 'jquery-ui-1.12.1/jquery-ui.css' %}">
<link rel="stylesheet" href="{% static 'css/base.css' %}?v{{ settings.VERSION }}">
<link rel="stylesheet" href="{% static 'css/base.css' %}?v{{ settings.VERSION }}">
<link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
<meta charset="UTF-8">
<meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
</head>
<body>

View File

@@ -26,7 +26,7 @@
<li class="occupied h{{ u.device.device_type.u_height }}u"{% ifequal u.device.face face_id %} style="background-color: #{{ u.device.device_role.color }}"{% endifequal %}>
{% ifequal u.device.face face_id %}
<a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.full_name }} ({{ u.device.device_type.u_height }}U)">
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.full_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}<br />{{ u.device.asset_tag }}{% endif %}">
{{ u.device.name|default:u.device.device_role }}
{% if u.device.devicebay_count %}
({{ u.device.get_children.count }}/{{ u.device.devicebay_count }})

View File

@@ -112,6 +112,29 @@
{% endif %}
</td>
</tr>
<tr>
<td>Serial Number</td>
<td>
{% if rack.serial %}
<span>{{ rack.serial }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Devices</td>
<td>
<a href="{% url 'dcim:device_list' %}?rack_id={{ rack.id }}">{{ rack.devices.count }}</a>
</td>
</tr>
</table>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Dimensions</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Type</td>
<td>
@@ -130,12 +153,6 @@
<td>Height</td>
<td>{{ rack.u_height }}U ({% if rack.desc_units %}descending{% else %}ascending{% endif %})</td>
</tr>
<tr>
<td>Devices</td>
<td>
<a href="{% url 'dcim:device_list' %}?rack_id={{ rack.id }}">{{ rack.devices.count }}</a>
</td>
</tr>
</table>
</div>
{% with rack.get_custom_fields as custom_fields %}

View File

@@ -10,6 +10,7 @@
{% render_field form.facility_id %}
{% render_field form.group %}
{% render_field form.role %}
{% render_field form.serial %}
</div>
</div>
<div class="panel panel-default">

View File

@@ -5,69 +5,78 @@
<h1>{% block title %}Reports{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% if reports %}
{% for module, module_reports in reports %}
<h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Description</th>
<th class="text-right">Last Run</th>
</tr>
</thead>
<tbody>
{% for report in module_reports %}
<h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
<table class="table table-hover table-headings reports">
<thead>
<tr>
<td>
<a href="{% url 'extras:report' name=report.full_name %}" name="report.{{ report.name }}"><strong>{{ report.name }}</strong></a>
</td>
<td>
{% include 'extras/inc/report_label.html' %}
</td>
<td>{{ report.description|default:"" }}</td>
{% if report.result %}
<td class="text-right">{{ report.result.created }}</td>
{% else %}
<td class="text-right text-muted">Never</td>
{% endif %}
<th>Name</th>
<th>Status</th>
<th>Description</th>
<th class="text-right">Last Run</th>
</tr>
{% for method, stats in report.result.data.items %}
</thead>
<tbody>
{% for report in module_reports %}
<tr>
<td colspan="3" class="method">
{{ method }}
<td>
<a href="{% url 'extras:report' name=report.full_name %}" name="report.{{ report.name }}"><strong>{{ report.name }}</strong></a>
</td>
<td class="text-right stats">
<label class="label label-success">{{ stats.success }}</label>
<label class="label label-info">{{ stats.info }}</label>
<label class="label label-warning">{{ stats.warning }}</label>
<label class="label label-danger">{{ stats.failure }}</label>
<td>
{% include 'extras/inc/report_label.html' %}
</td>
<td>{{ report.description|default:"" }}</td>
{% if report.result %}
<td class="text-right">{{ report.result.created }}</td>
{% else %}
<td class="text-right text-muted">Never</td>
{% endif %}
</tr>
{% for method, stats in report.result.data.items %}
<tr>
<td colspan="3" class="method">
{{ method }}
</td>
<td class="text-right stats">
<label class="label label-success">{{ stats.success }}</label>
<label class="label label-info">{{ stats.info }}</label>
<label class="label label-warning">{{ stats.warning }}</label>
<label class="label label-danger">{{ stats.failure }}</label>
</td>
</tr>
{% endfor %}
{% endfor %}
{% endfor %}
</tbody>
</table>
{% endfor %}
</tbody>
</table>
{% endfor %}
{% else %}
<div class="alert alert-info">
<p><strong>No reports found.</strong></p>
<p>Reports should be saved to <code>{{ settings.REPORTS_ROOT }}</code>. (This path can be changed by setting <code>REPORTS_ROOT</code> in NetBox's configuration.)</p>
</div>
{% endif %}
</div>
<div class="col-md-3">
<div class="panel panel-default">
{% for module, module_reports in reports %}
<div class="panel-heading">
<strong>{{ module|bettertitle }}</strong>
</div>
<ul class="list-group">
{% for report in module_reports %}
<a href="#report.{{ report.name }}" class="list-group-item">
<i class="fa fa-list-alt"></i> {{ report.name }}
<div class="pull-right">
{% include 'extras/inc/report_label.html' %}
</div>
</a>
{% endfor %}
</ul>
{% endfor %}
</div>
{% if reports %}
<div class="panel panel-default">
{% for module, module_reports in reports %}
<div class="panel-heading">
<strong>{{ module|bettertitle }}</strong>
</div>
<ul class="list-group">
{% for report in module_reports %}
<a href="#report.{{ report.name }}" class="list-group-item">
<i class="fa fa-list-alt"></i> {{ report.name }}
<div class="pull-right">
{% include 'extras/inc/report_label.html' %}
</div>
</a>
{% endfor %}
</ul>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -53,7 +53,7 @@
{% if perms.tenancy.add_tenantgroup %}
<div class="buttons pull-right">
<a href="{% url 'tenancy:tenantgroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a class="btn btn-xs btn-info disabled" title="Import"><i class="fa fa-download"></i></a>
<a href="{% url 'tenancy:tenantgroup_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'tenancy:tenantgroup_list' %}">Tenant Groups</a>
@@ -91,7 +91,7 @@
{% if perms.dcim.add_rackrole %}
<div class="buttons pull-right">
<a href="{% url 'dcim:rackrole_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a class="btn btn-xs btn-info disabled" title="Import"><i class="fa fa-download"></i></a>
<a href="{% url 'dcim:rackrole_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'dcim:rackrole_list' %}">Rack Roles</a>
@@ -121,7 +121,7 @@
{% if perms.dcim.add_devicerole %}
<div class="buttons pull-right">
<a href="{% url 'dcim:devicerole_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a class="btn btn-xs btn-info disabled" title="Import"><i class="fa fa-download"></i></a>
<a href="{% url 'dcim:devicerole_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'dcim:devicerole_list' %}">Device Roles</a>
@@ -130,7 +130,7 @@
{% if perms.dcim.add_platform %}
<div class="buttons pull-right">
<a href="{% url 'dcim:platform_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a class="btn btn-xs btn-info disabled" title="Import"><i class="fa fa-download"></i></a>
<a href="{% url 'dcim:platform_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'dcim:platform_list' %}">Platforms</a>
@@ -179,7 +179,7 @@
<a href="{% url 'dcim:interface_connections_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'dcim:power_connections_list' %}">Interface Connections</a>
<a href="{% url 'dcim:interface_connections_list' %}">Interface Connections</a>
</li>
</ul>
</li>
@@ -211,7 +211,7 @@
{% if perms.ipam.add_role %}
<div class="buttons pull-right">
<a href="{% url 'ipam:role_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a class="btn btn-xs btn-info disabled" title="Import"><i class="fa fa-download"></i></a>
<a href="{% url 'ipam:role_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'ipam:role_list' %}">Prefix/VLAN Roles</a>
@@ -231,7 +231,7 @@
{% if perms.ipam.add_rir %}
<div class="buttons pull-right">
<a href="{% url 'ipam:rir_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a class="btn btn-xs btn-info disabled" title="Import"><i class="fa fa-download"></i></a>
<a href="{% url 'ipam:rir_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'ipam:rir_list' %}">RIRs</a>
@@ -262,7 +262,7 @@
{% if perms.ipam.add_vlangroup %}
<div class="buttons pull-right">
<a href="{% url 'ipam:vlangroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a class="btn btn-xs btn-info disabled" title="Import"><i class="fa fa-download"></i></a>
<a href="{% url 'ipam:vlangroup_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'ipam:vlangroup_list' %}">VLAN Groups</a>
@@ -297,7 +297,7 @@
{% if perms.virtualization.add_clustertype %}
<div class="buttons pull-right">
<a href="{% url 'virtualization:clustertype_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a class="btn btn-xs btn-info disabled" title="Import"><i class="fa fa-download"></i></a>
<a href="{% url 'virtualization:clustertype_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'virtualization:clustertype_list' %}">Cluster Types</a>
@@ -306,7 +306,7 @@
{% if perms.virtualization.add_clustergroup %}
<div class="buttons pull-right">
<a href="{% url 'virtualization:clustergroup_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a class="btn btn-xs btn-info disabled" title="Import"><i class="fa fa-download"></i></a>
<a href="{% url 'virtualization:clustergroup_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'virtualization:clustergroup_list' %}">Cluster Groups</a>
@@ -330,7 +330,7 @@
{% if perms.circuits.add_circuittype %}
<div class="buttons pull-right">
<a href="{% url 'circuits:circuittype_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a class="btn btn-xs btn-info disabled" title="Import"><i class="fa fa-download"></i></a>
<a href="{% url 'circuits:circuittype_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'circuits:circuittype_list' %}">Circuit Types</a>
@@ -356,7 +356,6 @@
<li>
{% if perms.secrets.add_secret %}
<div class="buttons pull-right">
<a class="btn btn-xs btn-success disabled" title="Add"><i class="fa fa-plus"></i></a>
<a href="{% url 'secrets:secret_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
@@ -366,7 +365,7 @@
{% if perms.secrets.add_secretrole %}
<div class="buttons pull-right">
<a href="{% url 'secrets:secretrole_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a class="btn btn-xs btn-info disabled" title="Import"><i class="fa fa-download"></i></a>
<a href="{% url 'secrets:secretrole_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'secrets:secretrole_list' %}">Secret Roles</a>

View File

@@ -23,7 +23,7 @@
</div>
<div class="pull-right">
{% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ prefix.prefix }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-success">
<a href="{% url 'ipam:ipaddress_add' %}?address={{ prefix.prefix }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}{% if prefix.tenant %}&tenant={{ prefix.tenant.pk }}{% endif %}" class="btn btn-success">
<span class="fa fa-plus" aria-hidden="true"></span>
Add an IP Address
</a>

View File

@@ -0,0 +1,5 @@
{% extends 'rest_framework/base.html' %}
{% block branding %}
<a class="navbar-brand" href="/{{ settings.BASE_PATH }}">NetBox</a>
{% endblock %}

View File

@@ -1,70 +1,8 @@
{% extends '_base.html' %}
{% extends 'utilities/obj_import.html' %}
{% load static from staticfiles %}
{% load form_helpers %}
{% block content %}
<h1>{% block title %}Secret Import{% 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">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Device</td>
<td>Name of the parent device</td>
<td>edge-router1</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role</td>
<td>Login Credentials</td>
</tr>
<tr>
<td>Name (optional)</td>
<td>Username or other label</td>
<td>root</td>
</tr>
<tr>
<td>Secret</td>
<td>Secret data</td>
<td>MyP@ssw0rd!</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>edge-router1,Login Credentials,root,MyP@ssw0rd!</pre>
</div>
</div>
{{ block.super }}
{% include 'secrets/inc/private_key_modal.html' %}
{% endblock %}

View File

@@ -20,11 +20,27 @@
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Devices</strong></div>
<div class="panel-heading"><strong>Device Selection</strong></div>
<div class="panel-body">
{% render_field form.region %}
{% render_field form.site %}
{% render_field form.rack %}
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
<li role="presentation"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="search">
<div class="form-group">
<label class="col-md-3 control-label" for="id_search">Search</label>
<div class="col-md-9">
<input type="text" class="form-control" name="search" id="id_search" />
</div>
</div>
</div>
<div class="tab-pane" id="select">
{% render_field form.region %}
{% render_field form.site %}
{% render_field form.rack %}
</div>
</div>
{% render_field form.devices %}
</div>
</div>
@@ -40,5 +56,31 @@
{% endblock %}
{% block javascript %}
<script src="{% static 'js/livesearch.js' %}?v{{ settings.VERSION }}"></script>
<script type="text/javascript">
$(document).ready(function() {
var device_list = $('#id_devices');
var disabled_indicator = device_list.attr('disabled-indicator');
$('#id_search').autocomplete({
source: function(request, response) {
$.ajax({
type: 'GET',
url: netbox_api_path + 'dcim/devices/',
data: 'q=' + request.term,
beforeSend: function() {
device_list.empty();
},
success: function(data) {
response($.map(data.results, function(item) {
var option = $("<option></option>").attr("value", item['id']).text(item['display_name']);
if (disabled_indicator && item[disabled_indicator]) {
option.attr("disabled", "disabled");
}
device_list.append(option);
}));
}
});
}
});
});
</script>
{% endblock %}

View File

@@ -16,6 +16,9 @@ class TenancyRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = TenancyRootView
# Field choices
router.register(r'_choices', views.TenancyFieldChoicesViewSet, base_name='field-choice')
# Tenants
router.register(r'tenant-groups', views.TenantGroupViewSet)
router.register(r'tenants', views.TenantViewSet)

View File

@@ -5,10 +5,18 @@ from rest_framework.viewsets import ModelViewSet
from extras.api.views import CustomFieldModelViewSet
from tenancy import filters
from tenancy.models import Tenant, TenantGroup
from utilities.api import WritableSerializerMixin
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
from . import serializers
#
# Field choices
#
class TenancyFieldChoicesViewSet(FieldChoicesViewSet):
fields = ()
#
# Tenant Groups
#

View File

@@ -22,6 +22,17 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug']
class TenantGroupCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = TenantGroup
fields = ['name', 'slug']
help_texts = {
'name': 'Group name',
}
#
# Tenants
#

View File

@@ -11,6 +11,7 @@ urlpatterns = [
# Tenant groups
url(r'^tenant-groups/$', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
url(r'^tenant-groups/add/$', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'),
url(r'^tenant-groups/import/$', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
url(r'^tenant-groups/(?P<slug>[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),

View File

@@ -40,6 +40,13 @@ class TenantGroupEditView(TenantGroupCreateView):
permission_required = 'tenancy.change_tenantgroup'
class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'tenancy.add_tenantgroup'
model_form = forms.TenantGroupCSVForm
table = tables.TenantGroupTable
default_return_url = 'tenancy:tenantgroup_list'
class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'tenancy.delete_tenantgroup'
cls = TenantGroup

View File

@@ -1,18 +1,15 @@
from __future__ import unicode_literals
from collections import OrderedDict
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from rest_framework import authentication, exceptions
from rest_framework.compat import is_authenticated
from rest_framework.exceptions import APIException
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import BasePermission, DjangoModelPermissions, SAFE_METHODS
from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.permissions import BasePermission
from rest_framework.response import Response
from rest_framework.serializers import Field, ModelSerializer, ValidationError
from rest_framework.views import get_view_name as drf_get_view_name
from users.models import Token
from rest_framework.viewsets import ViewSet
WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
@@ -27,47 +24,6 @@ class ServiceUnavailable(APIException):
# Authentication
#
class TokenAuthentication(authentication.TokenAuthentication):
"""
A custom authentication scheme which enforces Token expiration times.
"""
model = Token
def authenticate_credentials(self, key):
model = self.get_model()
try:
token = model.objects.select_related('user').get(key=key)
except model.DoesNotExist:
raise exceptions.AuthenticationFailed("Invalid token")
# Enforce the Token's expiration time, if one has been set.
if token.is_expired:
raise exceptions.AuthenticationFailed("Token expired")
if not token.user.is_active:
raise exceptions.AuthenticationFailed("User inactive")
return token.user, token
class TokenPermissions(DjangoModelPermissions):
"""
Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
for unsafe requests (POST/PUT/PATCH/DELETE).
"""
def __init__(self):
# LOGIN_REQUIRED determines whether read-only access is provided to anonymous users.
self.authenticated_users_only = settings.LOGIN_REQUIRED
super(TokenPermissions, self).__init__()
def has_permission(self, request, view):
# If token authentication is in use, verify that the token allows write operations (for unsafe methods).
if request.method not in SAFE_METHODS and isinstance(request.auth, Token):
if not request.auth.write_enabled:
return False
return super(TokenPermissions, self).has_permission(request, view)
class IsAuthenticatedOrLoginNotRequired(BasePermission):
"""
Returns True if the user is authenticated or LOGIN_REQUIRED is False.
@@ -75,7 +31,7 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
def has_permission(self, request, view):
if not settings.LOGIN_REQUIRED:
return True
return request.user and is_authenticated(request.user)
return request.user.is_authenticated()
#
@@ -141,6 +97,55 @@ class ContentTypeFieldSerializer(Field):
raise ValidationError("Invalid content type")
#
# Views
#
class FieldChoicesViewSet(ViewSet):
"""
Expose the built-in numeric values which represent static choices for a model's field.
"""
permission_classes = [IsAuthenticatedOrLoginNotRequired]
fields = []
def __init__(self, *args, **kwargs):
super(FieldChoicesViewSet, self).__init__(*args, **kwargs)
# Compile a dict of all fields in this view
self._fields = OrderedDict()
for cls, field_list in self.fields:
for field_name in field_list:
model_name = cls._meta.verbose_name.lower().replace(' ', '-')
key = ':'.join([model_name, field_name])
choices = []
for k, v in cls._meta.get_field(field_name).choices:
if type(v) in [list, tuple]:
for k2, v2 in v:
choices.append({
'value': k2,
'label': v2,
})
else:
choices.append({
'value': k,
'label': v,
})
self._fields[key] = choices
def list(self, request):
return Response(self._fields)
def retrieve(self, request, pk):
if pk not in self._fields:
raise Http404
return Response(self._fields[pk])
def get_view_name(self):
return "Field Choices"
#
# Mixins
#
@@ -153,85 +158,3 @@ class WritableSerializerMixin(object):
if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
return self.write_serializer_class
return self.serializer_class
#
# Pagination
#
class OptionalLimitOffsetPagination(LimitOffsetPagination):
"""
Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects
matching a query, but retains the same format as a paginated request. The limit can only be disabled if
MAX_PAGE_SIZE has been set to 0 or None.
"""
def paginate_queryset(self, queryset, request, view=None):
try:
self.count = queryset.count()
except (AttributeError, TypeError):
self.count = len(queryset)
self.limit = self.get_limit(request)
self.offset = self.get_offset(request)
self.request = request
if self.limit and self.count > self.limit and self.template is not None:
self.display_page_controls = True
if self.count == 0 or self.offset > self.count:
return list()
if self.limit:
return list(queryset[self.offset:self.offset + self.limit])
else:
return list(queryset[self.offset:])
def get_limit(self, request):
if self.limit_query_param:
try:
limit = int(request.query_params[self.limit_query_param])
if limit < 0:
raise ValueError()
# Enforce maximum page size, if defined
if settings.MAX_PAGE_SIZE:
if limit == 0:
return settings.MAX_PAGE_SIZE
else:
return min(limit, settings.MAX_PAGE_SIZE)
return limit
except (KeyError, ValueError):
pass
return self.default_limit
#
# Renderers
#
class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
"""
Override the built-in BrowsableAPIRenderer to disable HTML forms.
"""
def show_form_for_method(self, *args, **kwargs):
return False
#
# Miscellaneous
#
def get_view_name(view_cls, suffix=None):
"""
Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`.
"""
if hasattr(view_cls, 'queryset'):
name = view_cls.queryset.model._meta.verbose_name
name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word
if suffix:
name = "{} {}".format(name, suffix)
return name
return drf_get_view_name(view_cls, suffix)

View File

@@ -10,7 +10,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError
from django.db.models import ProtectedError
from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField
from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template import TemplateSyntaxError
@@ -380,11 +380,13 @@ class BulkImportView(View):
table: The django-tables2 Table used to render the list of imported objects
template_name: The name of the template
default_return_url: The name of the URL to use for the cancel button
widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key)
"""
model_form = None
table = None
default_return_url = None
template_name = 'utilities/obj_import.html'
widget_attrs = {}
def _import_form(self, *args, **kwargs):
@@ -392,7 +394,7 @@ class BulkImportView(View):
required_fields = [name for name, field in self.model_form().fields.items() if field.required]
class ImportForm(BootstrapMixin, Form):
csv = CSVDataField(fields=fields, required_fields=required_fields)
csv = CSVDataField(fields=fields, required_fields=required_fields, widget=Textarea(attrs=self.widget_attrs))
return ImportForm(*args, **kwargs)

View File

@@ -3,7 +3,7 @@ from __future__ import unicode_literals
from rest_framework import serializers
from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
from dcim.constants import VIFACE_FF_CHOICES
from dcim.constants import IFACE_FF_VIRTUAL
from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.serializers import NestedTenantSerializer
@@ -122,12 +122,11 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
class InterfaceSerializer(serializers.ModelSerializer):
virtual_machine = NestedVirtualMachineSerializer()
form_factor = ChoiceFieldSerializer(choices=VIFACE_FF_CHOICES)
class Meta:
model = Interface
fields = [
'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description',
'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'description',
]
@@ -140,6 +139,7 @@ class NestedInterfaceSerializer(serializers.ModelSerializer):
class WritableInterfaceSerializer(ValidatedModelSerializer):
form_factor = serializers.IntegerField(default=IFACE_FF_VIRTUAL)
class Meta:
model = Interface

View File

@@ -16,6 +16,9 @@ class VirtualizationRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = VirtualizationRootView
# Field choices
router.register(r'_choices', views.VirtualizationFieldChoicesViewSet, base_name='field-choice')
# Clusters
router.register(r'cluster-types', views.ClusterTypeViewSet)
router.register(r'cluster-groups', views.ClusterGroupViewSet)

View File

@@ -4,12 +4,22 @@ from rest_framework.viewsets import ModelViewSet
from dcim.models import Interface
from extras.api.views import CustomFieldModelViewSet
from utilities.api import WritableSerializerMixin
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
from virtualization import filters
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
from . import serializers
#
# Field choices
#
class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(VirtualMachine, ['status']),
)
#
# Clusters
#
@@ -46,3 +56,4 @@ class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
serializer_class = serializers.InterfaceSerializer
write_serializer_class = serializers.WritableInterfaceSerializer
filter_class = filters.InterfaceFilter

View File

@@ -1,9 +1,12 @@
from __future__ import unicode_literals
import django_filters
from netaddr import EUI
from netaddr.core import AddrFormatError
from django.db.models import Q
from dcim.models import DeviceRole, Platform, Site
from dcim.models import DeviceRole, Interface, Platform, Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
@@ -75,7 +78,7 @@ class VirtualMachineFilter(CustomFieldFilterSet):
label='Cluster group (ID)',
)
cluster_group = NullableModelMultipleChoiceFilter(
name='cluster__group__slug',
name='cluster__group',
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
label='Cluster group (slug)',
@@ -85,12 +88,10 @@ class VirtualMachineFilter(CustomFieldFilterSet):
label='Cluster (ID)',
)
role_id = NullableModelMultipleChoiceFilter(
name='role_id',
queryset=DeviceRole.objects.all(),
label='Role (ID)',
)
role = NullableModelMultipleChoiceFilter(
name='role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label='Role (slug)',
@@ -109,7 +110,6 @@ class VirtualMachineFilter(CustomFieldFilterSet):
label='Platform (ID)',
)
platform = NullableModelMultipleChoiceFilter(
name='platform',
queryset=Platform.objects.all(),
to_field_name='slug',
label='Platform (slug)',
@@ -126,3 +126,35 @@ class VirtualMachineFilter(CustomFieldFilterSet):
Q(name__icontains=value) |
Q(comments__icontains=value)
)
class InterfaceFilter(django_filters.FilterSet):
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
name='virtual_machine',
queryset=VirtualMachine.objects.all(),
label='Virtual machine (ID)',
)
virtual_machine = django_filters.ModelMultipleChoiceFilter(
name='virtual_machine__name',
queryset=VirtualMachine.objects.all(),
to_field_name='name',
label='Virtual machine',
)
mac_address = django_filters.CharFilter(
method='_mac_address',
label='MAC address',
)
class Meta:
model = Interface
fields = ['name', 'enabled', 'mtu']
def _mac_address(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
try:
mac = EUI(value.strip())
return queryset.filter(mac_address=mac)
except AddrFormatError:
return queryset.none()

View File

@@ -6,7 +6,7 @@ from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Count
from dcim.constants import IFACE_FF_VIRTUAL, VIFACE_FF_CHOICES
from dcim.constants import IFACE_FF_VIRTUAL
from dcim.formfields import MACAddressFormField
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
@@ -21,6 +21,11 @@ from .constants import STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
VIFACE_FF_CHOICES = (
(IFACE_FF_VIRTUAL, 'Virtual'),
)
#
# Cluster types
#
@@ -33,6 +38,17 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug']
class ClusterTypeCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = ClusterType
fields = ['name', 'slug']
help_texts = {
'name': 'Name of cluster type',
}
#
# Cluster groups
#
@@ -45,6 +61,17 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug']
class ClusterGroupCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = ClusterGroup
fields = ['name', 'slug']
help_texts = {
'name': 'Name of cluster group',
}
#
# Clusters
#
@@ -159,7 +186,6 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
('site', 'site'),
('rack', 'rack'),
),
label='Device',
widget=APISelectMultiple(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name',
@@ -184,7 +210,7 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
# If the Cluster is assigned to a Site, all Devices must be assigned to that Site.
if self.cluster.site is not None:
for device in self.cleaned_data.get('devices'):
for device in self.cleaned_data.get('devices', []):
if device.site != self.cluster.site:
raise ValidationError({
'devices': "{} belongs to a different site ({}) than the cluster ({})".format(

View File

@@ -24,6 +24,10 @@ VIRTUALMACHINE_STATUS = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
VIRTUALMACHINE_ROLE = """
<label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>
"""
VIRTUALMACHINE_PRIMARY_IP = """
{{ record.primary_ip6.address.ip|default:"" }}
{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
@@ -37,6 +41,7 @@ VIRTUALMACHINE_PRIMARY_IP = """
class ClusterTypeTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
cluster_count = tables.Column(verbose_name='Clusters')
actions = tables.TemplateColumn(
template_code=CLUSTERTYPE_ACTIONS,
@@ -55,6 +60,7 @@ class ClusterTypeTable(BaseTable):
class ClusterGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
cluster_count = tables.Column(verbose_name='Clusters')
actions = tables.TemplateColumn(
template_code=CLUSTERGROUP_ACTIONS,
@@ -91,6 +97,7 @@ class VirtualMachineTable(BaseTable):
name = tables.LinkColumn()
status = tables.TemplateColumn(template_code=VIRTUALMACHINE_STATUS)
cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')])
role = tables.TemplateColumn(VIRTUALMACHINE_ROLE)
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
class Meta(BaseTable.Meta):

View File

@@ -12,12 +12,14 @@ urlpatterns = [
# Cluster types
url(r'^cluster-types/$', views.ClusterTypeListView.as_view(), name='clustertype_list'),
url(r'^cluster-types/add/$', views.ClusterTypeCreateView.as_view(), name='clustertype_add'),
url(r'^cluster-types/import/$', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
url(r'^cluster-types/(?P<slug>[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
# Cluster groups
url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
url(r'^cluster-groups/add/$', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'),
url(r'^cluster-groups/import/$', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
url(r'^cluster-groups/(?P<slug>[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),

View File

@@ -43,6 +43,13 @@ class ClusterTypeEditView(ClusterTypeCreateView):
permission_required = 'virtualization.change_clustertype'
class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'virtualization.add_clustertype'
model_form = forms.ClusterTypeCSVForm
table = tables.ClusterTypeTable
default_return_url = 'virtualization:clustertype_list'
class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_clustertype'
cls = ClusterType
@@ -74,6 +81,13 @@ class ClusterGroupEditView(ClusterGroupCreateView):
permission_required = 'virtualization.change_clustergroup'
class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'virtualization.add_clustergroup'
model_form = forms.ClusterGroupCSVForm
table = tables.ClusterGroupTable
default_return_url = 'virtualization:clustergroup_list'
class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'virtualization.delete_clustergroup'
cls = ClusterGroup

1
old_requirements.txt Normal file
View File

@@ -0,0 +1 @@
pycrypto

View File

@@ -1,13 +1,11 @@
cffi>=1.8
cryptography>=1.8
Django>=1.11
django-cors-headers>=2.0
django-debug-toolbar>=1.7
django-filter>=1.0.2
Django>=1.11,<2.0
django-cors-headers>=2.1
django-debug-toolbar>=1.8
django-filter>=1.0.4
django-mptt==0.8.7
django-rest-swagger>=2.1.0
django-tables2>=1.7.0
djangorestframework>=3.6.2
django-tables2>=1.10.0
djangorestframework>=3.6.4
graphviz>=0.6
Markdown>=2.6.7
natsort>=5.0.0
@@ -17,6 +15,5 @@ paramiko>=2.0.0
Pillow>=4.0.0
psycopg2>=2.7.3
py-gfm>=0.1.3
pycrypto>=2.6.1
sqlparse>=0.2
pycryptodome>=3.4.7
xmltodict>=0.10.2

View File

@@ -39,6 +39,11 @@ COMMAND="${PREFIX}find . -name \"*.pyc\" -delete"
echo "Cleaning up stale Python bytecode ($COMMAND)..."
eval $COMMAND
# Uninstall any Python packages which are no longer needed
COMMAND="${PREFIX}${PIP} uninstall -r old_requirements.txt -y"
echo "Removing old Python packages ($COMMAND)..."
eval $COMMAND
# Install any new Python packages
COMMAND="${PREFIX}${PIP} install -r requirements.txt --upgrade"
echo "Updating required Python packages ($COMMAND)..."