mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-02 02:27:44 -06:00
Compare commits
278 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b99704082b | ||
|
|
75d8852bf7 | ||
|
|
0444ac7db9 | ||
|
|
b2684aeefc | ||
|
|
6ccc6244dd | ||
|
|
e618bf40ec | ||
|
|
e3f0a12313 | ||
|
|
687e68db69 | ||
|
|
b10e29aaac | ||
|
|
d0c92b4f8a | ||
|
|
513408f16a | ||
|
|
64326e7c9d | ||
|
|
ce9d853883 | ||
|
|
814a0e7344 | ||
|
|
2c7c0ce29d | ||
|
|
2015d08407 | ||
|
|
9dea5656ad | ||
|
|
daadf7a49b | ||
|
|
2567412121 | ||
|
|
5e4fce248c | ||
|
|
824d2d8205 | ||
|
|
9718895ff9 | ||
|
|
9eec975800 | ||
|
|
5601be87f7 | ||
|
|
440610836a | ||
|
|
4fa536b940 | ||
|
|
aeec678ce9 | ||
|
|
9591fb9330 | ||
|
|
cbfdd5dbd1 | ||
|
|
cd2fa6ed78 | ||
|
|
bef9a0c77f | ||
|
|
6af91b581b | ||
|
|
afe805bd28 | ||
|
|
6af7403738 | ||
|
|
98fe83944f | ||
|
|
37e0388a5a | ||
|
|
99510a990a | ||
|
|
ca6c62facd | ||
|
|
6e5a099834 | ||
|
|
e0b17b1496 | ||
|
|
8341800a85 | ||
|
|
25f1fcc6cb | ||
|
|
d74d85a042 | ||
|
|
fcd4c9f7de | ||
|
|
25c46894b4 | ||
|
|
4a2e80aeee | ||
|
|
74a5960992 | ||
|
|
28b9dda55d | ||
|
|
3b36a35b9a | ||
|
|
b9dcf9ca12 | ||
|
|
faed3c1314 | ||
|
|
f0a85b1dd3 | ||
|
|
76f0463290 | ||
|
|
b14afaa687 | ||
|
|
14a908bf66 | ||
|
|
8f34b6b0b9 | ||
|
|
eab18a81c9 | ||
|
|
7a558d8332 | ||
|
|
fa79014585 | ||
|
|
8cf2ae7851 | ||
|
|
0708942ab8 | ||
|
|
4b4602b703 | ||
|
|
ab90a06c54 | ||
|
|
6dbf2043b9 | ||
|
|
041a166217 | ||
|
|
63ac4e2c42 | ||
|
|
77d721360f | ||
|
|
1d6299622b | ||
|
|
6f44f4245e | ||
|
|
de8fd550cb | ||
|
|
9bdb50c33e | ||
|
|
6ed33af063 | ||
|
|
a33e89fed7 | ||
|
|
b0a325f173 | ||
|
|
b7a90dd09a | ||
|
|
8d99ad3099 | ||
|
|
c49177e59c | ||
|
|
96d6be3608 | ||
|
|
aa84d04c8b | ||
|
|
c8b85202d1 | ||
|
|
af459cd19b | ||
|
|
c056d86b24 | ||
|
|
7d879bb0dc | ||
|
|
a9a55350df | ||
|
|
f019253c8e | ||
|
|
58e3d5ae09 | ||
|
|
2eb8b4fe71 | ||
|
|
989ec721d3 | ||
|
|
4fc0fd9a9a | ||
|
|
5afb98ffa7 | ||
|
|
d045429b51 | ||
|
|
c60c4ad0df | ||
|
|
78c3b25f0a | ||
|
|
13136d0ccb | ||
|
|
8faa16c831 | ||
|
|
6cdb62b67e | ||
|
|
4f774f8ba6 | ||
|
|
550a05487d | ||
|
|
bf1b8ab9b8 | ||
|
|
b74f338aa1 | ||
|
|
35aa8acd09 | ||
|
|
6a48b310d2 | ||
|
|
0b4d3446bf | ||
|
|
2b8e06faa2 | ||
|
|
c86a1123f0 | ||
|
|
e2ad1d4be0 | ||
|
|
219f084805 | ||
|
|
6f12297dcf | ||
|
|
098ff961e3 | ||
|
|
74528c6036 | ||
|
|
3324f397d9 | ||
|
|
2509405465 | ||
|
|
0f779dd682 | ||
|
|
6bf8a7707c | ||
|
|
8ff752a58b | ||
|
|
7938c9211f | ||
|
|
1f4d5b84b3 | ||
|
|
3a018888c8 | ||
|
|
a89fb734a0 | ||
|
|
b2f03dfb16 | ||
|
|
cbde6e8321 | ||
|
|
ed03449164 | ||
|
|
47a89999b8 | ||
|
|
a7dd2695a2 | ||
|
|
8035538b74 | ||
|
|
474b19d927 | ||
|
|
31ebbb3324 | ||
|
|
bddd29c99f | ||
|
|
e7116b81a4 | ||
|
|
d463161619 | ||
|
|
5116db3344 | ||
|
|
b131fbd774 | ||
|
|
324a5e10d7 | ||
|
|
69debfdefa | ||
|
|
93fccd5985 | ||
|
|
e55acf8c63 | ||
|
|
c19e358eef | ||
|
|
efe7b46021 | ||
|
|
ededd3f464 | ||
|
|
ac2aa7ea89 | ||
|
|
04c9ebd46d | ||
|
|
c3c3b80cd9 | ||
|
|
29c4394e64 | ||
|
|
76b9a1c3af | ||
|
|
6184eb6664 | ||
|
|
e413012cbb | ||
|
|
ea2e734ba8 | ||
|
|
4ee63f4ff8 | ||
|
|
946a1b751b | ||
|
|
4ab40c4489 | ||
|
|
7944ee6419 | ||
|
|
72690bfd0a | ||
|
|
0f0d0c150a | ||
|
|
3b9ac3b986 | ||
|
|
79b1bbb9e1 | ||
|
|
533b4082d8 | ||
|
|
81d955ab7d | ||
|
|
57373c9d6f | ||
|
|
bc9158a74f | ||
|
|
9f3647cd53 | ||
|
|
d294e916a4 | ||
|
|
249faffe42 | ||
|
|
3327954a34 | ||
|
|
3b76377cac | ||
|
|
9889e120bd | ||
|
|
00e0fb5798 | ||
|
|
1fd189f9b1 | ||
|
|
b73f980eb2 | ||
|
|
65ea2af4b7 | ||
|
|
8a9c6ce37a | ||
|
|
1bbe7f95d6 | ||
|
|
d09ede8d1f | ||
|
|
bcb9ab7116 | ||
|
|
75c3e62ca8 | ||
|
|
38aee33df0 | ||
|
|
2daffdf087 | ||
|
|
03d71f9764 | ||
|
|
fa906c74c0 | ||
|
|
d933d034e0 | ||
|
|
397943b222 | ||
|
|
6b41794e12 | ||
|
|
b6e5bafd65 | ||
|
|
e6c06b39e8 | ||
|
|
a25534f3de | ||
|
|
aa6c840c45 | ||
|
|
aee9314bbf | ||
|
|
3bb10bca1b | ||
|
|
1f9e4dc707 | ||
|
|
76efea87ff | ||
|
|
483ad256a8 | ||
|
|
618566abe8 | ||
|
|
1413f5d89e | ||
|
|
4cc84aed5a | ||
|
|
300e67388b | ||
|
|
2981ead41b | ||
|
|
e4960873f3 | ||
|
|
2abee211a2 | ||
|
|
65b008a493 | ||
|
|
2236d2f941 | ||
|
|
41b2b7dbf6 | ||
|
|
27c21237ff | ||
|
|
faa12abc70 | ||
|
|
7ca4c816c0 | ||
|
|
6f68628377 | ||
|
|
82a98f0e8f | ||
|
|
1939db1574 | ||
|
|
06af05708a | ||
|
|
fa2ccc1c18 | ||
|
|
b790d7d50f | ||
|
|
af5dba2e0d | ||
|
|
589cbeb559 | ||
|
|
8dbeec8b00 | ||
|
|
8f4980044a | ||
|
|
cc4470ade7 | ||
|
|
3b4c8fa49c | ||
|
|
b4d68382ce | ||
|
|
4be5c33905 | ||
|
|
88b022d742 | ||
|
|
e5b19a9374 | ||
|
|
bd6e68fe6c | ||
|
|
8e2a69af56 | ||
|
|
450c51604c | ||
|
|
d47bf4ab6b | ||
|
|
d241cce502 | ||
|
|
c466dc5999 | ||
|
|
b62cd32428 | ||
|
|
b9223dda1a | ||
|
|
b9c09b2fc2 | ||
|
|
deda796e42 | ||
|
|
55ab720695 | ||
|
|
275223ec53 | ||
|
|
f44b20bbda | ||
|
|
c96d03cc4b | ||
|
|
8cb38de7d5 | ||
|
|
d2c3fea5b9 | ||
|
|
8ee083f7c1 | ||
|
|
9a9e3c1479 | ||
|
|
48b8602c3f | ||
|
|
e1fc78bc44 | ||
|
|
65fb10059a | ||
|
|
2e8211399d | ||
|
|
6fe40ef223 | ||
|
|
3f94295d7e | ||
|
|
5c59677c57 | ||
|
|
0bd2aa9289 | ||
|
|
19d7caf1da | ||
|
|
b8d7dd170e | ||
|
|
c643e3a74f | ||
|
|
2d690ca38a | ||
|
|
c65b9fcb0b | ||
|
|
4f6f032ca2 | ||
|
|
50d20650b4 | ||
|
|
783341017f | ||
|
|
c9dc6d04ef | ||
|
|
82ad479037 | ||
|
|
0d46a65a36 | ||
|
|
7a50cd2320 | ||
|
|
5ba5e8def9 | ||
|
|
4f347d3428 | ||
|
|
d6c2fe2385 | ||
|
|
cb4643d810 | ||
|
|
d201dad535 | ||
|
|
32d8cf451a | ||
|
|
46da9866e3 | ||
|
|
534e6ac19e | ||
|
|
518af1b95c | ||
|
|
4f95ce4984 | ||
|
|
da10b34738 | ||
|
|
a9ab0a012f | ||
|
|
45a8ee7325 | ||
|
|
23451fe974 | ||
|
|
5def0e91d7 | ||
|
|
f301af5ecd | ||
|
|
dd62caf2f0 | ||
|
|
4a00971d44 | ||
|
|
4e64e1ea95 | ||
|
|
300aff71bb | ||
|
|
0c3970233e |
12
.travis.yml
12
.travis.yml
@@ -1,3 +1,11 @@
|
||||
sudo: required
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
env:
|
||||
- DOCKER_TAG=$TRAVIS_TAG
|
||||
|
||||
language: python
|
||||
python:
|
||||
- "2.7"
|
||||
@@ -6,3 +14,7 @@ install:
|
||||
- pip install pep8
|
||||
script:
|
||||
- ./scripts/cibuild.sh
|
||||
after_success:
|
||||
- if [ ! -z "$TRAVIS_TAG" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then
|
||||
./scripts/docker-build.sh;
|
||||
fi
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
# Contributing to NetBox
|
||||
## Getting Help
|
||||
|
||||
Thank you for your interest in contributing to NetBox! This document contains some quick pointers on reporting bugs and
|
||||
requesting new features.
|
||||
If you encounter any issues installing or using NetBox, try one of the following resources to get assistance. Please
|
||||
**do not** open an issue on GitHub except to report bugs or request features.
|
||||
|
||||
## Reporting Issues
|
||||
### Freenode IRC
|
||||
|
||||
* First, ensure that you've installed the latest stable version of NetBox. If you're running an older version, it's
|
||||
possible that the bug has already been fixed.
|
||||
Join the #netbox channel on [Freenode IRC](https://freenode.net/). You can connect to Freenode at irc.freenode.net using
|
||||
an IRC client, or you can use their [webchat client](https://webchat.freenode.net/).
|
||||
|
||||
* Check the [issues list](https://github.com/digitalocean/netbox/issues) to see if the bug you've found has already been
|
||||
reported. If you think you may be experiencing a reported issue, please add a quick comment to it with a "+1" and a
|
||||
quick description of how it's affecting your installation.
|
||||
### Reddit
|
||||
|
||||
* If you're unsure whether the behavior you're seeing is expected, you can join #netbox on irc.freenode.net and ask
|
||||
before going through the trouble of submitting an issue report.
|
||||
We have established [/r/netbox](https://www.reddit.com/r/netbox) on Reddit for NetBox issues and general discussion.
|
||||
Reddit registration is free and does not require providing an email address (although it is encouraged).
|
||||
|
||||
* When submitting an issue, please be as descriptive as possible. Be sure to describe:
|
||||
## Reporting Bugs
|
||||
|
||||
* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases) of
|
||||
NetBox. If you're running an older version, it's possible that the bug has already been fixed.
|
||||
|
||||
* Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the bug you've found has
|
||||
already been reported. If you think you may be experiencing a reported issue that hasn't already been resolved, please
|
||||
click "add a reaction" in the top right corner of the issue and add a thumbs up (+1). You might also want to add a
|
||||
comment describing how it's affecting your installation. This will allow us to prioritize bugs based on how many users
|
||||
are affected.
|
||||
|
||||
* If you haven't found an existing issue that describes your suspected bug, please inquire about it on IRC or Reddit.
|
||||
**Do not** file an issue until you have received confirmation that it is in fact a bug. Invalid issues are very
|
||||
distracting and slow the pace at which NetBox is developed.
|
||||
|
||||
* When submitting an issue, please be as descriptive as possible. Be sure to include:
|
||||
|
||||
* The environment in which NetBox is running
|
||||
* The exact steps that can be taken to reproduce the issue (if applicable)
|
||||
* Any error messages returned
|
||||
* Screenshots (if applicable)
|
||||
|
||||
* Keep in mind that we prioritize bugs based on their severity and how much work is required to resolve them. It may
|
||||
take some time for someone to address your issue. If it's been longer than a week with no updates, please ping us on
|
||||
IRC.
|
||||
take some time for someone to address your issue.
|
||||
|
||||
## Feature Requests
|
||||
|
||||
* First, check the [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you're requesting
|
||||
has already been requested (and possibly rejected). If it has, click "add a reaction" in the top right corner of the
|
||||
issue and add a thumbs up (+1). This ensures that the issue has a better chance of making it onto the roadmap. Also feel
|
||||
free to add a comment with any additional justification for the feature.
|
||||
* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you're
|
||||
requesting is already listed. (Be sure to search closed issues as well, since some feature requests are rejected.) If
|
||||
the feature you'd like to see has already been requested, click "add a reaction" in the top right corner of the issue
|
||||
and add a thumbs up (+1). This ensures that the issue has a better chance of making it onto the roadmap. Also feel free
|
||||
to add a comment with any additional justification for the feature.
|
||||
|
||||
* While discussion of new features is welcome, it's important to limit the scope of NetBox's feature set to avoid
|
||||
* While suggestions for new features are welcome, it's important to limit the scope of NetBox's feature set to avoid
|
||||
feature creep. For example, the following features would be firmly out of scope for NetBox:
|
||||
|
||||
* Ticket management
|
||||
@@ -40,14 +54,18 @@ feature creep. For example, the following features would be firmly out of scope
|
||||
* Acting as a DNS server
|
||||
* Acting as an authentication server
|
||||
|
||||
* If you're not sure whether the feature you want is a good fit for NetBox, please ask in #netbox on irc.freenode.net.
|
||||
Even if it's not quite right for NetBox, we may be able to point you to a tool better suited for the job.
|
||||
* Before filing a new feature request, propose it on IRC or Reddit first. Feedback you receive there will help validate
|
||||
and shape the proposed feature before filing a formal issue.
|
||||
|
||||
* When submitting a feature request, be sure to include the following:
|
||||
* Good feature requests are very narrowly defined. Be sure to enumerate specific functionality and data schema. The more
|
||||
effort you put into writing a feature request, the better its chances are of being implemented. Overly broad feature
|
||||
requests will be closed.
|
||||
|
||||
* A brief description of the functionality
|
||||
* When submitting a feature request on GitHub, be sure to include the following:
|
||||
|
||||
* A detailed description of the proposed functionality
|
||||
* A use case for the feature; who would use it and what value it would add to NetBox
|
||||
* A rough description of any changes necessary to the database schema (if applicable)
|
||||
* A rough description of any changes necessary to the database schema
|
||||
* Any third-party libraries or other resources which would be involved
|
||||
|
||||
## Submitting Pull Requests
|
||||
@@ -56,9 +74,8 @@ Even if it's not quite right for NetBox, we may be able to point you to a tool b
|
||||
before beginning work. This will help prevent wasting time on something that might we might not be able to implement.
|
||||
When suggesting a new feature, also make sure it won't conflict with any work that's already in progress.
|
||||
|
||||
* When submitting a pull request, please be sure to work off of branch `develop`, rather than branch `master`.
|
||||
In NetBox, the `develop` branch is used for ongoing development, while `master` is used for tagging new
|
||||
stable releases.
|
||||
* When submitting a pull request, please be sure to work off of the `develop` branch, rather than `master`. In NetBox,
|
||||
the `develop` branch is used for ongoing development, while `master` is used for tagging new stable releases.
|
||||
|
||||
* All code submissions should meet the following criteria (CI will enforce these checks):
|
||||
|
||||
|
||||
30
Dockerfile
30
Dockerfile
@@ -1,24 +1,14 @@
|
||||
FROM ubuntu:14.04
|
||||
FROM python:2.7-wheezy
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python2.7 \
|
||||
python-dev \
|
||||
git \
|
||||
python-pip \
|
||||
libxml2-dev \
|
||||
libxslt1-dev \
|
||||
libffi-dev \
|
||||
graphviz \
|
||||
libpq-dev \
|
||||
build-essential \
|
||||
gunicorn \
|
||||
--no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /opt/netbox \
|
||||
&& cd /opt/netbox \
|
||||
&& git clone --depth 1 https://github.com/digitalocean/netbox.git -b master . \
|
||||
&& pip install -r requirements.txt \
|
||||
&& apt-get purge -y --auto-remove git build-essential
|
||||
WORKDIR /opt/netbox
|
||||
|
||||
ARG BRANCH=master
|
||||
ARG URL=https://github.com/digitalocean/netbox.git
|
||||
RUN git clone --depth 1 $URL -b $BRANCH . && \
|
||||
apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev && \
|
||||
pip install gunicorn==17.5 && \
|
||||
pip install django-auth-ldap && \
|
||||
pip install -r requirements.txt
|
||||
|
||||
ADD docker/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# NetBox
|
||||

|
||||
|
||||
NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers.
|
||||
|
||||
@@ -25,6 +25,6 @@ Questions? Comments? Please join us on IRC in **#netbox** on **irc.freenode.net*
|
||||
|
||||
# Installation
|
||||
|
||||
Please see docs/getting-started.md for instructions on installing NetBox.
|
||||
Please see [the documentation](http://netbox.readthedocs.io/en/latest/) for instructions on installing NetBox.
|
||||
|
||||
To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
|
||||
|
||||
@@ -2,7 +2,7 @@ NetBox's local configuration is held in `netbox/netbox/configuration.py`. An exa
|
||||
|
||||
## ALLOWED_HOSTS
|
||||
|
||||
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.
|
||||
This is a list of valid fully-qualified domain names (FQDNs) that is used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different (e.g. when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server). NetBox will not permit access to the server via any other hostnames (or IPs). The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts `HTTP POST` to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/1.9/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, has `USE_X_FORWARDED_HOST = True` (in `netbox/netbox/settings.py`) which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/1.9/ref/settings/#allowed-hosts)).
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
@@ -47,9 +47,17 @@ In order to send email, NetBox needs an email server configured. The following i
|
||||
|
||||
---
|
||||
|
||||
# ENFORCE_GLOBAL_UNIQUE
|
||||
|
||||
Default: False
|
||||
|
||||
Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table (all prefixes and IP addresses not assigned to a VRF), set `ENFORCE_GLOBAL_UNIQUE` to True.
|
||||
|
||||
---
|
||||
|
||||
## LOGIN_REQUIRED
|
||||
|
||||
Default: False,
|
||||
Default: False
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -10,15 +10,21 @@ Sites can be assigned an optional facility ID to identify the actual facility ho
|
||||
|
||||
# Racks
|
||||
|
||||
Within each site exist one or more racks. Each rack within NetBox represents a physical two- or four-post equipment rack in which equipment is mounted. Rack height is measured in *rack units *(U); most racks are between 42U and 48U, but NetBox allows you to define racks of any height. Each rack has two faces (front and rear) on which devices can be mounted.
|
||||
Within each site exist one or more racks. Each rack within NetBox represents a physical two- or four-post equipment rack in which equipment is mounted. Rack height is measured in *rack units* (U); most racks are between 42U and 48U, but NetBox allows you to define racks of any height. Each rack has two faces (front and rear) on which devices can be mounted.
|
||||
|
||||
Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, M204.313) whereas internally you refer to is simply as "R113." The facility ID can alternatively be used to store a rack's serial number.
|
||||
|
||||
The available rack types include 2- and 4-post frames, 4-post cabinet, and wall-mounted frame and cabinet. Rail-to-rail width may be 19 or 23 inches.
|
||||
|
||||
### Rack Groups
|
||||
|
||||
Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site is a campus, each group might be a building. If each site is a building, each rack group might be a floor or room.
|
||||
|
||||
Each group is assigned to a parent site for easy navigation. Hierarchical recursion of rack groups is not currently supported.
|
||||
Each group is assigned to a parent site for easy navigation. Hierarchical recursion of rack groups is not supported.
|
||||
|
||||
### Rack Roles
|
||||
|
||||
Each rak can optionally be assigned to a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices.
|
||||
|
||||
---
|
||||
|
||||
@@ -74,7 +80,7 @@ The assignment of platforms to devices is an entirely optional feature, and may
|
||||
|
||||
### Modules
|
||||
|
||||
A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand.
|
||||
A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each module can optionally be assigned to a manufacturer.
|
||||
|
||||
### Components
|
||||
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
This section entails features of NetBox which are not crucial to its primary functions, but that provide additional value.
|
||||
This section entails features of NetBox which are not crucial to its primary functions, but provide additional value.
|
||||
|
||||
# Custom Fields
|
||||
|
||||
Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address` and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows.
|
||||
|
||||
However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data.
|
||||
|
||||
Custom fields must be created through the admin UI under Extras > Custom Fields. To create a new custom field, select the object(s) to which you want it to apply, and the type of field it will be. NetBox supports six field types:
|
||||
|
||||
* Free-form text (up to 255 characters)
|
||||
* Integer
|
||||
* Boolean (true/false)
|
||||
* Date
|
||||
* URL
|
||||
* Selection
|
||||
|
||||
Assign the field a name. This should be a simple database-friendly string, e.g. `tps_report`. You may optionally assign the field a human-friendly label (e.g. "TPS report") as well; the label will be displayed on forms. If a description is provided, it will appear beneath the field in a form.
|
||||
|
||||
Marking the field as required will require the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields. (The default value has no effect for selection fields.)
|
||||
|
||||
When creating a selection field, you should create at least two choices. These choices will be arranged first by weight, with lower weights appearing higher in the list, and then alphabetically.
|
||||
|
||||
## Using Custom Fields
|
||||
|
||||
When a single object is edited, the form will include any custom fields which have been defined for the object type. These fields are included in the "Custom Fields" panel. On the backend, each custom field value is saved separately from the core object as an independent database call, so it's best to avoid adding too many custom fields per object.
|
||||
|
||||
When editing multiple objects, custom field values are saved in bulk. There is no significant difference in overhead when saving a custom field value for 100 objects versus one object. However, the bulk operation must be performed separately for each custom field.
|
||||
|
||||
# Export Templates
|
||||
|
||||
@@ -8,6 +35,8 @@ Each export template is associated with a certain type of object. For instance,
|
||||
|
||||
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable. Typically, you'll want to iterate through this list using a for loop.
|
||||
|
||||
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
|
||||
|
||||
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
|
||||
|
||||
## Example
|
||||
|
||||
22
docs/data-model/tenancy.md
Normal file
22
docs/data-model/tenancy.md
Normal file
@@ -0,0 +1,22 @@
|
||||
NetBox supports the concept of individual tenants within its parent organization. Typically, these are used to represent individual customers or internal departments.
|
||||
|
||||
# Tenants
|
||||
|
||||
A tenant represents a discrete organization. Certain resources within NetBox can be assigned to a tenant. This makes it very convenient to track which resources are assigned to which customers, for instance.
|
||||
|
||||
The following objects can be assigned to tenants:
|
||||
|
||||
* Sites
|
||||
* Racks
|
||||
* Devices
|
||||
* VRFs
|
||||
* Prefixes
|
||||
* IP addresses
|
||||
* VLANs
|
||||
* Circuits
|
||||
|
||||
If a prefix or IP address is not assigned to a tenant, it will appear to inherit the tenant to which its parent VRF is assigned, if any.
|
||||
|
||||
### Tenant Groups
|
||||
|
||||
Tenants can be grouped by type. For instance, you might create one group called "Customers" and one called "Acquisitions." The assignment of tenants to groups is optional.
|
||||
@@ -50,4 +50,4 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
|
||||
|
||||
# Getting Started
|
||||
|
||||
See the [getting started](getting-started.md) guide for help with getting NetBox up and running quickly.
|
||||
See the [installation guide](installation/postgresql.md) for help getting NetBox up and running quickly.
|
||||
|
||||
@@ -10,9 +10,10 @@ NetBox requires following system dependencies:
|
||||
* libffi-dev
|
||||
* graphviz
|
||||
* libpq-dev
|
||||
* libssl-dev
|
||||
|
||||
```
|
||||
# sudo apt-get install -y python2.7 python-dev git python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev
|
||||
# sudo apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
|
||||
```
|
||||
|
||||
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
|
||||
@@ -163,6 +164,18 @@ Are you sure you want to do this?
|
||||
Type 'yes' to continue, or 'no' to cancel: yes
|
||||
```
|
||||
|
||||
# Load Initial Data (Optional)
|
||||
|
||||
NetBox ships with some initial data to help you get started: RIR definitions, common devices roles, etc. You can delete any seed data that you don't want to keep.
|
||||
|
||||
!!! note
|
||||
This step is optional. It's perfectly fine to start using NetBox without using this initial data if you'd rather create everything from scratch.
|
||||
|
||||
```
|
||||
# ./manage.py loaddata initial_data
|
||||
Installed 43 object(s) from 4 fixture(s)
|
||||
```
|
||||
|
||||
# Test the Application
|
||||
|
||||
At this point, NetBox should be able to run. We can verify this by starting a development instance:
|
||||
|
||||
BIN
docs/netbox_logo.png
Normal file
BIN
docs/netbox_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -17,6 +17,7 @@ pages:
|
||||
- 'DCIM': 'data-model/dcim.md'
|
||||
- 'IPAM': 'data-model/ipam.md'
|
||||
- 'Secrets': 'data-model/secrets.md'
|
||||
- 'Tenancy': 'data-model/tenancy.md'
|
||||
- 'Extras': 'data-model/extras.md'
|
||||
- 'API Integration': 'api-integration.md'
|
||||
|
||||
|
||||
@@ -21,10 +21,11 @@ class CircuitTypeAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Circuit)
|
||||
class CircuitAdmin(admin.ModelAdmin):
|
||||
list_display = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id']
|
||||
list_filter = ['provider']
|
||||
list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed_human',
|
||||
'upstream_speed_human', 'commit_rate_human', 'xconnect_id']
|
||||
list_filter = ['provider', 'type', 'tenant']
|
||||
exclude = ['interface']
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super(CircuitAdmin, self).get_queryset(request)
|
||||
return qs.select_related('provider', 'type', 'site')
|
||||
return qs.select_related('provider', 'type', 'tenant', 'site')
|
||||
|
||||
@@ -2,17 +2,20 @@ from rest_framework import serializers
|
||||
|
||||
from circuits.models import Provider, CircuitType, Circuit
|
||||
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
|
||||
from extras.api.serializers import CustomFieldSerializer
|
||||
from tenancy.api.serializers import TenantNestedSerializer
|
||||
|
||||
|
||||
#
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderSerializer(serializers.ModelSerializer):
|
||||
class ProviderSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
|
||||
fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||
'custom_fields']
|
||||
|
||||
|
||||
class ProviderNestedSerializer(ProviderSerializer):
|
||||
@@ -42,16 +45,17 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer):
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitSerializer(serializers.ModelSerializer):
|
||||
class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
provider = ProviderNestedSerializer()
|
||||
type = CircuitTypeNestedSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
site = SiteNestedSerializer()
|
||||
interface = InterfaceNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['id', 'cid', 'provider', 'type', 'site', 'interface', 'install_date', 'port_speed', 'commit_rate',
|
||||
'xconnect_id', 'comments']
|
||||
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed',
|
||||
'upstream_speed', 'commit_rate', 'xconnect_id', 'comments', 'custom_fields']
|
||||
|
||||
|
||||
class CircuitNestedSerializer(CircuitSerializer):
|
||||
|
||||
@@ -3,22 +3,23 @@ from rest_framework import generics
|
||||
from circuits.models import Provider, CircuitType, Circuit
|
||||
from circuits.filters import CircuitFilter
|
||||
|
||||
from extras.api.views import CustomFieldModelAPIView
|
||||
from . import serializers
|
||||
|
||||
|
||||
class ProviderListView(generics.ListAPIView):
|
||||
class ProviderListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List all providers
|
||||
"""
|
||||
queryset = Provider.objects.all()
|
||||
queryset = Provider.objects.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
|
||||
|
||||
class ProviderDetailView(generics.RetrieveAPIView):
|
||||
class ProviderDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single provider
|
||||
"""
|
||||
queryset = Provider.objects.all()
|
||||
queryset = Provider.objects.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
|
||||
|
||||
@@ -38,18 +39,20 @@ class CircuitTypeDetailView(generics.RetrieveAPIView):
|
||||
serializer_class = serializers.CircuitTypeSerializer
|
||||
|
||||
|
||||
class CircuitListView(generics.ListAPIView):
|
||||
class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List circuits (filterable)
|
||||
"""
|
||||
queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device')
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.CircuitSerializer
|
||||
filter_class = CircuitFilter
|
||||
|
||||
|
||||
class CircuitDetailView(generics.RetrieveAPIView):
|
||||
class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single circuit
|
||||
"""
|
||||
queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device')
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.CircuitSerializer
|
||||
|
||||
@@ -3,10 +3,14 @@ import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import Site
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NullableModelMultipleChoiceFilter
|
||||
|
||||
from .models import Provider, Circuit, CircuitType
|
||||
|
||||
|
||||
class ProviderFilter(django_filters.FilterSet):
|
||||
class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
@@ -28,14 +32,14 @@ class ProviderFilter(django_filters.FilterSet):
|
||||
fields = ['q', 'name', 'account', 'asn']
|
||||
|
||||
def search(self, queryset, value):
|
||||
value = value.strip()
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(account__icontains=value)
|
||||
Q(account__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class CircuitFilter(django_filters.FilterSet):
|
||||
class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
@@ -62,6 +66,17 @@ class CircuitFilter(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Circuit type (slug)',
|
||||
)
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
queryset=Site.objects.all(),
|
||||
@@ -79,5 +94,9 @@ class CircuitFilter(django_filters.FilterSet):
|
||||
fields = ['q', 'provider_id', 'provider', 'type_id', 'type', 'site_id', 'site', 'interface', 'install_date']
|
||||
|
||||
def search(self, queryset, value):
|
||||
value = value.strip()
|
||||
return queryset.filter(cid__icontains=value)
|
||||
return queryset.filter(
|
||||
Q(cid__icontains=value) |
|
||||
Q(xconnect_id__icontains=value) |
|
||||
Q(pp_info__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
26
netbox/circuits/fixtures/initial_data.json
Normal file
26
netbox/circuits/fixtures/initial_data.json
Normal file
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"model": "circuits.circuittype",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "Internet",
|
||||
"slug": "internet"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "circuits.circuittype",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Private WAN",
|
||||
"slug": "private-wan"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "circuits.circuittype",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "Out-of-Band",
|
||||
"slug": "out-of-band"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -2,8 +2,11 @@ from django import forms
|
||||
from django.db.models import Count
|
||||
|
||||
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
|
||||
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||
from tenancy.forms import bulkedit_tenant_choices
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, BootstrapMixin, BulkImportForm, CommentField, ConfirmationForm, CSVDataField, Livesearch, SmallTextarea,
|
||||
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea,
|
||||
SlugField,
|
||||
)
|
||||
|
||||
@@ -14,7 +17,7 @@ from .models import Circuit, CircuitType, Provider
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderForm(forms.ModelForm, BootstrapMixin):
|
||||
class ProviderForm(BootstrapMixin, CustomFieldForm):
|
||||
slug = SlugField()
|
||||
comments = CommentField()
|
||||
|
||||
@@ -45,7 +48,7 @@ class ProviderImportForm(BulkImportForm, BootstrapMixin):
|
||||
csv = CSVDataField(csv_form=ProviderFromCSVForm)
|
||||
|
||||
|
||||
class ProviderBulkEditForm(forms.Form, BootstrapMixin):
|
||||
class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
asn = forms.IntegerField(required=False, label='ASN')
|
||||
account = forms.CharField(max_length=30, required=False, label='Account number')
|
||||
@@ -55,18 +58,9 @@ class ProviderBulkEditForm(forms.Form, BootstrapMixin):
|
||||
comments = CommentField()
|
||||
|
||||
|
||||
class ProviderBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
def provider_site_choices():
|
||||
site_choices = Site.objects.all()
|
||||
return [(s.slug, s.name) for s in site_choices]
|
||||
|
||||
|
||||
class ProviderFilterForm(forms.Form, BootstrapMixin):
|
||||
site = forms.MultipleChoiceField(required=False, choices=provider_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Provider
|
||||
site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
|
||||
|
||||
|
||||
#
|
||||
@@ -81,15 +75,11 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
|
||||
fields = ['name', 'slug']
|
||||
|
||||
|
||||
class CircuitTypeBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=CircuitType.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
#
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitForm(forms.ModelForm, BootstrapMixin):
|
||||
class CircuitForm(BootstrapMixin, CustomFieldForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
|
||||
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
|
||||
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
@@ -108,8 +98,8 @@ class CircuitForm(forms.ModelForm, BootstrapMixin):
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'cid', 'type', 'provider', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
|
||||
'port_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
|
||||
'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
|
||||
'port_speed', 'upstream_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
|
||||
]
|
||||
help_texts = {
|
||||
'cid': "Unique circuit ID",
|
||||
@@ -169,50 +159,37 @@ class CircuitFromCSVForm(forms.ModelForm):
|
||||
error_messages={'invalid_choice': 'Provider not found.'})
|
||||
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid circuit type.'})
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Site not found.'})
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id',
|
||||
'pp_info']
|
||||
fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'upstream_speed',
|
||||
'commit_rate', 'xconnect_id', 'pp_info']
|
||||
|
||||
|
||||
class CircuitImportForm(BulkImportForm, BootstrapMixin):
|
||||
csv = CSVDataField(csv_form=CircuitFromCSVForm)
|
||||
|
||||
|
||||
class CircuitBulkEditForm(forms.Form, BootstrapMixin):
|
||||
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
|
||||
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
|
||||
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
|
||||
comments = CommentField()
|
||||
|
||||
|
||||
class CircuitBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
def circuit_type_choices():
|
||||
type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits'))
|
||||
return [(t.slug, '{} ({})'.format(t.name, t.circuit_count)) for t in type_choices]
|
||||
|
||||
|
||||
def circuit_provider_choices():
|
||||
provider_choices = Provider.objects.annotate(circuit_count=Count('circuits'))
|
||||
return [(p.slug, '{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices]
|
||||
|
||||
|
||||
def circuit_site_choices():
|
||||
site_choices = Site.objects.annotate(circuit_count=Count('circuits'))
|
||||
return [(s.slug, '{} ({})'.format(s.name, s.circuit_count)) for s in site_choices]
|
||||
|
||||
|
||||
class CircuitFilterForm(forms.Form, BootstrapMixin):
|
||||
type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices)
|
||||
provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Circuit
|
||||
type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
|
||||
to_field_name='slug')
|
||||
provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
|
||||
to_field_name='slug')
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuits')), to_field_name='slug')
|
||||
|
||||
22
netbox/circuits/migrations/0004_circuit_add_tenant.py
Normal file
22
netbox/circuits/migrations/0004_circuit_add_tenant.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-07-26 21:59
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0001_initial'),
|
||||
('circuits', '0003_provider_32bit_asn_support'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='circuit',
|
||||
name='tenant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='tenancy.Tenant'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-08-08 20:24
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0004_circuit_add_tenant'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='circuit',
|
||||
name='upstream_speed',
|
||||
field=models.PositiveIntegerField(blank=True, help_text=b'Upstream speed, if different from port speed', null=True, verbose_name=b'Upstream speed (Kbps)'),
|
||||
),
|
||||
]
|
||||
@@ -1,12 +1,15 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import models
|
||||
|
||||
from dcim.fields import ASNField
|
||||
from dcim.models import Site, Interface
|
||||
from extras.models import CustomFieldModel, CustomFieldValue
|
||||
from tenancy.models import Tenant
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
|
||||
|
||||
class Provider(CreatedUpdatedModel):
|
||||
class Provider(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
|
||||
stores information pertinent to the user's relationship with the Provider.
|
||||
@@ -19,6 +22,7 @@ class Provider(CreatedUpdatedModel):
|
||||
noc_contact = models.TextField(blank=True, verbose_name='NOC contact')
|
||||
admin_contact = models.TextField(blank=True, verbose_name='Admin contact')
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -57,7 +61,7 @@ class CircuitType(models.Model):
|
||||
return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
|
||||
|
||||
|
||||
class Circuit(CreatedUpdatedModel):
|
||||
class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
|
||||
circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device
|
||||
@@ -66,21 +70,25 @@ class Circuit(CreatedUpdatedModel):
|
||||
cid = models.CharField(max_length=50, verbose_name='Circuit ID')
|
||||
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
|
||||
type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
|
||||
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
|
||||
site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT)
|
||||
interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
|
||||
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
|
||||
port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
|
||||
upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
|
||||
help_text='Upstream speed, if different from port speed')
|
||||
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
|
||||
xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
|
||||
pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
class Meta:
|
||||
ordering = ['provider', 'cid']
|
||||
unique_together = ['provider', 'cid']
|
||||
|
||||
def __unicode__(self):
|
||||
return "{0} {1}".format(self.provider, self.cid)
|
||||
return u'{} {}'.format(self.provider, self.cid)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('circuits:circuit', args=[self.pk])
|
||||
@@ -90,9 +98,11 @@ class Circuit(CreatedUpdatedModel):
|
||||
self.cid,
|
||||
self.provider.name,
|
||||
self.type.name,
|
||||
self.tenant.name if self.tenant else '',
|
||||
self.site.name,
|
||||
self.install_date.isoformat() if self.install_date else '',
|
||||
str(self.port_speed),
|
||||
str(self.upstream_speed),
|
||||
str(self.commit_rate) if self.commit_rate else '',
|
||||
self.xconnect_id,
|
||||
self.pp_info,
|
||||
@@ -113,12 +123,18 @@ class Circuit(CreatedUpdatedModel):
|
||||
else:
|
||||
return '{} Kbps'.format(speed)
|
||||
|
||||
@property
|
||||
def port_speed_human(self):
|
||||
return self._humanize_speed(self.port_speed)
|
||||
port_speed_human.admin_order_field = 'port_speed'
|
||||
|
||||
def upstream_speed_human(self):
|
||||
if not self.upstream_speed:
|
||||
return ''
|
||||
return self._humanize_speed(self.upstream_speed)
|
||||
upstream_speed_human.admin_order_field = 'upstream_speed'
|
||||
|
||||
@property
|
||||
def commit_rate_human(self):
|
||||
if not self.commit_rate:
|
||||
return ''
|
||||
return self._humanize_speed(self.commit_rate)
|
||||
commit_rate_human.admin_order_field = 'commit_rate'
|
||||
|
||||
@@ -6,9 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import Circuit, CircuitType, Provider
|
||||
|
||||
|
||||
CIRCUITTYPE_EDIT_LINK = """
|
||||
CIRCUITTYPE_ACTIONS = """
|
||||
{% if perms.circuit.change_circuittype %}
|
||||
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}">Edit</a>
|
||||
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -21,11 +21,12 @@ class ProviderTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn('circuits:provider', args=[Accessor('slug')], verbose_name='Name')
|
||||
asn = tables.Column(verbose_name='ASN')
|
||||
account = tables.Column(verbose_name='Account')
|
||||
circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Provider
|
||||
fields = ('pk', 'name', 'asn', 'circuit_count')
|
||||
fields = ('pk', 'name', 'asn', 'account', 'circuit_count')
|
||||
|
||||
|
||||
#
|
||||
@@ -37,11 +38,12 @@ class CircuitTypeTable(BaseTable):
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
circuit_count = tables.Column(verbose_name='Circuits')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
edit = tables.TemplateColumn(template_code=CIRCUITTYPE_EDIT_LINK, verbose_name='')
|
||||
actions = tables.TemplateColumn(template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = CircuitType
|
||||
fields = ('pk', 'name', 'circuit_count', 'slug', 'edit')
|
||||
fields = ('pk', 'name', 'circuit_count', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -53,10 +55,13 @@ class CircuitTable(BaseTable):
|
||||
cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID')
|
||||
type = tables.Column(verbose_name='Type')
|
||||
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
port_speed_human = tables.Column(verbose_name='Port Speed')
|
||||
commit_rate_human = tables.Column(verbose_name='Commit Rate')
|
||||
port_speed = tables.Column(accessor=Accessor('port_speed_human'), order_by=Accessor('port_speed'),
|
||||
verbose_name='Port Speed')
|
||||
commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'),
|
||||
verbose_name='Commit Rate')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Circuit
|
||||
fields = ('pk', 'cid', 'type', 'provider', 'site', 'port_speed_human', 'commit_rate_human')
|
||||
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'site', 'port_speed', 'commit_rate')
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
|
||||
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
@@ -27,10 +28,12 @@ def provider(request, slug):
|
||||
|
||||
provider = get_object_or_404(Provider, slug=slug)
|
||||
circuits = Circuit.objects.filter(provider=provider).select_related('site', 'interface__device')
|
||||
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
|
||||
|
||||
return render(request, 'circuits/provider.html', {
|
||||
'provider': provider,
|
||||
'circuits': circuits,
|
||||
'show_graphs': show_graphs,
|
||||
})
|
||||
|
||||
|
||||
@@ -63,20 +66,10 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
template_name = 'circuits/provider_bulk_edit.html'
|
||||
default_redirect_url = 'circuits:provider_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
for field in ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
|
||||
|
||||
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'circuits.delete_provider'
|
||||
cls = Provider
|
||||
form = forms.ProviderBulkDeleteForm
|
||||
default_redirect_url = 'circuits:provider_list'
|
||||
|
||||
|
||||
@@ -102,7 +95,6 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'circuits.delete_circuittype'
|
||||
cls = CircuitType
|
||||
form = forms.CircuitTypeBulkDeleteForm
|
||||
default_redirect_url = 'circuits:circuittype_list'
|
||||
|
||||
|
||||
@@ -111,7 +103,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class CircuitListView(ObjectListView):
|
||||
queryset = Circuit.objects.select_related('provider', 'type', 'site')
|
||||
queryset = Circuit.objects.select_related('provider', 'type', 'tenant', 'site')
|
||||
filter = filters.CircuitFilter
|
||||
filter_form = forms.CircuitFilterForm
|
||||
table = tables.CircuitTable
|
||||
@@ -158,18 +150,8 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
template_name = 'circuits/circuit_bulk_edit.html'
|
||||
default_redirect_url = 'circuits:circuit_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
for field in ['type', 'provider', 'port_speed', 'commit_rate', 'comments']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
|
||||
|
||||
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'circuits.delete_circuit'
|
||||
cls = Circuit
|
||||
form = forms.CircuitBulkDeleteForm
|
||||
default_redirect_url = 'circuits:circuit_list'
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.db.models import Count
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
|
||||
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site,
|
||||
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,9 +24,17 @@ class RackGroupAdmin(admin.ModelAdmin):
|
||||
}
|
||||
|
||||
|
||||
@admin.register(RackRole)
|
||||
class RackRoleAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'slug', 'color']
|
||||
prepopulated_fields = {
|
||||
'slug': ['name'],
|
||||
}
|
||||
|
||||
|
||||
@admin.register(Rack)
|
||||
class RackAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'facility_id', 'site', 'u_height']
|
||||
list_display = ['name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height']
|
||||
|
||||
|
||||
#
|
||||
@@ -78,8 +86,8 @@ class DeviceTypeAdmin(admin.ModelAdmin):
|
||||
InterfaceTemplateAdmin,
|
||||
DeviceBayTemplateAdmin,
|
||||
]
|
||||
list_display = ['model', 'manufacturer', 'slug', 'u_height', 'console_ports', 'console_server_ports', 'power_ports',
|
||||
'power_outlets', 'interfaces', 'device_bays']
|
||||
list_display = ['model', 'manufacturer', 'slug', 'part_number', 'u_height', 'console_ports', 'console_server_ports',
|
||||
'power_ports', 'power_outlets', 'interfaces', 'device_bays']
|
||||
list_filter = ['manufacturer']
|
||||
|
||||
def get_queryset(self, request):
|
||||
@@ -175,7 +183,8 @@ class DeviceAdmin(admin.ModelAdmin):
|
||||
DeviceBayAdmin,
|
||||
ModuleAdmin,
|
||||
]
|
||||
list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'serial']
|
||||
list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag',
|
||||
'serial']
|
||||
list_filter = ['device_role']
|
||||
|
||||
def get_queryset(self, request):
|
||||
|
||||
@@ -3,21 +3,24 @@ from rest_framework import serializers
|
||||
from ipam.models import IPAddress
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType,
|
||||
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet,
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
|
||||
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
|
||||
)
|
||||
from extras.api.serializers import CustomFieldSerializer
|
||||
from tenancy.api.serializers import TenantNestedSerializer
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteSerializer(serializers.ModelSerializer):
|
||||
class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
tenant = TenantNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = ['id', 'name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
|
||||
'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
|
||||
fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments',
|
||||
'custom_fields', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
|
||||
|
||||
|
||||
class SiteNestedSerializer(SiteSerializer):
|
||||
@@ -38,24 +41,44 @@ class RackGroupSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'name', 'slug', 'site']
|
||||
|
||||
|
||||
class RackGroupNestedSerializer(SiteSerializer):
|
||||
class RackGroupNestedSerializer(RackGroupSerializer):
|
||||
|
||||
class Meta(SiteSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
|
||||
|
||||
class RackRoleNestedSerializer(RackRoleSerializer):
|
||||
|
||||
class Meta(RackRoleSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
|
||||
class RackSerializer(serializers.ModelSerializer):
|
||||
class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
site = SiteNestedSerializer()
|
||||
group = RackGroupNestedSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
role = RackRoleNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments']
|
||||
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
|
||||
'u_height', 'comments', 'custom_fields']
|
||||
|
||||
|
||||
class RackNestedSerializer(RackSerializer):
|
||||
@@ -69,8 +92,8 @@ class RackDetailSerializer(RackSerializer):
|
||||
rear_units = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(RackSerializer.Meta):
|
||||
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments', 'front_units',
|
||||
'rear_units']
|
||||
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
|
||||
'u_height', 'comments', 'custom_fields', 'front_units', 'rear_units']
|
||||
|
||||
def get_front_units(self, obj):
|
||||
units = obj.get_rack_units(face=RACK_FACE_FRONT)
|
||||
@@ -111,8 +134,8 @@ class DeviceTypeSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = ['id', 'manufacturer', 'model', 'slug', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
|
||||
'is_network_device']
|
||||
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||
'is_console_server', 'is_pdu', 'is_network_device']
|
||||
|
||||
|
||||
class DeviceTypeNestedSerializer(DeviceTypeSerializer):
|
||||
@@ -164,9 +187,9 @@ class DeviceTypeDetailSerializer(DeviceTypeSerializer):
|
||||
interface_templates = InterfaceTemplateNestedSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta(DeviceTypeSerializer.Meta):
|
||||
fields = ['id', 'manufacturer', 'model', 'slug', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
|
||||
'is_network_device', 'console_port_templates', 'cs_port_templates', 'power_port_templates',
|
||||
'power_outlet_templates', 'interface_templates']
|
||||
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||
'is_console_server', 'is_pdu', 'is_network_device', 'console_port_templates', 'cs_port_templates',
|
||||
'power_port_templates', 'power_outlet_templates', 'interface_templates']
|
||||
|
||||
|
||||
#
|
||||
@@ -215,9 +238,10 @@ class DeviceIPAddressNestedSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'family', 'address']
|
||||
|
||||
|
||||
class DeviceSerializer(serializers.ModelSerializer):
|
||||
class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
device_type = DeviceTypeNestedSerializer()
|
||||
device_role = DeviceRoleNestedSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
platform = PlatformNestedSerializer()
|
||||
rack = RackNestedSerializer()
|
||||
primary_ip = DeviceIPAddressNestedSerializer()
|
||||
@@ -227,8 +251,9 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'platform', 'serial', 'rack', 'position',
|
||||
'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments']
|
||||
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
|
||||
'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'comments', 'custom_fields']
|
||||
|
||||
def get_parent_device(self, obj):
|
||||
try:
|
||||
@@ -380,6 +405,25 @@ class DeviceBayDetailSerializer(DeviceBaySerializer):
|
||||
fields = ['id', 'device', 'name', 'installed_device']
|
||||
|
||||
|
||||
#
|
||||
# Modules
|
||||
#
|
||||
|
||||
class ModuleSerializer(serializers.ModelSerializer):
|
||||
device = DeviceNestedSerializer()
|
||||
manufacturer = ManufacturerNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered']
|
||||
|
||||
|
||||
class ModuleNestedSerializer(ModuleSerializer):
|
||||
|
||||
class Meta(ModuleSerializer.Meta):
|
||||
fields = ['id', 'device', 'parent', 'name']
|
||||
|
||||
|
||||
#
|
||||
# Interface connections
|
||||
#
|
||||
|
||||
@@ -18,6 +18,10 @@ urlpatterns = [
|
||||
url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'),
|
||||
url(r'^rack-groups/(?P<pk>\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'),
|
||||
|
||||
# Rack roles
|
||||
url(r'^rack-roles/$', RackRoleListView.as_view(), name='rackrole_list'),
|
||||
url(r'^rack-roles/(?P<pk>\d+)/$', RackRoleDetailView.as_view(), name='rackrole_detail'),
|
||||
|
||||
# Racks
|
||||
url(r'^racks/$', RackListView.as_view(), name='rack_list'),
|
||||
url(r'^racks/(?P<pk>\d+)/$', RackDetailView.as_view(), name='rack_detail'),
|
||||
@@ -50,6 +54,7 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
|
||||
url(r'^devices/(?P<pk>\d+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'),
|
||||
url(r'^devices/(?P<pk>\d+)/modules/$', ModuleListView.as_view(), name='device_modules'),
|
||||
|
||||
# Console ports
|
||||
url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),
|
||||
@@ -61,7 +66,8 @@ urlpatterns = [
|
||||
url(r'^interfaces/(?P<pk>\d+)/$', InterfaceDetailView.as_view(), name='interface_detail'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_INTERFACE},
|
||||
name='interface_graphs'),
|
||||
url(r'^interface-connections/(?P<pk>\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection'),
|
||||
url(r'^interface-connections/$', InterfaceConnectionListView.as_view(), name='interfaceconnection_list'),
|
||||
url(r'^interface-connections/(?P<pk>\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection_detail'),
|
||||
|
||||
# Miscellaneous
|
||||
url(r'^related-connections/$', RelatedConnectionsView.as_view(), name='related_connections'),
|
||||
|
||||
@@ -5,37 +5,39 @@ from rest_framework.settings import api_settings
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface,
|
||||
InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
|
||||
InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
|
||||
)
|
||||
from dcim import filters
|
||||
from .exceptions import MissingFilterException
|
||||
from . import serializers
|
||||
from extras.api.views import CustomFieldModelAPIView
|
||||
from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer
|
||||
from utilities.api import ServiceUnavailable
|
||||
from .exceptions import MissingFilterException
|
||||
from . import serializers
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteListView(generics.ListAPIView):
|
||||
class SiteListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List all sites
|
||||
"""
|
||||
queryset = Site.objects.all()
|
||||
queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.SiteSerializer
|
||||
|
||||
|
||||
class SiteDetailView(generics.RetrieveAPIView):
|
||||
class SiteDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single site
|
||||
"""
|
||||
queryset = Site.objects.all()
|
||||
queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.SiteSerializer
|
||||
|
||||
|
||||
@@ -47,7 +49,7 @@ class RackGroupListView(generics.ListAPIView):
|
||||
"""
|
||||
List all rack groups
|
||||
"""
|
||||
queryset = RackGroup.objects.all()
|
||||
queryset = RackGroup.objects.select_related('site')
|
||||
serializer_class = serializers.RackGroupSerializer
|
||||
filter_class = filters.RackGroupFilter
|
||||
|
||||
@@ -56,28 +58,50 @@ class RackGroupDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single rack group
|
||||
"""
|
||||
queryset = RackGroup.objects.all()
|
||||
queryset = RackGroup.objects.select_related('site')
|
||||
serializer_class = serializers.RackGroupSerializer
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleListView(generics.ListAPIView):
|
||||
"""
|
||||
List all rack roles
|
||||
"""
|
||||
queryset = RackRole.objects.all()
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
|
||||
|
||||
class RackRoleDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single rack role
|
||||
"""
|
||||
queryset = RackRole.objects.all()
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackListView(generics.ListAPIView):
|
||||
class RackListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List racks (filterable)
|
||||
"""
|
||||
queryset = Rack.objects.select_related('site')
|
||||
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.RackSerializer
|
||||
filter_class = filters.RackFilter
|
||||
|
||||
|
||||
class RackDetailView(generics.RetrieveAPIView):
|
||||
class RackDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single rack
|
||||
"""
|
||||
queryset = Rack.objects.select_related('site')
|
||||
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.RackDetailSerializer
|
||||
|
||||
|
||||
@@ -189,22 +213,25 @@ class PlatformDetailView(generics.RetrieveAPIView):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceListView(generics.ListAPIView):
|
||||
class DeviceListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List devices (filterable)
|
||||
"""
|
||||
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'platform', 'rack__site')\
|
||||
.prefetch_related('primary_ip4__nat_outside', 'primary_ip6__nat_outside')
|
||||
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform',
|
||||
'rack__site', 'parent_bay').prefetch_related('primary_ip4__nat_outside',
|
||||
'primary_ip6__nat_outside',
|
||||
'custom_field_values__field')
|
||||
serializer_class = serializers.DeviceSerializer
|
||||
filter_class = filters.DeviceFilter
|
||||
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer]
|
||||
|
||||
|
||||
class DeviceDetailView(generics.RetrieveAPIView):
|
||||
class DeviceDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single device
|
||||
"""
|
||||
queryset = Device.objects.all()
|
||||
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'platform',
|
||||
'rack__site', 'parent_bay').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.DeviceSerializer
|
||||
|
||||
|
||||
@@ -326,6 +353,14 @@ class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView):
|
||||
queryset = InterfaceConnection.objects.all()
|
||||
|
||||
|
||||
class InterfaceConnectionListView(generics.ListAPIView):
|
||||
"""
|
||||
Retrieve a list of all interface connections
|
||||
"""
|
||||
serializer_class = serializers.InterfaceConnectionSerializer
|
||||
queryset = InterfaceConnection.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
@@ -339,18 +374,23 @@ class DeviceBayListView(generics.ListAPIView):
|
||||
def get_queryset(self):
|
||||
|
||||
device = get_object_or_404(Device, pk=self.kwargs['pk'])
|
||||
queryset = DeviceBay.objects.filter(device=device).select_related('installed_device')
|
||||
return DeviceBay.objects.filter(device=device).select_related('installed_device')
|
||||
|
||||
# Filter by type (physical or virtual)
|
||||
iface_type = self.request.query_params.get('type')
|
||||
if iface_type == 'physical':
|
||||
queryset = queryset.exclude(form_factor=IFACE_FF_VIRTUAL)
|
||||
elif iface_type == 'virtual':
|
||||
queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL)
|
||||
elif iface_type is not None:
|
||||
queryset = queryset.empty()
|
||||
|
||||
return queryset
|
||||
#
|
||||
# Modules
|
||||
#
|
||||
|
||||
class ModuleListView(generics.ListAPIView):
|
||||
"""
|
||||
List device modules (by device)
|
||||
"""
|
||||
serializer_class = serializers.ModuleSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
device = get_object_or_404(Device, pk=self.kwargs['pk'])
|
||||
return Module.objects.filter(device=device).select_related('device', 'manufacturer')
|
||||
|
||||
|
||||
#
|
||||
@@ -391,6 +431,13 @@ class RelatedConnectionsView(APIView):
|
||||
Retrieve all connections related to a given console/power/interface connection
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(RelatedConnectionsView, self).__init__()
|
||||
|
||||
# Custom fields
|
||||
self.content_type = ContentType.objects.get_for_model(Device)
|
||||
self.custom_fields = self.content_type.custom_fields.prefetch_related('choices')
|
||||
|
||||
def get(self, request):
|
||||
|
||||
peer_device = request.GET.get('peer-device')
|
||||
@@ -411,53 +458,36 @@ class RelatedConnectionsView(APIView):
|
||||
return Response()
|
||||
|
||||
else:
|
||||
raise MissingFilterException(detail='Must specify search parameters (peer-device and peer-interface).')
|
||||
raise MissingFilterException(detail='Must specify search parameters "peer-device" and "peer-interface".')
|
||||
|
||||
# Initialize response skeleton
|
||||
response = dict()
|
||||
response['device'] = serializers.DeviceSerializer(device).data
|
||||
response['console-ports'] = []
|
||||
response['power-ports'] = []
|
||||
response['interfaces'] = []
|
||||
response = {
|
||||
'device': serializers.DeviceSerializer(device, context={'view': self}).data,
|
||||
'console-ports': [],
|
||||
'power-ports': [],
|
||||
'interfaces': [],
|
||||
}
|
||||
|
||||
# Build console connections
|
||||
# Console connections
|
||||
console_ports = ConsolePort.objects.filter(device=device).select_related('cs_port__device')
|
||||
for cp in console_ports:
|
||||
cp_info = dict()
|
||||
cp_info['name'] = cp.name
|
||||
if cp.cs_port:
|
||||
cp_info['console-server'] = cp.cs_port.device.name
|
||||
cp_info['port'] = cp.cs_port.name
|
||||
else:
|
||||
cp_info['console-server'] = None
|
||||
cp_info['port'] = None
|
||||
response['console-ports'].append(cp_info)
|
||||
data = serializers.ConsolePortSerializer(instance=cp).data
|
||||
del(data['device'])
|
||||
response['console-ports'].append(data)
|
||||
|
||||
# Build power connections
|
||||
# Power connections
|
||||
power_ports = PowerPort.objects.filter(device=device).select_related('power_outlet__device')
|
||||
for pp in power_ports:
|
||||
pp_info = dict()
|
||||
pp_info['name'] = pp.name
|
||||
if pp.power_outlet:
|
||||
pp_info['pdu'] = pp.power_outlet.device.name
|
||||
pp_info['outlet'] = pp.power_outlet.name
|
||||
else:
|
||||
pp_info['pdu'] = None
|
||||
pp_info['outlet'] = None
|
||||
response['power-ports'].append(pp_info)
|
||||
data = serializers.PowerPortSerializer(instance=pp).data
|
||||
del(data['device'])
|
||||
response['power-ports'].append(data)
|
||||
|
||||
# Built interface connections
|
||||
interfaces = Interface.objects.filter(device=device)
|
||||
# Interface connections
|
||||
interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b',
|
||||
'circuit')
|
||||
for iface in interfaces:
|
||||
iface_info = dict()
|
||||
iface_info['name'] = iface.name
|
||||
peer_interface = iface.get_connected_interface()
|
||||
if peer_interface:
|
||||
iface_info['device'] = peer_interface.device.name
|
||||
iface_info['interface'] = peer_interface.name
|
||||
else:
|
||||
iface_info['device'] = None
|
||||
iface_info['interface'] = None
|
||||
response['interfaces'].append(iface_info)
|
||||
data = serializers.InterfaceDetailSerializer(instance=iface).data
|
||||
del(data['device'])
|
||||
response['interfaces'].append(data)
|
||||
|
||||
return Response(response)
|
||||
|
||||
@@ -2,28 +2,41 @@ import django_filters
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NullableModelMultipleChoiceFilter
|
||||
from .models import (
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
|
||||
Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site,
|
||||
Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
|
||||
)
|
||||
|
||||
|
||||
class SiteFilter(django_filters.FilterSet):
|
||||
class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
)
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = ['q', 'name', 'facility', 'asn']
|
||||
|
||||
def search(self, queryset, value):
|
||||
value = value.strip()
|
||||
qs_filter = Q(name__icontains=value) | Q(facility__icontains=value) | Q(physical_address__icontains=value) | \
|
||||
Q(shipping_address__icontains=value)
|
||||
Q(shipping_address__icontains=value) | Q(comments__icontains=value)
|
||||
try:
|
||||
qs_filter |= Q(asn=int(value))
|
||||
qs_filter |= Q(asn=int(value.strip()))
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
@@ -47,7 +60,7 @@ class RackGroupFilter(django_filters.FilterSet):
|
||||
fields = ['site_id', 'site']
|
||||
|
||||
|
||||
class RackFilter(django_filters.FilterSet):
|
||||
class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
@@ -63,27 +76,49 @@ class RackFilter(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
group_id = NullableModelMultipleChoiceFilter(
|
||||
name='group',
|
||||
queryset=RackGroup.objects.all(),
|
||||
label='Group (ID)',
|
||||
)
|
||||
group = django_filters.ModelMultipleChoiceFilter(
|
||||
group = NullableModelMultipleChoiceFilter(
|
||||
name='group',
|
||||
queryset=RackGroup.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Group',
|
||||
)
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
role_id = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=RackRole.objects.all(),
|
||||
label='Role (ID)',
|
||||
)
|
||||
role = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=RackRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['q', 'site_id', 'site', 'u_height']
|
||||
|
||||
def search(self, queryset, value):
|
||||
value = value.strip()
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(facility_id__icontains=value)
|
||||
Q(facility_id__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
@@ -102,11 +137,11 @@ class DeviceTypeFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = ['manufacturer_id', 'manufacturer', 'model', 'u_height', 'is_console_server', 'is_pdu',
|
||||
fields = ['manufacturer_id', 'manufacturer', 'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu',
|
||||
'is_network_device']
|
||||
|
||||
|
||||
class DeviceFilter(django_filters.FilterSet):
|
||||
class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
@@ -143,6 +178,17 @@ class DeviceFilter(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
device_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device_type',
|
||||
queryset=DeviceType.objects.all(),
|
||||
@@ -165,12 +211,12 @@ class DeviceFilter(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Device model (slug)',
|
||||
)
|
||||
platform_id = django_filters.ModelMultipleChoiceFilter(
|
||||
platform_id = NullableModelMultipleChoiceFilter(
|
||||
name='platform',
|
||||
queryset=Platform.objects.all(),
|
||||
label='Platform (ID)',
|
||||
)
|
||||
platform = django_filters.ModelMultipleChoiceFilter(
|
||||
platform = NullableModelMultipleChoiceFilter(
|
||||
name='platform',
|
||||
queryset=Platform.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -195,16 +241,17 @@ class DeviceFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['q', 'name', 'site_id', 'site', 'rack_id', 'role_id', 'role', 'device_type_id', 'manufacturer_id',
|
||||
'manufacturer', 'model', 'platform_id', 'platform', 'status', 'is_console_server', 'is_pdu',
|
||||
'is_network_device']
|
||||
fields = ['q', 'name', 'serial', 'asset_tag', 'site_id', 'site', 'rack_id', 'role_id', 'role', 'device_type_id',
|
||||
'manufacturer_id', 'manufacturer', 'model', 'platform_id', 'platform', 'status', 'is_console_server',
|
||||
'is_pdu', 'is_network_device']
|
||||
|
||||
def search(self, queryset, value):
|
||||
value = value.strip()
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(serial__icontains=value) |
|
||||
Q(modules__serial__icontains=value)
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(modules__serial__icontains=value.strip()) |
|
||||
Q(asset_tag=value.strip()) |
|
||||
Q(comments__icontains=value)
|
||||
).distinct()
|
||||
|
||||
|
||||
|
||||
201
netbox/dcim/fixtures/initial_data.json
Normal file
201
netbox/dcim/fixtures/initial_data.json
Normal file
@@ -0,0 +1,201 @@
|
||||
[
|
||||
{
|
||||
"model": "dcim.devicerole",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "Console Server",
|
||||
"slug": "console-server",
|
||||
"color": "teal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.devicerole",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Core Switch",
|
||||
"slug": "core-switch",
|
||||
"color": "blue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.devicerole",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "Distribution Switch",
|
||||
"slug": "distribution-switch",
|
||||
"color": "blue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.devicerole",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"name": "Access Switch",
|
||||
"slug": "access-switch",
|
||||
"color": "blue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.devicerole",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"name": "Management Switch",
|
||||
"slug": "management-switch",
|
||||
"color": "orange"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.devicerole",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"name": "Firewall",
|
||||
"slug": "firewall",
|
||||
"color": "red"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.devicerole",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"name": "Router",
|
||||
"slug": "router",
|
||||
"color": "purple"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.devicerole",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"name": "Server",
|
||||
"slug": "server",
|
||||
"color": "medium_gray"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.devicerole",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"name": "PDU",
|
||||
"slug": "pdu",
|
||||
"color": "dark_gray"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.manufacturer",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "APC",
|
||||
"slug": "apc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.manufacturer",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Cisco",
|
||||
"slug": "cisco"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.manufacturer",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "Dell",
|
||||
"slug": "dell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.manufacturer",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"name": "HP",
|
||||
"slug": "hp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.manufacturer",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"name": "Juniper",
|
||||
"slug": "juniper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.manufacturer",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"name": "Arista",
|
||||
"slug": "arista"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.manufacturer",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"name": "Opengear",
|
||||
"slug": "opengear"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.manufacturer",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"name": "Super Micro",
|
||||
"slug": "super-micro"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.platform",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "Cisco IOS",
|
||||
"slug": "cisco-ios",
|
||||
"rpc_client": "cisco-ios"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.platform",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Cisco NX-OS",
|
||||
"slug": "cisco-nx-os",
|
||||
"rpc_client": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.platform",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "Juniper Junos",
|
||||
"slug": "juniper-junos",
|
||||
"rpc_client": "juniper-junos"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.platform",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"name": "Arista EOS",
|
||||
"slug": "arista-eos",
|
||||
"rpc_client": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.platform",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"name": "Linux",
|
||||
"slug": "linux",
|
||||
"rpc_client": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "dcim.platform",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"name": "Opengear",
|
||||
"slug": "opengear",
|
||||
"rpc_client": "opengear"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -3,17 +3,21 @@ import re
|
||||
from django import forms
|
||||
from django.db.models import Count, Q
|
||||
|
||||
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||
from ipam.models import IPAddress
|
||||
from tenancy.forms import bulkedit_tenant_choices
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, BootstrapMixin, BulkImportForm, CommentField, ConfirmationForm, CSVDataField, ExpandableNameField,
|
||||
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
|
||||
APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField,
|
||||
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
|
||||
)
|
||||
|
||||
from .models import (
|
||||
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
|
||||
Interface, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole,
|
||||
Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
|
||||
)
|
||||
|
||||
|
||||
@@ -38,17 +42,50 @@ def get_device_by_name_or_pk(name):
|
||||
return device
|
||||
|
||||
|
||||
def bulkedit_platform_choices():
|
||||
choices = [
|
||||
(None, '---------'),
|
||||
(0, 'None'),
|
||||
]
|
||||
choices += [(p.pk, p.name) for p in Platform.objects.all()]
|
||||
return choices
|
||||
|
||||
|
||||
def bulkedit_rackgroup_choices():
|
||||
"""
|
||||
Include an option to remove the currently assigned group from a rack.
|
||||
"""
|
||||
choices = [
|
||||
(None, '---------'),
|
||||
(0, 'None'),
|
||||
]
|
||||
choices += [(r.pk, r) for r in RackGroup.objects.all()]
|
||||
return choices
|
||||
|
||||
|
||||
def bulkedit_rackrole_choices():
|
||||
"""
|
||||
Include an option to remove the currently assigned role from a rack.
|
||||
"""
|
||||
choices = [
|
||||
(None, '---------'),
|
||||
(0, 'None'),
|
||||
]
|
||||
choices += [(r.pk, r.name) for r in RackRole.objects.all()]
|
||||
return choices
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteForm(forms.ModelForm, BootstrapMixin):
|
||||
class SiteForm(BootstrapMixin, CustomFieldForm):
|
||||
slug = SlugField()
|
||||
comments = CommentField()
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = ['name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
|
||||
fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments']
|
||||
widgets = {
|
||||
'physical_address': SmallTextarea(attrs={'rows': 3}),
|
||||
'shipping_address': SmallTextarea(attrs={'rows': 3}),
|
||||
@@ -63,16 +100,29 @@ class SiteForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
|
||||
class SiteFromCSVForm(forms.ModelForm):
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = ['name', 'slug', 'facility', 'asn']
|
||||
fields = ['name', 'slug', 'tenant', 'facility', 'asn']
|
||||
|
||||
|
||||
class SiteImportForm(BulkImportForm, BootstrapMixin):
|
||||
csv = CSVDataField(csv_form=SiteFromCSVForm)
|
||||
|
||||
|
||||
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
|
||||
|
||||
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Site
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
|
||||
|
||||
#
|
||||
# Rack groups
|
||||
#
|
||||
@@ -85,25 +135,27 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin):
|
||||
fields = ['site', 'name', 'slug']
|
||||
|
||||
|
||||
class RackGroupBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=RackGroup.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
def rackgroup_site_choices():
|
||||
site_choices = Site.objects.annotate(rack_count=Count('rack_groups'))
|
||||
return [(s.slug, '{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
|
||||
|
||||
|
||||
class RackGroupFilterForm(forms.Form, BootstrapMixin):
|
||||
site = forms.MultipleChoiceField(required=False, choices=rackgroup_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug')
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleForm(forms.ModelForm, BootstrapMixin):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['name', 'slug', 'color']
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackForm(forms.ModelForm, BootstrapMixin):
|
||||
class RackForm(BootstrapMixin, CustomFieldForm):
|
||||
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect(
|
||||
api_url='/api/dcim/rack-groups/?site_id={{site}}',
|
||||
))
|
||||
@@ -111,7 +163,7 @@ class RackForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['site', 'group', 'name', 'facility_id', 'u_height', 'comments']
|
||||
fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'comments']
|
||||
help_texts = {
|
||||
'site': "The site at which the rack exists",
|
||||
'name': "Organizational rack name",
|
||||
@@ -139,10 +191,15 @@ class RackFromCSVForm(forms.ModelForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Site not found.'})
|
||||
group_name = forms.CharField(required=False)
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
role = forms.ModelChoiceField(RackRole.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Role not found.'})
|
||||
type = forms.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['site', 'group_name', 'name', 'facility_id', 'u_height']
|
||||
fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height']
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -156,38 +213,45 @@ class RackFromCSVForm(forms.ModelForm):
|
||||
except RackGroup.DoesNotExist:
|
||||
self.add_error('group_name', "Invalid rack group ({})".format(group))
|
||||
|
||||
def clean_type(self):
|
||||
rack_type = self.cleaned_data['type']
|
||||
if not rack_type:
|
||||
return None
|
||||
try:
|
||||
choices = {v.lower(): k for k, v in RACK_TYPE_CHOICES}
|
||||
return choices[rack_type.lower()]
|
||||
except KeyError:
|
||||
raise forms.ValidationError('Invalid rack type ({}). Valid choices are: {}.'.format(
|
||||
rack_type,
|
||||
', '.join({v: k for k, v in RACK_TYPE_CHOICES}),
|
||||
))
|
||||
|
||||
|
||||
class RackImportForm(BulkImportForm, BootstrapMixin):
|
||||
csv = CSVDataField(csv_form=RackFromCSVForm)
|
||||
|
||||
|
||||
class RackBulkEditForm(forms.Form, BootstrapMixin):
|
||||
class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
|
||||
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False)
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site')
|
||||
group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group')
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
role = forms.TypedChoiceField(choices=bulkedit_rackrole_choices, coerce=int, required=False, label='Role')
|
||||
type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type')
|
||||
width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width')
|
||||
u_height = forms.IntegerField(required=False, label='Height (U)')
|
||||
comments = CommentField()
|
||||
|
||||
|
||||
class RackBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
def rack_site_choices():
|
||||
site_choices = Site.objects.annotate(rack_count=Count('racks'))
|
||||
return [(s.slug, '{} ({})'.format(s.name, s.rack_count)) for s in site_choices]
|
||||
|
||||
|
||||
def rack_group_choices():
|
||||
group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
|
||||
return [(g.pk, '{} ({})'.format(g, g.rack_count)) for g in group_choices]
|
||||
|
||||
|
||||
class RackFilterForm(forms.Form, BootstrapMixin):
|
||||
site = forms.MultipleChoiceField(required=False, choices=rack_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group',
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Rack
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug')
|
||||
group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site')
|
||||
.annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None'))
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
role = FilterChoiceField(queryset=RackRole.objects.annotate(filter_count=Count('racks')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
|
||||
|
||||
#
|
||||
@@ -202,10 +266,6 @@ class ManufacturerForm(forms.ModelForm, BootstrapMixin):
|
||||
fields = ['name', 'slug']
|
||||
|
||||
|
||||
class ManufacturerBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Manufacturer.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
#
|
||||
# Device types
|
||||
#
|
||||
@@ -215,8 +275,8 @@ class DeviceTypeForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = ['manufacturer', 'model', 'slug', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
|
||||
'is_network_device', 'subdevice_role']
|
||||
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
|
||||
'is_pdu', 'is_network_device', 'subdevice_role']
|
||||
|
||||
|
||||
class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
|
||||
@@ -225,18 +285,9 @@ class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin):
|
||||
u_height = forms.IntegerField(min_value=1, required=False)
|
||||
|
||||
|
||||
class DeviceTypeBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
def devicetype_manufacturer_choices():
|
||||
manufacturer_choices = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
|
||||
return [(m.slug, '{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices]
|
||||
|
||||
|
||||
class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
|
||||
manufacturer = forms.MultipleChoiceField(required=False, choices=devicetype_manufacturer_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
|
||||
to_field_name='slug')
|
||||
|
||||
|
||||
#
|
||||
@@ -303,10 +354,6 @@ class DeviceRoleForm(forms.ModelForm, BootstrapMixin):
|
||||
fields = ['name', 'slug', 'color']
|
||||
|
||||
|
||||
class DeviceRoleBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=DeviceRole.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
#
|
||||
# Platforms
|
||||
#
|
||||
@@ -319,15 +366,11 @@ class PlatformForm(forms.ModelForm, BootstrapMixin):
|
||||
fields = ['name', 'slug']
|
||||
|
||||
|
||||
class PlatformBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Platform.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
#
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
class DeviceForm(BootstrapMixin, CustomFieldForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
|
||||
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
@@ -348,8 +391,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['name', 'device_role', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status',
|
||||
'platform', 'primary_ip4', 'primary_ip6', 'comments']
|
||||
fields = ['name', 'device_role', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position',
|
||||
'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments']
|
||||
help_texts = {
|
||||
'device_role': "The function this device serves",
|
||||
'serial': "Chassis serial number",
|
||||
@@ -373,10 +416,10 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
for family in [4, 6]:
|
||||
ip_choices = []
|
||||
interface_ips = IPAddress.objects.filter(family=family, interface__device=self.instance)
|
||||
ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
|
||||
ip_choices += [(ip.id, u'{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
|
||||
nat_ips = IPAddress.objects.filter(family=family, nat_inside__interface__device=self.instance)\
|
||||
.select_related('nat_inside__interface')
|
||||
ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
|
||||
ip_choices += [(ip.id, u'{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
|
||||
self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices
|
||||
|
||||
else:
|
||||
@@ -388,7 +431,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
self.fields['primary_ip6'].widget.attrs['readonly'] = True
|
||||
|
||||
# Limit rack choices
|
||||
if self.is_bound:
|
||||
if self.is_bound and self.data.get('site'):
|
||||
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
|
||||
elif self.initial.get('site'):
|
||||
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
|
||||
@@ -396,8 +439,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
self.fields['rack'].choices = []
|
||||
|
||||
# Rack position
|
||||
pk = self.instance.pk if self.instance.pk else None
|
||||
try:
|
||||
pk = self.instance.pk if self.instance.pk else None
|
||||
if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
|
||||
position_choices = Rack.objects.get(pk=self.data['rack'])\
|
||||
.get_rack_units(face=self.data.get('face'), exclude=pk)
|
||||
@@ -425,32 +468,33 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
|
||||
else:
|
||||
self.fields['device_type'].choices = []
|
||||
|
||||
# Disable rack assignment if this is a child device installed in a parent device
|
||||
if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
|
||||
self.fields['site'].disabled = True
|
||||
self.fields['rack'].disabled = True
|
||||
self.initial['site'] = self.instance.parent_bay.device.rack.site_id
|
||||
self.initial['rack'] = self.instance.parent_bay.device.rack_id
|
||||
|
||||
class DeviceFromCSVForm(forms.ModelForm):
|
||||
|
||||
class BaseDeviceFromCSVForm(forms.ModelForm):
|
||||
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid device role.'})
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid manufacturer.'})
|
||||
model_name = forms.CharField()
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid platform.'})
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={
|
||||
'invalid_choice': 'Invalid site name.',
|
||||
})
|
||||
rack_name = forms.CharField()
|
||||
face = forms.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
fields = []
|
||||
model = Device
|
||||
fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'site', 'rack_name',
|
||||
'position', 'face']
|
||||
|
||||
def clean(self):
|
||||
|
||||
manufacturer = self.cleaned_data.get('manufacturer')
|
||||
model_name = self.cleaned_data.get('model_name')
|
||||
site = self.cleaned_data.get('site')
|
||||
rack_name = self.cleaned_data.get('rack_name')
|
||||
|
||||
# Validate device type
|
||||
if manufacturer and model_name:
|
||||
@@ -459,6 +503,25 @@ class DeviceFromCSVForm(forms.ModelForm):
|
||||
except DeviceType.DoesNotExist:
|
||||
self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name))
|
||||
|
||||
|
||||
class DeviceFromCSVForm(BaseDeviceFromCSVForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={
|
||||
'invalid_choice': 'Invalid site name.',
|
||||
})
|
||||
rack_name = forms.CharField()
|
||||
face = forms.CharField(required=False)
|
||||
|
||||
class Meta(BaseDeviceFromCSVForm.Meta):
|
||||
fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'rack_name', 'position', 'face']
|
||||
|
||||
def clean(self):
|
||||
|
||||
super(DeviceFromCSVForm, self).clean()
|
||||
|
||||
site = self.cleaned_data.get('site')
|
||||
rack_name = self.cleaned_data.get('rack_name')
|
||||
|
||||
# Validate rack
|
||||
if site and rack_name:
|
||||
try:
|
||||
@@ -468,70 +531,77 @@ class DeviceFromCSVForm(forms.ModelForm):
|
||||
|
||||
def clean_face(self):
|
||||
face = self.cleaned_data['face']
|
||||
if face:
|
||||
if not face:
|
||||
return None
|
||||
try:
|
||||
return {
|
||||
'front': 0,
|
||||
'rear': 1,
|
||||
}[face.lower()]
|
||||
except KeyError:
|
||||
raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face))
|
||||
|
||||
|
||||
class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
|
||||
parent = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Parent device not found.'})
|
||||
device_bay_name = forms.CharField(required=False)
|
||||
|
||||
class Meta(BaseDeviceFromCSVForm.Meta):
|
||||
fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag',
|
||||
'parent', 'device_bay_name']
|
||||
|
||||
def clean(self):
|
||||
|
||||
super(ChildDeviceFromCSVForm, self).clean()
|
||||
|
||||
parent = self.cleaned_data.get('parent')
|
||||
device_bay_name = self.cleaned_data.get('device_bay_name')
|
||||
|
||||
# Validate device bay
|
||||
if parent and device_bay_name:
|
||||
try:
|
||||
return {
|
||||
'front': 0,
|
||||
'rear': 1,
|
||||
}[face.lower()]
|
||||
except KeyError:
|
||||
raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face))
|
||||
return face
|
||||
device_bay = DeviceBay.objects.get(device=parent, name=device_bay_name)
|
||||
if device_bay.installed_device:
|
||||
self.add_error('device_bay_name',
|
||||
"Device bay ({} {}) is already occupied".format(parent, device_bay_name))
|
||||
else:
|
||||
self.instance.parent_bay = device_bay
|
||||
except DeviceBay.DoesNotExist:
|
||||
self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name))
|
||||
|
||||
|
||||
class DeviceImportForm(BulkImportForm, BootstrapMixin):
|
||||
csv = CSVDataField(csv_form=DeviceFromCSVForm)
|
||||
|
||||
|
||||
class DeviceBulkEditForm(forms.Form, BootstrapMixin):
|
||||
class ChildDeviceImportForm(BulkImportForm, BootstrapMixin):
|
||||
csv = CSVDataField(csv_form=ChildDeviceFromCSVForm)
|
||||
|
||||
|
||||
class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
|
||||
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, label='Platform')
|
||||
platform_delete = forms.BooleanField(required=False, label='Set platform to "none"')
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
platform = forms.TypedChoiceField(choices=bulkedit_platform_choices, coerce=int, required=False,
|
||||
label='Platform')
|
||||
status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status')
|
||||
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
|
||||
|
||||
|
||||
class DeviceBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
def device_site_choices():
|
||||
site_choices = Site.objects.annotate(device_count=Count('racks__devices'))
|
||||
return [(s.slug, '{} ({})'.format(s.name, s.device_count)) for s in site_choices]
|
||||
|
||||
|
||||
def device_rack_group_choices():
|
||||
group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices'))
|
||||
return [(g.pk, '{} ({})'.format(g, g.device_count)) for g in group_choices]
|
||||
|
||||
|
||||
def device_role_choices():
|
||||
role_choices = DeviceRole.objects.annotate(device_count=Count('devices'))
|
||||
return [(r.slug, '{} ({})'.format(r.name, r.device_count)) for r in role_choices]
|
||||
|
||||
|
||||
def device_type_choices():
|
||||
type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances'))
|
||||
return [(t.pk, '{} ({})'.format(t, t.device_count)) for t in type_choices]
|
||||
|
||||
|
||||
def device_platform_choices():
|
||||
platform_choices = Platform.objects.annotate(device_count=Count('devices'))
|
||||
return [(p.slug, '{} ({})'.format(p.name, p.device_count)) for p in platform_choices]
|
||||
|
||||
|
||||
class DeviceFilterForm(forms.Form, BootstrapMixin):
|
||||
site = forms.MultipleChoiceField(required=False, choices=device_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group',
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
role = forms.MultipleChoiceField(required=False, choices=device_role_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type',
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices)
|
||||
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Device
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
|
||||
rack_group_id = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')),
|
||||
label='Rack Group')
|
||||
role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug')
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
device_type_id = FilterChoiceField(queryset=DeviceType.objects.select_related('manufacturer')
|
||||
.annotate(filter_count=Count('instances')), label='Type')
|
||||
platform = FilterChoiceField(queryset=Platform.objects.annotate(filter_count=Count('devices')),
|
||||
to_field_name='slug', null_option=(0, 'None'))
|
||||
status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES))
|
||||
|
||||
|
||||
@@ -1185,4 +1255,4 @@ class ModuleForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = ['name', 'part_id', 'serial']
|
||||
fields = ['name', 'manufacturer', 'part_id', 'serial']
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.7 on 2016-07-14 21:38
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0009_site_32bit_asn_support'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='devicebay',
|
||||
name='installed_device',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parent_bay', to='dcim.Device'),
|
||||
),
|
||||
]
|
||||
20
netbox/dcim/migrations/0011_devicetype_part_number.py
Normal file
20
netbox/dcim/migrations/0011_devicetype_part_number.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-07-26 15:05
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0010_devicebay_installed_device_set_null'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='devicetype',
|
||||
name='part_number',
|
||||
field=models.CharField(blank=True, help_text=b'Discrete part number (optional)', max_length=50),
|
||||
),
|
||||
]
|
||||
32
netbox/dcim/migrations/0012_site_rack_device_add_tenant.py
Normal file
32
netbox/dcim/migrations/0012_site_rack_device_add_tenant.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-07-26 21:59
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0001_initial'),
|
||||
('dcim', '0011_devicetype_part_number'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='tenant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='tenancy.Tenant'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='tenant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='tenancy.Tenant'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='tenant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='tenancy.Tenant'),
|
||||
),
|
||||
]
|
||||
25
netbox/dcim/migrations/0013_add_interface_form_factors.py
Normal file
25
netbox/dcim/migrations/0013_add_interface_form_factors.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-08-06 20:24
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0012_site_rack_device_add_tenant'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet', [[800, b'100BASE-TX (10/100M)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Modular', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1300, b'XFP (10GE)'], [1200, b'SFP+ (10GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus']]]], default=1200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interfacetemplate',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet', [[800, b'100BASE-TX (10/100M)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Modular', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1300, b'XFP (10GE)'], [1200, b'SFP+ (10GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus']]]], default=1200),
|
||||
),
|
||||
]
|
||||
25
netbox/dcim/migrations/0014_rack_add_type_width.py
Normal file
25
netbox/dcim/migrations/0014_rack_add_type_width.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-08-08 21:11
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0013_add_interface_form_factors'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='type',
|
||||
field=models.PositiveSmallIntegerField(blank=True, choices=[(100, b'2-post frame'), (200, b'4-post frame'), (300, b'4-post cabinet'), (1000, b'Wall-mounted frame'), (1100, b'Wall-mounted cabinet')], null=True, verbose_name=b'Type'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='width',
|
||||
field=models.PositiveSmallIntegerField(choices=[(19, b'19 inches'), (23, b'23 inches')], default=19, help_text=b'Rail-to-rail width', verbose_name=b'Width'),
|
||||
),
|
||||
]
|
||||
21
netbox/dcim/migrations/0015_rack_add_u_height_validator.py
Normal file
21
netbox/dcim/migrations/0015_rack_add_u_height_validator.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-08-09 21:18
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0014_rack_add_type_width'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='rack',
|
||||
name='u_height',
|
||||
field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name=b'Height (U)'),
|
||||
),
|
||||
]
|
||||
21
netbox/dcim/migrations/0016_module_add_manufacturer.py
Normal file
21
netbox/dcim/migrations/0016_module_add_manufacturer.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-08-10 13:45
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0015_rack_add_u_height_validator'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='module',
|
||||
name='manufacturer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='modules', to='dcim.Manufacturer'),
|
||||
),
|
||||
]
|
||||
33
netbox/dcim/migrations/0017_rack_add_role.py
Normal file
33
netbox/dcim/migrations/0017_rack_add_role.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-08-10 14:58
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0016_module_add_manufacturer'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RackRole',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, unique=True)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('color', models.CharField(choices=[[b'teal', b'Teal'], [b'green', b'Green'], [b'blue', b'Blue'], [b'purple', b'Purple'], [b'yellow', b'Yellow'], [b'orange', b'Orange'], [b'red', b'Red'], [b'light_gray', b'Light Gray'], [b'medium_gray', b'Medium Gray'], [b'dark_gray', b'Dark Gray']], max_length=30)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='role',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='dcim.RackRole'),
|
||||
),
|
||||
]
|
||||
21
netbox/dcim/migrations/0018_device_add_asset_tag.py
Normal file
21
netbox/dcim/migrations/0018_device_add_asset_tag.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-08-11 15:42
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
import utilities.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0017_rack_add_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='asset_tag',
|
||||
field=utilities.fields.NullableCharField(blank=True, help_text=b'A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name=b'Asset tag'),
|
||||
),
|
||||
]
|
||||
25
netbox/dcim/migrations/0019_new_iface_form_factors.py
Normal file
25
netbox/dcim/migrations/0019_new_iface_form_factors.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-09-13 15:20
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0018_device_add_asset_tag'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interfacetemplate',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus']]], [b'Other', [[32767, b'Other']]]], default=1200),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +1,44 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import MultipleObjectsReturned, ValidationError
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Count, Q, ObjectDoesNotExist
|
||||
|
||||
from extras.models import CustomFieldModel, CustomField, CustomFieldValue
|
||||
from extras.rpc import RPC_CLIENTS
|
||||
from tenancy.models import Tenant
|
||||
from utilities.fields import NullableCharField
|
||||
from utilities.managers import NaturalOrderByManager
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
|
||||
from .fields import ASNField, MACAddressField
|
||||
|
||||
|
||||
RACK_TYPE_2POST = 100
|
||||
RACK_TYPE_4POST = 200
|
||||
RACK_TYPE_CABINET = 300
|
||||
RACK_TYPE_WALLFRAME = 1000
|
||||
RACK_TYPE_WALLCABINET = 1100
|
||||
RACK_TYPE_CHOICES = (
|
||||
(RACK_TYPE_2POST, '2-post frame'),
|
||||
(RACK_TYPE_4POST, '4-post frame'),
|
||||
(RACK_TYPE_CABINET, '4-post cabinet'),
|
||||
(RACK_TYPE_WALLFRAME, 'Wall-mounted frame'),
|
||||
(RACK_TYPE_WALLCABINET, 'Wall-mounted cabinet'),
|
||||
)
|
||||
|
||||
RACK_WIDTH_19IN = 19
|
||||
RACK_WIDTH_23IN = 23
|
||||
RACK_WIDTH_CHOICES = (
|
||||
(RACK_WIDTH_19IN, '19 inches'),
|
||||
(RACK_WIDTH_23IN, '23 inches'),
|
||||
)
|
||||
|
||||
RACK_FACE_FRONT = 0
|
||||
RACK_FACE_REAR = 1
|
||||
RACK_FACE_CHOICES = [
|
||||
@@ -38,7 +64,7 @@ COLOR_RED = 'red'
|
||||
COLOR_GRAY1 = 'light_gray'
|
||||
COLOR_GRAY2 = 'medium_gray'
|
||||
COLOR_GRAY3 = 'dark_gray'
|
||||
DEVICE_ROLE_COLOR_CHOICES = [
|
||||
ROLE_COLOR_CHOICES = [
|
||||
[COLOR_TEAL, 'Teal'],
|
||||
[COLOR_GREEN, 'Green'],
|
||||
[COLOR_BLUE, 'Blue'],
|
||||
@@ -51,23 +77,101 @@ DEVICE_ROLE_COLOR_CHOICES = [
|
||||
[COLOR_GRAY3, 'Dark Gray'],
|
||||
]
|
||||
|
||||
# Virtual
|
||||
IFACE_FF_VIRTUAL = 0
|
||||
IFACE_FF_100M_COPPER = 800
|
||||
IFACE_FF_1GE_COPPER = 1000
|
||||
IFACE_FF_SFP = 1100
|
||||
IFACE_FF_10GE_COPPER = 1150
|
||||
IFACE_FF_SFP_PLUS = 1200
|
||||
IFACE_FF_XFP = 1300
|
||||
IFACE_FF_QSFP_PLUS = 1400
|
||||
# Ethernet
|
||||
IFACE_FF_100ME_FIXED = 800
|
||||
IFACE_FF_1GE_FIXED = 1000
|
||||
IFACE_FF_1GE_GBIC = 1050
|
||||
IFACE_FF_1GE_SFP = 1100
|
||||
IFACE_FF_10GE_FIXED = 1150
|
||||
IFACE_FF_10GE_SFP_PLUS = 1200
|
||||
IFACE_FF_10GE_XFP = 1300
|
||||
IFACE_FF_10GE_XENPAK = 1310
|
||||
IFACE_FF_10GE_X2 = 1320
|
||||
IFACE_FF_25GE_SFP28 = 1350
|
||||
IFACE_FF_40GE_QSFP_PLUS = 1400
|
||||
IFACE_FF_100GE_CFP = 1500
|
||||
IFACE_FF_100GE_QSFP28 = 1600
|
||||
# Fibrechannel
|
||||
IFACE_FF_1GFC_SFP = 3010
|
||||
IFACE_FF_2GFC_SFP = 3020
|
||||
IFACE_FF_4GFC_SFP = 3040
|
||||
IFACE_FF_8GFC_SFP_PLUS = 3080
|
||||
IFACE_FF_16GFC_SFP_PLUS = 3160
|
||||
# Serial
|
||||
IFACE_FF_T1 = 4000
|
||||
IFACE_FF_E1 = 4010
|
||||
IFACE_FF_T3 = 4040
|
||||
IFACE_FF_E3 = 4050
|
||||
# Stacking
|
||||
IFACE_FF_STACKWISE = 5000
|
||||
IFACE_FF_STACKWISE_PLUS = 5050
|
||||
# Other
|
||||
IFACE_FF_OTHER = 32767
|
||||
|
||||
IFACE_FF_CHOICES = [
|
||||
[IFACE_FF_VIRTUAL, 'Virtual'],
|
||||
[IFACE_FF_100M_COPPER, '10/100M (100BASE-TX)'],
|
||||
[IFACE_FF_1GE_COPPER, '1GE (1000BASE-T)'],
|
||||
[IFACE_FF_SFP, '1GE (SFP)'],
|
||||
[IFACE_FF_10GE_COPPER, '10GE (10GBASE-T)'],
|
||||
[IFACE_FF_SFP_PLUS, '10GE (SFP+)'],
|
||||
[IFACE_FF_XFP, '10GE (XFP)'],
|
||||
[IFACE_FF_QSFP_PLUS, '40GE (QSFP+)'],
|
||||
[
|
||||
'Virtual interfaces',
|
||||
[
|
||||
[IFACE_FF_VIRTUAL, 'Virtual'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'Ethernet (fixed)',
|
||||
[
|
||||
[IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'],
|
||||
[IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'],
|
||||
[IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'Ethernet (modular)',
|
||||
[
|
||||
[IFACE_FF_1GE_GBIC, 'GBIC (1GE)'],
|
||||
[IFACE_FF_1GE_SFP, 'SFP (1GE)'],
|
||||
[IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'],
|
||||
[IFACE_FF_10GE_XFP, 'XFP (10GE)'],
|
||||
[IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'],
|
||||
[IFACE_FF_10GE_X2, 'X2 (10GE)'],
|
||||
[IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
|
||||
[IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
|
||||
[IFACE_FF_100GE_CFP, 'CFP (100GE)'],
|
||||
[IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'FibreChannel',
|
||||
[
|
||||
[IFACE_FF_1GFC_SFP, 'SFP (1GFC)'],
|
||||
[IFACE_FF_2GFC_SFP, 'SFP (2GFC)'],
|
||||
[IFACE_FF_4GFC_SFP, 'SFP (4GFC)'],
|
||||
[IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
|
||||
[IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'Serial',
|
||||
[
|
||||
[IFACE_FF_T1, 'T1 (1.544 Mbps)'],
|
||||
[IFACE_FF_E1, 'E1 (2.048 Mbps)'],
|
||||
[IFACE_FF_T3, 'T3 (45 Mbps)'],
|
||||
[IFACE_FF_E3, 'E3 (34 Mbps)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'Stacking',
|
||||
[
|
||||
[IFACE_FF_STACKWISE, 'Cisco StackWise'],
|
||||
[IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'Other',
|
||||
[
|
||||
[IFACE_FF_OTHER, 'Other'],
|
||||
]
|
||||
],
|
||||
]
|
||||
|
||||
STATUS_ACTIVE = True
|
||||
@@ -137,18 +241,32 @@ def order_interfaces(queryset, sql_col, primary_ordering=tuple()):
|
||||
}).order_by(*ordering)
|
||||
|
||||
|
||||
class Site(CreatedUpdatedModel):
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteManager(NaturalOrderByManager):
|
||||
|
||||
def get_queryset(self):
|
||||
return self.natural_order_by('name')
|
||||
|
||||
|
||||
class Site(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
A Site represents a geographic location within a network; typically a building or campus. The optional facility
|
||||
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='sites', on_delete=models.PROTECT)
|
||||
facility = models.CharField(max_length=50, blank=True)
|
||||
asn = ASNField(blank=True, null=True, verbose_name='ASN')
|
||||
physical_address = models.CharField(max_length=200, blank=True)
|
||||
shipping_address = models.CharField(max_length=200, blank=True)
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
objects = SiteManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -163,6 +281,7 @@ class Site(CreatedUpdatedModel):
|
||||
return ','.join([
|
||||
self.name,
|
||||
self.slug,
|
||||
self.tenant.name if self.tenant else '',
|
||||
self.facility,
|
||||
str(self.asn),
|
||||
])
|
||||
@@ -188,6 +307,10 @@ class Site(CreatedUpdatedModel):
|
||||
return self.circuits.count()
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackGroup(models.Model):
|
||||
"""
|
||||
Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
|
||||
@@ -206,13 +329,37 @@ class RackGroup(models.Model):
|
||||
]
|
||||
|
||||
def __unicode__(self):
|
||||
return '{} - {}'.format(self.site.name, self.name)
|
||||
return u'{} - {}'.format(self.site.name, self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
|
||||
|
||||
|
||||
class Rack(CreatedUpdatedModel):
|
||||
class RackRole(models.Model):
|
||||
"""
|
||||
Racks can be organized by functional role, similar to Devices.
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
|
||||
|
||||
|
||||
class RackManager(NaturalOrderByManager):
|
||||
|
||||
def get_queryset(self):
|
||||
return self.natural_order_by('site__name', 'name')
|
||||
|
||||
|
||||
class Rack(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
||||
Each Rack is assigned to a Site and (optionally) a RackGroup.
|
||||
@@ -221,8 +368,17 @@ class Rack(CreatedUpdatedModel):
|
||||
facility_id = NullableCharField(max_length=30, 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)
|
||||
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)')
|
||||
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)
|
||||
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')
|
||||
u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)',
|
||||
validators=[MinValueValidator(1), MaxValueValidator(100)])
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
objects = RackManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
@@ -254,6 +410,10 @@ class Rack(CreatedUpdatedModel):
|
||||
self.group.name if self.group else '',
|
||||
self.name,
|
||||
self.facility_id or '',
|
||||
self.tenant.name if self.tenant else '',
|
||||
self.role.name if self.role else '',
|
||||
self.get_type_display() if self.type else '',
|
||||
str(self.width),
|
||||
str(self.u_height),
|
||||
])
|
||||
|
||||
@@ -342,6 +502,15 @@ class Rack(CreatedUpdatedModel):
|
||||
def get_0u_devices(self):
|
||||
return self.devices.filter(position=0)
|
||||
|
||||
def get_utilization(self):
|
||||
"""
|
||||
Determine the utilization rate of the rack and return it as a percentage.
|
||||
"""
|
||||
if self.u_consumed is None:
|
||||
self.u_consumed = 0
|
||||
u_available = self.u_height - self.u_consumed
|
||||
return int(float(self.u_height - u_available) / self.u_height * 100)
|
||||
|
||||
|
||||
#
|
||||
# Device Types
|
||||
@@ -382,6 +551,7 @@ class DeviceType(models.Model):
|
||||
manufacturer = models.ForeignKey('Manufacturer', related_name='device_types', on_delete=models.PROTECT)
|
||||
model = models.CharField(max_length=50)
|
||||
slug = models.SlugField()
|
||||
part_number = models.CharField(max_length=50, blank=True, help_text="Discrete part number (optional)")
|
||||
u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1)
|
||||
is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth",
|
||||
help_text="Device consumes both front and rear rack faces")
|
||||
@@ -404,7 +574,7 @@ class DeviceType(models.Model):
|
||||
]
|
||||
|
||||
def __unicode__(self):
|
||||
return "{} {}".format(self.manufacturer, self.model)
|
||||
return u'{} {}'.format(self.manufacturer, self.model)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
@@ -512,7 +682,7 @@ class InterfaceTemplate(models.Model):
|
||||
"""
|
||||
device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=30)
|
||||
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
|
||||
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
|
||||
mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
|
||||
|
||||
objects = InterfaceTemplateManager()
|
||||
@@ -551,7 +721,7 @@ class DeviceRole(models.Model):
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
color = models.CharField(max_length=30, choices=DEVICE_ROLE_COLOR_CHOICES)
|
||||
color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -583,7 +753,13 @@ class Platform(models.Model):
|
||||
return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
|
||||
|
||||
|
||||
class Device(CreatedUpdatedModel):
|
||||
class DeviceManager(NaturalOrderByManager):
|
||||
|
||||
def get_queryset(self):
|
||||
return self.natural_order_by('name')
|
||||
|
||||
|
||||
class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
|
||||
DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
|
||||
@@ -597,9 +773,12 @@ class Device(CreatedUpdatedModel):
|
||||
"""
|
||||
device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT)
|
||||
device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT)
|
||||
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='devices', on_delete=models.PROTECT)
|
||||
platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
|
||||
name = NullableCharField(max_length=50, blank=True, null=True, unique=True)
|
||||
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
|
||||
asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
|
||||
help_text='A unique tag used to identify this device')
|
||||
rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT)
|
||||
position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
|
||||
verbose_name='Position (U)',
|
||||
@@ -611,6 +790,9 @@ class Device(CreatedUpdatedModel):
|
||||
primary_ip6 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL,
|
||||
blank=True, null=True, verbose_name='Primary IPv6')
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
objects = DeviceManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -624,6 +806,10 @@ class Device(CreatedUpdatedModel):
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate device type assignment
|
||||
if not hasattr(self, 'device_type'):
|
||||
raise ValidationError("Must specify device type.")
|
||||
|
||||
# Child devices cannot be assigned to a rack face/unit
|
||||
if self.device_type.is_child_device and (self.face is not None or self.position):
|
||||
raise ValidationError("Child device types cannot be assigned a rack face or position.")
|
||||
@@ -633,10 +819,7 @@ class Device(CreatedUpdatedModel):
|
||||
raise ValidationError("Must specify rack face with rack position.")
|
||||
|
||||
# Validate rack space
|
||||
try:
|
||||
rack_face = self.face if not self.device_type.is_full_depth else None
|
||||
except DeviceType.DoesNotExist:
|
||||
raise ValidationError("Must specify device type.")
|
||||
rack_face = self.face if not self.device_type.is_full_depth else None
|
||||
exclude_list = [self.pk] if self.pk else []
|
||||
try:
|
||||
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
|
||||
@@ -680,14 +863,19 @@ class Device(CreatedUpdatedModel):
|
||||
self.device_type.device_bay_templates.all()]
|
||||
)
|
||||
|
||||
# Update Rack assignment for any child Devices
|
||||
Device.objects.filter(parent_bay__device=self).update(rack=self.rack)
|
||||
|
||||
def to_csv(self):
|
||||
return ','.join([
|
||||
self.name or '',
|
||||
self.device_role.name,
|
||||
self.tenant.name if self.tenant else '',
|
||||
self.device_type.manufacturer.name,
|
||||
self.device_type.model,
|
||||
self.platform.name if self.platform else '',
|
||||
self.serial,
|
||||
self.asset_tag if self.asset_tag else '',
|
||||
self.rack.site.name,
|
||||
self.rack.name,
|
||||
str(self.position) if self.position else '',
|
||||
@@ -870,7 +1058,7 @@ class Interface(models.Model):
|
||||
"""
|
||||
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=30)
|
||||
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS)
|
||||
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
|
||||
mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
|
||||
mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management',
|
||||
help_text="This interface is used only for out-of-band management")
|
||||
@@ -885,6 +1073,13 @@ class Interface(models.Model):
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
|
||||
if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected:
|
||||
raise ValidationError({'form_factor': "Virtual interfaces cannot be connected to another interface or "
|
||||
"circuit. Disconnect the interface or choose a physical form "
|
||||
"factor."})
|
||||
|
||||
@property
|
||||
def is_physical(self):
|
||||
return self.form_factor != IFACE_FF_VIRTUAL
|
||||
@@ -918,8 +1113,8 @@ class Interface(models.Model):
|
||||
return connection.interface_a
|
||||
except InterfaceConnection.DoesNotExist:
|
||||
return None
|
||||
except InterfaceConnection.MultipleObjectsReturned as e:
|
||||
raise e("Multiple connections found for {0} interface {1}!".format(self.device, self))
|
||||
except InterfaceConnection.MultipleObjectsReturned:
|
||||
raise MultipleObjectsReturned("Multiple connections found for {} interface {}!".format(self.device, self))
|
||||
|
||||
|
||||
class InterfaceConnection(models.Model):
|
||||
@@ -953,14 +1148,15 @@ class DeviceBay(models.Model):
|
||||
"""
|
||||
device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=50, verbose_name='Name')
|
||||
installed_device = models.OneToOneField('Device', related_name='parent_bay', blank=True, null=True)
|
||||
installed_device = models.OneToOneField('Device', related_name='parent_bay', on_delete=models.SET_NULL, blank=True,
|
||||
null=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['device', 'name']
|
||||
unique_together = ['device', 'name']
|
||||
|
||||
def __unicode__(self):
|
||||
return '{} - {}'.format(self.device.name, self.name)
|
||||
return u'{} - {}'.format(self.device.name, self.name)
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -982,6 +1178,8 @@ class Module(models.Model):
|
||||
device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE)
|
||||
parent = models.ForeignKey('self', related_name='submodules', blank=True, null=True, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=50, verbose_name='Name')
|
||||
manufacturer = models.ForeignKey('Manufacturer', related_name='modules', blank=True, null=True,
|
||||
on_delete=models.PROTECT)
|
||||
part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True)
|
||||
serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)
|
||||
discovered = models.BooleanField(default=False, verbose_name='Discovered')
|
||||
|
||||
@@ -10,36 +10,58 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
COLOR_LABEL = """
|
||||
<label class="label {{ record.color }}">{{ record }}</label>
|
||||
"""
|
||||
|
||||
DEVICE_LINK = """
|
||||
<a href="{% url 'dcim:device' pk=record.pk %}">
|
||||
{{ record.name|default:'<span class="label label-info">Unnamed device</span>' }}
|
||||
</a>
|
||||
"""
|
||||
|
||||
RACKGROUP_EDIT_LINK = """
|
||||
RACKGROUP_ACTIONS = """
|
||||
{% if perms.dcim.change_rackgroup %}
|
||||
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}">Edit</a>
|
||||
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICEROLE_EDIT_LINK = """
|
||||
RACKROLE_ACTIONS = """
|
||||
{% if perms.dcim.change_rackrole %}
|
||||
<a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
RACK_ROLE = """
|
||||
{% if record.role %}
|
||||
<label class="label {{ record.role.color }}">{{ value }}</label>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICEROLE_ACTIONS = """
|
||||
{% if perms.dcim.change_devicerole %}
|
||||
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}">Edit</a>
|
||||
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
MANUFACTURER_EDIT_LINK = """
|
||||
MANUFACTURER_ACTIONS = """
|
||||
{% if perms.dcim.change_manufacturer %}
|
||||
<a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}">Edit</a>
|
||||
<a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
PLATFORM_EDIT_LINK = """
|
||||
PLATFORM_ACTIONS = """
|
||||
{% if perms.dcim.change_platform %}
|
||||
<a href="{% url 'dcim:platform_edit' slug=record.slug %}">Edit</a>
|
||||
<a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICE_ROLE = """
|
||||
<label class="label {{ record.device_role.color }}">{{ value }}</label>
|
||||
"""
|
||||
|
||||
STATUS_ICON = """
|
||||
{% if record.status %}
|
||||
<span class="glyphicon glyphicon-ok-sign text-success" title="Active" aria-hidden="true"></span>
|
||||
@@ -48,14 +70,21 @@ STATUS_ICON = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% load helpers %}
|
||||
{% utilization_graph record.get_utilization %}
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
|
||||
facility = tables.Column(verbose_name='Facility')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
asn = tables.Column(verbose_name='ASN')
|
||||
rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
|
||||
device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
|
||||
@@ -65,8 +94,8 @@ class SiteTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Site
|
||||
fields = ('name', 'facility', 'asn', 'rack_count', 'device_count', 'prefix_count', 'vlan_count',
|
||||
'circuit_count')
|
||||
fields = ('pk', 'name', 'facility', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
|
||||
'vlan_count', 'circuit_count')
|
||||
|
||||
|
||||
#
|
||||
@@ -79,11 +108,30 @@ class RackGroupTable(BaseTable):
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
rack_count = tables.Column(verbose_name='Racks')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
edit = tables.TemplateColumn(template_code=RACKGROUP_EDIT_LINK, verbose_name='')
|
||||
actions = tables.TemplateColumn(template_code=RACKGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackGroup
|
||||
fields = ('pk', 'name', 'site', 'rack_count', 'slug', 'edit')
|
||||
fields = ('pk', 'name', 'site', 'rack_count', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
rack_count = tables.Column(verbose_name='Racks')
|
||||
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=RACKROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackGroup
|
||||
fields = ('pk', 'name', 'rack_count', 'color', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -96,12 +144,30 @@ class RackTable(BaseTable):
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
|
||||
facility_id = tables.Column(verbose_name='Facility ID')
|
||||
u_height = tables.Column(verbose_name='Height (U)')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
role = tables.TemplateColumn(RACK_ROLE, verbose_name='Role')
|
||||
u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
|
||||
devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
|
||||
u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used')
|
||||
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Rack
|
||||
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'u_height', 'devices')
|
||||
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'u_consumed',
|
||||
'utilization')
|
||||
|
||||
|
||||
class RackImportTable(BaseTable):
|
||||
name = tables.LinkColumn('dcim:rack', args=[Accessor('pk')], verbose_name='Name')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
|
||||
facility_id = tables.Column(verbose_name='Facility ID')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
u_height = tables.Column(verbose_name='Height (U)')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Rack
|
||||
fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height')
|
||||
|
||||
|
||||
#
|
||||
@@ -113,11 +179,12 @@ class ManufacturerTable(BaseTable):
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
devicetype_count = tables.Column(verbose_name='Device Types')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
edit = tables.TemplateColumn(template_code=MANUFACTURER_EDIT_LINK, verbose_name='')
|
||||
actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Manufacturer
|
||||
fields = ('pk', 'name', 'devicetype_count', 'slug', 'edit')
|
||||
fields = ('pk', 'name', 'devicetype_count', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -126,93 +193,77 @@ class ManufacturerTable(BaseTable):
|
||||
|
||||
class DeviceTypeTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
manufacturer = tables.Column(verbose_name='Manufacturer')
|
||||
model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
|
||||
part_number = tables.Column(verbose_name='Part Number')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceType
|
||||
fields = ('pk', 'model', 'manufacturer', 'u_height')
|
||||
fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height')
|
||||
|
||||
|
||||
#
|
||||
# Device type components
|
||||
#
|
||||
|
||||
class ConsolePortTemplateTable(tables.Table):
|
||||
class ConsolePortTemplateTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
|
||||
class Meta:
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ConsolePortTemplate
|
||||
fields = ('pk', 'name')
|
||||
empty_text = "None"
|
||||
show_header = False
|
||||
attrs = {
|
||||
'class': 'table table-hover',
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateTable(tables.Table):
|
||||
class ConsoleServerPortTemplateTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
|
||||
class Meta:
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = ('pk', 'name')
|
||||
empty_text = "None"
|
||||
show_header = False
|
||||
attrs = {
|
||||
'class': 'table table-hover',
|
||||
}
|
||||
|
||||
|
||||
class PowerPortTemplateTable(tables.Table):
|
||||
class PowerPortTemplateTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
|
||||
class Meta:
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPortTemplate
|
||||
fields = ('pk', 'name')
|
||||
empty_text = "None"
|
||||
show_header = False
|
||||
attrs = {
|
||||
'class': 'table table-hover',
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletTemplateTable(tables.Table):
|
||||
class PowerOutletTemplateTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
|
||||
class Meta:
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerOutletTemplate
|
||||
fields = ('pk', 'name')
|
||||
empty_text = "None"
|
||||
show_header = False
|
||||
attrs = {
|
||||
'class': 'table table-hover',
|
||||
}
|
||||
|
||||
|
||||
class InterfaceTemplateTable(tables.Table):
|
||||
class InterfaceTemplateTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
|
||||
class Meta:
|
||||
class Meta(BaseTable.Meta):
|
||||
model = InterfaceTemplate
|
||||
fields = ('pk', 'name')
|
||||
fields = ('pk', 'name', 'form_factor')
|
||||
empty_text = "None"
|
||||
show_header = False
|
||||
attrs = {
|
||||
'class': 'table table-hover panel-body',
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayTemplateTable(tables.Table):
|
||||
class DeviceBayTemplateTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
|
||||
class Meta:
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceBayTemplate
|
||||
fields = ('pk', 'name')
|
||||
empty_text = "None"
|
||||
show_header = False
|
||||
attrs = {
|
||||
'class': 'table table-hover panel-body',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
@@ -223,13 +274,14 @@ class DeviceRoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
device_count = tables.Column(verbose_name='Devices')
|
||||
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
color = tables.Column(verbose_name='Color')
|
||||
edit = tables.TemplateColumn(template_code=DEVICEROLE_EDIT_LINK, verbose_name='')
|
||||
actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceRole
|
||||
fields = ('pk', 'name', 'device_count', 'slug', 'color')
|
||||
fields = ('pk', 'name', 'device_count', 'color', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -241,11 +293,11 @@ class PlatformTable(BaseTable):
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
device_count = tables.Column(verbose_name='Devices')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
edit = tables.TemplateColumn(template_code=PLATFORM_EDIT_LINK, verbose_name='')
|
||||
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', 'edit')
|
||||
fields = ('pk', 'name', 'device_count', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -256,20 +308,22 @@ class DeviceTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
|
||||
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
|
||||
device_role = tables.Column(verbose_name='Role')
|
||||
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
|
||||
device_type = tables.Column(verbose_name='Type')
|
||||
primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address',
|
||||
template_code="{{ record.primary_ip.address.ip }}")
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Device
|
||||
fields = ('pk', 'name', 'status', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
|
||||
fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
|
||||
|
||||
|
||||
class DeviceImportTable(BaseTable):
|
||||
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
|
||||
position = tables.Column(verbose_name='Position')
|
||||
@@ -278,7 +332,7 @@ class DeviceImportTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Device
|
||||
fields = ('name', 'site', 'rack', 'position', 'device_role', 'device_type')
|
||||
fields = ('name', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
|
||||
empty_text = False
|
||||
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@ class SiteTest(APITestCase):
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
'tenant',
|
||||
'facility',
|
||||
'asn',
|
||||
'physical_address',
|
||||
'shipping_address',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
'count_prefixes',
|
||||
'count_vlans',
|
||||
'count_racks',
|
||||
@@ -40,8 +42,13 @@ class SiteTest(APITestCase):
|
||||
'display_name',
|
||||
'site',
|
||||
'group',
|
||||
'tenant',
|
||||
'role',
|
||||
'type',
|
||||
'width',
|
||||
'u_height',
|
||||
'comments'
|
||||
'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
graph_fields = [
|
||||
@@ -115,8 +122,13 @@ class RackTest(APITestCase):
|
||||
'display_name',
|
||||
'site',
|
||||
'group',
|
||||
'tenant',
|
||||
'role',
|
||||
'type',
|
||||
'width',
|
||||
'u_height',
|
||||
'comments'
|
||||
'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
detail_fields = [
|
||||
@@ -126,8 +138,13 @@ class RackTest(APITestCase):
|
||||
'display_name',
|
||||
'site',
|
||||
'group',
|
||||
'tenant',
|
||||
'role',
|
||||
'type',
|
||||
'width',
|
||||
'u_height',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
'front_units',
|
||||
'rear_units'
|
||||
]
|
||||
@@ -204,6 +221,7 @@ class DeviceTypeTest(APITestCase):
|
||||
'manufacturer',
|
||||
'model',
|
||||
'slug',
|
||||
'part_number',
|
||||
'u_height',
|
||||
'is_full_depth',
|
||||
'is_console_server',
|
||||
@@ -310,8 +328,10 @@ class DeviceTest(APITestCase):
|
||||
'display_name',
|
||||
'device_type',
|
||||
'device_role',
|
||||
'tenant',
|
||||
'platform',
|
||||
'serial',
|
||||
'asset_tag',
|
||||
'rack',
|
||||
'position',
|
||||
'face',
|
||||
@@ -321,6 +341,7 @@ class DeviceTest(APITestCase):
|
||||
'primary_ip4',
|
||||
'primary_ip6',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
nested_fields = ['id', 'name', 'display_name']
|
||||
@@ -355,6 +376,7 @@ class DeviceTest(APITestCase):
|
||||
def test_get_list_flat(self, endpoint='/api/dcim/devices/?format=json_flat'):
|
||||
|
||||
flat_fields = [
|
||||
'asset_tag',
|
||||
'comments',
|
||||
'device_role_id',
|
||||
'device_role_name',
|
||||
@@ -387,6 +409,7 @@ class DeviceTest(APITestCase):
|
||||
'rack_name',
|
||||
'serial',
|
||||
'status',
|
||||
'tenant',
|
||||
]
|
||||
|
||||
response = self.client.get(endpoint)
|
||||
|
||||
@@ -13,59 +13,67 @@ class DeviceTestCase(TestCase):
|
||||
|
||||
def test_racked_device(self):
|
||||
test = DeviceForm(data={
|
||||
'device_role': get_id(DeviceRole, 'leaf-switch'),
|
||||
'name': 'test',
|
||||
'site': get_id(Site, 'test1'),
|
||||
'face': RACK_FACE_FRONT,
|
||||
'platform': get_id(Platform, 'juniper-junos'),
|
||||
'device_type': get_id(DeviceType, 'qfx5100-48s'),
|
||||
'position': 41,
|
||||
'rack': '1',
|
||||
'device_role': get_id(DeviceRole, 'leaf-switch'),
|
||||
'tenant': None,
|
||||
'manufacturer': get_id(Manufacturer, 'juniper'),
|
||||
'device_type': get_id(DeviceType, 'qfx5100-48s'),
|
||||
'site': get_id(Site, 'test1'),
|
||||
'rack': '1',
|
||||
'face': RACK_FACE_FRONT,
|
||||
'position': 41,
|
||||
'platform': get_id(Platform, 'juniper-junos'),
|
||||
'status': STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(test.is_valid(), test.fields['position'].choices)
|
||||
self.assertTrue(test.save())
|
||||
|
||||
def test_racked_device_occupied(self):
|
||||
test = DeviceForm(data={
|
||||
'device_role': get_id(DeviceRole, 'leaf-switch'),
|
||||
'name': 'test',
|
||||
'site': get_id(Site, 'test1'),
|
||||
'face': RACK_FACE_FRONT,
|
||||
'platform': get_id(Platform, 'juniper-junos'),
|
||||
'device_type': get_id(DeviceType, 'qfx5100-48s'),
|
||||
'position': 1,
|
||||
'rack': '1',
|
||||
'device_role': get_id(DeviceRole, 'leaf-switch'),
|
||||
'tenant': None,
|
||||
'manufacturer': get_id(Manufacturer, 'juniper'),
|
||||
'device_type': get_id(DeviceType, 'qfx5100-48s'),
|
||||
'site': get_id(Site, 'test1'),
|
||||
'rack': '1',
|
||||
'face': RACK_FACE_FRONT,
|
||||
'position': 1,
|
||||
'platform': get_id(Platform, 'juniper-junos'),
|
||||
'status': STATUS_ACTIVE,
|
||||
})
|
||||
self.assertFalse(test.is_valid())
|
||||
|
||||
def test_non_racked_device(self):
|
||||
test = DeviceForm(data={
|
||||
'device_role': get_id(DeviceRole, 'pdu'),
|
||||
'name': 'test',
|
||||
'site': get_id(Site, 'test1'),
|
||||
'face': None,
|
||||
'platform': None,
|
||||
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
|
||||
'position': None,
|
||||
'rack': '1',
|
||||
'device_role': get_id(DeviceRole, 'pdu'),
|
||||
'tenant': None,
|
||||
'manufacturer': get_id(Manufacturer, 'servertech'),
|
||||
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
|
||||
'site': get_id(Site, 'test1'),
|
||||
'rack': '1',
|
||||
'face': None,
|
||||
'position': None,
|
||||
'platform': None,
|
||||
'status': STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(test.is_valid())
|
||||
self.assertTrue(test.save())
|
||||
|
||||
def test_non_racked_device_with_face(self):
|
||||
test = DeviceForm(data={
|
||||
'device_role': get_id(DeviceRole, 'pdu'),
|
||||
'name': 'test',
|
||||
'site': get_id(Site, 'test1'),
|
||||
'face': RACK_FACE_REAR,
|
||||
'platform': None,
|
||||
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
|
||||
'position': None,
|
||||
'rack': '1',
|
||||
'device_role': get_id(DeviceRole, 'pdu'),
|
||||
'tenant': None,
|
||||
'manufacturer': get_id(Manufacturer, 'servertech'),
|
||||
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
|
||||
'site': get_id(Site, 'test1'),
|
||||
'rack': '1',
|
||||
'face': RACK_FACE_REAR,
|
||||
'position': None,
|
||||
'platform': None,
|
||||
'status': STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(test.is_valid())
|
||||
self.assertTrue(test.save())
|
||||
|
||||
@@ -15,6 +15,7 @@ urlpatterns = [
|
||||
url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
|
||||
url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'),
|
||||
url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
|
||||
url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
|
||||
url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
|
||||
url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
|
||||
url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
|
||||
@@ -25,6 +26,12 @@ urlpatterns = [
|
||||
url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
|
||||
url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
|
||||
|
||||
# Rack roles
|
||||
url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
|
||||
url(r'^rack-roles/add/$', views.RackRoleEditView.as_view(), name='rackrole_add'),
|
||||
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'),
|
||||
|
||||
# Racks
|
||||
url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
|
||||
url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'),
|
||||
@@ -50,31 +57,29 @@ urlpatterns = [
|
||||
url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
|
||||
url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
|
||||
|
||||
# Component templates
|
||||
url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateAddView.as_view(),
|
||||
name='devicetype_add_consoleport'),
|
||||
url(r'^device-types/(?P<pk>\d+)/console-ports/delete/$', views.component_template_delete,
|
||||
{'model': ConsolePortTemplate}, name='devicetype_delete_consoleport'),
|
||||
url(r'^device-types/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateAddView.as_view(),
|
||||
name='devicetype_add_consoleserverport'),
|
||||
url(r'^device-types/(?P<pk>\d+)/console-server-ports/delete/$', views.component_template_delete,
|
||||
{'model': ConsoleServerPortTemplate}, name='devicetype_delete_consoleserverport'),
|
||||
url(r'^device-types/(?P<pk>\d+)/power-ports/add/$', views.PowerPortTemplateAddView.as_view(),
|
||||
name='devicetype_add_powerport'),
|
||||
url(r'^device-types/(?P<pk>\d+)/power-ports/delete/$', views.component_template_delete,
|
||||
{'model': PowerPortTemplate}, name='devicetype_delete_powerport'),
|
||||
url(r'^device-types/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletTemplateAddView.as_view(),
|
||||
name='devicetype_add_poweroutlet'),
|
||||
url(r'^device-types/(?P<pk>\d+)/power-outlets/delete/$', views.component_template_delete,
|
||||
{'model': PowerOutletTemplate}, name='devicetype_delete_poweroutlet'),
|
||||
url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateAddView.as_view(),
|
||||
name='devicetype_add_interface'),
|
||||
url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.component_template_delete,
|
||||
{'model': InterfaceTemplate}, name='devicetype_delete_interface'),
|
||||
url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateAddView.as_view(),
|
||||
name='devicetype_add_devicebay'),
|
||||
url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.component_template_delete,
|
||||
{'model': DeviceBayTemplate}, name='devicetype_delete_devicebay'),
|
||||
# Console port templates
|
||||
url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateAddView.as_view(), name='devicetype_add_consoleport'),
|
||||
url(r'^device-types/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'),
|
||||
|
||||
# Console server port templates
|
||||
url(r'^device-types/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateAddView.as_view(), name='devicetype_add_consoleserverport'),
|
||||
url(r'^device-types/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'),
|
||||
|
||||
# Power port templates
|
||||
url(r'^device-types/(?P<pk>\d+)/power-ports/add/$', views.PowerPortTemplateAddView.as_view(), name='devicetype_add_powerport'),
|
||||
url(r'^device-types/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'),
|
||||
|
||||
# Power outlet templates
|
||||
url(r'^device-types/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletTemplateAddView.as_view(), name='devicetype_add_poweroutlet'),
|
||||
url(r'^device-types/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'),
|
||||
|
||||
# Interface templates
|
||||
url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateAddView.as_view(), name='devicetype_add_interface'),
|
||||
url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
|
||||
|
||||
# Device bay templates
|
||||
url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateAddView.as_view(), name='devicetype_add_devicebay'),
|
||||
url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'),
|
||||
|
||||
# Device roles
|
||||
url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
|
||||
@@ -92,6 +97,7 @@ urlpatterns = [
|
||||
url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
|
||||
url(r'^devices/add/$', views.DeviceEditView.as_view(), name='device_add'),
|
||||
url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'),
|
||||
url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
|
||||
url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
|
||||
url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
|
||||
url(r'^devices/(?P<pk>\d+)/$', views.device, name='device'),
|
||||
@@ -104,6 +110,7 @@ urlpatterns = [
|
||||
|
||||
# Console ports
|
||||
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.consoleport_edit, name='consoleport_edit'),
|
||||
@@ -111,6 +118,7 @@ urlpatterns = [
|
||||
|
||||
# Console server ports
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.consoleserverport_add, name='consoleserverport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.consoleserverport_edit, name='consoleserverport_edit'),
|
||||
@@ -118,6 +126,7 @@ urlpatterns = [
|
||||
|
||||
# Power ports
|
||||
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.powerport_add, name='powerport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.powerport_edit, name='powerport_edit'),
|
||||
@@ -125,6 +134,7 @@ urlpatterns = [
|
||||
|
||||
# Power outlets
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.poweroutlet_add, name='poweroutlet_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.poweroutlet_edit, name='poweroutlet_edit'),
|
||||
@@ -132,6 +142,7 @@ urlpatterns = [
|
||||
|
||||
# Device bays
|
||||
url(r'^devices/(?P<pk>\d+)/bays/add/$', views.devicebay_add, name='devicebay_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.devicebay_edit, name='devicebay_edit'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.devicebay_delete, name='devicebay_delete'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
|
||||
@@ -146,8 +157,9 @@ urlpatterns = [
|
||||
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
|
||||
|
||||
# Interfaces
|
||||
url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_bulk_add'),
|
||||
url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_add_multi'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.interface_add, name='interface_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
|
||||
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
|
||||
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.interface_edit, name='interface_edit'),
|
||||
|
||||
@@ -7,8 +7,8 @@ from django.contrib.auth.decorators import permission_required
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db.models import Count, ProtectedError
|
||||
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
|
||||
from django.db.models import Count, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.http import urlencode
|
||||
@@ -16,8 +16,7 @@ from django.views.generic import View
|
||||
|
||||
from ipam.models import Prefix, IPAddress, VLAN
|
||||
from circuits.models import Circuit
|
||||
from extras.models import TopologyMap
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
@@ -28,7 +27,7 @@ from .models import (
|
||||
CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
|
||||
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
|
||||
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
Site,
|
||||
RackRole, Site,
|
||||
)
|
||||
|
||||
|
||||
@@ -63,9 +62,11 @@ def expand_pattern(string):
|
||||
#
|
||||
|
||||
class SiteListView(ObjectListView):
|
||||
queryset = Site.objects.all()
|
||||
queryset = Site.objects.select_related('tenant')
|
||||
filter = filters.SiteFilter
|
||||
filter_form = forms.SiteFilterForm
|
||||
table = tables.SiteTable
|
||||
edit_permissions = ['dcim.change_rack', 'dcim.delete_rack']
|
||||
template_name = 'dcim/site_list.html'
|
||||
|
||||
|
||||
@@ -81,12 +82,14 @@ def site(request, slug):
|
||||
}
|
||||
rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
|
||||
topology_maps = TopologyMap.objects.filter(site=site)
|
||||
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_SITE).exists()
|
||||
|
||||
return render(request, 'dcim/site.html', {
|
||||
'site': site,
|
||||
'stats': stats,
|
||||
'rack_groups': rack_groups,
|
||||
'topology_maps': topology_maps,
|
||||
'show_graphs': show_graphs,
|
||||
})
|
||||
|
||||
|
||||
@@ -112,12 +115,20 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
obj_list_url = 'dcim:site_list'
|
||||
|
||||
|
||||
class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'dcim.change_site'
|
||||
cls = Site
|
||||
form = forms.SiteBulkEditForm
|
||||
template_name = 'dcim/site_bulk_edit.html'
|
||||
default_redirect_url = 'dcim:site_list'
|
||||
|
||||
|
||||
#
|
||||
# Rack groups
|
||||
#
|
||||
|
||||
class RackGroupListView(ObjectListView):
|
||||
queryset = RackGroup.objects.annotate(rack_count=Count('racks'))
|
||||
queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
|
||||
filter = filters.RackGroupFilter
|
||||
filter_form = forms.RackGroupFilterForm
|
||||
table = tables.RackGroupTable
|
||||
@@ -129,22 +140,49 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.change_rackgroup'
|
||||
model = RackGroup
|
||||
form_class = forms.RackGroupForm
|
||||
success_url = 'dcim:rackgroup_list'
|
||||
cancel_url = 'dcim:rackgroup_list'
|
||||
|
||||
|
||||
class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_rackgroup'
|
||||
cls = RackGroup
|
||||
form = forms.RackGroupBulkDeleteForm
|
||||
default_redirect_url = 'dcim:rackgroup_list'
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleListView(ObjectListView):
|
||||
queryset = RackRole.objects.annotate(rack_count=Count('racks'))
|
||||
table = tables.RackRoleTable
|
||||
edit_permissions = ['dcim.change_rackrole', 'dcim.delete_rackrole']
|
||||
template_name = 'dcim/rackrole_list.html'
|
||||
|
||||
|
||||
class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.change_rackrole'
|
||||
model = RackRole
|
||||
form_class = forms.RackRoleForm
|
||||
success_url = 'dcim:rackrole_list'
|
||||
cancel_url = 'dcim:rackrole_list'
|
||||
|
||||
|
||||
class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_rackrole'
|
||||
cls = RackRole
|
||||
default_redirect_url = 'dcim:rackrole_list'
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackListView(ObjectListView):
|
||||
queryset = Rack.objects.select_related('site').annotate(device_count=Count('devices', distinct=True))
|
||||
queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type')\
|
||||
.annotate(device_count=Count('devices', distinct=True),
|
||||
u_consumed=Coalesce(Sum('devices__device_type__u_height'), 0))
|
||||
filter = filters.RackFilter
|
||||
filter_form = forms.RackFilterForm
|
||||
table = tables.RackTable
|
||||
@@ -156,7 +194,7 @@ def rack(request, pk):
|
||||
|
||||
rack = get_object_or_404(Rack, pk=pk)
|
||||
|
||||
nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True)\
|
||||
nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)\
|
||||
.select_related('device_type__manufacturer')
|
||||
next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
|
||||
prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
|
||||
@@ -188,7 +226,7 @@ class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
permission_required = 'dcim.add_rack'
|
||||
form = forms.RackImportForm
|
||||
table = tables.RackTable
|
||||
table = tables.RackImportTable
|
||||
template_name = 'dcim/rack_import.html'
|
||||
obj_list_url = 'dcim:rack_list'
|
||||
|
||||
@@ -200,20 +238,10 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
template_name = 'dcim/rack_bulk_edit.html'
|
||||
default_redirect_url = 'dcim:rack_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
for field in ['site', 'group', 'u_height', 'comments']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
|
||||
|
||||
class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_rack'
|
||||
cls = Rack
|
||||
form = forms.RackBulkDeleteForm
|
||||
default_redirect_url = 'dcim:rack_list'
|
||||
|
||||
|
||||
@@ -239,7 +267,6 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_manufacturer'
|
||||
cls = Manufacturer
|
||||
form = forms.ManufacturerBulkDeleteForm
|
||||
default_redirect_url = 'dcim:manufacturer_list'
|
||||
|
||||
|
||||
@@ -321,20 +348,10 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
template_name = 'dcim/devicetype_bulk_edit.html'
|
||||
default_redirect_url = 'dcim:devicetype_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
for field in ['manufacturer', 'u_height']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
|
||||
|
||||
class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_devicetype'
|
||||
cls = DeviceType
|
||||
form = forms.DeviceTypeBulkDeleteForm
|
||||
default_redirect_url = 'dcim:devicetype_list'
|
||||
|
||||
|
||||
@@ -396,68 +413,65 @@ class ConsolePortTemplateAddView(ComponentTemplateCreateView):
|
||||
form = forms.ConsolePortTemplateForm
|
||||
|
||||
|
||||
class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_consoleporttemplate'
|
||||
cls = ConsolePortTemplate
|
||||
parent_cls = DeviceType
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateAddView(ComponentTemplateCreateView):
|
||||
model = ConsoleServerPortTemplate
|
||||
form = forms.ConsoleServerPortTemplateForm
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_consoleserverporttemplate'
|
||||
cls = ConsoleServerPortTemplate
|
||||
parent_cls = DeviceType
|
||||
|
||||
|
||||
class PowerPortTemplateAddView(ComponentTemplateCreateView):
|
||||
model = PowerPortTemplate
|
||||
form = forms.PowerPortTemplateForm
|
||||
|
||||
|
||||
class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_powerporttemplate'
|
||||
cls = PowerPortTemplate
|
||||
parent_cls = DeviceType
|
||||
|
||||
|
||||
class PowerOutletTemplateAddView(ComponentTemplateCreateView):
|
||||
model = PowerOutletTemplate
|
||||
form = forms.PowerOutletTemplateForm
|
||||
|
||||
|
||||
class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_poweroutlettemplate'
|
||||
cls = PowerOutletTemplate
|
||||
parent_cls = DeviceType
|
||||
|
||||
|
||||
class InterfaceTemplateAddView(ComponentTemplateCreateView):
|
||||
model = InterfaceTemplate
|
||||
form = forms.InterfaceTemplateForm
|
||||
|
||||
|
||||
class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_interfacetemplate'
|
||||
cls = InterfaceTemplate
|
||||
parent_cls = DeviceType
|
||||
|
||||
|
||||
class DeviceBayTemplateAddView(ComponentTemplateCreateView):
|
||||
model = DeviceBayTemplate
|
||||
form = forms.DeviceBayTemplateForm
|
||||
|
||||
|
||||
def component_template_delete(request, pk, model):
|
||||
|
||||
devicetype = get_object_or_404(DeviceType, pk=pk)
|
||||
|
||||
class ComponentTemplateBulkDeleteForm(ConfirmationForm):
|
||||
pk = ModelMultipleChoiceField(queryset=model.objects.all(), widget=MultipleHiddenInput)
|
||||
|
||||
if '_confirm' in request.POST:
|
||||
form = ComponentTemplateBulkDeleteForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
# Delete component templates
|
||||
objects_to_delete = model.objects.filter(pk__in=[v.id for v in form.cleaned_data['pk']])
|
||||
try:
|
||||
deleted_count = objects_to_delete.count()
|
||||
objects_to_delete.delete()
|
||||
except ProtectedError, e:
|
||||
handle_protectederror(list(objects_to_delete), request, e)
|
||||
return redirect('dcim:devicetype', {'pk': devicetype.pk})
|
||||
|
||||
messages.success(request, "Deleted {} {}".format(deleted_count, model._meta.verbose_name_plural))
|
||||
return redirect('dcim:devicetype', pk=devicetype.pk)
|
||||
|
||||
else:
|
||||
form = ComponentTemplateBulkDeleteForm(initial={'pk': request.POST.getlist('pk')})
|
||||
|
||||
selected_objects = model.objects.filter(pk__in=request.POST.getlist('pk'))
|
||||
if not selected_objects:
|
||||
messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural))
|
||||
return redirect('dcim:devicetype', pk=devicetype.pk)
|
||||
|
||||
return render(request, 'dcim/component_template_delete.html', {
|
||||
'devicetype': devicetype,
|
||||
'form': form,
|
||||
'selected_objects': selected_objects,
|
||||
'cancel_url': reverse('dcim:devicetype', kwargs={'pk': devicetype.pk}),
|
||||
})
|
||||
class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_devicebaytemplate'
|
||||
cls = DeviceBayTemplate
|
||||
parent_cls = DeviceType
|
||||
|
||||
|
||||
#
|
||||
@@ -482,7 +496,6 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_devicerole'
|
||||
cls = DeviceRole
|
||||
form = forms.DeviceRoleBulkDeleteForm
|
||||
default_redirect_url = 'dcim:devicerole_list'
|
||||
|
||||
|
||||
@@ -508,7 +521,6 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_platform'
|
||||
cls = Platform
|
||||
form = forms.PlatformBulkDeleteForm
|
||||
default_redirect_url = 'dcim:platform_list'
|
||||
|
||||
|
||||
@@ -517,8 +529,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class DeviceListView(ObjectListView):
|
||||
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'rack__site', 'primary_ip4',
|
||||
'primary_ip6')
|
||||
queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'rack__site',
|
||||
'primary_ip4', 'primary_ip6')
|
||||
filter = filters.DeviceFilter
|
||||
filter_form = forms.DeviceFilterForm
|
||||
table = tables.DeviceTable
|
||||
@@ -571,6 +583,9 @@ def device(request, pk):
|
||||
related_devices = Device.objects.filter(name__istartswith=base_name).exclude(pk=device.pk)\
|
||||
.select_related('rack', 'device_type__manufacturer')[:10]
|
||||
|
||||
# Show graph button on interfaces only if at least one graph has been created.
|
||||
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists()
|
||||
|
||||
return render(request, 'dcim/device.html', {
|
||||
'device': device,
|
||||
'console_ports': console_ports,
|
||||
@@ -583,6 +598,7 @@ def device(request, pk):
|
||||
'ip_addresses': ip_addresses,
|
||||
'secrets': secrets,
|
||||
'related_devices': related_devices,
|
||||
'show_graphs': show_graphs,
|
||||
})
|
||||
|
||||
|
||||
@@ -609,6 +625,23 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
obj_list_url = 'dcim:device_list'
|
||||
|
||||
|
||||
class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
permission_required = 'dcim.add_device'
|
||||
form = forms.ChildDeviceImportForm
|
||||
table = tables.DeviceImportTable
|
||||
template_name = 'dcim/device_import_child.html'
|
||||
obj_list_url = 'dcim:device_list'
|
||||
|
||||
def save_obj(self, obj):
|
||||
# Inherent rack from parent device
|
||||
obj.rack = obj.parent_bay.device.rack
|
||||
obj.save()
|
||||
# Save the reverse relation
|
||||
device_bay = obj.parent_bay
|
||||
device_bay.installed_device = obj
|
||||
device_bay.save()
|
||||
|
||||
|
||||
class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'dcim.change_device'
|
||||
cls = Device
|
||||
@@ -616,34 +649,18 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
template_name = 'dcim/device_bulk_edit.html'
|
||||
default_redirect_url = 'dcim:device_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
if form.cleaned_data['platform']:
|
||||
fields_to_update['platform'] = form.cleaned_data['platform']
|
||||
elif form.cleaned_data['platform_delete']:
|
||||
fields_to_update['platform'] = None
|
||||
if form.cleaned_data['status']:
|
||||
status = form.cleaned_data['status']
|
||||
fields_to_update['status'] = True if status == 'True' else False
|
||||
for field in ['device_type', 'device_role', 'serial']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
|
||||
|
||||
class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_device'
|
||||
cls = Device
|
||||
form = forms.DeviceBulkDeleteForm
|
||||
default_redirect_url = 'dcim:device_list'
|
||||
|
||||
|
||||
def device_inventory(request, pk):
|
||||
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
modules = Module.objects.filter(device=device, parent=None).prefetch_related('submodules')
|
||||
modules = Module.objects.filter(device=device, parent=None).select_related('manufacturer')\
|
||||
.prefetch_related('submodules')
|
||||
|
||||
return render(request, 'dcim/device_inventory.html', {
|
||||
'device': device,
|
||||
@@ -808,6 +825,12 @@ def consoleport_delete(request, pk):
|
||||
})
|
||||
|
||||
|
||||
class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_consoleport'
|
||||
cls = ConsolePort
|
||||
parent_cls = Device
|
||||
|
||||
|
||||
class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
permission_required = 'dcim.change_consoleport'
|
||||
form = forms.ConsoleConnectionImportForm
|
||||
@@ -963,6 +986,12 @@ def consoleserverport_delete(request, pk):
|
||||
})
|
||||
|
||||
|
||||
class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_consoleserverport'
|
||||
cls = ConsoleServerPort
|
||||
parent_cls = Device
|
||||
|
||||
|
||||
#
|
||||
# Power ports
|
||||
#
|
||||
@@ -1108,6 +1137,12 @@ def powerport_delete(request, pk):
|
||||
})
|
||||
|
||||
|
||||
class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_powerport'
|
||||
cls = PowerPort
|
||||
parent_cls = Device
|
||||
|
||||
|
||||
class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
permission_required = 'dcim.change_powerport'
|
||||
form = forms.PowerConnectionImportForm
|
||||
@@ -1261,6 +1296,12 @@ def poweroutlet_delete(request, pk):
|
||||
})
|
||||
|
||||
|
||||
class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_poweroutlet'
|
||||
cls = PowerOutlet
|
||||
parent_cls = Device
|
||||
|
||||
|
||||
#
|
||||
# Interfaces
|
||||
#
|
||||
@@ -1355,10 +1396,10 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'dcim.add_interface'
|
||||
cls = Device
|
||||
form = forms.InterfaceBulkCreateForm
|
||||
template_name = 'dcim/interface_bulk_add.html'
|
||||
template_name = 'dcim/interface_add_multi.html'
|
||||
default_redirect_url = 'dcim:device_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
def update_objects(self, pk_list, form, fields):
|
||||
|
||||
selected_devices = Device.objects.filter(pk__in=pk_list)
|
||||
interfaces = []
|
||||
@@ -1384,6 +1425,12 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView):
|
||||
len(selected_devices)))
|
||||
|
||||
|
||||
class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_interface'
|
||||
cls = Interface
|
||||
parent_cls = Device
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
@@ -1521,6 +1568,12 @@ def devicebay_depopulate(request, pk):
|
||||
})
|
||||
|
||||
|
||||
class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_devicebay'
|
||||
cls = DeviceBay
|
||||
parent_cls = Device
|
||||
|
||||
|
||||
#
|
||||
# Interface connections
|
||||
#
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Graph, ExportTemplate, TopologyMap, UserAction
|
||||
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction
|
||||
|
||||
|
||||
class CustomFieldForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
exclude = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(CustomFieldForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Organize the available ContentTypes
|
||||
queryset = self.fields['obj_type'].queryset.order_by('app_label', 'model')
|
||||
self.fields['obj_type'].choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset]
|
||||
|
||||
|
||||
class CustomFieldChoiceAdmin(admin.TabularInline):
|
||||
model = CustomFieldChoice
|
||||
extra = 5
|
||||
|
||||
|
||||
@admin.register(CustomField)
|
||||
class CustomFieldAdmin(admin.ModelAdmin):
|
||||
inlines = [CustomFieldChoiceAdmin]
|
||||
list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description']
|
||||
form = CustomFieldForm
|
||||
|
||||
def models(self, obj):
|
||||
return ', '.join([ct.name for ct in obj.obj_type.all()])
|
||||
|
||||
|
||||
@admin.register(Graph)
|
||||
@@ -19,3 +49,9 @@ class TopologyMapAdmin(admin.ModelAdmin):
|
||||
prepopulated_fields = {
|
||||
'slug': ['name'],
|
||||
}
|
||||
|
||||
|
||||
@admin.register(UserAction)
|
||||
class UserActionAdmin(admin.ModelAdmin):
|
||||
actions = None
|
||||
list_display = ['user', 'action', 'content_type', 'object_id', 'message']
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from extras.models import Graph
|
||||
from extras.models import CF_TYPE_SELECT, CustomFieldChoice, Graph
|
||||
|
||||
|
||||
class CustomFieldSerializer(serializers.Serializer):
|
||||
"""
|
||||
Extends a ModelSerializer to render any CustomFields and their values associated with an object.
|
||||
"""
|
||||
custom_fields = serializers.SerializerMethodField()
|
||||
|
||||
def get_custom_fields(self, obj):
|
||||
|
||||
# Gather all CustomFields applicable to this object
|
||||
fields = {cf.name: None for cf in self.context['view'].custom_fields}
|
||||
|
||||
# Attach any defined CustomFieldValues to their respective CustomFields
|
||||
for cfv in obj.custom_field_values.all():
|
||||
|
||||
# Attempt to suppress database lookups for CustomFieldChoices by using the cached choice set from the view
|
||||
# context.
|
||||
if cfv.field.type == CF_TYPE_SELECT and hasattr(self, 'custom_field_choices'):
|
||||
cfc = {
|
||||
'id': int(cfv.serialized_value),
|
||||
'value': self.context['view'].custom_field_choices[int(cfv.serialized_value)]
|
||||
}
|
||||
fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfc).data
|
||||
# Fall back to hitting the database in case we're in a view that doesn't inherit CustomFieldModelAPIView.
|
||||
elif cfv.field.type == CF_TYPE_SELECT:
|
||||
fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfv.value).data
|
||||
else:
|
||||
fields[cfv.field.name] = cfv.value
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
class CustomFieldChoiceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = CustomFieldChoice
|
||||
fields = ['id', 'value']
|
||||
|
||||
|
||||
class GraphSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import graphviz
|
||||
from rest_framework import generics
|
||||
from rest_framework.views import APIView
|
||||
import tempfile
|
||||
from wsgiref.util import FileWrapper
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -15,6 +14,24 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_P
|
||||
from .serializers import GraphSerializer
|
||||
|
||||
|
||||
class CustomFieldModelAPIView(object):
|
||||
"""
|
||||
Include the applicable set of CustomField in the view context.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(CustomFieldModelAPIView, self).__init__()
|
||||
self.content_type = ContentType.objects.get_for_model(self.queryset.model)
|
||||
self.custom_fields = self.content_type.custom_fields.prefetch_related('choices')
|
||||
|
||||
# Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object.
|
||||
custom_field_choices = {}
|
||||
for field in self.custom_fields:
|
||||
for cfc in field.choices.all():
|
||||
custom_field_choices[cfc.id] = cfc.value
|
||||
self.custom_field_choices = custom_field_choices
|
||||
|
||||
|
||||
class GraphListView(generics.ListAPIView):
|
||||
"""
|
||||
Returns a list of relevant graphs
|
||||
|
||||
46
netbox/extras/filters.py
Normal file
46
netbox/extras/filters.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import django_filters
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from .models import CF_TYPE_SELECT, CustomField
|
||||
|
||||
|
||||
class CustomFieldFilter(django_filters.Filter):
|
||||
"""
|
||||
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
|
||||
"""
|
||||
|
||||
def __init__(self, cf_type, *args, **kwargs):
|
||||
self.cf_type = cf_type
|
||||
super(CustomFieldFilter, self).__init__(*args, **kwargs)
|
||||
|
||||
def filter(self, queryset, value):
|
||||
# Skip filter on empty value
|
||||
if not value.strip():
|
||||
return queryset
|
||||
# Treat 0 as None for Select fields
|
||||
try:
|
||||
if self.cf_type == CF_TYPE_SELECT and int(value) == 0:
|
||||
return queryset.exclude(
|
||||
custom_field_values__field__name=self.name,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(
|
||||
custom_field_values__field__name=self.name,
|
||||
custom_field_values__serialized_value=value,
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldFilterSet(django_filters.FilterSet):
|
||||
"""
|
||||
Dynamically add a Filter for each CustomField applicable to the parent model.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
|
||||
|
||||
obj_type = ContentType.objects.get_for_model(self._meta.model)
|
||||
custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
|
||||
for cf in custom_fields:
|
||||
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)
|
||||
158
netbox/extras/forms.py
Normal file
158
netbox/extras/forms.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from .models import (
|
||||
CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue
|
||||
)
|
||||
|
||||
|
||||
def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
|
||||
"""
|
||||
Retrieve all CustomFields applicable to the given ContentType
|
||||
"""
|
||||
field_dict = OrderedDict()
|
||||
kwargs = {'obj_type': content_type}
|
||||
if filterable_only:
|
||||
kwargs['is_filterable'] = True
|
||||
custom_fields = CustomField.objects.filter(**kwargs)
|
||||
|
||||
for cf in custom_fields:
|
||||
field_name = 'cf_{}'.format(str(cf.name))
|
||||
|
||||
# Integer
|
||||
if cf.type == CF_TYPE_INTEGER:
|
||||
field = forms.IntegerField(required=cf.required, initial=cf.default)
|
||||
|
||||
# Boolean
|
||||
elif cf.type == CF_TYPE_BOOLEAN:
|
||||
choices = (
|
||||
(None, '---------'),
|
||||
(1, 'True'),
|
||||
(0, 'False'),
|
||||
)
|
||||
if cf.default.lower() in ['true', 'yes', '1']:
|
||||
initial = True
|
||||
elif cf.default.lower() in ['false', 'no', '0']:
|
||||
initial = False
|
||||
else:
|
||||
initial = None
|
||||
field = forms.NullBooleanField(required=cf.required, initial=initial,
|
||||
widget=forms.Select(choices=choices))
|
||||
|
||||
# Date
|
||||
elif cf.type == CF_TYPE_DATE:
|
||||
field = forms.DateField(required=cf.required, initial=cf.default)
|
||||
|
||||
# Select
|
||||
elif cf.type == CF_TYPE_SELECT:
|
||||
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
|
||||
if not cf.required:
|
||||
choices = [(0, 'None')] + choices
|
||||
if bulk_edit or filterable_only:
|
||||
choices = [(None, '---------')] + choices
|
||||
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
|
||||
|
||||
# URL
|
||||
elif cf.type == CF_TYPE_URL:
|
||||
field = forms.URLField(required=cf.required, initial=cf.default)
|
||||
|
||||
# Text
|
||||
else:
|
||||
field = forms.CharField(max_length=255, required=cf.required, initial=cf.default)
|
||||
|
||||
field.model = cf
|
||||
field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
|
||||
field.help_text = cf.description
|
||||
|
||||
field_dict[field_name] = field
|
||||
|
||||
return field_dict
|
||||
|
||||
|
||||
class CustomFieldForm(forms.ModelForm):
|
||||
custom_fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
self.obj_type = ContentType.objects.get_for_model(self._meta.model)
|
||||
|
||||
super(CustomFieldForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Add all applicable CustomFields to the form
|
||||
custom_fields = []
|
||||
for name, field in get_custom_fields_for_model(self.obj_type).items():
|
||||
self.fields[name] = field
|
||||
custom_fields.append(name)
|
||||
self.custom_fields = custom_fields
|
||||
|
||||
# If editing an existing object, initialize values for all custom fields
|
||||
if self.instance.pk:
|
||||
existing_values = CustomFieldValue.objects.filter(obj_type=self.obj_type, obj_id=self.instance.pk)\
|
||||
.select_related('field')
|
||||
for cfv in existing_values:
|
||||
self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.value
|
||||
|
||||
def _save_custom_fields(self):
|
||||
|
||||
for field_name in self.custom_fields:
|
||||
try:
|
||||
cfv = CustomFieldValue.objects.select_related('field').get(field=self.fields[field_name].model,
|
||||
obj_type=self.obj_type,
|
||||
obj_id=self.instance.pk)
|
||||
except CustomFieldValue.DoesNotExist:
|
||||
# Skip this field if none exists already and its value is empty
|
||||
if self.cleaned_data[field_name] in [None, u'']:
|
||||
continue
|
||||
cfv = CustomFieldValue(
|
||||
field=self.fields[field_name].model,
|
||||
obj_type=self.obj_type,
|
||||
obj_id=self.instance.pk
|
||||
)
|
||||
cfv.value = self.cleaned_data[field_name]
|
||||
cfv.save()
|
||||
|
||||
def save(self, commit=True):
|
||||
obj = super(CustomFieldForm, self).save(commit)
|
||||
|
||||
# Handle custom fields the same way we do M2M fields
|
||||
if commit:
|
||||
self._save_custom_fields()
|
||||
else:
|
||||
self.save_custom_fields = self._save_custom_fields
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
class CustomFieldBulkEditForm(forms.Form):
|
||||
custom_fields = []
|
||||
|
||||
def __init__(self, model, *args, **kwargs):
|
||||
|
||||
self.obj_type = ContentType.objects.get_for_model(model)
|
||||
|
||||
super(CustomFieldBulkEditForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Add all applicable CustomFields to the form
|
||||
custom_fields = []
|
||||
for name, field in get_custom_fields_for_model(self.obj_type, bulk_edit=True).items():
|
||||
field.required = False
|
||||
self.fields[name] = field
|
||||
custom_fields.append(name)
|
||||
self.custom_fields = custom_fields
|
||||
|
||||
|
||||
class CustomFieldFilterForm(forms.Form):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
self.obj_type = ContentType.objects.get_for_model(self.model)
|
||||
|
||||
super(CustomFieldFilterForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Add all applicable CustomFields to the form
|
||||
custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items()
|
||||
for name, field in custom_fields:
|
||||
field.required = False
|
||||
self.fields[name] = field
|
||||
68
netbox/extras/migrations/0002_custom_fields.py
Normal file
68
netbox/extras/migrations/0002_custom_fields.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-08-23 20:33
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('extras', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomField',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('type', models.PositiveSmallIntegerField(choices=[(100, b'Text'), (200, b'Integer'), (300, b'Boolean (true/false)'), (400, b'Date'), (500, b'URL'), (600, b'Selection')], default=100)),
|
||||
('name', models.CharField(max_length=50, unique=True)),
|
||||
('label', models.CharField(blank=True, help_text=b"Name of the field as displayed to users (if not provided, the field's name will be used)", max_length=50)),
|
||||
('description', models.CharField(blank=True, max_length=100)),
|
||||
('required', models.BooleanField(default=False, help_text=b'Determines whether this field is required when creating new objects or editing an existing object.')),
|
||||
('is_filterable', models.BooleanField(default=True, help_text=b'This field can be used to filter objects.')),
|
||||
('default', models.CharField(blank=True, help_text=b'Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.', max_length=100)),
|
||||
('weight', models.PositiveSmallIntegerField(default=100, help_text=b'Fields with higher weights appear lower in a form')),
|
||||
('obj_type', models.ManyToManyField(help_text=b'The object(s) to which this field applies.', related_name='custom_fields', to='contenttypes.ContentType', verbose_name=b'Object(s)')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['weight', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CustomFieldChoice',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('value', models.CharField(max_length=100)),
|
||||
('weight', models.PositiveSmallIntegerField(default=100, help_text=b'Higher weights appear lower in the list')),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='extras.CustomField')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['field', 'weight', 'value'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CustomFieldValue',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('obj_id', models.PositiveIntegerField()),
|
||||
('serialized_value', models.CharField(max_length=255)),
|
||||
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='extras.CustomField')),
|
||||
('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['obj_type', 'obj_id'],
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='customfieldvalue',
|
||||
unique_together=set([('field', 'obj_type', 'obj_id')]),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='customfieldchoice',
|
||||
unique_together=set([('field', 'value')]),
|
||||
),
|
||||
]
|
||||
@@ -1,12 +1,37 @@
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.http import HttpResponse
|
||||
from django.template import Template, Context
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from dcim.models import Site
|
||||
|
||||
CUSTOMFIELD_MODELS = (
|
||||
'site', 'rack', 'device', # DCIM
|
||||
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM
|
||||
'provider', 'circuit', # Circuits
|
||||
'tenant', # Tenants
|
||||
)
|
||||
|
||||
CF_TYPE_TEXT = 100
|
||||
CF_TYPE_INTEGER = 200
|
||||
CF_TYPE_BOOLEAN = 300
|
||||
CF_TYPE_DATE = 400
|
||||
CF_TYPE_URL = 500
|
||||
CF_TYPE_SELECT = 600
|
||||
CUSTOMFIELD_TYPE_CHOICES = (
|
||||
(CF_TYPE_TEXT, 'Text'),
|
||||
(CF_TYPE_INTEGER, 'Integer'),
|
||||
(CF_TYPE_BOOLEAN, 'Boolean (true/false)'),
|
||||
(CF_TYPE_DATE, 'Date'),
|
||||
(CF_TYPE_URL, 'URL'),
|
||||
(CF_TYPE_SELECT, 'Selection'),
|
||||
)
|
||||
|
||||
GRAPH_TYPE_INTERFACE = 100
|
||||
GRAPH_TYPE_PROVIDER = 200
|
||||
@@ -18,9 +43,10 @@ GRAPH_TYPE_CHOICES = (
|
||||
)
|
||||
|
||||
EXPORTTEMPLATE_MODELS = [
|
||||
'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection',
|
||||
'aggregate', 'prefix', 'ipaddress', 'vlan',
|
||||
'provider', 'circuit'
|
||||
'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', # DCIM
|
||||
'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM
|
||||
'provider', 'circuit', # Circuits
|
||||
'tenant', # Tenants
|
||||
]
|
||||
|
||||
ACTION_CREATE = 1
|
||||
@@ -39,6 +65,140 @@ ACTION_CHOICES = (
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldModel(object):
|
||||
|
||||
def cf(self):
|
||||
"""
|
||||
Name-based CustomFieldValue accessor for use in templates
|
||||
"""
|
||||
if not hasattr(self, 'custom_fields'):
|
||||
return dict()
|
||||
return {field.name: value for field, value in self.custom_fields.items()}
|
||||
|
||||
def get_custom_fields(self):
|
||||
"""
|
||||
Return a dictionary of custom fields for a single object in the form {<field>: value}.
|
||||
"""
|
||||
|
||||
# Find all custom fields applicable to this type of object
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
fields = CustomField.objects.filter(obj_type=content_type)
|
||||
|
||||
# If the object exists, populate its custom fields with values
|
||||
if hasattr(self, 'pk'):
|
||||
values = CustomFieldValue.objects.filter(obj_type=content_type, obj_id=self.pk).select_related('field')
|
||||
values_dict = {cfv.field_id: cfv.value for cfv in values}
|
||||
return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
|
||||
else:
|
||||
return OrderedDict([(field, None) for field in fields])
|
||||
|
||||
|
||||
class CustomField(models.Model):
|
||||
obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
|
||||
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
|
||||
help_text="The object(s) to which this field applies.")
|
||||
type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_TYPE_TEXT)
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
label = models.CharField(max_length=50, blank=True, help_text="Name of the field as displayed to users (if not "
|
||||
"provided, the field's name will be used)")
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating "
|
||||
"new objects or editing an existing object.")
|
||||
is_filterable = models.BooleanField(default=True, help_text="This field can be used to filter objects.")
|
||||
default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. Use \"true\" or "
|
||||
"\"false\" for booleans. N/A for selection "
|
||||
"fields.")
|
||||
weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a "
|
||||
"form")
|
||||
|
||||
class Meta:
|
||||
ordering = ['weight', 'name']
|
||||
|
||||
def __unicode__(self):
|
||||
return self.label or self.name.replace('_', ' ').capitalize()
|
||||
|
||||
def serialize_value(self, value):
|
||||
"""
|
||||
Serialize the given value to a string suitable for storage as a CustomFieldValue
|
||||
"""
|
||||
if value is None:
|
||||
return ''
|
||||
if self.type == CF_TYPE_BOOLEAN:
|
||||
return str(int(bool(value)))
|
||||
if self.type == CF_TYPE_DATE:
|
||||
return value.strftime('%Y-%m-%d')
|
||||
if self.type == CF_TYPE_SELECT:
|
||||
# Could be ModelChoiceField or TypedChoiceField
|
||||
return str(value.id) if hasattr(value, 'id') else str(value)
|
||||
return str(value)
|
||||
|
||||
def deserialize_value(self, serialized_value):
|
||||
"""
|
||||
Convert a string into the object it represents depending on the type of field
|
||||
"""
|
||||
if serialized_value is '':
|
||||
return None
|
||||
if self.type == CF_TYPE_INTEGER:
|
||||
return int(serialized_value)
|
||||
if self.type == CF_TYPE_BOOLEAN:
|
||||
return bool(int(serialized_value))
|
||||
if self.type == CF_TYPE_DATE:
|
||||
# Read date as YYYY-MM-DD
|
||||
return date(*[int(n) for n in serialized_value.split('-')])
|
||||
if self.type == CF_TYPE_SELECT:
|
||||
# return CustomFieldChoice.objects.get(pk=int(serialized_value))
|
||||
return self.choices.get(pk=int(serialized_value))
|
||||
return serialized_value
|
||||
|
||||
|
||||
class CustomFieldValue(models.Model):
|
||||
field = models.ForeignKey('CustomField', related_name='values')
|
||||
obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
|
||||
obj_id = models.PositiveIntegerField()
|
||||
obj = GenericForeignKey('obj_type', 'obj_id')
|
||||
serialized_value = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
ordering = ['obj_type', 'obj_id']
|
||||
unique_together = ['field', 'obj_type', 'obj_id']
|
||||
|
||||
def __unicode__(self):
|
||||
return '{} {}'.format(self.obj, self.field)
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self.field.deserialize_value(self.serialized_value)
|
||||
|
||||
@value.setter
|
||||
def value(self, value):
|
||||
self.serialized_value = self.field.serialize_value(value)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Delete this object if it no longer has a value to store
|
||||
if self.pk and self.value is None:
|
||||
self.delete()
|
||||
else:
|
||||
super(CustomFieldValue, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class CustomFieldChoice(models.Model):
|
||||
field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT},
|
||||
on_delete=models.CASCADE)
|
||||
value = models.CharField(max_length=100)
|
||||
weight = models.PositiveSmallIntegerField(default=100, help_text="Higher weights appear lower in the list")
|
||||
|
||||
class Meta:
|
||||
ordering = ['field', 'weight', 'value']
|
||||
unique_together = ['field', 'value']
|
||||
|
||||
def __unicode__(self):
|
||||
return self.value
|
||||
|
||||
def clean(self):
|
||||
if self.field.type != CF_TYPE_SELECT:
|
||||
raise ValidationError("Custom field choices can only be assigned to selection fields.")
|
||||
|
||||
|
||||
class Graph(models.Model):
|
||||
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
|
||||
weight = models.PositiveSmallIntegerField(default=1000)
|
||||
@@ -77,7 +237,7 @@ class ExportTemplate(models.Model):
|
||||
]
|
||||
|
||||
def __unicode__(self):
|
||||
return "{}: {}".format(self.content_type, self.name)
|
||||
return u'{}: {}'.format(self.content_type, self.name)
|
||||
|
||||
def to_response(self, context_dict, filename):
|
||||
"""
|
||||
@@ -85,10 +245,10 @@ class ExportTemplate(models.Model):
|
||||
"""
|
||||
template = Template(self.template_code)
|
||||
mime_type = 'text/plain' if not self.mime_type else self.mime_type
|
||||
response = HttpResponse(
|
||||
template.render(Context(context_dict)),
|
||||
content_type=mime_type
|
||||
)
|
||||
output = template.render(Context(context_dict))
|
||||
# Replace CRLF-style line terminators
|
||||
output = output.replace('\r\n', '\n')
|
||||
response = HttpResponse(output, content_type=mime_type)
|
||||
if self.file_extension:
|
||||
filename += '.{}'.format(self.file_extension)
|
||||
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
||||
@@ -98,7 +258,7 @@ class ExportTemplate(models.Model):
|
||||
class TopologyMap(models.Model):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
site = models.ForeignKey(Site, related_name='topology_maps', blank=True, null=True)
|
||||
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True)
|
||||
device_patterns = models.TextField(help_text="Identify devices to include in the diagram using regular expressions,"
|
||||
"one per line. Each line will result in a new tier of the drawing. "
|
||||
"Separate multiple regexes on a line using commas. Devices will be "
|
||||
@@ -176,8 +336,8 @@ class UserAction(models.Model):
|
||||
|
||||
def __unicode__(self):
|
||||
if self.message:
|
||||
return ' '.join([self.user, self.message])
|
||||
return ' '.join([self.user, self.get_action_display(), self.content_type])
|
||||
return u'{} {}'.format(self.user, self.message)
|
||||
return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
|
||||
|
||||
def icon(self):
|
||||
if self.action in [ACTION_CREATE, ACTION_IMPORT]:
|
||||
|
||||
0
netbox/extras/tests/__init__.py
Normal file
0
netbox/extras/tests/__init__.py
Normal file
97
netbox/extras/tests/test_customfields.py
Normal file
97
netbox/extras/tests/test_customfields.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from datetime import date
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Site
|
||||
|
||||
from extras.models import (
|
||||
CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE,
|
||||
CF_TYPE_SELECT, CF_TYPE_URL,
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
Site.objects.bulk_create([
|
||||
Site(name='Site A', slug='site-a'),
|
||||
Site(name='Site B', slug='site-b'),
|
||||
Site(name='Site C', slug='site-c'),
|
||||
])
|
||||
|
||||
def test_simple_fields(self):
|
||||
|
||||
DATA = (
|
||||
{'field_type': CF_TYPE_TEXT, 'field_value': 'Foobar!', 'empty_value': ''},
|
||||
{'field_type': CF_TYPE_INTEGER, 'field_value': 0, 'empty_value': None},
|
||||
{'field_type': CF_TYPE_INTEGER, 'field_value': 42, 'empty_value': None},
|
||||
{'field_type': CF_TYPE_BOOLEAN, 'field_value': True, 'empty_value': None},
|
||||
{'field_type': CF_TYPE_BOOLEAN, 'field_value': False, 'empty_value': None},
|
||||
{'field_type': CF_TYPE_DATE, 'field_value': date(2016, 6, 23), 'empty_value': None},
|
||||
{'field_type': CF_TYPE_URL, 'field_value': 'http://example.com/', 'empty_value': ''},
|
||||
)
|
||||
|
||||
obj_type = ContentType.objects.get_for_model(Site)
|
||||
|
||||
for data in DATA:
|
||||
|
||||
# Create a custom field
|
||||
cf = CustomField(type=data['field_type'], name='my_field', required=False)
|
||||
cf.save()
|
||||
cf.obj_type = [obj_type]
|
||||
cf.save()
|
||||
|
||||
# Assign a value to the first Site
|
||||
site = Site.objects.first()
|
||||
cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id)
|
||||
cfv.value = data['field_value']
|
||||
cfv.save()
|
||||
|
||||
# Retrieve the stored value
|
||||
cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first()
|
||||
self.assertEqual(cfv.value, data['field_value'])
|
||||
|
||||
# Delete the stored value
|
||||
cfv.value = data['empty_value']
|
||||
cfv.save()
|
||||
self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0)
|
||||
|
||||
# Delete the custom field
|
||||
cf.delete()
|
||||
|
||||
def test_select_field(self):
|
||||
|
||||
obj_type = ContentType.objects.get_for_model(Site)
|
||||
|
||||
# Create a custom field
|
||||
cf = CustomField(type=CF_TYPE_SELECT, name='my_field', required=False)
|
||||
cf.save()
|
||||
cf.obj_type = [obj_type]
|
||||
cf.save()
|
||||
|
||||
# Create some choices for the field
|
||||
CustomFieldChoice.objects.bulk_create([
|
||||
CustomFieldChoice(field=cf, value='Option A'),
|
||||
CustomFieldChoice(field=cf, value='Option B'),
|
||||
CustomFieldChoice(field=cf, value='Option C'),
|
||||
])
|
||||
|
||||
# Assign a value to the first Site
|
||||
site = Site.objects.first()
|
||||
cfv = CustomFieldValue(field=cf, obj_type=obj_type, obj_id=site.id)
|
||||
cfv.value = cf.choices.first()
|
||||
cfv.save()
|
||||
|
||||
# Retrieve the stored value
|
||||
cfv = CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).first()
|
||||
self.assertEqual(str(cfv.value), 'Option A')
|
||||
|
||||
# Delete the stored value
|
||||
cfv.value = None
|
||||
cfv.save()
|
||||
self.assertEqual(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site.pk).count(), 0)
|
||||
|
||||
# Delete the custom field
|
||||
cf.delete()
|
||||
@@ -1,13 +1,18 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import (
|
||||
Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VRF,
|
||||
Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(VRF)
|
||||
class VRFAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'rd']
|
||||
list_display = ['name', 'rd', 'tenant', 'enforce_unique']
|
||||
list_filter = ['tenant']
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super(VRFAdmin, self).get_queryset(request)
|
||||
return qs.select_related('tenant')
|
||||
|
||||
|
||||
@admin.register(Role)
|
||||
@@ -35,7 +40,7 @@ class AggregateAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Prefix)
|
||||
class PrefixAdmin(admin.ModelAdmin):
|
||||
list_display = ['prefix', 'vrf', 'site', 'status', 'role', 'vlan']
|
||||
list_display = ['prefix', 'vrf', 'tenant', 'site', 'status', 'role', 'vlan']
|
||||
list_filter = ['family', 'site', 'status', 'role']
|
||||
search_fields = ['prefix']
|
||||
|
||||
@@ -46,7 +51,7 @@ class PrefixAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(IPAddress)
|
||||
class IPAddressAdmin(admin.ModelAdmin):
|
||||
list_display = ['address', 'vrf', 'nat_inside']
|
||||
list_display = ['address', 'vrf', 'tenant', 'nat_inside']
|
||||
list_filter = ['family']
|
||||
fields = ['address', 'vrf', 'device', 'interface', 'nat_inside']
|
||||
readonly_fields = ['interface', 'device', 'nat_inside']
|
||||
@@ -57,12 +62,20 @@ class IPAddressAdmin(admin.ModelAdmin):
|
||||
return qs.select_related('vrf', 'nat_inside')
|
||||
|
||||
|
||||
@admin.register(VLANGroup)
|
||||
class VLANGroupAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'site', 'slug']
|
||||
prepopulated_fields = {
|
||||
'slug': ['name'],
|
||||
}
|
||||
|
||||
|
||||
@admin.register(VLAN)
|
||||
class VLANAdmin(admin.ModelAdmin):
|
||||
list_display = ['site', 'vid', 'name', 'status', 'role']
|
||||
list_filter = ['site', 'status', 'role']
|
||||
list_display = ['site', 'vid', 'name', 'tenant', 'status', 'role']
|
||||
list_filter = ['site', 'tenant', 'status', 'role']
|
||||
search_fields = ['vid', 'name']
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super(VLANAdmin, self).get_queryset(request)
|
||||
return qs.select_related('site', 'role')
|
||||
return qs.select_related('site', 'tenant', 'role')
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
|
||||
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
|
||||
from extras.api.serializers import CustomFieldSerializer
|
||||
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
|
||||
from tenancy.api.serializers import TenantNestedSerializer
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFSerializer(serializers.ModelSerializer):
|
||||
class VRFSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
tenant = TenantNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'name', 'rd', 'description']
|
||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
|
||||
|
||||
|
||||
class VRFNestedSerializer(VRFSerializer):
|
||||
@@ -21,6 +24,15 @@ class VRFNestedSerializer(VRFSerializer):
|
||||
fields = ['id', 'name', 'rd']
|
||||
|
||||
|
||||
class VRFTenantSerializer(VRFSerializer):
|
||||
"""
|
||||
Include tenant serializer. Useful for determining tenant inheritance for Prefixes and IPAddresses.
|
||||
"""
|
||||
|
||||
class Meta(VRFSerializer.Meta):
|
||||
fields = ['id', 'name', 'rd', 'tenant']
|
||||
|
||||
|
||||
#
|
||||
# Roles
|
||||
#
|
||||
@@ -59,12 +71,12 @@ class RIRNestedSerializer(RIRSerializer):
|
||||
# Aggregates
|
||||
#
|
||||
|
||||
class AggregateSerializer(serializers.ModelSerializer):
|
||||
class AggregateSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
rir = RIRNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description']
|
||||
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
|
||||
|
||||
|
||||
class AggregateNestedSerializer(AggregateSerializer):
|
||||
@@ -73,17 +85,38 @@ class AggregateNestedSerializer(AggregateSerializer):
|
||||
fields = ['id', 'family', 'prefix']
|
||||
|
||||
|
||||
#
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupSerializer(serializers.ModelSerializer):
|
||||
site = SiteNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ['id', 'name', 'slug', 'site']
|
||||
|
||||
|
||||
class VLANGroupNestedSerializer(VLANGroupSerializer):
|
||||
|
||||
class Meta(VLANGroupSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANSerializer(serializers.ModelSerializer):
|
||||
class VLANSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
site = SiteNestedSerializer()
|
||||
group = VLANGroupNestedSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
role = RoleNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'site', 'vid', 'name', 'status', 'role', 'display_name']
|
||||
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
|
||||
'custom_fields']
|
||||
|
||||
|
||||
class VLANNestedSerializer(VLANSerializer):
|
||||
@@ -96,15 +129,17 @@ class VLANNestedSerializer(VLANSerializer):
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class PrefixSerializer(serializers.ModelSerializer):
|
||||
class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
site = SiteNestedSerializer()
|
||||
vrf = VRFNestedSerializer()
|
||||
vrf = VRFTenantSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
vlan = VLANNestedSerializer()
|
||||
role = RoleNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['id', 'family', 'prefix', 'site', 'vrf', 'vlan', 'status', 'role', 'description']
|
||||
fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description',
|
||||
'custom_fields']
|
||||
|
||||
|
||||
class PrefixNestedSerializer(PrefixSerializer):
|
||||
@@ -117,13 +152,15 @@ class PrefixNestedSerializer(PrefixSerializer):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressSerializer(serializers.ModelSerializer):
|
||||
vrf = VRFNestedSerializer()
|
||||
class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
vrf = VRFTenantSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
interface = InterfaceNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['id', 'family', 'address', 'vrf', 'interface', 'description', 'nat_inside', 'nat_outside']
|
||||
fields = ['id', 'family', 'address', 'vrf', 'tenant', 'interface', 'description', 'nat_inside', 'nat_outside',
|
||||
'custom_fields']
|
||||
|
||||
|
||||
class IPAddressNestedSerializer(IPAddressSerializer):
|
||||
|
||||
@@ -29,6 +29,10 @@ urlpatterns = [
|
||||
url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'),
|
||||
|
||||
# VLAN groups
|
||||
url(r'^vlan-groups/$', VLANGroupListView.as_view(), name='vlangroup_list'),
|
||||
url(r'^vlan-groups/(?P<pk>\d+)/$', VLANGroupDetailView.as_view(), name='vlangroup_detail'),
|
||||
|
||||
# VLANs
|
||||
url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
|
||||
url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
from rest_framework import generics
|
||||
|
||||
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
|
||||
from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter, VRFFilter
|
||||
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
|
||||
from ipam import filters
|
||||
|
||||
from extras.api.views import CustomFieldModelAPIView
|
||||
from . import serializers
|
||||
|
||||
|
||||
class VRFListView(generics.ListAPIView):
|
||||
#
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List all VRFs
|
||||
"""
|
||||
queryset = VRF.objects.all()
|
||||
queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.VRFSerializer
|
||||
filter_class = VRFFilter
|
||||
filter_class = filters.VRFFilter
|
||||
|
||||
|
||||
class VRFDetailView(generics.RetrieveAPIView):
|
||||
class VRFDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single VRF
|
||||
"""
|
||||
queryset = VRF.objects.all()
|
||||
queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.VRFSerializer
|
||||
|
||||
|
||||
#
|
||||
# Roles
|
||||
#
|
||||
|
||||
class RoleListView(generics.ListAPIView):
|
||||
"""
|
||||
List all roles
|
||||
@@ -39,6 +48,10 @@ class RoleDetailView(generics.RetrieveAPIView):
|
||||
serializer_class = serializers.RoleSerializer
|
||||
|
||||
|
||||
#
|
||||
# RIRs
|
||||
#
|
||||
|
||||
class RIRListView(generics.ListAPIView):
|
||||
"""
|
||||
List all RIRs
|
||||
@@ -55,71 +68,112 @@ class RIRDetailView(generics.RetrieveAPIView):
|
||||
serializer_class = serializers.RIRSerializer
|
||||
|
||||
|
||||
class AggregateListView(generics.ListAPIView):
|
||||
#
|
||||
# Aggregates
|
||||
#
|
||||
|
||||
class AggregateListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List aggregates (filterable)
|
||||
"""
|
||||
queryset = Aggregate.objects.select_related('rir')
|
||||
queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.AggregateSerializer
|
||||
filter_class = AggregateFilter
|
||||
filter_class = filters.AggregateFilter
|
||||
|
||||
|
||||
class AggregateDetailView(generics.RetrieveAPIView):
|
||||
class AggregateDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single aggregate
|
||||
"""
|
||||
queryset = Aggregate.objects.select_related('rir')
|
||||
queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.AggregateSerializer
|
||||
|
||||
|
||||
class PrefixListView(generics.ListAPIView):
|
||||
#
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class PrefixListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List prefixes (filterable)
|
||||
"""
|
||||
queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'role')
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
filter_class = PrefixFilter
|
||||
filter_class = filters.PrefixFilter
|
||||
|
||||
|
||||
class PrefixDetailView(generics.RetrieveAPIView):
|
||||
class PrefixDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single prefix
|
||||
"""
|
||||
queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'role')
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
|
||||
|
||||
class IPAddressListView(generics.ListAPIView):
|
||||
#
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List IP addresses (filterable)
|
||||
"""
|
||||
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
|
||||
.prefetch_related('nat_outside')
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\
|
||||
.prefetch_related('nat_outside', 'custom_field_values__field')
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
filter_class = IPAddressFilter
|
||||
filter_class = filters.IPAddressFilter
|
||||
|
||||
|
||||
class IPAddressDetailView(generics.RetrieveAPIView):
|
||||
class IPAddressDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single IP address
|
||||
"""
|
||||
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
|
||||
.prefetch_related('nat_outside')
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\
|
||||
.prefetch_related('nat_outside', 'custom_field_values__field')
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
|
||||
|
||||
class VLANListView(generics.ListAPIView):
|
||||
#
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupListView(generics.ListAPIView):
|
||||
"""
|
||||
List all VLAN groups
|
||||
"""
|
||||
queryset = VLANGroup.objects.select_related('site')
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
filter_class = filters.VLANGroupFilter
|
||||
|
||||
|
||||
class VLANGroupDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single VLAN group
|
||||
"""
|
||||
queryset = VLANGroup.objects.select_related('site')
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
|
||||
|
||||
#
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List VLANs (filterable)
|
||||
"""
|
||||
queryset = VLAN.objects.select_related('site', 'role')
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.VLANSerializer
|
||||
filter_class = VLANFilter
|
||||
filter_class = filters.VLANFilter
|
||||
|
||||
|
||||
class VLANDetailView(generics.RetrieveAPIView):
|
||||
class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single VLAN
|
||||
"""
|
||||
queryset = VLAN.objects.select_related('site', 'role')
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.VLANSerializer
|
||||
|
||||
@@ -2,24 +2,55 @@ import django_filters
|
||||
from netaddr import IPNetwork
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import Site, Device, Interface
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NullableModelMultipleChoiceFilter
|
||||
|
||||
from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, Role
|
||||
from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
|
||||
|
||||
|
||||
class VRFFilter(django_filters.FilterSet):
|
||||
class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
)
|
||||
name = django_filters.CharFilter(
|
||||
name='name',
|
||||
lookup_type='icontains',
|
||||
label='Name',
|
||||
)
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
|
||||
def search(self, queryset, value):
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(rd__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['name', 'rd']
|
||||
|
||||
|
||||
class AggregateFilter(django_filters.FilterSet):
|
||||
class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
)
|
||||
rir_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='rir',
|
||||
queryset=RIR.objects.all(),
|
||||
@@ -36,8 +67,17 @@ class AggregateFilter(django_filters.FilterSet):
|
||||
model = Aggregate
|
||||
fields = ['family', 'rir_id', 'rir', 'date_added']
|
||||
|
||||
def search(self, queryset, value):
|
||||
qs_filter = Q(description__icontains=value)
|
||||
try:
|
||||
prefix = str(IPNetwork(value.strip()).cidr)
|
||||
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
|
||||
except AddrFormatError:
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
class PrefixFilter(django_filters.FilterSet):
|
||||
|
||||
class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
@@ -46,21 +86,34 @@ class PrefixFilter(django_filters.FilterSet):
|
||||
action='search_by_parent',
|
||||
label='Parent prefix',
|
||||
)
|
||||
vrf = django_filters.MethodFilter(
|
||||
action='_vrf',
|
||||
vrf_id = NullableModelMultipleChoiceFilter(
|
||||
name='vrf_id',
|
||||
queryset=VRF.objects.all(),
|
||||
label='VRF',
|
||||
)
|
||||
# Duplicate of `vrf` for backward-compatibility
|
||||
vrf_id = django_filters.MethodFilter(
|
||||
action='_vrf',
|
||||
label='VRF',
|
||||
vrf = NullableModelMultipleChoiceFilter(
|
||||
name='vrf',
|
||||
queryset=VRF.objects.all(),
|
||||
to_field_name='rd',
|
||||
label='VRF (RD)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
site_id = NullableModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
site = NullableModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -75,12 +128,12 @@ class PrefixFilter(django_filters.FilterSet):
|
||||
name='vlan__vid',
|
||||
label='VLAN number (1-4095)',
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
role_id = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=Role.objects.all(),
|
||||
label='Role (ID)',
|
||||
)
|
||||
role = django_filters.ModelMultipleChoiceFilter(
|
||||
role = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=Role.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -89,15 +142,16 @@ class PrefixFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['family', 'site_id', 'site', 'vrf', 'vrf_id', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
|
||||
fields = ['family', 'site_id', 'site', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role']
|
||||
|
||||
def search(self, queryset, value):
|
||||
value = value.strip()
|
||||
qs_filter = Q(description__icontains=value)
|
||||
try:
|
||||
query = str(IPNetwork(value).cidr)
|
||||
return queryset.filter(prefix__net_contains_or_equals=query)
|
||||
prefix = str(IPNetwork(value.strip()).cidr)
|
||||
qs_filter |= Q(prefix__net_contains_or_equals=prefix)
|
||||
except AddrFormatError:
|
||||
return queryset.none()
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
def search_by_parent(self, queryset, value):
|
||||
value = value.strip()
|
||||
@@ -109,31 +163,55 @@ class PrefixFilter(django_filters.FilterSet):
|
||||
except AddrFormatError:
|
||||
return queryset.none()
|
||||
|
||||
def _vrf(self, queryset, value):
|
||||
def _tenant(self, queryset, value):
|
||||
if str(value) == '':
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(tenant__slug=value) |
|
||||
Q(tenant__isnull=True, vrf__tenant__slug=value)
|
||||
)
|
||||
|
||||
def _tenant_id(self, queryset, value):
|
||||
try:
|
||||
vrf_id = int(value)
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
return queryset.none()
|
||||
if vrf_id == 0:
|
||||
return queryset.filter(vrf__isnull=True)
|
||||
return queryset.filter(vrf__pk=value)
|
||||
return queryset.filter(
|
||||
Q(tenant__pk=value) |
|
||||
Q(tenant__isnull=True, vrf__tenant__pk=value)
|
||||
)
|
||||
|
||||
|
||||
class IPAddressFilter(django_filters.FilterSet):
|
||||
class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
)
|
||||
vrf = django_filters.MethodFilter(
|
||||
action='_vrf',
|
||||
parent = django_filters.MethodFilter(
|
||||
action='search_by_parent',
|
||||
label='Parent prefix',
|
||||
)
|
||||
vrf_id = NullableModelMultipleChoiceFilter(
|
||||
name='vrf_id',
|
||||
queryset=VRF.objects.all(),
|
||||
label='VRF',
|
||||
)
|
||||
# Duplicate of `vrf` for backward-compatibility
|
||||
vrf_id = django_filters.MethodFilter(
|
||||
action='_vrf',
|
||||
label='VRF',
|
||||
vrf = NullableModelMultipleChoiceFilter(
|
||||
name='vrf',
|
||||
queryset=VRF.objects.all(),
|
||||
to_field_name='rd',
|
||||
label='VRF (RD)',
|
||||
)
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='interface__device',
|
||||
@@ -154,29 +232,29 @@ class IPAddressFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['q', 'family', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id']
|
||||
fields = ['q', 'family', 'device_id', 'device', 'interface_id']
|
||||
|
||||
def search(self, queryset, value):
|
||||
value = value.strip()
|
||||
qs_filter = Q(description__icontains=value)
|
||||
try:
|
||||
query = str(IPNetwork(value))
|
||||
return queryset.filter(address__net_host=query)
|
||||
ipaddress = str(IPNetwork(value.strip()))
|
||||
qs_filter |= Q(address__net_host=ipaddress)
|
||||
except AddrFormatError:
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
def search_by_parent(self, queryset, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
try:
|
||||
query = str(IPNetwork(value).cidr)
|
||||
return queryset.filter(address__net_contained_or_equal=query)
|
||||
except AddrFormatError:
|
||||
return queryset.none()
|
||||
|
||||
def _vrf(self, queryset, value):
|
||||
if str(value) == '':
|
||||
return queryset
|
||||
try:
|
||||
vrf_id = int(value)
|
||||
except ValueError:
|
||||
return queryset.none()
|
||||
if vrf_id == 0:
|
||||
return queryset.filter(vrf__isnull=True)
|
||||
return queryset.filter(vrf__pk=value)
|
||||
|
||||
|
||||
class VLANFilter(django_filters.FilterSet):
|
||||
class VLANGroupFilter(django_filters.FilterSet):
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
queryset=Site.objects.all(),
|
||||
@@ -188,6 +266,39 @@ class VLANFilter(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ['site_id', 'site']
|
||||
|
||||
|
||||
class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
group_id = NullableModelMultipleChoiceFilter(
|
||||
name='group',
|
||||
queryset=VLANGroup.objects.all(),
|
||||
label='Group (ID)',
|
||||
)
|
||||
group = NullableModelMultipleChoiceFilter(
|
||||
name='group',
|
||||
queryset=VLANGroup.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Group',
|
||||
)
|
||||
name = django_filters.CharFilter(
|
||||
name='name',
|
||||
lookup_type='icontains',
|
||||
@@ -197,12 +308,23 @@ class VLANFilter(django_filters.FilterSet):
|
||||
name='vid',
|
||||
label='VLAN number (1-4095)',
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
tenant_id = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = NullableModelMultipleChoiceFilter(
|
||||
name='tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
role_id = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=Role.objects.all(),
|
||||
label='Role (ID)',
|
||||
)
|
||||
role = django_filters.ModelMultipleChoiceFilter(
|
||||
role = NullableModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=Role.objects.all(),
|
||||
to_field_name='slug',
|
||||
@@ -212,3 +334,11 @@ class VLANFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['site_id', 'site', 'vid', 'name', 'status', 'role_id', 'role']
|
||||
|
||||
def search(self, queryset, value):
|
||||
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
|
||||
try:
|
||||
qs_filter |= Q(vid=int(value))
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
125
netbox/ipam/fixtures/initial_data.json
Normal file
125
netbox/ipam/fixtures/initial_data.json
Normal file
@@ -0,0 +1,125 @@
|
||||
[
|
||||
{
|
||||
"model": "ipam.aggregate",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"created": "2016-08-01",
|
||||
"last_updated": "2016-08-01T15:22:20.938Z",
|
||||
"family": 4,
|
||||
"prefix": "10.0.0.0/8",
|
||||
"rir": 6,
|
||||
"date_added": null,
|
||||
"description": "Private IPv4 space"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "ipam.aggregate",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"created": "2016-08-01",
|
||||
"last_updated": "2016-08-01T15:22:32.679Z",
|
||||
"family": 4,
|
||||
"prefix": "172.16.0.0/12",
|
||||
"rir": 6,
|
||||
"date_added": null,
|
||||
"description": "Private IPv4 space"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "ipam.aggregate",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"created": "2016-08-01",
|
||||
"last_updated": "2016-08-01T15:22:42.289Z",
|
||||
"family": 4,
|
||||
"prefix": "192.168.0.0/16",
|
||||
"rir": 6,
|
||||
"date_added": null,
|
||||
"description": "Private IPv4 space"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "ipam.rir",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "ARIN",
|
||||
"slug": "arin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "ipam.rir",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "RIPE",
|
||||
"slug": "ripe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "ipam.rir",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "APNIC",
|
||||
"slug": "apnic"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "ipam.rir",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"name": "LACNIC",
|
||||
"slug": "lacnic"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "ipam.rir",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"name": "AFRINIC",
|
||||
"slug": "afrinic"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "ipam.rir",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"name": "RFC 1918",
|
||||
"slug": "rfc-1918"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "ipam.role",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "Production",
|
||||
"slug": "production",
|
||||
"weight": 1000
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "ipam.role",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Development",
|
||||
"slug": "development",
|
||||
"weight": 1000
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "ipam.role",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "Management",
|
||||
"slug": "management",
|
||||
"weight": 1000
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "ipam.role",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"name": "Backup",
|
||||
"slug": "backup",
|
||||
"weight": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,31 +1,49 @@
|
||||
from netaddr import IPNetwork
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Count
|
||||
|
||||
from dcim.models import Site, Device, Interface
|
||||
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||
from tenancy.forms import bulkedit_tenant_choices
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
BootstrapMixin, ConfirmationForm, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField,
|
||||
APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField,
|
||||
)
|
||||
|
||||
from .models import (
|
||||
Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLAN_STATUS_CHOICES, VRF,
|
||||
Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF,
|
||||
)
|
||||
|
||||
|
||||
FORM_PREFIX_STATUS_CHOICES = (('', '---------'),) + PREFIX_STATUS_CHOICES
|
||||
FORM_VLAN_STATUS_CHOICES = (('', '---------'),) + VLAN_STATUS_CHOICES
|
||||
IP_FAMILY_CHOICES = [
|
||||
('', 'All'),
|
||||
(4, 'IPv4'),
|
||||
(6, 'IPv6'),
|
||||
]
|
||||
|
||||
|
||||
def bulkedit_vrf_choices():
|
||||
"""
|
||||
Include an option to assign the object to the global table.
|
||||
"""
|
||||
choices = [
|
||||
(None, '---------'),
|
||||
(0, 'Global'),
|
||||
]
|
||||
choices += [(v.pk, v.name) for v in VRF.objects.all()]
|
||||
return choices
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFForm(forms.ModelForm, BootstrapMixin):
|
||||
class VRFForm(BootstrapMixin, CustomFieldForm):
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['name', 'rd', 'description']
|
||||
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
|
||||
labels = {
|
||||
'rd': "RD",
|
||||
}
|
||||
@@ -35,23 +53,28 @@ class VRFForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
|
||||
class VRFFromCSVForm(forms.ModelForm):
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['name', 'rd', 'description']
|
||||
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
|
||||
|
||||
|
||||
class VRFImportForm(BulkImportForm, BootstrapMixin):
|
||||
csv = CSVDataField(csv_form=VRFFromCSVForm)
|
||||
|
||||
|
||||
class VRFBulkEditForm(forms.Form, BootstrapMixin):
|
||||
class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
class VRFBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = VRF
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
|
||||
null_option=(0, None))
|
||||
|
||||
|
||||
#
|
||||
@@ -66,15 +89,11 @@ class RIRForm(forms.ModelForm, BootstrapMixin):
|
||||
fields = ['name', 'slug']
|
||||
|
||||
|
||||
class RIRBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=RIR.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
#
|
||||
# Aggregates
|
||||
#
|
||||
|
||||
class AggregateForm(forms.ModelForm, BootstrapMixin):
|
||||
class AggregateForm(BootstrapMixin, CustomFieldForm):
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
@@ -99,25 +118,18 @@ class AggregateImportForm(BulkImportForm, BootstrapMixin):
|
||||
csv = CSVDataField(csv_form=AggregateFromCSVForm)
|
||||
|
||||
|
||||
class AggregateBulkEditForm(forms.Form, BootstrapMixin):
|
||||
class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR')
|
||||
date_added = forms.DateField(required=False)
|
||||
description = forms.CharField(max_length=50, required=False)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
class AggregateBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
def aggregate_rir_choices():
|
||||
rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates'))
|
||||
return [(r.slug, '{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices]
|
||||
|
||||
|
||||
class AggregateFilterForm(forms.Form, BootstrapMixin):
|
||||
rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR',
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Aggregate
|
||||
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
|
||||
rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug',
|
||||
label='RIR')
|
||||
|
||||
|
||||
#
|
||||
@@ -132,15 +144,11 @@ class RoleForm(forms.ModelForm, BootstrapMixin):
|
||||
fields = ['name', 'slug']
|
||||
|
||||
|
||||
class RoleBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Role.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
#
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class PrefixForm(forms.ModelForm, BootstrapMixin):
|
||||
class PrefixForm(BootstrapMixin, CustomFieldForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
|
||||
widget=forms.Select(attrs={'filter-for': 'vlan'}))
|
||||
vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN',
|
||||
@@ -149,7 +157,7 @@ class PrefixForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'description']
|
||||
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'description']
|
||||
help_texts = {
|
||||
'prefix': "IPv4 or IPv6 network",
|
||||
'vrf': "VRF (if applicable)",
|
||||
@@ -173,32 +181,61 @@ class PrefixForm(forms.ModelForm, BootstrapMixin):
|
||||
self.fields['vlan'].choices = []
|
||||
|
||||
def clean_prefix(self):
|
||||
data = self.cleaned_data['prefix']
|
||||
try:
|
||||
prefix = IPNetwork(data)
|
||||
except:
|
||||
raise
|
||||
prefix = self.cleaned_data['prefix']
|
||||
if prefix.version == 4 and prefix.prefixlen == 32:
|
||||
raise forms.ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 "
|
||||
"addresses instead.")
|
||||
elif prefix.version == 6 and prefix.prefixlen == 128:
|
||||
raise forms.ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 "
|
||||
"addresses instead.")
|
||||
return data
|
||||
return prefix
|
||||
|
||||
|
||||
class PrefixFromCSVForm(forms.ModelForm):
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
|
||||
error_messages={'invalid_choice': 'VRF not found.'})
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Site not found.'})
|
||||
vlan_group_name = forms.CharField(required=False)
|
||||
vlan_vid = forms.IntegerField(required=False)
|
||||
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in PREFIX_STATUS_CHOICES])
|
||||
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid role.'})
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['prefix', 'vrf', 'site', 'status_name', 'role', 'description']
|
||||
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role',
|
||||
'description']
|
||||
|
||||
def clean(self):
|
||||
|
||||
super(PrefixFromCSVForm, self).clean()
|
||||
|
||||
site = self.cleaned_data.get('site')
|
||||
vlan_group_name = self.cleaned_data.get('vlan_group_name')
|
||||
vlan_vid = self.cleaned_data.get('vlan_vid')
|
||||
|
||||
# Validate VLAN
|
||||
vlan_group = None
|
||||
if vlan_group_name:
|
||||
try:
|
||||
vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name)
|
||||
except VLANGroup.DoesNotExist:
|
||||
self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
|
||||
if vlan_vid and vlan_group:
|
||||
try:
|
||||
self.instance.vlan = VLAN.objects.get(group=vlan_group, vid=vlan_vid)
|
||||
except VLAN.DoesNotExist:
|
||||
self.add_error('vlan_vid', "Invalid VLAN ID ({} - {}).".format(vlan_group, vlan_vid))
|
||||
elif vlan_vid and site:
|
||||
try:
|
||||
self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid)
|
||||
except VLAN.MultipleObjectsReturned:
|
||||
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
|
||||
elif vlan_vid:
|
||||
self.add_error('vlan_vid', "Must specify site and/or VLAN group when assigning a VLAN.")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
m = super(PrefixFromCSVForm, self).save(commit=False)
|
||||
@@ -213,52 +250,38 @@ class PrefixImportForm(BulkImportForm, BootstrapMixin):
|
||||
csv = CSVDataField(csv_form=PrefixFromCSVForm)
|
||||
|
||||
|
||||
class PrefixBulkEditForm(forms.Form, BootstrapMixin):
|
||||
class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF',
|
||||
help_text="Select the VRF to assign, or check below to remove VRF assignment")
|
||||
vrf_global = forms.BooleanField(required=False, label='Set VRF to global')
|
||||
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
status = forms.ChoiceField(choices=FORM_PREFIX_STATUS_CHOICES, required=False)
|
||||
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
|
||||
description = forms.CharField(max_length=50, required=False)
|
||||
|
||||
|
||||
class PrefixBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
def prefix_vrf_choices():
|
||||
vrf_choices = [('', 'All'), (0, 'Global')]
|
||||
vrf_choices += [(v.pk, v.name) for v in VRF.objects.all()]
|
||||
return vrf_choices
|
||||
|
||||
|
||||
def prefix_site_choices():
|
||||
site_choices = Site.objects.annotate(prefix_count=Count('prefixes'))
|
||||
return [(s.slug, '{} ({})'.format(s.name, s.prefix_count)) for s in site_choices]
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
def prefix_status_choices():
|
||||
status_counts = {}
|
||||
for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
|
||||
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
|
||||
|
||||
|
||||
def prefix_role_choices():
|
||||
role_choices = Role.objects.annotate(prefix_count=Count('prefixes'))
|
||||
return [(r.slug, '{} ({})'.format(r.name, r.prefix_count)) for r in role_choices]
|
||||
|
||||
|
||||
class PrefixFilterForm(forms.Form, BootstrapMixin):
|
||||
parent = forms.CharField(required=False, label='Search Within')
|
||||
vrf = forms.ChoiceField(required=False, choices=prefix_vrf_choices, label='VRF')
|
||||
status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices)
|
||||
site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Prefix
|
||||
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
|
||||
'placeholder': 'Network',
|
||||
}))
|
||||
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
|
||||
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd',
|
||||
label='VRF', null_option=(0, 'Global'))
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
|
||||
|
||||
|
||||
@@ -266,7 +289,7 @@ class PrefixFilterForm(forms.Form, BootstrapMixin):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressForm(forms.ModelForm, BootstrapMixin):
|
||||
class IPAddressForm(BootstrapMixin, CustomFieldForm):
|
||||
nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
|
||||
widget=forms.Select(attrs={'filter-for': 'nat_device'}))
|
||||
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
|
||||
@@ -281,7 +304,7 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['address', 'vrf', 'nat_device', 'nat_inside', 'description']
|
||||
fields = ['address', 'vrf', 'tenant', 'nat_device', 'nat_inside', 'description']
|
||||
help_texts = {
|
||||
'address': "IPv4 or IPv6 address and mask",
|
||||
'vrf': "VRF (if applicable)",
|
||||
@@ -330,6 +353,8 @@ class IPAddressForm(forms.ModelForm, BootstrapMixin):
|
||||
class IPAddressFromCSVForm(forms.ModelForm):
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
|
||||
error_messages={'invalid_choice': 'VRF not found.'})
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Device not found.'})
|
||||
interface_name = forms.CharField(required=False)
|
||||
@@ -337,7 +362,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['address', 'vrf', 'device', 'interface_name', 'is_primary', 'description']
|
||||
fields = ['address', 'vrf', 'tenant', 'device', 'interface_name', 'is_primary', 'description']
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -368,9 +393,9 @@ class IPAddressFromCSVForm(forms.ModelForm):
|
||||
name=self.cleaned_data['interface_name'])
|
||||
# Set as primary for device
|
||||
if self.cleaned_data['is_primary']:
|
||||
if self.instance.family == 4:
|
||||
if self.instance.address.version == 4:
|
||||
self.instance.primary_ip4_for = self.cleaned_data['device']
|
||||
elif self.instance.family == 6:
|
||||
elif self.instance.address.version == 6:
|
||||
self.instance.primary_ip6_for = self.cleaned_data['device']
|
||||
|
||||
return super(IPAddressFromCSVForm, self).save(commit=commit)
|
||||
@@ -380,61 +405,92 @@ class IPAddressImportForm(BulkImportForm, BootstrapMixin):
|
||||
csv = CSVDataField(csv_form=IPAddressFromCSVForm)
|
||||
|
||||
|
||||
class IPAddressBulkEditForm(forms.Form, BootstrapMixin):
|
||||
class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF',
|
||||
help_text="Select the VRF to assign, or check below to remove VRF assignment")
|
||||
vrf_global = forms.BooleanField(required=False, label='Set VRF to global')
|
||||
description = forms.CharField(max_length=50, required=False)
|
||||
vrf = forms.TypedChoiceField(choices=bulkedit_vrf_choices, coerce=int, required=False, label='VRF')
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
class IPAddressBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = IPAddress
|
||||
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
|
||||
'placeholder': 'Prefix',
|
||||
}))
|
||||
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
|
||||
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd',
|
||||
label='VRF', null_option=(0, 'Global'))
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
|
||||
to_field_name='slug', null_option=(0, 'None'))
|
||||
|
||||
|
||||
def ipaddress_family_choices():
|
||||
return [('', 'All'), (4, 'IPv4'), (6, 'IPv6')]
|
||||
#
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupForm(forms.ModelForm, BootstrapMixin):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ['site', 'name', 'slug']
|
||||
|
||||
|
||||
def ipaddress_vrf_choices():
|
||||
vrf_choices = [('', 'All'), (0, 'Global')]
|
||||
vrf_choices += [(v.pk, v.name) for v in VRF.objects.all()]
|
||||
return vrf_choices
|
||||
|
||||
|
||||
class IPAddressFilterForm(forms.Form, BootstrapMixin):
|
||||
family = forms.ChoiceField(required=False, choices=ipaddress_family_choices, label='Address Family')
|
||||
vrf = forms.ChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF')
|
||||
class VLANGroupFilterForm(forms.Form, BootstrapMixin):
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug')
|
||||
|
||||
|
||||
#
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANForm(forms.ModelForm, BootstrapMixin):
|
||||
class VLANForm(BootstrapMixin, CustomFieldForm):
|
||||
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect(
|
||||
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
|
||||
))
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['site', 'vid', 'name', 'status', 'role']
|
||||
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
|
||||
help_texts = {
|
||||
'site': "The site at which this VLAN exists",
|
||||
'group': "VLAN group (optional)",
|
||||
'vid': "Configured VLAN ID",
|
||||
'name': "Configured VLAN name",
|
||||
'status': "Operational status of this VLAN",
|
||||
'role': "The primary function of this VLAN",
|
||||
}
|
||||
widgets = {
|
||||
'site': forms.Select(attrs={'filter-for': 'group'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(VLANForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit VLAN group choices
|
||||
if self.is_bound and self.data.get('site'):
|
||||
self.fields['group'].queryset = VLANGroup.objects.filter(site__pk=self.data['site'])
|
||||
elif self.initial.get('site'):
|
||||
self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site'])
|
||||
else:
|
||||
self.fields['group'].choices = []
|
||||
|
||||
|
||||
class VLANFromCSVForm(forms.ModelForm):
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Device not found.'})
|
||||
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'VLAN group not found.'})
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
|
||||
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid role.'})
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['site', 'vid', 'name', 'status_name', 'role']
|
||||
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status_name', 'role', 'description']
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
m = super(VLANFromCSVForm, self).save(commit=False)
|
||||
@@ -449,37 +505,30 @@ class VLANImportForm(BulkImportForm, BootstrapMixin):
|
||||
csv = CSVDataField(csv_form=VLANFromCSVForm)
|
||||
|
||||
|
||||
class VLANBulkEditForm(forms.Form, BootstrapMixin):
|
||||
class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False)
|
||||
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False)
|
||||
tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant')
|
||||
status = forms.ChoiceField(choices=FORM_VLAN_STATUS_CHOICES, required=False)
|
||||
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
|
||||
|
||||
|
||||
class VLANBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
def vlan_site_choices():
|
||||
site_choices = Site.objects.annotate(vlan_count=Count('vlans'))
|
||||
return [(s.slug, '{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
def vlan_status_choices():
|
||||
status_counts = {}
|
||||
for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
|
||||
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
|
||||
|
||||
|
||||
def vlan_role_choices():
|
||||
role_choices = Role.objects.annotate(vlan_count=Count('vlans'))
|
||||
return [(r.slug, '{} ({})'.format(r.name, r.vlan_count)) for r in role_choices]
|
||||
|
||||
|
||||
class VLANFilterForm(forms.Form, BootstrapMixin):
|
||||
site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
|
||||
role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
|
||||
widget=forms.SelectMultiple(attrs={'size': 8}))
|
||||
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = VLAN
|
||||
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug')
|
||||
group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
|
||||
null_option=(0, 'None'))
|
||||
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
|
||||
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
|
||||
null_option=(0, 'None'))
|
||||
|
||||
20
netbox/ipam/migrations/0002_vrf_add_enforce_unique.py
Normal file
20
netbox/ipam/migrations/0002_vrf_add_enforce_unique.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.7 on 2016-07-14 19:34
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vrf',
|
||||
name='enforce_unique',
|
||||
field=models.BooleanField(default=True, help_text=b'Prevent duplicate prefixes/IP addresses within this VRF', verbose_name=b'Enforce unique space'),
|
||||
),
|
||||
]
|
||||
38
netbox/ipam/migrations/0003_ipam_add_vlangroups.py
Normal file
38
netbox/ipam/migrations/0003_ipam_add_vlangroups.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.7 on 2016-07-15 16:22
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0010_devicebay_installed_device_set_null'),
|
||||
('ipam', '0002_vrf_add_enforce_unique'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VLANGroup',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50)),
|
||||
('slug', models.SlugField()),
|
||||
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vlan_groups', to='dcim.Site')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['site', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vlan',
|
||||
name='group',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='ipam.VLANGroup'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='vlangroup',
|
||||
unique_together=set([('site', 'name'), ('site', 'slug')]),
|
||||
),
|
||||
]
|
||||
27
netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py
Normal file
27
netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.7 on 2016-07-15 17:14
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0003_ipam_add_vlangroups'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='vlan',
|
||||
options={'ordering': ['site', 'group', 'vid'], 'verbose_name': 'VLAN', 'verbose_name_plural': 'VLANs'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='vlangroup',
|
||||
options={'ordering': ['site', 'name'], 'verbose_name': 'VLAN group', 'verbose_name_plural': 'VLAN groups'},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='vlan',
|
||||
unique_together=set([('group', 'name'), ('group', 'vid')]),
|
||||
),
|
||||
]
|
||||
25
netbox/ipam/migrations/0005_auto_20160725_1842.py
Normal file
25
netbox/ipam/migrations/0005_auto_20160725_1842.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-07-25 18:42
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0004_ipam_vlangroup_uniqueness'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vlan',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='vlan',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
]
|
||||
27
netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py
Normal file
27
netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-07-27 14:39
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0001_initial'),
|
||||
('ipam', '0005_auto_20160725_1842'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vlan',
|
||||
name='tenant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='tenancy.Tenant'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vrf',
|
||||
name='tenant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vrfs', to='tenancy.Tenant'),
|
||||
),
|
||||
]
|
||||
27
netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py
Normal file
27
netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.8 on 2016-07-28 15:32
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0001_initial'),
|
||||
('ipam', '0006_vrf_vlan_add_tenant'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ipaddress',
|
||||
name='tenant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='tenancy.Tenant'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='prefix',
|
||||
name='tenant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='tenancy.Tenant'),
|
||||
),
|
||||
]
|
||||
19
netbox/ipam/migrations/0008_prefix_change_order.py
Normal file
19
netbox/ipam/migrations/0008_prefix_change_order.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10 on 2016-09-15 16:08
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0007_prefix_ipaddress_add_tenant'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='prefix',
|
||||
options={'ordering': ['vrf', 'family', 'prefix'], 'verbose_name_plural': 'prefixes'},
|
||||
),
|
||||
]
|
||||
@@ -1,12 +1,18 @@
|
||||
from netaddr import IPNetwork, cidr_merge
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models.expressions import RawSQL
|
||||
|
||||
from dcim.models import Interface
|
||||
from extras.models import CustomFieldModel, CustomFieldValue
|
||||
from tenancy.models import Tenant
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
from utilities.sql import NullsFirstQuerySet
|
||||
|
||||
from .fields import IPNetworkField, IPAddressField
|
||||
|
||||
@@ -37,7 +43,7 @@ STATUS_CHOICE_CLASSES = {
|
||||
}
|
||||
|
||||
|
||||
class VRF(CreatedUpdatedModel):
|
||||
class VRF(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
|
||||
table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF
|
||||
@@ -45,7 +51,11 @@ class VRF(CreatedUpdatedModel):
|
||||
"""
|
||||
name = models.CharField(max_length=50)
|
||||
rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher')
|
||||
tenant = models.ForeignKey(Tenant, related_name='vrfs', blank=True, null=True, on_delete=models.PROTECT)
|
||||
enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space',
|
||||
help_text="Prevent duplicate prefixes/IP addresses within this VRF")
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -62,6 +72,8 @@ class VRF(CreatedUpdatedModel):
|
||||
return ','.join([
|
||||
self.name,
|
||||
self.rd,
|
||||
self.tenant.name if self.tenant else '',
|
||||
'True' if self.enforce_unique else '',
|
||||
self.description,
|
||||
])
|
||||
|
||||
@@ -86,7 +98,7 @@ class RIR(models.Model):
|
||||
return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
|
||||
|
||||
|
||||
class Aggregate(CreatedUpdatedModel):
|
||||
class Aggregate(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
|
||||
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
|
||||
@@ -96,6 +108,7 @@ class Aggregate(CreatedUpdatedModel):
|
||||
rir = models.ForeignKey('RIR', related_name='aggregates', on_delete=models.PROTECT, verbose_name='RIR')
|
||||
date_added = models.DateField(blank=True, null=True)
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
class Meta:
|
||||
ordering = ['family', 'prefix']
|
||||
@@ -180,7 +193,7 @@ class Role(models.Model):
|
||||
return self.vlans.count()
|
||||
|
||||
|
||||
class PrefixQuerySet(models.QuerySet):
|
||||
class PrefixQuerySet(NullsFirstQuerySet):
|
||||
|
||||
def annotate_depth(self, limit=None):
|
||||
"""
|
||||
@@ -215,7 +228,7 @@ class PrefixQuerySet(models.QuerySet):
|
||||
return filter(lambda p: p.depth <= limit, queryset)
|
||||
|
||||
|
||||
class Prefix(CreatedUpdatedModel):
|
||||
class Prefix(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
|
||||
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
|
||||
@@ -226,16 +239,18 @@ class Prefix(CreatedUpdatedModel):
|
||||
site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True)
|
||||
vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
|
||||
verbose_name='VRF')
|
||||
tenant = models.ForeignKey(Tenant, related_name='prefixes', blank=True, null=True, on_delete=models.PROTECT)
|
||||
vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True,
|
||||
verbose_name='VLAN')
|
||||
status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=1)
|
||||
role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True)
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
objects = PrefixQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['family', 'prefix']
|
||||
ordering = ['vrf', 'family', 'prefix']
|
||||
verbose_name_plural = 'prefixes'
|
||||
|
||||
def __unicode__(self):
|
||||
@@ -244,6 +259,16 @@ class Prefix(CreatedUpdatedModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:prefix', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
# Disallow host masks
|
||||
if self.prefix:
|
||||
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
|
||||
raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses "
|
||||
"instead.")
|
||||
elif self.prefix.version == 6 and self.prefix.prefixlen == 128:
|
||||
raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses "
|
||||
"instead.")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.prefix:
|
||||
# Clear host bits from prefix
|
||||
@@ -256,6 +281,7 @@ class Prefix(CreatedUpdatedModel):
|
||||
return ','.join([
|
||||
str(self.prefix),
|
||||
self.vrf.rd if self.vrf else '',
|
||||
self.tenant.name if self.tenant else '',
|
||||
self.site.name if self.site else '',
|
||||
self.get_status_display(),
|
||||
self.role.name if self.role else '',
|
||||
@@ -277,9 +303,23 @@ class Prefix(CreatedUpdatedModel):
|
||||
return STATUS_CHOICE_CLASSES[self.status]
|
||||
|
||||
|
||||
class IPAddress(CreatedUpdatedModel):
|
||||
class IPAddressManager(models.Manager):
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
By default, PostgreSQL will order INETs with shorter (larger) prefix lengths ahead of those with longer
|
||||
(smaller) masks. This makes no sense when ordering IPs, which should be ordered solely by family and host
|
||||
address. We can use HOST() to extract just the host portion of the address (ignoring its mask), but we must
|
||||
then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each
|
||||
IP address as a /32 or /128.
|
||||
"""
|
||||
qs = super(IPAddressManager, self).get_queryset()
|
||||
return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host')
|
||||
|
||||
|
||||
class IPAddress(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
An IPAddress represents an individual IPV4 or IPv6 address and its mask. The mask length should match what is
|
||||
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
|
||||
configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
|
||||
Prefixes, IPAddresses can optionally be assigned to a VRF. An IPAddress can optionally be assigned to an Interface.
|
||||
Interfaces can have zero or more IPAddresses assigned to them.
|
||||
@@ -292,11 +332,15 @@ class IPAddress(CreatedUpdatedModel):
|
||||
address = IPAddressField()
|
||||
vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True,
|
||||
verbose_name='VRF')
|
||||
tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT)
|
||||
interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True,
|
||||
null=True)
|
||||
nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True,
|
||||
null=True, verbose_name='NAT IP (inside)')
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
objects = IPAddressManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['family', 'address']
|
||||
@@ -309,6 +353,21 @@ class IPAddress(CreatedUpdatedModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:ipaddress', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Enforce unique IP space if applicable
|
||||
if self.vrf and self.vrf.enforce_unique:
|
||||
duplicate_ips = IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip))\
|
||||
.exclude(pk=self.pk)
|
||||
if duplicate_ips:
|
||||
raise ValidationError("Duplicate IP address found in VRF {}: {}".format(self.vrf,
|
||||
duplicate_ips.first()))
|
||||
elif not self.vrf and settings.ENFORCE_GLOBAL_UNIQUE:
|
||||
duplicate_ips = IPAddress.objects.filter(vrf=None, address__net_host=str(self.address.ip))\
|
||||
.exclude(pk=self.pk)
|
||||
if duplicate_ips:
|
||||
raise ValidationError("Duplicate IP address found in global table: {}".format(duplicate_ips.first()))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.address:
|
||||
# Infer address family from IPAddress object
|
||||
@@ -327,6 +386,7 @@ class IPAddress(CreatedUpdatedModel):
|
||||
return ','.join([
|
||||
str(self.address),
|
||||
self.vrf.rd if self.vrf else '',
|
||||
self.tenant.name if self.tenant else '',
|
||||
self.device.identifier if self.device else '',
|
||||
self.interface.name if self.interface else '',
|
||||
'True' if is_primary else '',
|
||||
@@ -340,23 +400,58 @@ class IPAddress(CreatedUpdatedModel):
|
||||
return None
|
||||
|
||||
|
||||
class VLAN(CreatedUpdatedModel):
|
||||
class VLANGroup(models.Model):
|
||||
"""
|
||||
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
|
||||
"""
|
||||
name = models.CharField(max_length=50)
|
||||
slug = models.SlugField()
|
||||
site = models.ForeignKey('dcim.Site', related_name='vlan_groups')
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
unique_together = [
|
||||
['site', 'name'],
|
||||
['site', 'slug'],
|
||||
]
|
||||
verbose_name = 'VLAN group'
|
||||
verbose_name_plural = 'VLAN groups'
|
||||
|
||||
def __unicode__(self):
|
||||
return u'{} - {}'.format(self.site.name, self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
|
||||
|
||||
|
||||
class VLAN(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
|
||||
to a Site, however VLAN IDs need not be unique within a Site. Like Prefixes, each VLAN is assigned an operational
|
||||
status and optionally a user-defined Role. A VLAN can have zero or more Prefixes assigned to it.
|
||||
to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup,
|
||||
within which all VLAN IDs and names but be unique.
|
||||
|
||||
Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero
|
||||
or more Prefixes assigned to it.
|
||||
"""
|
||||
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT)
|
||||
group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
|
||||
vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
|
||||
MinValueValidator(1),
|
||||
MaxValueValidator(4094)
|
||||
])
|
||||
name = models.CharField(max_length=30)
|
||||
name = models.CharField(max_length=64)
|
||||
tenant = models.ForeignKey(Tenant, related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
|
||||
status = models.PositiveSmallIntegerField('Status', choices=VLAN_STATUS_CHOICES, default=1)
|
||||
role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'vid']
|
||||
ordering = ['site', 'group', 'vid']
|
||||
unique_together = [
|
||||
['group', 'vid'],
|
||||
['group', 'name'],
|
||||
]
|
||||
verbose_name = 'VLAN'
|
||||
verbose_name_plural = 'VLANs'
|
||||
|
||||
@@ -366,18 +461,27 @@ class VLAN(CreatedUpdatedModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:vlan', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate VLAN group
|
||||
if self.group and self.group.site != self.site:
|
||||
raise ValidationError("VLAN group must belong to the assigned site ({}).".format(self.site))
|
||||
|
||||
def to_csv(self):
|
||||
return ','.join([
|
||||
self.site.name,
|
||||
self.group.name if self.group else '',
|
||||
str(self.vid),
|
||||
self.name,
|
||||
self.tenant.name if self.tenant else '',
|
||||
self.get_status_display(),
|
||||
self.role.name if self.role else '',
|
||||
self.description,
|
||||
])
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return u"{} ({})".format(self.vid, self.name)
|
||||
return u'{} ({})'.format(self.vid, self.name)
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CHOICE_CLASSES[self.status]
|
||||
|
||||
@@ -3,27 +3,24 @@ from django_tables2.utils import Accessor
|
||||
|
||||
from utilities.tables import BaseTable, ToggleColumn
|
||||
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VRF
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
|
||||
|
||||
|
||||
RIR_EDIT_LINK = """
|
||||
{% if perms.ipam.change_rir %}<a href="{% url 'ipam:rir_edit' slug=record.slug %}">Edit</a>{% endif %}
|
||||
RIR_ACTIONS = """
|
||||
{% if perms.ipam.change_rir %}
|
||||
<a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% with record.get_utilization as percentage %}
|
||||
<div class="progress text-center">
|
||||
{% if percentage < 15 %}<span style="font-size: 12px;">{{ percentage }}%</span>{% endif %}
|
||||
<div class="progress-bar progress-bar-{% if percentage >= 90 %}danger{% elif percentage >= 75 %}warning{% else %}success{% endif %}"
|
||||
role="progressbar" aria-valuenow="{{ percentage }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ percentage }}%">
|
||||
{% if percentage >= 15 %}{{ percentage }}%{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% load helpers %}
|
||||
{% utilization_graph record.get_utilization %}
|
||||
"""
|
||||
|
||||
ROLE_EDIT_LINK = """
|
||||
{% if perms.ipam.change_role %}<a href="{% url 'ipam:role_edit' slug=record.slug %}">Edit</a>{% endif %}
|
||||
ROLE_ACTIONS = """
|
||||
{% if perms.ipam.change_role %}
|
||||
<a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
PREFIX_LINK = """
|
||||
@@ -42,6 +39,26 @@ PREFIX_LINK_BRIEF = """
|
||||
</span>
|
||||
"""
|
||||
|
||||
IPADDRESS_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
|
||||
{% elif perms.ipam.add_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }}</a>
|
||||
{% else %}
|
||||
{{ record.0 }}
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VRF_LINK = """
|
||||
{% if record.vrf %}
|
||||
<a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
|
||||
{% elif prefix.vrf %}
|
||||
{{ prefix.vrf }}
|
||||
{% else %}
|
||||
Global
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
STATUS_LABEL = """
|
||||
{% if record.pk %}
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
@@ -50,6 +67,22 @@ STATUS_LABEL = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLANGROUP_ACTIONS = """
|
||||
{% if perms.ipam.change_vlangroup %}
|
||||
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
TENANT_LINK = """
|
||||
{% if record.tenant %}
|
||||
<a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}">{{ record.tenant }}</a>
|
||||
{% elif record.vrf.tenant %}
|
||||
<a href="{% url 'tenancy:tenant' slug=record.vrf.tenant.slug %}">{{ record.vrf.tenant }}</a>*
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
@@ -59,11 +92,12 @@ class VRFTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
|
||||
rd = tables.Column(verbose_name='RD')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
description = tables.Column(orderable=False, verbose_name='Description')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VRF
|
||||
fields = ('pk', 'name', 'rd', 'description')
|
||||
fields = ('pk', 'name', 'rd', 'tenant', 'description')
|
||||
|
||||
|
||||
#
|
||||
@@ -75,11 +109,11 @@ class RIRTable(BaseTable):
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
aggregate_count = tables.Column(verbose_name='Aggregates')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
edit = tables.TemplateColumn(template_code=RIR_EDIT_LINK, verbose_name='')
|
||||
actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RIR
|
||||
fields = ('pk', 'name', 'aggregate_count', 'slug', 'edit')
|
||||
fields = ('pk', 'name', 'aggregate_count', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -110,11 +144,11 @@ class RoleTable(BaseTable):
|
||||
prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
|
||||
vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
edit = tables.TemplateColumn(template_code=ROLE_EDIT_LINK, verbose_name='')
|
||||
actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Role
|
||||
fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'edit')
|
||||
fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -125,25 +159,31 @@ class PrefixTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
|
||||
prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix')
|
||||
vrf = tables.Column(orderable=False, default='Global', verbose_name='VRF')
|
||||
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
|
||||
tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
role = tables.Column(verbose_name='Role')
|
||||
description = tables.Column(orderable=False, verbose_name='Description')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Prefix
|
||||
fields = ('pk', 'prefix', 'status', 'vrf', 'site', 'role', 'description')
|
||||
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'role', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not record.pk else '',
|
||||
}
|
||||
|
||||
|
||||
class PrefixBriefTable(BaseTable):
|
||||
prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF, verbose_name='Prefix')
|
||||
vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
|
||||
role = tables.Column(verbose_name='Role')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Prefix
|
||||
fields = ('prefix', 'status', 'site', 'role')
|
||||
fields = ('prefix', 'vrf', 'status', 'site', 'role')
|
||||
orderable = False
|
||||
|
||||
|
||||
#
|
||||
@@ -152,8 +192,9 @@ class PrefixBriefTable(BaseTable):
|
||||
|
||||
class IPAddressTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address')
|
||||
vrf = tables.Column(orderable=False, default='Global', verbose_name='VRF')
|
||||
address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
|
||||
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
|
||||
tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
|
||||
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
|
||||
verbose_name='Device')
|
||||
interface = tables.Column(orderable=False, verbose_name='Interface')
|
||||
@@ -161,7 +202,10 @@ class IPAddressTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('pk', 'address', 'vrf', 'device', 'interface', 'description')
|
||||
fields = ('pk', 'address', 'vrf', 'tenant', 'device', 'interface', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
|
||||
}
|
||||
|
||||
|
||||
class IPAddressBriefTable(BaseTable):
|
||||
@@ -177,6 +221,24 @@ class IPAddressBriefTable(BaseTable):
|
||||
fields = ('address', 'device', 'interface', 'nat_inside')
|
||||
|
||||
|
||||
#
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
vlan_count = tables.Column(verbose_name='VLANs')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=VLANGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLANGroup
|
||||
fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# VLANs
|
||||
#
|
||||
@@ -185,10 +247,12 @@ class VLANTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
|
||||
name = tables.Column(verbose_name='Name')
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
|
||||
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
|
||||
role = tables.Column(verbose_name='Role')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLAN
|
||||
fields = ('pk', 'vid', 'site', 'name', 'status', 'role')
|
||||
fields = ('pk', 'vid', 'site', 'group', 'name', 'tenant', 'status', 'role')
|
||||
|
||||
@@ -58,6 +58,12 @@ urlpatterns = [
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
|
||||
|
||||
# VLAN groups
|
||||
url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'),
|
||||
url(r'^vlan-groups/add/$', views.VLANGroupEditView.as_view(), name='vlangroup_add'),
|
||||
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'),
|
||||
|
||||
# VLANs
|
||||
url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
|
||||
url(r'^vlans/add/$', views.VLANEditView.as_view(), name='vlan_add'),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from netaddr import IPSet
|
||||
import netaddr
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.db.models import Count
|
||||
from django.db.models import Count, Q
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
|
||||
from dcim.models import Device
|
||||
@@ -12,7 +12,7 @@ from utilities.views import (
|
||||
)
|
||||
|
||||
from . import filters, forms, tables
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VRF
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
|
||||
|
||||
|
||||
def add_available_prefixes(parent, prefix_list):
|
||||
@@ -21,7 +21,7 @@ def add_available_prefixes(parent, prefix_list):
|
||||
"""
|
||||
|
||||
# Find all unallocated space
|
||||
available_prefixes = IPSet(parent) ^ IPSet([p.prefix for p in prefix_list])
|
||||
available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
|
||||
available_prefixes = [Prefix(prefix=p) for p in available_prefixes.iter_cidrs()]
|
||||
|
||||
# Concatenate and sort complete list of children
|
||||
@@ -31,13 +31,65 @@ def add_available_prefixes(parent, prefix_list):
|
||||
return prefix_list
|
||||
|
||||
|
||||
def add_available_ipaddresses(prefix, ipaddress_list):
|
||||
"""
|
||||
Annotate ranges of available IP addresses within a given prefix.
|
||||
"""
|
||||
|
||||
output = []
|
||||
prev_ip = None
|
||||
|
||||
# Ignore the "network address" for IPv4 prefixes larger than /31
|
||||
if prefix.version == 4 and prefix.prefixlen < 31:
|
||||
first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1)
|
||||
else:
|
||||
first_ip_in_prefix = netaddr.IPAddress(prefix.first)
|
||||
|
||||
# Ignore the broadcast address for IPv4 prefixes larger than /31
|
||||
if prefix.version == 4 and prefix.prefixlen < 31:
|
||||
last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1)
|
||||
else:
|
||||
last_ip_in_prefix = netaddr.IPAddress(prefix.last)
|
||||
|
||||
if not ipaddress_list:
|
||||
return [(
|
||||
int(last_ip_in_prefix - first_ip_in_prefix + 1),
|
||||
'{}/{}'.format(first_ip_in_prefix, prefix.prefixlen)
|
||||
)]
|
||||
|
||||
# Account for any available IPs before the first real IP
|
||||
if ipaddress_list[0].address.ip > first_ip_in_prefix:
|
||||
skipped_count = int(ipaddress_list[0].address.ip - first_ip_in_prefix)
|
||||
first_skipped = '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen)
|
||||
output.append((skipped_count, first_skipped))
|
||||
|
||||
# Iterate through existing IPs and annotate free ranges
|
||||
for ip in ipaddress_list:
|
||||
if prev_ip:
|
||||
diff = int(ip.address.ip - prev_ip.address.ip)
|
||||
if diff > 1:
|
||||
first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen)
|
||||
output.append((diff - 1, first_skipped))
|
||||
output.append(ip)
|
||||
prev_ip = ip
|
||||
|
||||
# Include any remaining available IPs
|
||||
if prev_ip.address.ip < last_ip_in_prefix:
|
||||
skipped_count = int(last_ip_in_prefix - prev_ip.address.ip)
|
||||
first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen)
|
||||
output.append((skipped_count, first_skipped))
|
||||
|
||||
return output
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFListView(ObjectListView):
|
||||
queryset = VRF.objects.all()
|
||||
queryset = VRF.objects.select_related('tenant')
|
||||
filter = filters.VRFFilter
|
||||
filter_form = forms.VRFFilterForm
|
||||
table = tables.VRFTable
|
||||
edit_permissions = ['ipam.change_vrf', 'ipam.delete_vrf']
|
||||
template_name = 'ipam/vrf_list.html'
|
||||
@@ -47,10 +99,11 @@ def vrf(request, pk):
|
||||
|
||||
vrf = get_object_or_404(VRF.objects.all(), pk=pk)
|
||||
prefixes = Prefix.objects.filter(vrf=vrf)
|
||||
prefix_table = tables.PrefixBriefTable(prefixes)
|
||||
|
||||
return render(request, 'ipam/vrf.html', {
|
||||
'vrf': vrf,
|
||||
'prefixes': prefixes,
|
||||
'prefix_table': prefix_table,
|
||||
})
|
||||
|
||||
|
||||
@@ -58,6 +111,7 @@ class VRFEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.change_vrf'
|
||||
model = VRF
|
||||
form_class = forms.VRFForm
|
||||
template_name = 'ipam/vrf_edit.html'
|
||||
cancel_url = 'ipam:vrf_list'
|
||||
|
||||
|
||||
@@ -82,20 +136,10 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
template_name = 'ipam/vrf_bulk_edit.html'
|
||||
default_redirect_url = 'ipam:vrf_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
for field in ['description']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
|
||||
|
||||
class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'ipam.delete_vrf'
|
||||
cls = VRF
|
||||
form = forms.VRFBulkDeleteForm
|
||||
default_redirect_url = 'ipam:vrf_list'
|
||||
|
||||
|
||||
@@ -121,7 +165,6 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
|
||||
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'ipam.delete_rir'
|
||||
cls = RIR
|
||||
form = forms.RIRBulkDeleteForm
|
||||
default_redirect_url = 'ipam:rir_list'
|
||||
|
||||
|
||||
@@ -147,7 +190,7 @@ class AggregateListView(ObjectListView):
|
||||
if a.prefix.version == 4:
|
||||
ipv4_total += a.prefix.size
|
||||
elif a.prefix.version == 6:
|
||||
ipv6_total += a.prefix.size / 2**64
|
||||
ipv6_total += a.prefix.size / 2 ** 64
|
||||
|
||||
return {
|
||||
'ipv4_total': ipv4_total,
|
||||
@@ -180,6 +223,7 @@ class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.change_aggregate'
|
||||
model = Aggregate
|
||||
form_class = forms.AggregateForm
|
||||
template_name = 'ipam/aggregate_edit.html'
|
||||
cancel_url = 'ipam:aggregate_list'
|
||||
|
||||
|
||||
@@ -204,20 +248,10 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
template_name = 'ipam/aggregate_bulk_edit.html'
|
||||
default_redirect_url = 'ipam:aggregate_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
for field in ['rir', 'date_added', 'description']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
|
||||
|
||||
class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'ipam.delete_aggregate'
|
||||
cls = Aggregate
|
||||
form = forms.AggregateBulkDeleteForm
|
||||
default_redirect_url = 'ipam:aggregate_list'
|
||||
|
||||
|
||||
@@ -243,7 +277,6 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'ipam.delete_role'
|
||||
cls = Role
|
||||
form = forms.RoleBulkDeleteForm
|
||||
default_redirect_url = 'ipam:role_list'
|
||||
|
||||
|
||||
@@ -252,7 +285,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class PrefixListView(ObjectListView):
|
||||
queryset = Prefix.objects.select_related('site', 'role')
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'role')
|
||||
filter = filters.PrefixFilter
|
||||
filter_form = forms.PrefixFilterForm
|
||||
table = tables.PrefixTable
|
||||
@@ -275,10 +308,12 @@ def prefix(request, pk):
|
||||
aggregate = None
|
||||
|
||||
# Count child IP addresses
|
||||
ipaddress_count = IPAddress.objects.filter(address__net_contained_or_equal=str(prefix.prefix)).count()
|
||||
ipaddress_count = IPAddress.objects.filter(vrf=prefix.vrf, address__net_contained_or_equal=str(prefix.prefix))\
|
||||
.count()
|
||||
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contains=str(prefix.prefix))\
|
||||
parent_prefixes = Prefix.objects.filter(Q(vrf=prefix.vrf) | Q(vrf__isnull=True))\
|
||||
.filter(prefix__net_contains=str(prefix.prefix))\
|
||||
.select_related('site', 'role').annotate_depth()
|
||||
parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
|
||||
|
||||
@@ -288,7 +323,13 @@ def prefix(request, pk):
|
||||
duplicate_prefix_table = tables.PrefixBriefTable(duplicate_prefixes)
|
||||
|
||||
# Child prefixes table
|
||||
child_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix))\
|
||||
if prefix.vrf:
|
||||
# If the prefix is in a VRF, show child prefixes only within that VRF.
|
||||
child_prefixes = Prefix.objects.filter(vrf=prefix.vrf)
|
||||
else:
|
||||
# If the prefix is in the global table, show child prefixes from all VRFs.
|
||||
child_prefixes = Prefix.objects.all()
|
||||
child_prefixes = child_prefixes.filter(prefix__net_contained=str(prefix.prefix))\
|
||||
.select_related('site', 'role').annotate_depth(limit=0)
|
||||
if child_prefixes:
|
||||
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
|
||||
@@ -312,7 +353,8 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.change_prefix'
|
||||
model = Prefix
|
||||
form_class = forms.PrefixForm
|
||||
fields_initial = ['site', 'vrf', 'prefix']
|
||||
template_name = 'ipam/prefix_edit.html'
|
||||
fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
|
||||
cancel_url = 'ipam:prefix_list'
|
||||
|
||||
|
||||
@@ -337,24 +379,10 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
template_name = 'ipam/prefix_bulk_edit.html'
|
||||
default_redirect_url = 'ipam:prefix_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
if form.cleaned_data['vrf']:
|
||||
fields_to_update['vrf'] = form.cleaned_data['vrf']
|
||||
elif form.cleaned_data['vrf_global']:
|
||||
fields_to_update['vrf'] = None
|
||||
for field in ['site', 'status', 'role', 'description']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
|
||||
|
||||
class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'ipam.delete_prefix'
|
||||
cls = Prefix
|
||||
form = forms.PrefixBulkDeleteForm
|
||||
default_redirect_url = 'ipam:prefix_list'
|
||||
|
||||
|
||||
@@ -363,8 +391,9 @@ def prefix_ipaddresses(request, pk):
|
||||
prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
|
||||
|
||||
# Find all IPAddresses belonging to this Prefix
|
||||
ipaddresses = IPAddress.objects.filter(address__net_contained_or_equal=str(prefix.prefix))\
|
||||
ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_contained_or_equal=str(prefix.prefix))\
|
||||
.select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
|
||||
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses)
|
||||
|
||||
ip_table = tables.IPAddressTable(ipaddresses)
|
||||
ip_table.model = IPAddress
|
||||
@@ -383,7 +412,7 @@ def prefix_ipaddresses(request, pk):
|
||||
#
|
||||
|
||||
class IPAddressListView(ObjectListView):
|
||||
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
|
||||
filter = filters.IPAddressFilter
|
||||
filter_form = forms.IPAddressFilterForm
|
||||
table = tables.IPAddressTable
|
||||
@@ -462,33 +491,46 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
template_name = 'ipam/ipaddress_bulk_edit.html'
|
||||
default_redirect_url = 'ipam:ipaddress_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
if form.cleaned_data['vrf']:
|
||||
fields_to_update['vrf'] = form.cleaned_data['vrf']
|
||||
elif form.cleaned_data['vrf_global']:
|
||||
fields_to_update['vrf'] = None
|
||||
for field in ['description']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
|
||||
|
||||
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'ipam.delete_ipaddress'
|
||||
cls = IPAddress
|
||||
form = forms.IPAddressBulkDeleteForm
|
||||
default_redirect_url = 'ipam:ipaddress_list'
|
||||
|
||||
|
||||
#
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupListView(ObjectListView):
|
||||
queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
|
||||
filter = filters.VLANGroupFilter
|
||||
filter_form = forms.VLANGroupFilterForm
|
||||
table = tables.VLANGroupTable
|
||||
edit_permissions = ['ipam.change_vlangroup', 'ipam.delete_vlangroup']
|
||||
template_name = 'ipam/vlangroup_list.html'
|
||||
|
||||
|
||||
class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.change_vlangroup'
|
||||
model = VLANGroup
|
||||
form_class = forms.VLANGroupForm
|
||||
success_url = 'ipam:vlangroup_list'
|
||||
cancel_url = 'ipam:vlangroup_list'
|
||||
|
||||
|
||||
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'ipam.delete_vlangroup'
|
||||
cls = VLANGroup
|
||||
default_redirect_url = 'ipam:vlangroup_list'
|
||||
|
||||
|
||||
#
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANListView(ObjectListView):
|
||||
queryset = VLAN.objects.select_related('site', 'role')
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
|
||||
filter = filters.VLANFilter
|
||||
filter_form = forms.VLANFilterForm
|
||||
table = tables.VLANTable
|
||||
@@ -500,10 +542,11 @@ def vlan(request, pk):
|
||||
|
||||
vlan = get_object_or_404(VLAN.objects.select_related('site', 'role'), pk=pk)
|
||||
prefixes = Prefix.objects.filter(vlan=vlan)
|
||||
prefix_table = tables.PrefixBriefTable(prefixes)
|
||||
|
||||
return render(request, 'ipam/vlan.html', {
|
||||
'vlan': vlan,
|
||||
'prefixes': prefixes,
|
||||
'prefix_table': prefix_table,
|
||||
})
|
||||
|
||||
|
||||
@@ -511,6 +554,7 @@ class VLANEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.change_vlan'
|
||||
model = VLAN
|
||||
form_class = forms.VLANForm
|
||||
template_name = 'ipam/vlan_edit.html'
|
||||
cancel_url = 'ipam:vlan_list'
|
||||
|
||||
|
||||
@@ -535,18 +579,8 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
template_name = 'ipam/vlan_bulk_edit.html'
|
||||
default_redirect_url = 'ipam:vlan_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
for field in ['site', 'status', 'role']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
|
||||
|
||||
class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'ipam.delete_vlan'
|
||||
cls = VLAN
|
||||
form = forms.VLANBulkDeleteForm
|
||||
default_redirect_url = 'ipam:vlan_list'
|
||||
|
||||
@@ -82,3 +82,7 @@ BANNER_BOTTOM = ''
|
||||
# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
|
||||
# prefer IPv4 instead.
|
||||
PREFER_IPV4 = False
|
||||
|
||||
# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table
|
||||
# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
|
||||
ENFORCE_GLOBAL_UNIQUE = False
|
||||
|
||||
@@ -12,7 +12,7 @@ except ImportError:
|
||||
"the documentation.")
|
||||
|
||||
|
||||
VERSION = '1.2.3-dev'
|
||||
VERSION = '1.6.1'
|
||||
|
||||
# Import local configuration
|
||||
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
|
||||
@@ -41,6 +41,7 @@ SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H
|
||||
BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
|
||||
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
|
||||
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
|
||||
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
|
||||
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
|
||||
|
||||
# Attempt to import LDAP configuration if it has been defined
|
||||
@@ -70,7 +71,7 @@ if LDAP_CONFIGURED:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. "
|
||||
"You can remove netbox/ldap.py to disable LDAP.")
|
||||
"You can remove netbox/ldap_config.py to disable LDAP.")
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -107,6 +108,7 @@ INSTALLED_APPS = (
|
||||
'ipam',
|
||||
'extras',
|
||||
'secrets',
|
||||
'tenancy',
|
||||
'users',
|
||||
'utilities',
|
||||
)
|
||||
@@ -162,6 +164,9 @@ STATICFILES_DIRS = (
|
||||
os.path.join(BASE_DIR, "project-static"),
|
||||
)
|
||||
|
||||
# Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.)
|
||||
DATA_UPLOAD_MAX_NUMBER_FIELDS = None
|
||||
|
||||
# Messages
|
||||
MESSAGE_TAGS = {
|
||||
messages.ERROR: 'danger',
|
||||
@@ -170,7 +175,6 @@ MESSAGE_TAGS = {
|
||||
# Authentication URLs
|
||||
LOGIN_URL = '/login/'
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
LOGOUT_URL = '/logout/'
|
||||
|
||||
# Secrets
|
||||
SECRETS_MIN_PUBKEY_SIZE = 2048
|
||||
|
||||
@@ -2,10 +2,12 @@ from django.conf.urls import include, url
|
||||
from django.contrib import admin
|
||||
from django.views.defaults import page_not_found
|
||||
|
||||
from views import home, trigger_500
|
||||
from views import home, trigger_500, handle_500
|
||||
from users.views import login, logout
|
||||
|
||||
|
||||
handler500 = handle_500
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
# Default page
|
||||
@@ -20,6 +22,7 @@ urlpatterns = [
|
||||
url(r'^dcim/', include('dcim.urls', namespace='dcim')),
|
||||
url(r'^ipam/', include('ipam.urls', namespace='ipam')),
|
||||
url(r'^secrets/', include('secrets.urls', namespace='secrets')),
|
||||
url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
|
||||
url(r'^profile/', include('users.urls', namespace='users')),
|
||||
|
||||
# API
|
||||
@@ -27,6 +30,7 @@ urlpatterns = [
|
||||
url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')),
|
||||
url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')),
|
||||
url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')),
|
||||
url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
|
||||
url(r'^api/docs/', include('rest_framework_swagger.urls')),
|
||||
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
from markdown import markdown
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
from django.shortcuts import render
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from circuits.models import Provider, Circuit
|
||||
from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection
|
||||
from extras.models import UserAction
|
||||
from ipam.models import Aggregate, Prefix, IPAddress, VLAN
|
||||
from ipam.models import Aggregate, Prefix, IPAddress, VLAN, VRF
|
||||
from secrets.models import Secret
|
||||
from tenancy.models import Tenant
|
||||
|
||||
|
||||
def home(request):
|
||||
|
||||
stats = {
|
||||
|
||||
# DCIM
|
||||
# Organization
|
||||
'site_count': Site.objects.count(),
|
||||
'tenant_count': Tenant.objects.count(),
|
||||
|
||||
# DCIM
|
||||
'rack_count': Rack.objects.count(),
|
||||
'device_count': Device.objects.count(),
|
||||
'interface_connections_count': InterfaceConnection.objects.count(),
|
||||
@@ -25,6 +26,7 @@ def home(request):
|
||||
'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(),
|
||||
|
||||
# IPAM
|
||||
'vrf_count': VRF.objects.count(),
|
||||
'aggregate_count': Aggregate.objects.count(),
|
||||
'prefix_count': Prefix.objects.count(),
|
||||
'ipaddress_count': IPAddress.objects.count(),
|
||||
@@ -41,12 +43,20 @@ def home(request):
|
||||
|
||||
return render(request, 'home.html', {
|
||||
'stats': stats,
|
||||
'recent_activity': UserAction.objects.select_related('user')[:15]
|
||||
'recent_activity': UserAction.objects.select_related('user')[:50]
|
||||
})
|
||||
|
||||
|
||||
def trigger_500(request):
|
||||
"""Hot-wired method of triggering a server error to test reporting."""
|
||||
|
||||
raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
|
||||
"person you are.")
|
||||
|
||||
|
||||
def handle_500(request):
|
||||
"""Custom server error handler"""
|
||||
type_, error, traceback = sys.exc_info()
|
||||
return render(request, '500.html', {
|
||||
'exception': str(type_),
|
||||
'error': error,
|
||||
}, status=500)
|
||||
|
||||
@@ -21,6 +21,9 @@ body {
|
||||
margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
.navbar-brand {
|
||||
padding: 12px 15px 8px;
|
||||
}
|
||||
.footer, .push {
|
||||
height: 60px; /* .push must be the same height as .footer */
|
||||
}
|
||||
@@ -31,7 +34,8 @@ body {
|
||||
footer p {
|
||||
margin: 20px 0;
|
||||
}
|
||||
@media (max-width: 1120px) {
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.navbar-header {
|
||||
float: none;
|
||||
}
|
||||
@@ -51,6 +55,7 @@ footer p {
|
||||
}
|
||||
.navbar-collapse.collapse {
|
||||
display: none!important;
|
||||
max-height: none;
|
||||
}
|
||||
.navbar-nav {
|
||||
float: none!important;
|
||||
@@ -81,13 +86,11 @@ th.pk, td.pk {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
|
||||
/* Paginator */
|
||||
nav ul.pagination {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Racks */
|
||||
div.rack_header {
|
||||
margin-left: 36px;
|
||||
@@ -225,6 +228,22 @@ ul.rack li.h41u { height: 820px; }
|
||||
ul.rack li.h41u a, ul.rack li.h41u span { padding: 400px 0; }
|
||||
ul.rack li.h42u { height: 840px; }
|
||||
ul.rack li.h42u a, ul.rack li.h42u span { padding: 410px 0; }
|
||||
ul.rack li.h43u { height: 860px; }
|
||||
ul.rack li.h43u a, ul.rack li.h43u span { padding: 420px 0; }
|
||||
ul.rack li.h44u { height: 880px; }
|
||||
ul.rack li.h44u a, ul.rack li.h44u span { padding: 430px 0; }
|
||||
ul.rack li.h45u { height: 900px; }
|
||||
ul.rack li.h45u a, ul.rack li.h45u span { padding: 440px 0; }
|
||||
ul.rack li.h46u { height: 920px; }
|
||||
ul.rack li.h46u a, ul.rack li.h46u span { padding: 450px 0; }
|
||||
ul.rack li.h47u { height: 940px; }
|
||||
ul.rack li.h47u a, ul.rack li.h47u span { padding: 460px 0; }
|
||||
ul.rack li.h48u { height: 960px; }
|
||||
ul.rack li.h48u a, ul.rack li.h48u span { padding: 470px 0; }
|
||||
ul.rack li.h49u { height: 980px; }
|
||||
ul.rack li.h49u a, ul.rack li.h49u span { padding: 480px 0; }
|
||||
ul.rack li.h50u { height: 1000px; }
|
||||
ul.rack li.h50u a, ul.rack li.h50u span { padding: 490px 0; }
|
||||
ul.rack li.occupied a {
|
||||
color: #ffffff;
|
||||
display: block;
|
||||
@@ -275,27 +294,39 @@ ul.rack_near_face li.empty:hover a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Rack elevation colors (from http://flatuicolors.com) */
|
||||
.teal { background-color: #1abc9c; border-bottom: 1px solid #16a085; }
|
||||
.teal:hover { background-color: #16a085; }
|
||||
.green { background-color: #2ecc71; border-bottom: 1px solid #27ae60; }
|
||||
.green:hover { background-color: #27ae60; }
|
||||
.blue { background-color: #3498db; border-bottom: 1px solid #2980b9; }
|
||||
.blue:hover { background-color: #2980b9; }
|
||||
.purple { background-color: #9b59b6; border-bottom: 1px solid #8e44ad; }
|
||||
.purple:hover { background-color: #8e44ad; }
|
||||
.yellow { background-color: #f1c40f; border-bottom: 1px solid #f39c12; }
|
||||
.yellow:hover { background-color: #f39c12; }
|
||||
.orange { background-color: #e67e22; border-bottom: 1px solid #d35400; }
|
||||
.orange:hover { background-color: #d35400; }
|
||||
.red { background-color: #e74c3c; border-bottom: 1px solid #c0392b; }
|
||||
.red:hover { background-color: #c0392b; }
|
||||
.light_gray { background-color: #dce2e3; border-bottom: 1px solid #bdc3c7; }
|
||||
.light_gray:hover { background-color: #bdc3c7; }
|
||||
.medium_gray { background-color: #95a5a6; border-bottom: 1px solid #7f8c8d; }
|
||||
.medium_gray:hover { background-color: #7f8c8d; }
|
||||
.dark_gray { background-color: #34495e; border-bottom: 1px solid #2c3e50; }
|
||||
.dark_gray:hover { background-color: #2c3e50; }
|
||||
/* Colors (from http://flatuicolors.com) */
|
||||
.teal { background-color: #1abc9c; }
|
||||
.green { background-color: #2ecc71; }
|
||||
.blue { background-color: #3498db; }
|
||||
.purple { background-color: #9b59b6; }
|
||||
.yellow { background-color: #f1c40f; }
|
||||
.orange { background-color: #e67e22; }
|
||||
.red { background-color: #e74c3c; }
|
||||
.light_gray { background-color: #dce2e3; }
|
||||
.medium_gray { background-color: #95a5a6; }
|
||||
.dark_gray { background-color: #34495e; }
|
||||
|
||||
/* Rack elevation coloring */
|
||||
ul.rack .teal { border-bottom: 1px solid #16a085; }
|
||||
ul.rack .teal:hover { background-color: #16a085; }
|
||||
ul.rack .green { border-bottom: 1px solid #27ae60; }
|
||||
ul.rack .green:hover { background-color: #27ae60; }
|
||||
ul.rack .blue { border-bottom: 1px solid #2980b9; }
|
||||
ul.rack .blue:hover { background-color: #2980b9; }
|
||||
ul.rack .purple { border-bottom: 1px solid #8e44ad; }
|
||||
ul.rack .purple:hover { background-color: #8e44ad; }
|
||||
ul.rack .yellow { border-bottom: 1px solid #f39c12; }
|
||||
ul.rack .yellow:hover { background-color: #f39c12; }
|
||||
ul.rack .orange { border-bottom: 1px solid #d35400; }
|
||||
ul.rack .orange:hover { background-color: #d35400; }
|
||||
ul.rack .red { border-bottom: 1px solid #c0392b; }
|
||||
ul.rack .red:hover { background-color: #c0392b; }
|
||||
ul.rack .light_gray { border-bottom: 1px solid #bdc3c7; }
|
||||
ul.rack .light_gray:hover { background-color: #bdc3c7; }
|
||||
ul.rack .medium_gray { border-bottom: 1px solid #7f8c8d; }
|
||||
ul.rack .medium_gray:hover { background-color: #7f8c8d; }
|
||||
ul.rack .dark_gray { border-bottom: 1px solid #2c3e50; }
|
||||
ul.rack .dark_gray:hover { background-color: #2c3e50; }
|
||||
|
||||
/* Misc */
|
||||
.banner-bottom {
|
||||
|
||||
BIN
netbox/project-static/img/netbox.ico
Normal file
BIN
netbox/project-static/img/netbox.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
netbox/project-static/img/netbox_logo.png
Normal file
BIN
netbox/project-static/img/netbox_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -13,11 +13,11 @@ $(document).ready(function() {
|
||||
|
||||
// Slugify
|
||||
function slugify(s, num_chars) {
|
||||
s = s.replace(/[^\-\.\w\s]/g, ''); // Remove unneeded chars
|
||||
s = s.replace(/^\s+|\s+$/g, ''); // Trim leading/trailing spaces
|
||||
s = s.replace(/[\-\.\s]+/g, '-'); // Convert spaces and decimals to hyphens
|
||||
s = s.toLowerCase(); // Convert to lowercase
|
||||
return s.substring(0, num_chars); // Trim to first num_chars chars
|
||||
s = s.replace(/[^\-\.\w\s]/g, ''); // Remove unneeded chars
|
||||
s = s.replace(/^[\s\.]+|[\s\.]+$/g, ''); // Trim leading/trailing spaces
|
||||
s = s.replace(/[\-\.\s]+/g, '-'); // Convert spaces and decimals to hyphens
|
||||
s = s.toLowerCase(); // Convert to lowercase
|
||||
return s.substring(0, num_chars); // Trim to first num_chars chars
|
||||
}
|
||||
var slug_field = $('#id_slug');
|
||||
slug_field.change(function() {
|
||||
@@ -25,68 +25,13 @@ $(document).ready(function() {
|
||||
});
|
||||
if (slug_field) {
|
||||
var slug_source = $('#id_' + slug_field.attr('slug-source'));
|
||||
slug_source.keyup(function() {
|
||||
slug_source.on('keyup change', function() {
|
||||
if (slug_field && !slug_field.attr('_changed')) {
|
||||
slug_field.val(slugify($(this).val(), 50));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper select fields
|
||||
$('select.helper-parent').change(function () {
|
||||
|
||||
// Resolve child field by ID specified in parent
|
||||
var child_field = $('#id_' + $(this).attr('child'));
|
||||
|
||||
// Wipe out any existing options within the child field
|
||||
child_field.empty();
|
||||
child_field.append($("<option></option>").attr("value", "").text(""));
|
||||
|
||||
// If the parent has a value set, fetch a list of child options via the API and populate the child field with them
|
||||
if ($(this).val()) {
|
||||
|
||||
// Construct the API request URL
|
||||
var api_url = $(this).attr('child-source');
|
||||
var parent_accessor = $(this).attr('parent-accessor');
|
||||
if (parent_accessor) {
|
||||
api_url += '?' + parent_accessor + '=' + $(this).val();
|
||||
} else {
|
||||
api_url += '?' + $(this).attr('name') + '_id=' + $(this).val();
|
||||
}
|
||||
var api_url_extra = $(this).attr('child-filter');
|
||||
if (api_url_extra) {
|
||||
api_url += '&' + api_url_extra;
|
||||
}
|
||||
|
||||
var disabled_indicator = $(this).attr('disabled-indicator');
|
||||
var disabled_exempt = child_field.attr('exempt');
|
||||
var child_display = $(this).attr('child-display');
|
||||
if (!child_display) {
|
||||
child_display = 'name';
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: api_url,
|
||||
dataType: 'json',
|
||||
success: function (response, status) {
|
||||
console.log(response);
|
||||
$.each(response, function (index, choice) {
|
||||
var option = $("<option></option>").attr("value", choice.id).text(choice[child_display]);
|
||||
if (disabled_indicator && choice[disabled_indicator] && choice.id != disabled_exempt) {
|
||||
option.attr("disabled", "disabled")
|
||||
}
|
||||
child_field.append(option);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Trigger change event in case the child field is the parent of another field
|
||||
child_field.change();
|
||||
|
||||
});
|
||||
|
||||
// API select widget
|
||||
$('select[filter-for]').change(function () {
|
||||
|
||||
|
||||
@@ -8,9 +8,15 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
// Update livesearch text when real field changes
|
||||
search_field.val(real_field.children('option:selected').text());
|
||||
real_field.change(function() {
|
||||
if (real_field.val()) {
|
||||
search_field.val(real_field.children('option:selected').text());
|
||||
}
|
||||
real_field.change(function() {
|
||||
if (real_field.val()) {
|
||||
search_field.val(real_field.children('option:selected').text());
|
||||
} else {
|
||||
search_field.val('');
|
||||
}
|
||||
});
|
||||
|
||||
search_field.autocomplete({
|
||||
|
||||
@@ -10,31 +10,35 @@ $(document).ready(function() {
|
||||
$('#privkey_modal').modal('show');
|
||||
} else {
|
||||
unlock_secret(secret_id, private_key);
|
||||
$(this).hide();
|
||||
$(this).siblings('button.lock-secret').show();
|
||||
}
|
||||
});
|
||||
|
||||
// Locking a secret
|
||||
$('button.lock-secret').click(function (event) {
|
||||
var secret_id = $(this).attr('secret-id');
|
||||
$('#secret_' + secret_id).html('********');
|
||||
var secret_div = $('#secret_' + secret_id);
|
||||
|
||||
// Delete the plaintext
|
||||
secret_div.html('********');
|
||||
$(this).hide();
|
||||
$(this).siblings('button.unlock-secret').show();
|
||||
});
|
||||
|
||||
// Adding/editing a secret
|
||||
$('form.requires-private-key').submit(function(event) {
|
||||
private_key_field = $('#id_private_key');
|
||||
private_key_field.parents('form').submit(function(event) {
|
||||
console.log("form submitted");
|
||||
var private_key = sessionStorage.getItem('private_key');
|
||||
if (private_key) {
|
||||
$('#id_private_key').val(private_key);
|
||||
} else {
|
||||
private_key_field.val(private_key);
|
||||
} else if ($('form .requires-private-key:first').val()) {
|
||||
console.log("we need a key!");
|
||||
$('#privkey_modal').modal('show');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Prompt the user to enter a private RSA key for decryption
|
||||
// Saving a private RSA key locally
|
||||
$('#submit_privkey').click(function() {
|
||||
var private_key = $('#user_privkey').val();
|
||||
sessionStorage.setItem('private_key', private_key);
|
||||
@@ -81,13 +85,16 @@ $(document).ready(function() {
|
||||
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||
},
|
||||
success: function (response, status) {
|
||||
var secret_plaintext = response.plaintext;
|
||||
$('#secret_' + secret_id).html(secret_plaintext);
|
||||
return true;
|
||||
$('#secret_' + secret_id).html(response.plaintext);
|
||||
$('button.unlock-secret[secret-id=' + secret_id + ']').hide();
|
||||
$('button.lock-secret[secret-id=' + secret_id + ']').show();
|
||||
},
|
||||
error: function (xhr, ajaxOptions, thrownError) {
|
||||
if (xhr.status == 403) {
|
||||
alert("Decryption failed: " + xhr.statusText);
|
||||
alert("Permission denied");
|
||||
} else {
|
||||
var json = jQuery.parseJSON(xhr.responseText);
|
||||
alert("Decryption failed: " + json['error']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ class SecretRoleListView(generics.ListAPIView):
|
||||
"""
|
||||
queryset = SecretRole.objects.all()
|
||||
serializer_class = serializers.SecretRoleSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
class SecretRoleDetailView(generics.RetrieveAPIView):
|
||||
@@ -36,6 +37,7 @@ class SecretRoleDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
queryset = SecretRole.objects.all()
|
||||
serializer_class = serializers.SecretRoleSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
class SecretListView(generics.GenericAPIView):
|
||||
@@ -47,6 +49,7 @@ class SecretListView(generics.GenericAPIView):
|
||||
serializer_class = serializers.SecretSerializer
|
||||
filter_class = SecretFilter
|
||||
renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, private_key=None):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
@@ -91,6 +94,7 @@ class SecretDetailView(generics.GenericAPIView):
|
||||
.prefetch_related('role__users', 'role__groups')
|
||||
serializer_class = serializers.SecretSerializer
|
||||
renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, pk, private_key=None):
|
||||
secret = get_object_or_404(Secret, pk=pk)
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import django_filters
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from .models import Secret, SecretRole
|
||||
from dcim.models import Device
|
||||
|
||||
|
||||
class SecretFilter(django_filters.FilterSet):
|
||||
q = django_filters.MethodFilter(
|
||||
action='search',
|
||||
label='Search',
|
||||
)
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=SecretRole.objects.all(),
|
||||
@@ -15,7 +22,19 @@ class SecretFilter(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (Name)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['name', 'role_id', 'role']
|
||||
fields = ['name', 'role_id', 'role', 'device']
|
||||
|
||||
def search(self, queryset, value):
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(device__name__icontains=value)
|
||||
)
|
||||
|
||||
42
netbox/secrets/fixtures/initial_data.json
Normal file
42
netbox/secrets/fixtures/initial_data.json
Normal file
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"model": "secrets.secretrole",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "Login Credentials",
|
||||
"slug": "login-credentials",
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "secrets.secretrole",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "RADIUS Key",
|
||||
"slug": "radius-key",
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "secrets.secretrole",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "SNMPv2 Community",
|
||||
"slug": "snmpv2-community",
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "secrets.secretrole",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"name": "SNMPv3 Credentials",
|
||||
"slug": "snmpv3-credentials",
|
||||
"users": [],
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -5,7 +5,7 @@ from django import forms
|
||||
from django.db.models import Count
|
||||
|
||||
from dcim.models import Device
|
||||
from utilities.forms import BootstrapMixin, BulkImportForm, ConfirmationForm, CSVDataField, SlugField
|
||||
from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
|
||||
|
||||
from .models import Secret, SecretRole, UserKey
|
||||
|
||||
@@ -42,17 +42,14 @@ class SecretRoleForm(forms.ModelForm, BootstrapMixin):
|
||||
fields = ['name', 'slug']
|
||||
|
||||
|
||||
class SecretRoleBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=SecretRole.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
#
|
||||
# Secrets
|
||||
#
|
||||
|
||||
class SecretForm(forms.ModelForm, BootstrapMixin):
|
||||
private_key = forms.CharField(widget=forms.HiddenInput())
|
||||
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext')
|
||||
private_key = forms.CharField(required=False, widget=forms.HiddenInput())
|
||||
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
|
||||
widget=forms.TextInput(attrs={'class': 'requires-private-key'}))
|
||||
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)')
|
||||
|
||||
class Meta:
|
||||
@@ -60,7 +57,8 @@ class SecretForm(forms.ModelForm, BootstrapMixin):
|
||||
fields = ['role', 'name', 'plaintext', 'plaintext2']
|
||||
|
||||
def clean(self):
|
||||
validate_rsa_key(self.cleaned_data['private_key'])
|
||||
if self.cleaned_data['plaintext']:
|
||||
validate_rsa_key(self.cleaned_data['private_key'])
|
||||
|
||||
def clean_plaintext2(self):
|
||||
plaintext = self.cleaned_data['plaintext']
|
||||
@@ -88,7 +86,7 @@ class SecretFromCSVForm(forms.ModelForm):
|
||||
|
||||
class SecretImportForm(BulkImportForm, BootstrapMixin):
|
||||
private_key = forms.CharField(widget=forms.HiddenInput())
|
||||
csv = CSVDataField(csv_form=SecretFromCSVForm)
|
||||
csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'}))
|
||||
|
||||
|
||||
class SecretBulkEditForm(forms.Form, BootstrapMixin):
|
||||
@@ -97,17 +95,8 @@ class SecretBulkEditForm(forms.Form, BootstrapMixin):
|
||||
name = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
class SecretBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
def secret_role_choices():
|
||||
role_choices = SecretRole.objects.annotate(secret_count=Count('secrets'))
|
||||
return [(r.slug, '{} ({})'.format(r.name, r.secret_count)) for r in role_choices]
|
||||
|
||||
|
||||
class SecretFilterForm(forms.Form, BootstrapMixin):
|
||||
role = forms.MultipleChoiceField(required=False, choices=secret_role_choices)
|
||||
role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug')
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -219,8 +219,8 @@ class Secret(CreatedUpdatedModel):
|
||||
|
||||
def __unicode__(self):
|
||||
if self.role and self.device:
|
||||
return "{} for {}".format(self.role, self.device)
|
||||
return "Secret"
|
||||
return u'{} for {}'.format(self.role, self.device)
|
||||
return u'Secret'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('secrets:secret', args=[self.pk])
|
||||
|
||||
@@ -6,9 +6,9 @@ from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import SecretRole, Secret
|
||||
|
||||
|
||||
SECRETROLE_EDIT_LINK = """
|
||||
SECRETROLE_ACTIONS = """
|
||||
{% if perms.secrets.change_secretrole %}
|
||||
<a href="{% url 'secrets:secretrole_edit' slug=record.slug %}">Edit</a>
|
||||
<a href="{% url 'secrets:secretrole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -22,11 +22,12 @@ class SecretRoleTable(BaseTable):
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
secret_count = tables.Column(verbose_name='Secrets')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
edit = tables.TemplateColumn(template_code=SECRETROLE_EDIT_LINK, verbose_name='')
|
||||
actions = tables.TemplateColumn(template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = SecretRole
|
||||
fields = ('pk', 'name', 'secret_count', 'slug', 'edit')
|
||||
fields = ('pk', 'name', 'secret_count', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -37,7 +37,6 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'secrets.delete_secretrole'
|
||||
cls = SecretRole
|
||||
form = forms.SecretRoleBulkDeleteForm
|
||||
default_redirect_url = 'secrets:secretrole_list'
|
||||
|
||||
|
||||
@@ -93,7 +92,7 @@ def secret_add(request, pk):
|
||||
|
||||
messages.success(request, "Added new secret: {0}".format(secret))
|
||||
if '_addanother' in request.POST:
|
||||
return redirect('secrets:secret_add')
|
||||
return redirect('dcim:device_addsecret', pk=device.pk)
|
||||
else:
|
||||
return redirect('secrets:secret', pk=secret.pk)
|
||||
|
||||
@@ -206,18 +205,8 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
template_name = 'secrets/secret_bulk_edit.html'
|
||||
default_redirect_url = 'secrets:secret_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
for field in ['role', 'name']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
|
||||
|
||||
class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'secrets.delete_secret'
|
||||
cls = Secret
|
||||
form = forms.SecretBulkDeleteForm
|
||||
default_redirect_url = 'secrets:secret_list'
|
||||
|
||||
@@ -12,13 +12,19 @@
|
||||
<div class="col-md-4 col-md-offset-4">
|
||||
<div class="panel panel-danger" style="margin-top: 200px">
|
||||
<div class="panel-heading">
|
||||
<strong>Server Error</strong>
|
||||
<strong>
|
||||
<i class="fa fa-warning"></i>
|
||||
Server Error
|
||||
</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>There was a problem with your request. This error has been logged and administrative staff have
|
||||
been notified. Please return to the home page and try again.</p>
|
||||
<p>If you are responsible for this installation, please consider
|
||||
<a href="https://github.com/digitalocean/netbox/issues">filing a bug report</a>.</p>
|
||||
<a href="https://github.com/digitalocean/netbox/issues">filing a bug report</a>. Additional
|
||||
information is provided below:</p>
|
||||
<pre><strong>{{ exception }}</strong><br />
|
||||
{{ error }}</pre>
|
||||
<div class="text-right">
|
||||
<a href="/" class="btn btn-primary">Home Page</a>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="stylesheet" href="{% static 'font-awesome-4.6.3/css/font-awesome.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'jquery-ui-1.11.4/jquery-ui.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/base.css' %}">
|
||||
<link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-default navbar-fixed-top">
|
||||
@@ -19,175 +20,199 @@
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/">NetBox</a>
|
||||
<a class="navbar-brand" href="/">
|
||||
<img src="{% static 'img/netbox_logo.png' %}" />
|
||||
</a>
|
||||
</div>
|
||||
<div id="navbar" class="navbar-collapse collapse">
|
||||
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="dropdown{% if request.path|startswith:'/dcim/sites/' %} active{% endif %}">
|
||||
{% if perms.dcim.add_site %}
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Sites <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'dcim:site_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Sites</a></li>
|
||||
<li><a href="{% url 'dcim:site_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Site</a></li>
|
||||
<li><a href="{% url 'dcim:site_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Sites</a></li>
|
||||
</ul>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:site_list' %}">Sites</a>
|
||||
{% endif %}
|
||||
<li class="dropdown{% if request.path|startswith:'/dcim/sites/' or 'tenancy' in request.path %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'dcim:site_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Sites</a></li>
|
||||
{% if perms.dcim.add_site %}
|
||||
<li><a href="{% url 'dcim:site_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Site</a></li>
|
||||
<li><a href="{% url 'dcim:site_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Sites</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'tenancy:tenant_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Tenants</a></li>
|
||||
{% if perms.tenancy.add_tenant %}
|
||||
<li><a href="{% url 'tenancy:tenant_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Tenant</a></li>
|
||||
<li><a href="{% url 'tenancy:tenant_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Tenants</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'tenancy:tenantgroup_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Tenant Groups</a></li>
|
||||
{% if perms.tenancy.add_tenantgroup %}
|
||||
<li><a href="{% url 'tenancy:tenantgroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Tenant Group</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|startswith:'/dcim/rack' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Racks <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'dcim:rack_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Racks</a></li>
|
||||
<li><a href="{% url 'dcim:rack_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Racks</a></li>
|
||||
{% if perms.dcim.add_rack %}
|
||||
<li><a href="{% url 'dcim:rack_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Rack</a></li>
|
||||
<li><a href="{% url 'dcim:rack_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Racks</a></li>
|
||||
<li><a href="{% url 'dcim:rack_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack</a></li>
|
||||
<li><a href="{% url 'dcim:rack_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Racks</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'dcim:rackgroup_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Rack Groups</a></li>
|
||||
<li><a href="{% url 'dcim:rackgroup_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Rack Groups</a></li>
|
||||
{% if perms.dcim.add_rackgroup %}
|
||||
<li><a href="{% url 'dcim:rackgroup_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Rack Group</a></li>
|
||||
<li><a href="{% url 'dcim:rackgroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Group</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'dcim:rackrole_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Rack Roles</a></li>
|
||||
{% if perms.dcim.add_rackrole %}
|
||||
<li><a href="{% url 'dcim:rackrole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Role</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|startswith:'/dcim/device' or request.path|startswith:'/dcim/manufacturers/' or request.path|startswith:'/dcim/platforms/' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'dcim:device_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Devices</a></li>
|
||||
<li><a href="{% url 'dcim:device_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Devices</a></li>
|
||||
{% if perms.dcim.add_device %}
|
||||
<li><a href="{% url 'dcim:device_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Device</a></li>
|
||||
<li><a href="{% url 'dcim:device_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Devices</a></li>
|
||||
<li><a href="{% url 'dcim:device_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Device</a></li>
|
||||
<li><a href="{% url 'dcim:device_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Devices</a></li>
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_device or perms.ipam.add_devicetype %}
|
||||
<li class="divider"></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'dcim:devicetype_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Device Types</a></li>
|
||||
<li><a href="{% url 'dcim:devicetype_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Device Types</a></li>
|
||||
{% if perms.dcim.add_devicetype %}
|
||||
<li><a href="{% url 'dcim:devicetype_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Device Type</a></li>
|
||||
<li><a href="{% url 'dcim:devicetype_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Device Type</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'dcim:devicerole_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Device Roles</a></li>
|
||||
<li><a href="{% url 'dcim:devicerole_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Device Roles</a></li>
|
||||
{% if perms.dcim.add_devicerole %}
|
||||
<li><a href="{% url 'dcim:devicerole_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Device Role</a></li>
|
||||
<li><a href="{% url 'dcim:devicerole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Device Role</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_devicerole or perms.dcim.add_manufacturer %}
|
||||
<li class="divider"></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'dcim:manufacturer_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Manufacturers</a></li>
|
||||
<li><a href="{% url 'dcim:manufacturer_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Manufacturers</a></li>
|
||||
{% if perms.dcim.add_manufacturer %}
|
||||
<li><a href="{% url 'dcim:manufacturer_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Manufacturer</a></li>
|
||||
<li><a href="{% url 'dcim:manufacturer_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Manufacturer</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_manufacturer or perms.dcim.add_platform %}
|
||||
<li class="divider"></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'dcim:platform_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Platforms</a></li>
|
||||
<li><a href="{% url 'dcim:platform_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Platforms</a></li>
|
||||
{% if perms.dcim.add_platform %}
|
||||
<li><a href="{% url 'dcim:platform_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Platform</a></li>
|
||||
<li><a href="{% url 'dcim:platform_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Platform</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|startswith:'/dcim/console-connections/' or request.path|startswith:'/dcim/power-connections/' or request.path|startswith:'/dcim/interface-connections/' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Connections <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'dcim:console_connections_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Console Connections</a></li>
|
||||
<li><a href="{% url 'dcim:console_connections_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Console Connections</a></li>
|
||||
{% if perms.dcim.change_consoleport %}
|
||||
<li><a href="{% url 'dcim:console_connections_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Console Connections</a></li>
|
||||
<li><a href="{% url 'dcim:console_connections_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Console Connections</a></li>
|
||||
{% endif %}
|
||||
{% if perms.ipam.change_consoleport or perms.ipam.change_powerport %}
|
||||
<li class="divider"></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'dcim:power_connections_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Power Connections</a></li>
|
||||
<li><a href="{% url 'dcim:power_connections_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Power Connections</a></li>
|
||||
{% if perms.dcim.change_powerport %}
|
||||
<li><a href="{% url 'dcim:power_connections_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Power Connections</a></li>
|
||||
<li><a href="{% url 'dcim:power_connections_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Power Connections</a></li>
|
||||
{% endif %}
|
||||
{% if perms.ipam.change_powerport or perms.ipam.add_interfaceconnection %}
|
||||
<li class="divider"></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'dcim:interface_connections_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Interface Connections</a></li>
|
||||
<li><a href="{% url 'dcim:interface_connections_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Interface Connections</a></li>
|
||||
{% if perms.dcim.add_interfaceconnection %}
|
||||
<li><a href="{% url 'dcim:interface_connections_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Interface Connections</a></li>
|
||||
<li><a href="{% url 'dcim:interface_connections_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Interface Connections</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|startswith:'/ipam/' and not request.path|startswith:'/ipam/vlans/' %} active{% endif %}">
|
||||
<li class="dropdown{% if request.path|startswith:'/ipam/' and not request.path|startswith:'/ipam/vlan' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">IP Space <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'ipam:ipaddress_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> IP Addresses</a></li>
|
||||
<li><a href="{% url 'ipam:ipaddress_list' %}"><i class="fa fa-search" aria-hidden="true"></i> IP Addresses</a></li>
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
<li><a href="{% url 'ipam:ipaddress_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add an IP</a></li>
|
||||
<li><a href="{% url 'ipam:ipaddress_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import IPs</a></li>
|
||||
<li><a href="{% url 'ipam:ipaddress_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add an IP</a></li>
|
||||
<li><a href="{% url 'ipam:ipaddress_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import IPs</a></li>
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_ipaddress or perms.ipam.add_prefix %}
|
||||
<li class="divider"></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'ipam:prefix_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Prefixes</a></li>
|
||||
<li><a href="{% url 'ipam:prefix_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Prefixes</a></li>
|
||||
{% if perms.ipam.add_prefix %}
|
||||
<li><a href="{% url 'ipam:prefix_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Prefix</a></li>
|
||||
<li><a href="{% url 'ipam:prefix_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Prefixes</a></li>
|
||||
<li><a href="{% url 'ipam:prefix_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Prefix</a></li>
|
||||
<li><a href="{% url 'ipam:prefix_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Prefixes</a></li>
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_prefix or perms.ipam.add_aggregate %}
|
||||
<li class="divider"></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'ipam:aggregate_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Aggregates</a></li>
|
||||
<li><a href="{% url 'ipam:aggregate_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Aggregates</a></li>
|
||||
{% if perms.ipam.add_aggregate %}
|
||||
<li><a href="{% url 'ipam:aggregate_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add an Aggregate</a></li>
|
||||
<li><a href="{% url 'ipam:aggregate_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Aggregates</a></li>
|
||||
<li><a href="{% url 'ipam:aggregate_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add an Aggregate</a></li>
|
||||
<li><a href="{% url 'ipam:aggregate_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Aggregates</a></li>
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_aggregate or perms.ipam.add_vrf %}
|
||||
<li class="divider"></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'ipam:vrf_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VRFs</a></li>
|
||||
<li><a href="{% url 'ipam:vrf_list' %}"><i class="fa fa-search" aria-hidden="true"></i> VRFs</a></li>
|
||||
{% if perms.ipam.add_vrf %}
|
||||
<li><a href="{% url 'ipam:vrf_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a VRF</a></li>
|
||||
<li><a href="{% url 'ipam:vrf_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import VRFs</a></li>
|
||||
<li><a href="{% url 'ipam:vrf_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a VRF</a></li>
|
||||
<li><a href="{% url 'ipam:vrf_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import VRFs</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'ipam:rir_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> RIRs</a></li>
|
||||
<li><a href="{% url 'ipam:rir_list' %}"><i class="fa fa-search" aria-hidden="true"></i> RIRs</a></li>
|
||||
{% if perms.ipam.add_rir %}
|
||||
<li><a href="{% url 'ipam:rir_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a RIR</a></li>
|
||||
<li><a href="{% url 'ipam:rir_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a RIR</a></li>
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_rir or perms.ipam.add_role %}
|
||||
<li class="divider"></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'ipam:role_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Prefix/VLAN Roles</a></li>
|
||||
<li><a href="{% url 'ipam:role_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Prefix Roles</a></li>
|
||||
{% if perms.ipam.add_role %}
|
||||
<li><a href="{% url 'ipam:role_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Role</a></li>
|
||||
<li><a href="{% url 'ipam:role_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Role</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|startswith:'/ipam/vlans/' %} active{% endif %}">
|
||||
{% if perms.ipam.add_vlan %}
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">VLANs <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'ipam:vlan_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLANs</a></li>
|
||||
<li><a href="{% url 'ipam:vlan_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a VLAN</a></li>
|
||||
<li><a href="{% url 'ipam:vlan_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import VLANs</a></li>
|
||||
</ul>
|
||||
{% else %}
|
||||
<a href="{% url 'ipam:vlan_list' %}">VLANs</a>
|
||||
{% endif %}
|
||||
<li class="dropdown{% if request.path|startswith:'/ipam/vlan' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">VLANs <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'ipam:vlan_list' %}"><i class="fa fa-search" aria-hidden="true"></i> VLANs</a></li>
|
||||
{% if perms.ipam.add_vlan %}
|
||||
<li><a href="{% url 'ipam:vlan_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a VLAN</a></li>
|
||||
<li><a href="{% url 'ipam:vlan_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import VLANs</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'ipam:vlangroup_list' %}"><i class="fa fa-search" aria-hidden="true"></i> VLAN Groups</a></li>
|
||||
{% if perms.ipam.add_vlangroup %}
|
||||
<li><a href="{% url 'ipam:vlangroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a VLAN Group</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'ipam:role_list' %}"><i class="fa fa-search" aria-hidden="true"></i> VLAN Roles</a></li>
|
||||
{% if perms.ipam.add_role %}
|
||||
<li><a href="{% url 'ipam:role_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Role</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|startswith:'/circuits/' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'circuits:provider_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Providers</a></li>
|
||||
<li><a href="{% url 'circuits:provider_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Providers</a></li>
|
||||
{% if perms.circuits.add_provider %}
|
||||
<li><a href="{% url 'circuits:provider_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Provider</a></li>
|
||||
<li><a href="{% url 'circuits:provider_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Providers</a></li>
|
||||
<li><a href="{% url 'circuits:provider_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Provider</a></li>
|
||||
<li><a href="{% url 'circuits:provider_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Providers</a></li>
|
||||
{% endif %}
|
||||
{% if perms.circuits.add_circuit or perms.circuits.add_provider %}
|
||||
<li class="divider"></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'circuits:circuit_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Circuits</a></li>
|
||||
<li><a href="{% url 'circuits:circuit_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Circuits</a></li>
|
||||
{% if perms.circuits.add_circuit %}
|
||||
<li><a href="{% url 'circuits:circuit_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Circuit</a></li>
|
||||
<li><a href="{% url 'circuits:circuit_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Circuits</a></li>
|
||||
<li><a href="{% url 'circuits:circuit_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Circuit</a></li>
|
||||
<li><a href="{% url 'circuits:circuit_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Circuits</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'circuits:circuittype_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Circuit Types</a></li>
|
||||
<li><a href="{% url 'circuits:circuittype_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Circuit Types</a></li>
|
||||
{% if perms.circuits.add_circuittype %}
|
||||
<li><a href="{% url 'circuits:circuittype_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Circuit Type</a></li>
|
||||
<li><a href="{% url 'circuits:circuittype_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Circuit Type</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
@@ -195,14 +220,14 @@
|
||||
<li class="dropdown{% if request.path|startswith:'/secrets/' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Secrets <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'secrets:secret_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Secrets</a></li>
|
||||
<li><a href="{% url 'secrets:secret_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Secrets</a></li>
|
||||
{% if perms.secrets.add_secret %}
|
||||
<li><a href="{% url 'secrets:secret_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import Secrets</a></li>
|
||||
<li><a href="{% url 'secrets:secret_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Secrets</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'secrets:secretrole_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> Secret Roles</a></li>
|
||||
<li><a href="{% url 'secrets:secretrole_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Secret Roles</a></li>
|
||||
{% if perms.secrets.add_secretrole %}
|
||||
<li><a href="{% url 'secrets:secretrole_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a Secret Role</a></li>
|
||||
<li><a href="{% url 'secrets:secretrole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Secret Role</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
@@ -212,12 +237,12 @@
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
{% if request.user.is_authenticated %}
|
||||
{% if request.user.is_staff %}
|
||||
<li><a href="{% url 'admin:index' %}"><i class="glyphicon glyphicon-cog" aria-hidden="true"></i> Admin</a></li>
|
||||
<li><a href="{% url 'admin:index' %}"><i class="fa fa-cogs" aria-hidden="true"></i> Admin</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'users:profile' %}"><i class="glyphicon glyphicon-user" aria-hidden="true"></i> Profile</a></li>
|
||||
<li><a href="{% url 'logout' %}"><i class="glyphicon glyphicon-log-out" aria-hidden="true"></i> Log out</a></li>
|
||||
<li><a href="{% url 'users:profile' %}"><i class="fa fa-user" aria-hidden="true"></i> Profile</a></li>
|
||||
<li><a href="{% url 'logout' %}"><i class="fa fa-sign-out" aria-hidden="true"></i> Log out</a></li>
|
||||
{% else %}
|
||||
<li><a href="{% url 'login' %}?next={{ request.path }}"><i class="glyphicon glyphicon-log-in" aria-hidden="true"></i> Log in</a></li>
|
||||
<li><a href="{% url 'login' %}?next={{ request.path }}"><i class="fa fa-sign-in" aria-hidden="true"></i> Log in</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block title %}{{ circuit.provider }} Circuit {{ circuit.cid }}{% endblock %}
|
||||
{% block title %}{{ circuit.provider }} - {{ circuit.cid }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'dcim:site' slug=circuit.site.slug %}">{{ circuit.site }}</a></li>
|
||||
<li><a href="{% url 'circuits:circuit_list' %}?site={{ circuit.site.slug }}">Circuits</a></li>
|
||||
<li>{{ circuit }}</li>
|
||||
<li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></li>
|
||||
<li><a href="{% url 'circuits:circuit_list' %}?provider={{ circuit.provider.slug }}">{{ circuit.provider }}</a></li>
|
||||
<li>{{ circuit.cid }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<form action="{% url 'circuits:circuit_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" placeholder="Circuit ID" />
|
||||
<input type="text" name="q" class="form-control" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
|
||||
<span class="fa fa-search" aria-hidden="true"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -28,18 +28,18 @@
|
||||
<div class="pull-right">
|
||||
{% if perms.circuits.change_circuit %}
|
||||
<a href="{% url 'circuits:circuit_edit' pk=circuit.pk %}" class="btn btn-warning">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
|
||||
<span class="fa fa-pencil" aria-hidden="true"></span>
|
||||
Edit this circuit
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.circuits.delete_circuit %}
|
||||
<a href="{% url 'circuits:circuit_delete' pk=circuit.pk %}" class="btn btn-danger">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
|
||||
<span class="fa fa-trash" aria-hidden="true"></span>
|
||||
Delete this circuit
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>{{ circuit.provider }} Circuit {{ circuit.cid }}</h1>
|
||||
<h1>{{ circuit.provider }} - {{ circuit.cid }}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
@@ -57,6 +57,64 @@
|
||||
<td>Circuit ID</td>
|
||||
<td>{{ circuit.cid }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td><a href="{{ circuit.type.get_absolute_url }}">{{ circuit.type }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tenant</td>
|
||||
<td>
|
||||
{% if circuit.tenant %}
|
||||
<a href="{{ circuit.tenant.get_absolute_url }}">{{ circuit.tenant }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Install Date</td>
|
||||
<td>
|
||||
{% if circuit.install_date %}
|
||||
{{ circuit.install_date }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Speed</td>
|
||||
<td>
|
||||
{% if circuit.upstream_speed %}
|
||||
<i class="fa fa-arrow-down" title="Downstream"></i> {{ circuit.port_speed_human }}
|
||||
<i class="fa fa-arrow-up" title="Upstream"></i> {{ circuit.upstream_speed_human }}
|
||||
{% else %}
|
||||
{{ circuit.port_speed_human }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Commit Rate</td>
|
||||
<td>
|
||||
{% if circuit.commit_rate %}
|
||||
{{ circuit.commit_rate_human }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% with circuit.get_custom_fields as custom_fields %}
|
||||
{% include 'inc/custom_fields_panel.html' %}
|
||||
{% endwith %}
|
||||
{% include 'inc/created_updated.html' with obj=circuit %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Termination</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body">
|
||||
<tr>
|
||||
<td>Site</td>
|
||||
<td>
|
||||
@@ -73,44 +131,34 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Install Date</td>
|
||||
<td>{{ circuit.install_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Port Speed</td>
|
||||
<td>{{ circuit.port_speed_human }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Commit Rate</td>
|
||||
<td>{% if circuit.commit_rate %}{{ circuit.commit_rate_human }}{% else %}<span class="text-muted">N/A</span>{% endif %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cross-Connect</td>
|
||||
<td>{{ circuit.xconnect_id }}</td>
|
||||
<td>
|
||||
{% if circuit.xconnect_id %}
|
||||
{{ circuit.xconnect_id }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Patch Panel/Port</td>
|
||||
<td>{{ circuit.pp_info }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Created</td>
|
||||
<td>{{ circuit.created }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last Updated</td>
|
||||
<td>{{ circuit.last_updated }}</td>
|
||||
<td>
|
||||
{% if circuit.pp_info %}
|
||||
{{ circuit.pp_info }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Comments</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if circuit.comments %}
|
||||
{% if circuit.comments %}
|
||||
{{ circuit.comments|gfm }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
|
||||
@@ -9,13 +9,28 @@
|
||||
{% render_field form.provider %}
|
||||
{% render_field form.cid %}
|
||||
{% render_field form.type %}
|
||||
{% render_field form.tenant %}
|
||||
{% render_field form.install_date %}
|
||||
{% render_field form.port_speed %}
|
||||
{% render_field form.commit_rate %}
|
||||
{% render_field form.xconnect_id %}
|
||||
{% render_field form.pp_info %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Bandwidth</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.port_speed %}
|
||||
{% render_field form.upstream_speed %}
|
||||
{% render_field form.commit_rate %}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.custom_fields %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Custom Fields</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Termination</strong></div>
|
||||
<div class="panel-body">
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
<td>Circuit type</td>
|
||||
<td>Transit</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tenant</td>
|
||||
<td>Name of tenant (optional)</td>
|
||||
<td>Strickland Propane</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Site</td>
|
||||
<td>Site name</td>
|
||||
@@ -55,8 +60,13 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Port Speed</td>
|
||||
<td>Physical speed in Kbps/td>
|
||||
<td>10000</td>
|
||||
<td>Physical speed in Kbps</td>
|
||||
<td>100000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Upstream Speed</td>
|
||||
<td>Upstream speed in Kbps (optional)</td>
|
||||
<td>20000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Commit rate</td>
|
||||
@@ -76,7 +86,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<pre>IC-603122,TeliaSonera,Transit,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14</pre>
|
||||
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,100000,,2000,937649,PP8371 ports 13/14</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,9 +7,13 @@
|
||||
<div class="pull-right">
|
||||
{% if perms.circuits.add_circuit %}
|
||||
<a href="{% url 'circuits:circuit_add' %}" class="btn btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a circuit
|
||||
</a>
|
||||
<a href="{% url 'circuits:circuit_import' %}" class="btn btn-info">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
Import circuits
|
||||
</a>
|
||||
{% endif %}
|
||||
{% include 'inc/export_button.html' with obj_type='circuits' %}
|
||||
</div>
|
||||
@@ -19,23 +23,7 @@
|
||||
{% include 'utilities/obj_table.html' with bulk_edit_url='circuits:circuit_bulk_edit' bulk_delete_url='circuits:circuit_bulk_delete' %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Search</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form action="{% url 'circuits:circuit_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" placeholder="Name" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="glyphicon glyphicon-search" aria-hidden="true"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% include 'inc/filter_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="pull-right">
|
||||
{% if perms.circuits.add_circuittype %}
|
||||
<a href="{% url 'circuits:circuittype_add' %}" class="btn btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a circuit type
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -6,27 +6,41 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
|
||||
<li>{{ provider }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<form action="{% url 'circuits:provider_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="fa fa-search" aria-hidden="true"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider_graphs' pk=provider.pk %}" title="Show graphs">
|
||||
<i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
|
||||
Graphs
|
||||
</button>
|
||||
{% if show_graphs %}
|
||||
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider_graphs' pk=provider.pk %}" title="Show graphs">
|
||||
<i class="fa fa-signal" aria-hidden="true"></i>
|
||||
Graphs
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.circuits.change_provider %}
|
||||
<a href="{% url 'circuits:provider_edit' slug=provider.slug %}" class="btn btn-warning">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
|
||||
<span class="fa fa-pencil" aria-hidden="true"></span>
|
||||
Edit this provider
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.circuits.delete_provider %}
|
||||
<a href="{% url 'circuits:provider_delete' slug=provider.slug %}" class="btn btn-danger">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
|
||||
<span class="fa fa-trash" aria-hidden="true"></span>
|
||||
Delete this provider
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -41,48 +55,72 @@
|
||||
<table class="table table-hover panel-body">
|
||||
<tr>
|
||||
<td>ASN</td>
|
||||
<td>{{ provider.asn }}</td>
|
||||
<td>
|
||||
{% if provider.asn %}
|
||||
{{ provider.asn }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Account</td>
|
||||
<td>{{ provider.account }}</td>
|
||||
<td>
|
||||
{% if provider.account %}
|
||||
{{ provider.account }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Customer Portal</td>
|
||||
<td>
|
||||
<a href="{{ provider.portal_url }}">{{ provider.portal_url }}</a>
|
||||
{% if provider.portal_url %}
|
||||
<a href="{{ provider.portal_url }}">{{ provider.portal_url }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NOC Contact</td>
|
||||
<td>{{ provider.noc_contact|linebreaksbr }}</td>
|
||||
<td>
|
||||
{% if provider.noc_contact %}
|
||||
{{ provider.noc_contact|linebreaksbr }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Admin Contact</td>
|
||||
<td>{{ provider.admin_contact|linebreaksbr }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Created</td>
|
||||
<td>{{ provider.created }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last Updated</td>
|
||||
<td>{{ provider.last_updated }}</td>
|
||||
<td>
|
||||
{% if provider.admin_contact %}
|
||||
{{ provider.admin_contact|linebreaksbr }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% with provider.get_custom_fields as custom_fields %}
|
||||
{% include 'inc/custom_fields_panel.html' %}
|
||||
{% endwith %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Comments</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if provider.comments %}
|
||||
{% if provider.comments %}
|
||||
{{ provider.comments|gfm }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/created_updated.html' with obj=provider %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
{% render_field form.admin_contact %}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.custom_fields %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Custom Fields</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Comments</strong></div>
|
||||
<div class="panel-body">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user