mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-28 08:07:45 -06:00
Compare commits
112 Commits
v2.2-beta2
...
v2.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a64404299 | ||
|
|
2afa6ed2cb | ||
|
|
34f1a9ebfb | ||
|
|
6f2f8697ae | ||
|
|
6ec9d1d6ce | ||
|
|
047f22e110 | ||
|
|
5fc3eac0f6 | ||
|
|
34259d5d9d | ||
|
|
91b6ebb0c0 | ||
|
|
60b4f1f89f | ||
|
|
023ff6834a | ||
|
|
d00cab0b0a | ||
|
|
17493ff655 | ||
|
|
6c27e6c4fe | ||
|
|
4cb0be4df3 | ||
|
|
40f555a3b6 | ||
|
|
881fdbe893 | ||
|
|
2bda399982 | ||
|
|
75d840fa1a | ||
|
|
800bdd8fc5 | ||
|
|
b04ade8060 | ||
|
|
7f4d96f33e | ||
|
|
74731bc6ae | ||
|
|
9d8daca54d | ||
|
|
bb898b719f | ||
|
|
3a0b57b50f | ||
|
|
38d2333165 | ||
|
|
a7f0b5adb3 | ||
|
|
109fff0fa6 | ||
|
|
8c591e7248 | ||
|
|
0dafa10e27 | ||
|
|
60e3ff0bf0 | ||
|
|
6052405eb7 | ||
|
|
17a45109f4 | ||
|
|
392b45e6cb | ||
|
|
f27e1ba885 | ||
|
|
990c9d6f7c | ||
|
|
b301b8f6f2 | ||
|
|
924cf9bfd4 | ||
|
|
7e6573d191 | ||
|
|
fdae3a3f31 | ||
|
|
51194e20f2 | ||
|
|
043f2cb214 | ||
|
|
651d462456 | ||
|
|
7cb287d6c6 | ||
|
|
d16d89028f | ||
|
|
baa277c592 | ||
|
|
007f930fed | ||
|
|
f253f164a3 | ||
|
|
abfe71bb04 | ||
|
|
0c645b12d1 | ||
|
|
f824d1eb3b | ||
|
|
afbbe1148f | ||
|
|
8403e91fc9 | ||
|
|
c300879995 | ||
|
|
ea1d298eb3 | ||
|
|
d17b36519a | ||
|
|
f4f0896470 | ||
|
|
2c9b41db75 | ||
|
|
a38cd449c5 | ||
|
|
977cad3830 | ||
|
|
987587b5f3 | ||
|
|
9351f686b1 | ||
|
|
42e557bd3f | ||
|
|
35c2c8e8de | ||
|
|
960e1d49c2 | ||
|
|
100d979d3e | ||
|
|
b734599f40 | ||
|
|
b41b4fc584 | ||
|
|
b69bf58c63 | ||
|
|
a73e598a6e | ||
|
|
af2de6776d | ||
|
|
98195c9e74 | ||
|
|
97188ad85b | ||
|
|
a01c9ff379 | ||
|
|
aa8f734bd1 | ||
|
|
f6d1163ddd | ||
|
|
5be30bd278 | ||
|
|
fa7b7288c9 | ||
|
|
9cc03aaa9a | ||
|
|
1bda56ea23 | ||
|
|
64a34ced72 | ||
|
|
e05d379101 | ||
|
|
a355783377 | ||
|
|
88239e0b0d | ||
|
|
5c63a499d5 | ||
|
|
50496b1a59 | ||
|
|
f7b0d22f86 | ||
|
|
ad95b86fdd | ||
|
|
43e1e0dbc8 | ||
|
|
f731900e2f | ||
|
|
b1bcaa33e7 | ||
|
|
17873706b7 | ||
|
|
e0ad2b4555 | ||
|
|
f89d91783b | ||
|
|
3ffe36e5ed | ||
|
|
be393a9d10 | ||
|
|
27eefd8705 | ||
|
|
097e0f38ff | ||
|
|
ce26b566a4 | ||
|
|
0e14bc1e02 | ||
|
|
ce6796ed9b | ||
|
|
c90cecc2fb | ||
|
|
b6bbcb0609 | ||
|
|
23f6832d9c | ||
|
|
88dace75a1 | ||
|
|
8eb140fd65 | ||
|
|
1f09f3d096 | ||
|
|
66be85a41f | ||
|
|
814c11167e | ||
|
|
57ddd5086f | ||
|
|
c171547037 |
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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)'],
|
||||
]
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
21
netbox/dcim/migrations/0046_rack_lengthen_facility_id.py
Normal file
21
netbox/dcim/migrations/0046_rack_lengthen_facility_id.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
25
netbox/dcim/migrations/0047_more_100ge_form_factors.py
Normal file
25
netbox/dcim/migrations/0047_more_100ge_form_factors.py
Normal 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),
|
||||
),
|
||||
]
|
||||
20
netbox/dcim/migrations/0048_rack_serial.py
Normal file
20
netbox/dcim/migrations/0048_rack_serial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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
72
netbox/dcim/querysets.py
Normal 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)
|
||||
@@ -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')
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')),
|
||||
|
||||
20
netbox/ipam/migrations/0020_ipaddress_add_role_carp.py
Normal file
20
netbox/ipam/migrations/0020_ipaddress_add_role_carp.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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
38
netbox/ipam/querysets.py
Normal 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))
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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
147
netbox/netbox/api.py
Normal 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
|
||||
@@ -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')
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ OBJ_TYPE_CHOICES = (
|
||||
('Tenancy', (
|
||||
('tenant', 'Tenants'),
|
||||
)),
|
||||
('Virtualization', (
|
||||
('cluster', 'Clusters'),
|
||||
('virtualmachine', 'Virtual machines'),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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):
|
||||
|
||||
13
netbox/secrets/querysets.py
Normal file
13
netbox/secrets/querysets.py
Normal 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.")
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }})
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
5
netbox/templates/rest_framework/api.html
Normal file
5
netbox/templates/rest_framework/api.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% extends 'rest_framework/base.html' %}
|
||||
|
||||
{% block branding %}
|
||||
<a class="navbar-brand" href="/{{ settings.BASE_PATH }}">NetBox</a>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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
1
old_requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
pycrypto
|
||||
@@ -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
|
||||
|
||||
@@ -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)..."
|
||||
|
||||
Reference in New Issue
Block a user